开发者问题收集

React 组件(作为节点包)钩子问题

2023-03-15
462

我正在开发一个 React 组件,旨在将其变成一个 npm 包,以便可以将其导入到其他各种 React 项目中。 使用“useRef”钩子似乎有问题。

这是我的 package.json

{
  "name": "@mperudirectio/react-player",
  "publishConfig": {
    "registry": "https://registry.npmjs.org/"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/mperudirectio/react-player"
  },
  "version": "0.0.1",
  "author": "matt p",
  "license": "MIT",
  "scripts": {
    "rollup": "rollup -c"
  },
  "devDependencies": {
    "@rollup/plugin-commonjs": "^24.0.1",
    "@rollup/plugin-node-resolve": "^15.0.1",
    "@rollup/plugin-typescript": "^11.0.0",
    "@types/react": "^18.0.28",
    "react": "^18.2.0",
    "rollup": "^3.19.1",
    "rollup-plugin-asset": "^1.1.1",
    "rollup-plugin-dts": "^5.2.0",
    "rollup-plugin-import-css": "^3.2.1",
    "tslib": "^2.5.0",
    "typescript": "^4.9.5"
  },
  "peerDependencies": {
    "react": "^18.2.0"
  },
  "main": "dist/cjs/index.js",
  "module": "dist/esm/index.js",
  "files": [
    "dist"
  ],
  "types": "dist/index.d.ts",
  "dependencies": {}
}

这是我的组件:

import React, { ChangeEvent, FC, useRef, useState, useEffect } from 'react';
import styles from './Player.css' assert { type: 'css' };

document.adoptedStyleSheets = [styles];

// CUSTOM HOOK
const useVideoPlayer = (videoElement: React.MutableRefObject<null | HTMLVideoElement>) => {
  const [playerState, setPlayerState] = useState({
    isPlaying: false,
    progress: 0,
    speed: 1,
    isMuted: false,
  });

  const togglePlay = () => {
    setPlayerState({
      ...playerState,
      isPlaying: !playerState.isPlaying,
    });
  };

  useEffect(() => {
    playerState.isPlaying
      ? videoElement.current?.play()
      : videoElement.current?.pause();
  }, [playerState.isPlaying, videoElement]);

  const handleOnTimeUpdate = () => {
    const progress = ((videoElement.current?.currentTime ?? 1) / (videoElement.current?.duration ?? 1)) * 100;
    setPlayerState({
      ...playerState,
      progress,
    });
  };

  const handleVideoProgress = (event: ChangeEvent<HTMLInputElement>) => {
      const manualChange = Number(event.target.value);
      if (videoElement.current) {
        videoElement.current.currentTime = ((videoElement.current?.duration ?? 0) / 100) * manualChange;
        setPlayerState({
          ...playerState,
          progress: manualChange,
        });
      }
  };

  const handleVideoSpeed = (event: ChangeEvent<HTMLSelectElement>) => {
      const speed = Number(event.target.value);
      if (videoElement.current) {
        videoElement.current.playbackRate = speed;
        
        setPlayerState({
          ...playerState,
          speed,
        });
      }
  };

  const toggleMute = () => {
    setPlayerState({
      ...playerState,
      isMuted: !playerState.isMuted,
    });
  };

  useEffect(() => {
    if (videoElement.current) {
      playerState.isMuted
      ? (videoElement.current.muted = true)
      : (videoElement.current.muted = false);
    }
  }, [playerState.isMuted, videoElement]);

  return {
    playerState,
    togglePlay,
    handleOnTimeUpdate,
    handleVideoProgress,
    handleVideoSpeed,
    toggleMute,
  };
};

// import video from "./assets/video.mp4";
const v = "https://file-examples.com/storage/fef1706276640fa2f99a5a4/2017/04/file_example_MP4_1280_10MG.mp4";

// MAIN COMPONENT THAT USES CUSTOM HOOK + USEREF
const App: FC = () => {
    const videoElement = useRef<null | HTMLVideoElement>(null);
    const {
      playerState,
      togglePlay,
      handleOnTimeUpdate,
      handleVideoProgress,
      handleVideoSpeed,
      toggleMute,
    } = useVideoPlayer(videoElement);
    return (
        <div className="container">
          <div className="video-wrapper">
            <video
              src={v}
              ref={videoElement}
              onTimeUpdate={handleOnTimeUpdate}
            />
            <div className="controls">
              <div className="actions">
                <button onClick={togglePlay}>
                  {!playerState.isPlaying ? (
                    <i className="bx bx-play"></i>
                  ) : (
                    <i className="bx bx-pause"></i>
                  )}
                </button>
              </div>
              <input
                type="range"
                min="0"
                max="100"
                value={playerState.progress}
                onChange={(e: ChangeEvent<HTMLInputElement>) => handleVideoProgress(e)}
              />
              <select
                className="velocity"
                value={playerState.speed}
                onChange={(e: ChangeEvent<HTMLSelectElement>) => handleVideoSpeed(e)}
              >
                <option value="0.50">0.50x</option>
                <option value="1">1x</option>
                <option value="1.25">1.25x</option>
                <option value="2">2x</option>
              </select>
              <button className="mute-btn" onClick={toggleMute}>
                {!playerState.isMuted ? (
                  <i className="bx bxs-volume-full"></i>
                ) : (
                  <i className="bx bxs-volume-mute"></i>
                )}
              </button>
            </div>
          </div>
        </div>
    );
};

export default App;

我在 npm 注册表上捆绑或发布包时没有任何问题,一切正常。 但是当我导入我的组件时,应用程序在运行时“爆炸”:

Warning: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:

  1. You might have mismatching versions of React and the renderer (such as React DOM)
  2. You might be breaking the Rules of Hooks
  3. You might have more than one copy of React in the same app See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.

Uncaught TypeError: Cannot read properties of null (reading 'useRef') at Object.useRef (react.development.js:1630:1) at App (Player.tsx:87:1) at renderWithHooks (react-dom.development.js:16305:1) at mountIndeterminateComponent (react-dom.development.js:20074:1) at beginWork (react-dom.development.js:21587:1) at HTMLUnknownElement.callCallback (react-dom.development.js:4164:1) at Object.invokeGuardedCallbackDev (react-dom.development.js:4213:1) at invokeGuardedCallback (react-dom.development.js:4277:1) at beginWork$1 (react-dom.development.js:27451:1) at performUnitOfWork (react-dom.development.js:26557:1)

问题是,如果我采用这个确切的代码并将其直接写入应该导入我的包的应用程序中,它就可以完美地工作。经过一些测试后,我意识到 ref 运行得不太好,就像通过包一样,它永远无法填充 refcurrent 参数。但我无法弄清楚包和应用程序中的“纯”代码之间的问题/差异是什么。

有人能帮我吗?谢谢!

更新 1

将组件从 function component 转换为 class purecomponent ,重现完全相同的行为、生命周期并使用 callback ref ,一切正常。

所以这绝对不是包和主机等之间 React 版本不同的问题。通过 hooks 处理 ref 可能不如在 class component 中那么稳定。

这是新的组件代码:

import React, { ChangeEvent } from 'react';
import styles from './Player.css' assert { type: 'css' };

document.adoptedStyleSheets = [styles];

const v = "https://file-examples.com/storage/fef1706276640fa2f99a5a4/2017/04/file_example_MP4_1280_10MG.mp4";

type PlayerPropz = {
  ComponentName?: string
};

type PlayerState = {
  isPlaying: boolean,
  progress: number,
  speed: number,
  isMuted: boolean,
};

class Player extends React.PureComponent<PlayerPropz, PlayerState> {
  declare private textInput: HTMLVideoElement | null;
  declare private setTextInputRef: (element: HTMLVideoElement) => void;
  declare public static defaultProps: PlayerPropz;

  constructor(props: PlayerPropz) {
    super(props);

    this.state = {
      isPlaying: false,
      progress: 0,
      speed: 1,
      isMuted: false,
    }

    this.textInput = null;

    this.setTextInputRef = (element: HTMLVideoElement) => {
      this.textInput = element;
    }

    this.togglePlay = this.togglePlay.bind(this);
    this.handleOnTimeUpdate = this.handleOnTimeUpdate.bind(this);
    this.handleVideoProgress = this.handleVideoProgress.bind(this);
    this.handleVideoSpeed = this.handleVideoSpeed.bind(this);
    this.toggleMute = this.toggleMute.bind(this);
  }

  componentDidMount(): void {
    const { ...state } = this.state;
    if (this.textInput) {
      state.isPlaying
        ? this.textInput.play()
        : this.textInput.pause();

      state.isMuted
        ? (this.textInput.muted = true)
        : (this.textInput.muted = false);
    }
  }

  componentDidUpdate(prevProps: Readonly<PlayerPropz>, prevState: Readonly<PlayerState>, snapshot?: any): void {
    const { ...state } = this.state;
    if (this.textInput && state.isPlaying != prevState.isPlaying) {
      state.isPlaying
        ? this.textInput.play()
        : this.textInput.pause();
    }

    if (this.textInput && state.isMuted != prevState.isMuted) {
      state.isMuted
        ? (this.textInput.muted = true)
        : (this.textInput.muted = false);
    }
  }

  togglePlay() {
    this.setState({ isPlaying: !this.state.isPlaying });
  }

  handleOnTimeUpdate() {
    if (this.textInput) {
      const progress = (this.textInput.currentTime / this.textInput.duration) * 100;
      this.setState({ progress });
    }
  };

  handleVideoProgress(event: ChangeEvent<HTMLInputElement>) {
    if (this.textInput) {
        const manualChange = Number(event.target.value);
        this.textInput.currentTime = (this.textInput.duration / 100) * manualChange;
        this.setState({ progress: manualChange });
    }
  };

  handleVideoSpeed(event: ChangeEvent<HTMLSelectElement>) {
      const speed = Number(event.target.value);
      if (this.textInput) {
        this.textInput.playbackRate = speed;
        this.setState({ speed });
      }
  };

  toggleMute() {
    this.setState({ isMuted: !this.state.isMuted });
  };

  render() {
    const { ...state } = this.state;
    return (
        <div className="container">
          <div className="video-wrapper">
            <video
              src={v}
              ref={(element: HTMLVideoElement) => this.setTextInputRef(element)}
              onTimeUpdate={() => this.handleOnTimeUpdate()}
            />
            <div className="controls">
              <div className="actions">
                <button onClick={() => this.togglePlay()}>
                  {!state.isPlaying ? (
                    <i className="bx bx-play"></i>
                  ) : (
                    <i className="bx bx-pause"></i>
                  )}
                </button>
              </div>
              <input
                type="range"
                min="0"
                max="100"
                value={state.progress}
                onChange={(e: ChangeEvent<HTMLInputElement>) => this.handleVideoProgress(e)}
              />
              <select
                className="velocity"
                value={state.speed}
                onChange={(e: ChangeEvent<HTMLSelectElement>) => this.handleVideoSpeed(e)}
              >
                <option value="0.50">0.50x</option>
                <option value="1">1x</option>
                <option value="1.25">1.25x</option>
                <option value="2">2x</option>
              </select>
              <button className="mute-btn" onClick={() => this.toggleMute()}>
                {!state.isMuted ? (
                  <i className="bx bxs-volume-full"></i>
                ) : (
                  <i className="bx bxs-volume-mute"></i>
                )}
              </button>
            </div>
          </div>
        </div>
    );
  }
};

Player.defaultProps = {
  ComponentName: 'Player'
};

export default Player;

更新 2

按照@LindaPaiste 的建议,直接从我的 custom hook 中更改结构并管理 callback ref ,现在问题已经“在生产中”得到解决:如果我通过 npm 在注册表中发布我的包发布 ,当我从主机应用程序下载包时,组件可以工作,它一直给我钩子错误,但现在仅限于本地。

注意 :为了使其也在本地工作,您必须 链接 yarn link / npm link )主机应用程序中的包和包的对等依赖项,在我的情况下,除了我的包之外,我还必须链接 react 包。

播放器组件代码:

import React, { FC, ChangeEvent } from 'react';
import useVideoPlayer from '../../hooks/useVideoPlayer';

import styles from './Player.css' assert { type: 'css' };

document.adoptedStyleSheets = [styles];

const v = "https://file-examples.com/storage/fef1706276640fa2f99a5a4/2017/04/file_example_MP4_1280_10MG.mp4";

const Player: FC = () => {
  const {
    playerState,
    togglePlay,
    handleOnTimeUpdate,
    handleVideoProgress,
    handleVideoSpeed,
    toggleMute,
    ref
  } = useVideoPlayer();
  return (
    <div className="container">
      <div className="video-wrapper">
        <video
          src={v}
          ref={ref}
          onTimeUpdate={handleOnTimeUpdate}
        />
        <div className="controls">
          <div className="actions">
            <button onClick={togglePlay}>
              {!playerState.isPlaying ? (
                <i className="bx bx-play"></i>
              ) : (
                <i className="bx bx-pause"></i>
              )}
            </button>
          </div>
          <input
            type="range"
            min="0"
            max="100"
            value={playerState.progress}
            onChange={(e: ChangeEvent<HTMLInputElement>) => handleVideoProgress(e)}
          />
          <select
            className="velocity"
            value={playerState.speed}
            onChange={(e: ChangeEvent<HTMLSelectElement>) => handleVideoSpeed(e)}
          >
            <option value="0.50">0.50x</option>
            <option value="1">1x</option>
            <option value="1.25">1.25x</option>
            <option value="2">2x</option>
          </select>
          <button className="mute-btn" onClick={toggleMute}>
            {!playerState.isMuted ? (
              <i className="bx bxs-volume-full"></i>
            ) : (
              <i className="bx bxs-volume-mute"></i>
            )}
          </button>
        </div>
      </div>
    </div>
  );
};

export default Player;

自定义钩子代码:

import React, { useState, useRef, useEffect, ChangeEvent } from 'react';

const useVideoPlayer = () => {
    const [playerState, setPlayerState] = useState({
      isPlaying: false,
      progress: 0,
      speed: 1,
      isMuted: false,
    });
  
    const videoElement = useRef<HTMLVideoElement | null>(null);
  
    const videoCallbackRef: React.RefCallback<HTMLVideoElement> = (element: HTMLVideoElement | null) => {
      if (element) {
        console.log('executed because the HTML video element was set.');
        videoElement.current = element;
      }
    }
  
    useEffect(() => {
      if (videoElement.current) {
        console.log('executed because playerState.isPlaying changed.');
        playerState.isPlaying
          ? videoElement.current.play()
          : videoElement.current.pause();
      }
      
    }, [playerState.isPlaying]);
  
    const togglePlay = () => {
      setPlayerState({
        ...playerState,
        isPlaying: !playerState.isPlaying,
      });
    };
  
    const handleOnTimeUpdate = () => {
      const progress = ((videoElement.current?.currentTime ?? 1) / (videoElement.current?.duration ?? 1)) * 100;
      setPlayerState({
        ...playerState,
        progress,
      });
    };
  
    const handleVideoProgress = (event: ChangeEvent<HTMLInputElement>) => {
      const manualChange = Number(event.target.value);
      if (videoElement.current) {
        videoElement.current.currentTime = ((videoElement.current?.duration ?? 0) / 100) * manualChange;
        setPlayerState({
          ...playerState,
          progress: manualChange,
        });
      }
    };
  
    const handleVideoSpeed = (event: ChangeEvent<HTMLSelectElement>) => {
      const speed = Number(event.target.value);
      if (videoElement.current) {
        videoElement.current.playbackRate = speed;
  
        setPlayerState({
          ...playerState,
          speed,
        });
      }
    };
  
    const toggleMute = () => {
      setPlayerState({
        ...playerState,
        isMuted: !playerState.isMuted,
      });
    };
  
    useEffect(() => {
      console.log('executed because playerState.isMuted changed.');
      if (videoElement.current) {
        playerState.isMuted
          ? (videoElement.current.muted = true)
          : (videoElement.current.muted = false);
      }
    }, [playerState.isMuted]);
  
    return {
      playerState,
      togglePlay,
      handleOnTimeUpdate,
      handleVideoProgress,
      handleVideoSpeed,
      toggleMute,
      ref: videoCallbackRef
    };
};

export default useVideoPlayer;

rollup 配置文件:

import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import typescript  from '@rollup/plugin-typescript';
import css from 'rollup-plugin-import-css';
import cleanup from 'rollup-plugin-cleanup';
import dts from 'rollup-plugin-dts';

import packageJson from './package.json' assert { type: 'json' };

export default [
  {
    input: 'src/index.ts',
        external: ['react'],
    output: [
      {
        file: packageJson.main,
        format: 'cjs',
        sourcemap: true,
        assetFileNames: "assets/[name]-[hash][extname]"
      },
      {
        file: packageJson.module,
        format: 'esm',
        sourcemap: true,
        assetFileNames: "assets/[name]-[hash][extname]"
      },
    ],
    plugins: [
      resolve(),
      commonjs(),
      typescript({ tsconfig: './tsconfig.json' }),
      css({ modules: true }),
      cleanup({ extensions: ['ts', 'tsx', 'js', 'jsx', 'mjs'] })
    ],
  },
  {
    input: 'dist/esm/types/index.d.ts',
    output: [{ file: 'dist/index.d.ts', format: 'esm', sourcemap: true }],
    plugins: [dts()],
  },
];

package.json

{
  "name": "@mperudirectio/react-player",
  "publishConfig": {
    "registry": "https://registry.npmjs.org/"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/mperudirectio/react-player"
  },
  "version": "0.0.6",
  "author": "matt p",
  "license": "MIT",
  "scripts": {
    "rollup": "rollup -c"
  },
  "devDependencies": {
    "@rollup/plugin-commonjs": "^24.0.1",
    "@rollup/plugin-node-resolve": "^15.0.1",
    "@rollup/plugin-typescript": "^11.0.0",
    "@types/react": "^18.0.28",
    "react": "^18.2.0",
    "rollup": "^3.19.1",
    "rollup-plugin-asset": "^1.1.1",
    "rollup-plugin-cleanup": "^3.2.1",
    "rollup-plugin-dts": "^5.2.0",
    "rollup-plugin-import-css": "^3.2.1",
    "tslib": "^2.5.0",
    "typescript": "^4.9.5"
  },
  "peerDependencies": {
    "react": "^18.2.0"
  },
  "main": "dist/cjs/index.js",
  "module": "dist/esm/index.js",
  "files": [
    "dist"
  ],
  "types": "dist/index.d.ts",
  "dependencies": {}
}
2个回答

这很可能是与您的构建过程/React 版本不匹配有关的问题。

主机应用程序和包应使用相同的 React 包。您可以尝试 npm ls reactnpm ls react-dom 。并且您应该检查您是否拥有 reactreact-dom 包的唯一实例。

如果您有不同的版本,则需要修复它。确保 React 未捆绑在您的库包中,并且依赖项具有兼容的版本。您可以将 react 指定为对等依赖项。

编辑:

一旦我安装了您的软件包并进行了检查,您确实已将 react 捆绑到您的代码中: 在此处输入图像描述

您需要更改您的汇总配置,以使 react 和 react-dom 为 外部

Алексей Мартинкевич
2023-03-15

“已解决”

将组件从 function component 转换为 class purecomponent ,重现完全相同的行为、生命周期并使用 callback ref 一切正常。

因此,这绝对不是包和主机等之间 React 版本不同的问题。通过 hooks 处理 ref 可能不如在 class component 中那么稳定。

这是新的组件代码:

import React, { ChangeEvent } from 'react';
import styles from './Player.css' assert { type: 'css' };

document.adoptedStyleSheets = [styles];

const v = "https://file-examples.com/storage/fef1706276640fa2f99a5a4/2017/04/file_example_MP4_1280_10MG.mp4";

type PlayerPropz = {
  ComponentName?: string
};

type PlayerState = {
  isPlaying: boolean,
  progress: number,
  speed: number,
  isMuted: boolean,
};

class Player extends React.PureComponent<PlayerPropz, PlayerState> {
  declare private textInput: HTMLVideoElement | null;
  declare private setTextInputRef: (element: HTMLVideoElement) => void;
  declare public static defaultProps: PlayerPropz;

  constructor(props: PlayerPropz) {
    super(props);

    this.state = {
      isPlaying: false,
      progress: 0,
      speed: 1,
      isMuted: false,
    }

    this.textInput = null;

    this.setTextInputRef = (element: HTMLVideoElement) => {
      this.textInput = element;
    }

    this.togglePlay = this.togglePlay.bind(this);
    this.handleOnTimeUpdate = this.handleOnTimeUpdate.bind(this);
    this.handleVideoProgress = this.handleVideoProgress.bind(this);
    this.handleVideoSpeed = this.handleVideoSpeed.bind(this);
    this.toggleMute = this.toggleMute.bind(this);
  }

  componentDidMount(): void {
    const { ...state } = this.state;
    if (this.textInput) {
      state.isPlaying
        ? this.textInput.play()
        : this.textInput.pause();

      state.isMuted
        ? (this.textInput.muted = true)
        : (this.textInput.muted = false);
    }
  }

  componentDidUpdate(prevProps: Readonly<PlayerPropz>, prevState: Readonly<PlayerState>, snapshot?: any): void {
    const { ...state } = this.state;
    if (this.textInput && state.isPlaying != prevState.isPlaying) {
      state.isPlaying
        ? this.textInput.play()
        : this.textInput.pause();
    }

    if (this.textInput && state.isMuted != prevState.isMuted) {
      state.isMuted
        ? (this.textInput.muted = true)
        : (this.textInput.muted = false);
    }
  }

  togglePlay() {
    this.setState({ isPlaying: !this.state.isPlaying });
  }

  handleOnTimeUpdate() {
    if (this.textInput) {
      const progress = (this.textInput.currentTime / this.textInput.duration) * 100;
      this.setState({ progress });
    }
  };

  handleVideoProgress(event: ChangeEvent<HTMLInputElement>) {
    if (this.textInput) {
        const manualChange = Number(event.target.value);
        this.textInput.currentTime = (this.textInput.duration / 100) * manualChange;
        this.setState({ progress: manualChange });
    }
  };

  handleVideoSpeed(event: ChangeEvent<HTMLSelectElement>) {
      const speed = Number(event.target.value);
      if (this.textInput) {
        this.textInput.playbackRate = speed;
        this.setState({ speed });
      }
  };

  toggleMute() {
    this.setState({ isMuted: !this.state.isMuted });
  };

  render() {
    const { ...state } = this.state;
    return (
        <div className="container">
          <div className="video-wrapper">
            <video
              src={v}
              ref={(element: HTMLVideoElement) => this.setTextInputRef(element)}
              onTimeUpdate={() => this.handleOnTimeUpdate()}
            />
            <div className="controls">
              <div className="actions">
                <button onClick={() => this.togglePlay()}>
                  {!state.isPlaying ? (
                    <i className="bx bx-play"></i>
                  ) : (
                    <i className="bx bx-pause"></i>
                  )}
                </button>
              </div>
              <input
                type="range"
                min="0"
                max="100"
                value={state.progress}
                onChange={(e: ChangeEvent<HTMLInputElement>) => this.handleVideoProgress(e)}
              />
              <select
                className="velocity"
                value={state.speed}
                onChange={(e: ChangeEvent<HTMLSelectElement>) => this.handleVideoSpeed(e)}
              >
                <option value="0.50">0.50x</option>
                <option value="1">1x</option>
                <option value="1.25">1.25x</option>
                <option value="2">2x</option>
              </select>
              <button className="mute-btn" onClick={() => this.toggleMute()}>
                {!state.isMuted ? (
                  <i className="bx bxs-volume-full"></i>
                ) : (
                  <i className="bx bxs-volume-mute"></i>
                )}
              </button>
            </div>
          </div>
        </div>
    );
  }
};

Player.defaultProps = {
  ComponentName: 'Player'
};

export default Player;
Matteo Pietro Peru
2023-03-16