使用含useState的自定义React UI库组件时出现TypeError求助
我基于React、Sass、Storybook、TypeScript和Rollup搭建了个人UI库,用来统一应用组件规范。之前一切正常,但使用包含useState Hook的组件时,应用抛出错误:
TypeError: Cannot read properties of null (reading 'useState')
主前端应用基于Next.js、Axios、Yup、Formik和TypeScript构建,不含useState的组件(比如Button)能正常使用,但所有带useState的组件都会触发这个错误。
我试过调整peerDependencies、修改Rollup的external配置等操作,都没解决,而且Rollup打包过程没有报错。相关配置与代码如下:
库的package.json
{"name": "@lucasReghini/library","publishConfig": {"registry": "https://npm.pkg.github.com/lucasReghini"},"version": "0.0.11","description": "react ui library","license": "MIT","scripts": {"rollup": "rollup -c","storybook": "start-storybook -p 6006","build-storybook": "build-storybook"},"author": "Lucas Reghini Magalhães","devDependencies": {"@babel/core": "^7.18.10","@rollup/plugin-commonjs": "^22.0.2","@rollup/plugin-image": "^2.1.1","@rollup/plugin-node-resolve": "^13.3.0","@rollup/plugin-typescript": "^8.3.4","@storybook/addon-actions": "^6.5.10","@storybook/addon-essentials": "^6.5.10","@storybook/addon-interactions": "^6.5.10","@storybook/addon-links": "^6.5.10","@storybook/builder-webpack5": "^6.5.10","@storybook/manager-webpack5": "^6.5.10","@storybook/preset-scss": "^1.0.3","@storybook/react": "^6.5.10","@storybook/testing-library": "^0.0.13","babel-loader": "^8.2.5","classnames": "^2.3.1","css-loader": "^6.7.1","postcss": "^8.4.16","rollup": "^2.78.1","rollup-plugin-dts": "^4.2.2","rollup-plugin-postcss": "^4.0.2","sass": "^1.54.5","sass-loader": "^13.0.2","style-loader": "^3.3.1","tslib": "^2.4.0","typescript": "^4.7.4","@types/react": ">=18","react": ">=18","react-dom": ">=18"},"main": "dist/cjs/index.js","module": "dist/esm/index.js","files": ["dist"],"types": "dist/index.d.ts","dependencies": {"classnames": "^2.3.1"},"peerDependencies": {"@types/react": ">=18","react": ">=18","react-dom": ">=18"}}
库的rollup.config.js
import resolve from "@rollup/plugin-node-resolve";import commonjs from "@rollup/plugin-commonjs";import typescript from "@rollup/plugin-typescript";import dts from "rollup-plugin-dts";import postcss from "rollup-plugin-postcss";import image from '@rollup/plugin-image'; const packageJson = require("./package.json"); export default [{ input: "src/index.ts", output: [{ file: packageJson.main, format: "cjs", sourcemap: true, },{ file: packageJson.module, format: "esm", sourcemap: true, },], plugins: [resolve(),commonjs(),typescript({ tsconfig: "./tsconfig.json" }),postcss(),image()], },{ input: "dist/esm/index.d.ts", output: [{ file: "dist/index.d.ts", format: "esm" }], plugins: [dts()], external: [/\.(css|less|scss)$/], },];
库的tsconfig.json
{"compilerOptions": {"target": "es5","esModuleInterop": true,"forceConsistentCasingInFileNames": true,"strict": true,"skipLibCheck": true,"jsx": "react","module": "ESNext","declaration": true,"declarationDir": "types","sourceMap": true,"outDir": "dist","moduleResolution": "node","allowSyntheticDefaultImports": true,"emitDeclarationOnly": true,"rootDir": "src"}}
报错的Input组件代码
import React, { ChangeEventHandler, useState } from 'react';import './Input.scss'import classNames from 'classnames'import { inputTypes } from '../../enums/presets';import { eyesClosed, eyesOpen } from '../../icons' export interface InputProps { id?: string, label?: string, error?:boolean, message?: string, disabled?: boolean, autoComplete?: boolean, isPassword?: boolean, types: inputTypes, onChange?: ChangeEventHandler<HTMLInputElement> } const Input: React.FC<InputProps> = ({id, disabled, label, message, error, onChange, autoComplete, isPassword, types, ...props}) => { const [isView, setIsView] = useState(false) const classNameInput = classNames({ form__input : true, 'error': error, 'font-16': true, }); const type = isPassword ? isView ? 'text' : 'password' : types return ( <div className='form'> <input onChange={onChange} className={classNameInput} type={type} id={id || 'ipt'} autoComplete={autoComplete ?'' : 'off'} placeholder=" "/> <label className='form__label' >{label}</label> {isPassword && <img className='icon' src={isView ? eyesClosed : eyesOpen} onClick={() => setIsView(e => !e)}/>} {error && <span className='message'>{message}</span>} </div> ) } export default Input
主应用的package.json
{"name": "main-frontend","version": "0.1.0","private": true,"scripts": {"dev": "next dev","build": "next build","start": "next start","lint": "next lint"},"dependencies": {"@lucasReghini/library": "^0.0.11","@next/bundle-analyzer": "^12.2.5","@reduxjs/toolkit": "^1.8.5","axios": "^0.27.2","babel-plugin-inline-react-svg": "^2.0.1","formik": "^2.2.9","next": "12.2.5","next-images": "^1.8.4","react": "18.2.0","react-dom": "18.2.0","react-redux": "^8.0.2","yup": "^0.32.11"},"devDependencies": {"@types/node": "18.7.13","@types/react": "18.0.17","@types/react-dom": "18.0.6","eslint": "8.22.0","eslint-config-next": "12.2.5","sass": "^1.54.5","typescript": "4.7.4"}}
请问有人遇到过此类问题吗?是否与库基于React、主应用基于Next.js有关?
解决方案
这个错误的核心原因是UI库打包时将React代码一同打包进了产物中,导致主应用中出现两个不同的React实例:一个是主应用自身的React,另一个是UI库打包进去的React。Hook只能在同一个React实例的组件树中正常工作,多实例会导致Hook调用失败。
修复步骤:
调整Rollup配置,将React相关依赖标记为外部依赖
修改rollup.config.js,在第一个打包配置中添加external字段,明确排除React、ReactDOM:export default [{ input: "src/index.ts", output: [{ file: packageJson.main, format: "cjs", sourcemap: true, },{ file: packageJson.module, format: "esm", sourcemap: true, },], // 添加这行,将React相关依赖设为外部 external: ["react", "react-dom"], plugins: [resolve(),commonjs(),typescript({ tsconfig: "./tsconfig.json" }),postcss(),image()], },{ input: "dist/esm/index.d.ts", output: [{ file: "dist/index.d.ts", format: "esm" }], plugins: [dts()], external: [/\.(css|less|scss)$/], },];确保UI库的React依赖仅存在于devDependencies
你的package.json已经将React、ReactDOM放在devDependencies中,这部分是对的,保持即可——peerDependencies声明了对React版本的要求,devDependencies仅用于开发时本地构建,不会被打包进产物。重新打包并发布UI库
执行npm run rollup重新打包,然后重新发布到你的私有仓库。主应用重新安装依赖
在主应用中执行npm install @lucasReghini/library@latest,确保安装的是修复后的版本。
额外验证:
可以检查UI库打包后的dist/cjs/index.js或dist/esm/index.js,搜索useState或React,如果没有看到React的源码被打包进去,而是以require("react")或import React from 'react'的形式存在,说明配置生效了。
这个问题和Next.js本身无关,本质是UI库打包时没有正确隔离React依赖导致的多实例冲突。
内容的提问来源于stack exchange,提问作者Luck dev

