从 npm 包实战深入理解 external 及实例唯一性

本文基于 vite (rollup)来讨论 external 配置,跟大家一起探讨 npm 包开发中和业务项目开发中,打包配置 external 所扮演的角色有何不同!(ps:为方便大家自己研究下案例代码的执行,完整代码已经上传 github,有需要的朋友可以自行 clone 下来玩玩。

一、常规理解和用法

很多时候我们都是从业务项目中接触到 external 这个配置,首屏优化、打包提速等场景有机会能用到。如分离 入口 bundle 的三方库 以降低单包体积;对一些不常变动的三方库使用 CDN 方式引用,减少每次项目构建总耗时等。

基于"业务项目"这一点出发,external 这个配置可以说是锦上添花的配置,它可以用于分离 bundle 体积,也可以用于提升构建效率。换句话说,我们不使用该配置进行项目打包后发布,也不会出现异常的问题。

但如果我们需要开发一个 npm 包,并且将其提供给其他开发者使用,external 还只是一个"锦上添花"的配置吗?没有它我们的 npm 包会不会在某些场景出现问题呢?我们接着往下看

二、错误的实战案例

这里,我通过一个错误的实战案例跟大家一起探讨对于 npm 包开发中,没有正确使用 external 配置会造成什么样的问题。

因为日常我们基于 Vue/React 这类库开发工具、组件库等 npm 包时,可能有意无意都会在 build 包的时候将其 external 掉,但是大家有没有思考过为什么需要 external 呢?是仅仅为了在业务层少点代码包体积吗?或者说不这样做会有什么问题吗?带着这样的疑问,我们进入进行的一个问题场景(完整案例代码已经上传 github

我一个基于 react 的 monorepo 工程中,有三个模块(A、B、C),他们的关系如上图所示:

  1. bundle 模块(B)依赖 context 模块(A)
  2. 业务项目(C)依赖 bundle 模块(B)
  3. 业务项目(C)依赖 context 模块(A)

他们之间的用法大概是这样的,context 模块(A)里面提供一个 ProvideruseContext,bundle (B)的某个组件会基于这个 context(A)实现一些的逻辑。当业务项目最终使用该组件时,会在这个组件外部包一层 Provider(也来源于 context 模块),并可自行基于 context 实现自己的业务逻辑。

其中 react-demo 业务项目的依赖如下(已经安装了上述提到的两个依赖模块):

json 复制代码
"dependencies": {
  "@my/react-bundle": "workspace:*",
  "@my/react-bundle-context": "workspace:*",
  "react": "^19.1.1",
  "react-dom": "^19.1.1"
},

其中核心的 react 业务项目组件源码如下:

ts 复制代码
import { useMyContext } from '@my/react-bundle-context'
import { CompA } from '@my/react-bundle'
import './App.css'

function App() {
  const { count, setCount } = useMyContext()!

  return (
    <>
      <p>count:{count}</p>
      <button onClick={() => setCount(count + 1)}>addCount</button>
      <CompA />
    </>
  )
}

export default App

其中 CompA 组件的实现跟 App 组件是一样的,也是展示一个 count 的值,并且有一个 setCount 的点击事件去递增 count 的值。唯一的区别就只是它被放在 @my/react-bundle 模块中,充当组件库的组件而已。

另外我在项目入口 main.ts 中使用 Provider 对 App 组件进行包裹:

ts 复制代码
createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <MyContextProvider>
      <App />
    </MyContextProvider>
  </StrictMode>,
)

到这里,我本次演示所需要的基本环境就准备完成了,紧接着马上开始案例演示。我们首先看一下非build模式 下的表现。我这里对 build模式 的定义为"打包后的模块"。也就是说 build模式下 @my/react-bundle 和 @my/react-bundle-context 两者是打包后的 bundle 包,非 build模式 下两者为源码包。(源码包就跟我们自己在业务项目中写各种ts、tsx文件并导入使用的效果是一样的...

首先我们看非build模式 下的表现,我对这两个包分别配置了 alias 指向他们的源码:

ps:这里对 monorepo 中的依赖关系、alias 等配置有疑惑的先不用强行理解,我后面会单独出文章分享。这里只要明确这里的演示表现都是"运行源码,非打包后结果"即可。

运行结果如下,不管点击 App 组件的按钮,还是 CompA 组件中点击按钮,均可实现同步的 count 变化:

那接下来,我对 @my/react-bundle、@my/react-bundle-context 这两个模块使用 rollup 进行打包,并且注释掉刚才配置 alias ,再运行一下 dev 看看效果会是怎样的。这里我给大家看看简单的 rollup 配置:

js 复制代码
export default {
  input: 'index.ts',
  output: {
    file: 'dist/index.js',
    format: 'es'
  },
  external: ['react'],
  plugins: [
    typescript({
      tsconfig: "./tsconfig.json"
    }),
    commonjs(),
    nodeResolve({
      extensions: [".js", ".jsx", ".ts", ".tsx", ".less"], //允许我们加载第三方模块
    })
  ]
};

没错,上述我已经 external 了 react。毕竟大家真的要做一些 react 的工具库或者组件库,这个坑大家一般都不会踩到,因为我们的潜意识认为我们只是提供工具库,业务项目自然会安装 react,没必要在我们的工具库中也打包进去(反正我自己就是这么想且这么干的)。下文还会对 Vue/React 这个有说明,大家接着往下看就行~

当我将两个 @my 开头的模块都通过上述的 rollup 配置打包后,我得到了两个 dist 包,并且我也注释掉业务项目中的 alias 配置了(意味着这个时候走的是 dist 包了):

接下来我再启动 react-demo 的 dev 看看效果:

咦,这时候我们可以发现项目直接报错了。并且报错的是找不到 MyContext.Provider ,这玩意我不就是在 main.ts 入口中包裹了吗?为什么会出现这个错误呢?那又该如何解决这个问题呢?我们接着往下看。

三、external 的神奇之处

前文当我对 @my/react-bundle、@my/react-bundle-context 这两个模块打包并提供给业务项目 react-demo 使用时,出现了报错,并且报错的根源都来自 @my/react-bundle 模块中的 CompA 组件:

这时候我们先看看 @my/react-bundle 的打包结果是什么:

上图中我们可以清楚看到,createContextuseMyContext 两者的源码都被打包进去 dist/index.js 中了。接下来,我在 @my/react-bundle 这个模块的 rollup 配置中将 @my/react-bundle-context 这个模块进行 external ,然后再看看它的打包结果:

此时可以发现,useMyContext 仅仅通过 import 的方式出现在打包产物中。它并没有被真实地打进 @my/react-bundle 的产物代码中,产物的表现更像是平时我们开发般 import xxx 的形式存在而已。

基于这一个打包结果,不妨再启动一下业务项目看看运行是否正常。为了让大家明确我接下来运行的应用读的是 bundle 产物,我就在这个产物中加一个 console.log 如下:

接着我们启动 dev 看看:

如上 gif 我们可以看到,刷新页面后出现了预期中的 console.log,并且业务项目的功能可以正常运行,count 值可以正常递增且显示也没有问题。

看着我们好像什么都没做,就配置了一个 external 的值就解决了这个报错,看来这个配置是有点神奇的作用!当然,这里只是我们自己挖坑给自己跳,所以可以很快地解决问题,但是如果身处紧张的功能开发期间,命中这样的问题,怕是会吓出一身冷汗。因此我们还是有必要搞清楚这个问题的由来,接着往下看

四、实例唯一性

既然开发 npm 包中会遇到这样的问题,那么后续的开发中我们应该怎么避免呢?或者说我们应该怎么意识到我们什么时候需要配置 external,怎么配置 external 呢?我相信这个答案就是------实例唯一性原则

就上述的案例中,我们尝试从原理探讨并分析造成这种现象的原因。首先我们看向 context 的源码实现(位于 context (A)模块):

我们的 MyContext.Provider、useMyContext 都是通过同一个 createContext 得来的,因此我们期望在所有后代 react 组件中可以正常拿到 MyContext 的值,我们要确保这个 MyContext 的实例唯一性

如果我们在 @my/react-bundle 模块的打包产物中没有 external 掉 @my/react-bundle-context ,那这个 MyContext 的唯一性就丢失了,因为它被 createContext 了两次。我扩展一下前文的关系图给大家看看:

为了方便叙述这里用 A、B、C 简称三者关系。如上图所示,当 B 模块自己 createContext 后(可以参考上文的 external 前后打包对比图),必然跟 C 应用的 Context 是对应不上的,因此 B 模块中的 Context 报错没找到 Provider 是很好理解的。

当我们正确把 B 模块配置 externl 掉 A 模块后,B 的打包产物中不再含有 A 模块的相关 createContext 源码,而是一句 import xxx from 'A' 带过,因此当此时 B 模块在 C 应用中运行时,可以正确对应上 C 应用最外层的 Provider。这时候前面所说的 实例唯一性 就体现出来了,这个唯一性就在于 createContext 被正确执行一次,并且它的执行代码位于 A 模块中。

总结

经过本文一个这么绕的案例,我相信大家应该对 external 这个配置会有新的感悟和理解了。当我们平常开发单页应用时不会注意到的配置,在开发工具类模块的 npm 包时却有重要作用,它是我们维护实例唯一性的重要配置

当我们历经了这个复杂的案例回来,我想再问大家一个简单点的问题,当我们基于 Vue/React 等框架开发npm包时,需要在打包时 external 掉对应的 Vue、React 依赖吗?为什么?如果你搞懂了本文案例的问题,我相信结尾的问题你的心里已经有答案了。

最后,我们再回顾一下,什么时候需要用到 external 配置,并且我们应该 external 掉什么东西?是我们开发的 npm包 依赖的所有模块都 external 掉并转移到业务项目安装吗?

这个问题留着给我和大家一起思考,毕竟在日后的开发生活中我们有可能会碰到这样的问题。不过相信大家只要围绕着"实例唯一性原则"进行思考,所有的问题都会迎刃而解!

相关推荐
羊锦磊3 小时前
[ vue 前端框架 ] 基本用法和vue.cli脚手架搭建
前端·vue.js·前端框架
brzhang3 小时前
高通把Arduino买了,你的“小破板”要变“AI核弹”了?
前端·后端·架构
她说..3 小时前
通过git拉取前端项目
java·前端·git·vscode·拉取代码
智能化咨询3 小时前
玩转ClaudeCode:通过Chrome DevTools MCP实现高级调试与反反爬策略
前端·chrome·chrome devtools
xjf77114 小时前
Nx项目中使用Vitest对原生JS组件进行单元测试
javascript·单元测试·前端框架·nx·vitest·前端测试
Roadinforest4 小时前
水墨风鼠标效果实现
前端·javascript·vue.js
银嘟嘟左卫门4 小时前
上手 Rokid JSAR:新手也能快速入门的 AR 开发之旅
前端
右子4 小时前
HTML Canvas API 技术简述与关系性指南
前端·javascript·canvas
Lotzinfly4 小时前
10个JavaScript浏览器API奇淫技巧你需要掌握😏😏😏
前端·javascript·面试