前端工程化—实战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 掉并转移到业务项目安装吗?

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

相关推荐
weixin_382395231 小时前
为小工厂量身打造:本地部署的物料管理系统带缺料计算
前端·制造
__zRainy__1 小时前
解决pnpm v10+不自动构建
前端·pnpm·工程化
猫猫不是喵喵.2 小时前
Vue3 Props 属性
前端·javascript·vue.js
醉城夜风~3 小时前
CSS元素显示模式(display)
前端·css
AI大模型-小华4 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子6 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅6 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni7 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学8 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端