记一次npmlink本地开发引发的bug

最近在开发一个个人项目时,使用了npm link引用本地其他目录下的包,碰到了库代码重复打包、Vue版本不一致等问题,最后发现是rollup和node_modules包寻址的问题,稍作记录。

<!--more-->

1. 项目结构

简单介绍一下整体的背景,应用Project A是一个Vue3项目,包含Vue3、VueRouter和Pinia等库,此外还依赖了本地的包Module B,B模块是一个pnpm monorepo项目,大概结构是

├── sdk-core
├── sdk-react
├── sdk-vue-base
├── sdk-vue2
├── sdk-vue3

其中sdk-core是框架无关的核心包、sdk-vue-base是基于Vue框架提供的通用功能,sdk-vue2sdk-vue3则分别是为Vue2和Vue3不同版本提供的相关API。

Project A实际上需要引入的是sdk-vue3,而sdk-vue3又依赖于sdk-vue-basesdk-core

Module B使用rollup打包,分别输出escjs文件

"main": "lib/index.js",
"module": "esm/index.js",
"types": "esm/index.d.ts",

由于Module B的还不是正式版,直接发布到私有npm仓库在通过常规npm i 的方式安装会导致调试比较麻烦,因此先通过link的方式直接引用本地模块。

流程是:通过rollup将sdk-vue3打包之后,再通过pnpm link --global将其链接到全局执行环境,然后在Project A中通过pnpm link sdk-vue3 --global添加依赖。

大概背景介绍完了,接下来就是整个过程中碰到的一些问题

2. 问题1:Rollup重复打包

由于sdk-vue-base依赖sdk-core,而sdk-vue3同时依赖sdk-vue-basesdk-core

在打包脚本中,首先会打包sdk-vue-base,然后再打包sdk-vue3,在最后sdk-vue3的打包产物中发现,sdk-core的代码被重复打包了两次!

经过搜索,找到了类似的issue

不管是哪种前端打包工具,其功能的本质是多文件打包成单文件,于是打包工具会分析依赖,按照各个模块各自的依赖,按顺序合并打包,最终输出到一个文件中,rollup也是如此

因此在没有特别声明的情况下,sdk-vue-base会合并sdk-core的代码,sdk-vue3又会再合并sdk-core的代码,这就导致sdk-vue3中有重复代码。

本质的原因是,sdk-vue3并不知道sdk-vue-base的打包产物中已经合并了sdk-core的代码,因此他就会重新从sdk-core这里再合并一次代码。

2.1. 方案1:修改依赖顺序

第一种解决办法是修改sdk-vue3的依赖,只让其依赖于sdk-vue-base,而sdk-core的接口通过sdk-vue-base暴露出去

// sdk-vue-base
export * from 'sdk-core'

// sdk-vue3
// import { xxxApi } from 'sdk-core'
import { xxxApi } from 'sdk-vue-base'

这样只有sdk-vue-base会合并sdk-core的代码,sdk-vue3不会感知到sdk-core,也就不会有重复代码了。

但是这种方式本质上会改变包的编写方式,需要手动控制包的依赖,个人并不推荐。

2.2. 方案2:external

第二种解决办法是通过rollup的external配置项声明,不将sdk-core的代码进行打包,而是保留原始的项目依赖

import { xxxApi } from 'sdk-core'

external配置如下

const external = ['vue', 'vue-router', 'react']

if (['sdk-vue','sdk-vue3', 'sdk-vue2'].includes(name)) {
    external.push('sdk-core')
}

这种方式的好处非常明显,由于sdk-vue-basesdk-vue3都会将sdk-core视为外部依赖,就不会将其进行代码合并,也就不会出现重复代码的情况了。

缺点在于,sdk-core现在需要作为公共包被外界感知到,在本地开发时,需要也将其link到全局执行环境,在生产环境时,需要也将其发布到包仓库中取。

同时sdk-vue3这些包应该需要依赖对应固定版本的sdk-core,因此这里还需要声明一下peerDependencies

可以看见,上面对于vuevue-router等第三方库也是同理,对于打个包而言,如果依赖的是外部包,那么就不应该再将其重复打包。

接下来的第二个问题就是跟这些第三方库相关的。

3. 问题2:node_modules寻址

由于sdk-vue3也依赖了vue等第三方库,于是在external里面也进行了配置。但由于sdk-vue3目录下面包含了一个example项目,展示sdk部分使用示例,因此在node_modules里面还是安装了vue相关的依赖。

在本地项目B中的node_modules中,也安装了vue相关依赖。

在项目开发运行时发现,部分状态并没有成功设置,比如onMounted钩子等,后来发现sdk-vue3依赖的Vue版本并不是项目B中node_modules的版本。

按照预期,link到项目B的sdk-vue3,他应该去本地项目B的node_modules里面找到vue(而不是去他自己所在目录为examle项目提供依赖的node_modules去找)。

但我忘记了,NodeJS 寻找依赖的顺序并不是这样的。

以Node支持的CommonJS 为例,require支持下面多种参数形式

  • 原生模块:http、fs等,在require解析完文件名之后,会先判断是否是原生模块,如果是则直接返回
  • 非原生模块的文件模块,在每个文件中可以通过module.paths找到改文件对应的搜索路径,nodejs会按照这个路径按顺序依次搜索模块
    • 绝对路径的文件模块:/xxx_path/mod,直接找到指定绝对路径的文件,速度最快
    • 相对路径的文件模块:./mod或../mod,相对于当前调用require的文件去查找,如果按照文件名没有找到模块,则会带上.js.json.node等扩展名按顺序查找并加载,还是找不到,则进入上一层path寻找
    • 目录作为模块:./dirname,如果发现给的的文件是一个目录,会尝试读取目录的package.json,并根据其中的main字段来查找,如果没有指定main字段,则会尝试使用index.jsindex.node文件作为模块

我们要关注的就是途中“查找文件模块”这一个步骤。重现一下依赖步骤

require('sdk-vue3')
  • 发现这个模块不是个原生模块,就会一层一层目录向上开始找node_modules目录
  • 在项目B根目录下发现node_modules,进去看一下,找到了link过来的sdk-vue3目录
  • sdk-vue3里面的文件依赖了vue,需要去找vue文件。
  • 注意这里是根据当前正在运行require的那个文件来查找,可以看见sdk-vue3对应的module.paths,应该是sdk-vue3根目录下的module_modules,而不是项目B目录下的node_modules

这就是为什么sdk-vue3无法使用项目B目录下的node_modules的原因。有没有什么办法能让npm link 过来的脚本,按照当前的node_modules目录查找呢?换言之,如何修改nodejs的模块查询规则?

可以手动控制文件的位置,但会影响sdk-vue3模块本身的打包。这种方式直接被否决了。

一种临时的方案是移除对应的依赖,通过函数参数的形式修改依赖

即将

const vue = require('vue')
// vue.xxx

修改为

function install(Vue){
  // vue.xxx
}

这种形式,由于需要修改SDK的接口,我并没有采取这种方案。

最后,为了解决这个问题,我还是将sdk-vue3这个包发布到了私有仓库中,然后通过正式的安装步骤,将其安装到项目真正的node_modules中。

4. yalc:npm link的替代方案

经历了上面两个问题的踩坑后,我发现npm link貌似并不是一个很好的实践方案。

  • 受到不同node 版本的影响
  • link的包太多容易混乱,不太容易调试link多包的场景
  • 文件软链接影响node_modules依赖查询

在搜索时发现了一个新的工具yalc,yalc可以模拟npm包的发布,看了一下其原理大概是在将包发布到本地存储,然后npm、yarn等包管理工具就可以直接从本地存储中下载过来,可以达到快速调试本地包的目的

全局安装

npm i yalc -g

发布本地包mod1

yalc publish 

在本地项目中使用依赖

yalc add mod1

改动mod1之后提交更新

yalc push

会触发本地项目的自动更新,甚至触发项目的HMR。

npm link相比,唯一的工作量是多了yalc push这一步,但可以把本地包当做是真实的包一样,体验还是不错的。

5. 小结

其他语言也很少有像nodejs这样每个项目一个node_modules的情况,大部分都被一个全局store仓库代替了。

npm link作为调试本地包的利器,软链接的方式感觉还是有点过于古老,本文总结了使用npm link在本地开发时遇见的一些问题,最后了解到了yalc作为npm link的替代方案。接下来会在项目中进一步使用yalc,看看有没有其他问题或使用技巧。