遇到了invalid hook call报错
上周,业务下游的同事反馈了一个生产问题,说我们传的值有问题,领导让我帮忙排查一下。我最近一直没做那个项目相关的需求,打开那个项目切到release分支,拉代码,npm i然后启动,结果就白屏了。。。
打开控制台,看到了invalid hook call的报错,发生在公共组提供的一个组件里面。这个报错其实还有点讲究,我们都知道react的hook因为实现方式的限制,必须保证每次render都按相同的顺序执行,所以hook的使用是有限制的。当违反这些限制的时候就会报invalid hook call的错。但是除了刚接触react的新手,几乎没人会犯这样的错误。
这个报错的另外两个可能的原因,分别是react和react-dom的版本不匹配,和项目里存在多个版本的react。我在node_modules里面查看了报错的那个组件下面的node_modules,发现果然这个组件又单独装了个react的包进去。这样一来,我们项目里面就存在了两个react版本,导致了invalid hook call报错。
项目的实际依赖结构如下:
project
— node_modules
—— react 16.14.0
—— 某组件
——— node_modules
———— react 17.2.0
为什么会装两个react
我最近一直没碰过那个项目,那其他人呢,其他人开发没遇见这个问题?我真忍不了了,开始翻那个组件的依赖,看到它peerDependencies里面有个react,但是这个应该不是问题,peerDependencies不会真的被安装。我又翻这个组件的深层的依赖,终于看到有个包直接把react写在了dependencies里面。然后我先暂时用force-resolution统一了项目里面的react版本,然后跟公共组的同事说了之后,他们就把dependencies里面的react和react-dom去掉了。
其实我们直接用npm ls react命令也能看出来,项目里面就精装了多少个react和每个react的版本。
npm的各种依赖
devDependencies
接下来就发散发散,说说npm的各种依赖吧。还记得半年前我刚工作的时候,还不懂devDependencies和dependencies的意义,改了组件的devDependencies,结果最后项目里没生效,还是向别人请教后才知道,组件的devDependencies最后不会被真的安装到项目里面去。
字面意思,devDependencies是指开发环境用到的依赖,比如用于预览组件的storybook,实际项目是用不到的,它是用来开发组件的时候预览用的,所以storybook就是典型的要写在devDependencies里面的。总结就是你有一个组件A,它依赖了一个npm包B,如果B写在dependencies里面,那么当项目安装A的时候也会安装B。而如果B是写在A的devDependencies里面,那么项目安装A就不会自动安装B。有的时候如果你错把一些依赖写在devDependencies里面,可能会导致项目里面少安装了一些依赖。这种时候建议使用peerDependencies
peerDependencies
peerDependencies意思就是我必须需要这个依赖,但是我不会自带这个东西,需要安装我的项目去安装这个依赖。比如有的组件只能运行在react项目中,则可以把react写在peerDependencies里面。然后安装这个组件的项目需要自己指明react为dependencies。如果实际项目中缺少这个依赖,则会提示报错。
我们应该明白各种依赖的意义,在实际开发过程中加以区分,将依赖的npm包写在最合适的位置。
对devDependencies的补充
(后续追加更新,修正上面的内容。以上针对devDependencies的内容,对于发布npm包来说是正确的。而在web站点项目中,dependencies和devDependencies仅作为一个规范,并没有实际区别。这也解释了为什么公司有的项目把依赖的业务组件写在devDependencies,却能正常运行。虽不规范,却不报错。待我去公司再确认一下,项目在生产的install命令是什么)
二次追加更新,今天和同事聊天的时候,又意识到一个问题(虽然这个同事平时有种摸鱼的感觉,但是每次和他交流都感觉收获满满),即被打包和被安装是两码事。所谓的devDependencies不会被安装确实是没错,但是组件里面的devDependencies也是会被打包的。因为打包器不会去管这个依赖是dependencies还是devDependencies甚至在不在package.json里,只要node_modules里面能找到这个包,并且从打包入口开始根据import或require构建依赖关系时,确实遇到了这个包,这个包就会被打到最后的代码里面,除非用external字段指明不打这个包。用同事的话说,就是被塞到最后那个面目全非的打包后的js里面去了。
确实我之前也发现过这个问题,我写过一个简单的npm包,把react写在devDependencies里面,但是打包出来的产物快三千行,只有不到一百行是我的代码,剩下全是react我真的很难绷,后来我也是用external字段指明不打包react,最终才得到了简洁干净的打包产物。当时我还有点纳闷,明明是写在devDependencies里面,也没写在devDependencies里,怎么还被打包呢,今天才明白,写在devDependencies里面只会让我在安装我自己的npm包的时候不会再安装一个react进去,但是不能避免我的npm包里面含有react的代码。
其实这又进一步解释了为什么web站点项目中dependencies和devDependencies意义不大,我今天特地看了我们pipeline的命令,安装这一步没有指定是生产环境,所以devDependencies依赖也被装到node_modules里面去了,所以最后build的时候当然也会把devDependencies打进去。
npm机制
扁平化
接下来再说说npm的机制。我们经常发现,有的时候项目里面明明package.json里面写的各种依赖很少,但是项目node_modules里面却有着巨多的文件夹。这其实是因为npm的扁平化机制。npm在安装包的过程中,会试图将依赖的依赖,以及更深层次的依赖,都提取到最外层来。提取到最外层,更有利于减少包的重复安装。
例如一个项目,依赖了A和B,而A和B又都依赖了C。npm在早期没有做扁平化处理的时候,会普通的嵌套安装,A里面装一个C,B里面装一个C,造成了重复安装。而扁平化之后,C会被提取出来,安装在最外层,也就是和A,B同级(前提是A和B依赖的C的版本是兼容的,比如相同版本,或者指定的版本范围存在交集)。如果A依赖C@1.0.0,B依赖C@1.0.1,那么不一定哪个版本的C会被提取出来安装到最外层,然后另一个版本的C被安装到自己的父级下面,这取决于A和B的安装顺序了。这样的扁平化管理,一定程度上减少了包的重复安装,但是具有一定的不确定性,被提升上去的版本可能会在某次更改后发生变化。
第三次追更:我是不是中了邪啊,前一天在文章里写的知识点,第二天就发作了。还是同一个倒霉项目,技术中心那边有一个包,把react-router-dom从dependencies里面去掉了,我们的组件库依赖了这个包,并且没锁版本,导致昨天好好的,今天发布的集成分支跑流水线的时候install出来的结果和之前不一样。react-router-dom里面自带了react-router包,原本装在node_modules最外层的是5.x版本,结果技术中心那个包把react-router-dom去掉了之后,安装的顺序发生了改变,最外层的变成4.x版本了,5.x版本被装到里层了,真的服气,老项目不上传packagelock真的是很坑。别人升级一个包,我们这边三个人找白屏原因找两小时。
package-lock.json
第三次追更内容:既然说到了package-lock.json,就展开来说说。
作用大家都知道了,锁版本用的。你可能以为,你package.json里面不用模糊版本号,你的依赖就不会变,大错特错,你不能保证你的依赖在依赖别人时有没有锁版本号,你也改变不了。这就容易导致很多问题,我前面已经讲过惨痛的教训了。包括很多人,可能遇到git冲突,或者其他问题时,喜欢把package-lock.json删了,然后重新install,这是非常危险的,因为很多深层级的依赖可能都被你不知不觉的升级了。
package-lock.json里面一些有用的字段:
- version不用说,实际安装的版本号
- requires里面是这个子依赖的package.json里面的dependencies的东西
- dependencies字段是这个子依赖的package.json里面的dependencies实际被安装到子依赖的node_modules的东西(即没被扁平化提取到最外层的包,因为版本冲突)
- integrity:校验用哈希值
- resolved:安装的源。同时也通过这个源的地址去校验缓存是否可用,如果源地址一致,则从缓存中拿依赖压缩包。不一致则去新的源地址下载依赖压缩包。所以我们也能知道,必须有package-lock.json,npm才有可能使用缓存
幽灵依赖
这种扁平化导致了一个问题就是幽灵依赖。即,我明明没有安装过某个包,但是我却可以使用它。还拿上面的例子,项目依赖了A和B,并没有直接依赖C,也就是说项目指明的依赖里面是没有C的,但是因为扁平化机制,C被提升到node_modules的最外层,项目里直接从C里面import东西,也是能做到的,这个就很恐怖了。
幽灵依赖会导致一些问题,首先最直接的一点就是,如果引入了幽灵依赖的原始依赖被移出,幽灵依赖也不存在了,但是去除原始依赖的人可能没察觉到这一点,直接导致项目挂掉。再次,更神不知鬼不觉的情况就是,你升级了原始依赖的版本,新版本的原始依赖不包含幽灵依赖了,项目还是直接挂掉。
还有一些问题就是,幽灵依赖的具体版本不可控,项目会在没有察觉的情况下使用新版本的幽灵依赖,而新版本和老版本可能不兼容,比如去掉了某个API之类的,还是会导致项目报错。
pnpm
针对npm的扁平化提升遗留的缺点,和幽灵依赖的问题,新的包管理器pnpm出现了。pnpm通过硬链接,将node_modules下面的包都指向了全局的一个pnpm store当中。多个项目用到相同的包,其实最后硬盘里只会安装一份,这样也节省了硬盘的空间。总的来说,pnpm其实是具有很多优点的。我们的项目也尝试过使用pnpm,但是因为自身项目不规范等等的原因(比如项目本身存在使用幽灵依赖的情况),失败了,最后又转回了npm。
如果有机会从头开始规范做的话,还是挺建议使用pnpm的