从 Monorepo 重温 ESM 的模块化机制

本文基于 pnpm workspace 的 monorepo 架构跟大家一起重温 esm 的模块机制,驱散大家对于 monorepo 架构中模块导入导出的疑云。(ps:为方便大家自己研究下案例代码的执行,完整代码已经上传 github,有需要的朋友可以自行 clone 下来玩玩。

前言

Monorepo 的代码组织方式是目前较为常见的前端工程之一,许多优秀的开源项目都在使用。如 ReactVue 两大 MV 框架是吧。近年来随着这种代码组织方式的发展,我们也是在越来越多的场景、项目中可以看到类似代码架构的工程......

或许对于一些同学来说这种较为新颖的工程多少有点"高大上"了,很容易让自己迷迷糊糊地就忘了"本"。还有刚接触这玩意就害怕、发怵,导致一些基础的原理没能联想起来。于是乎,我决定借此机会跟大家一起基于 pnpm workspace 下重温 esm 的模块化机制,一起剥开迷雾,不再畏惧这头"披着狼皮的羊"。

回顾 esm 的模块化加载机制

这里,我们围绕一个问题进行展开,当我们写下:import { useState } from 'react'; 这行代码时,它是怎么运行起来的,我们为什么可以拿到这个 useState 并且使用?如果你已经很清楚这个问题的答案,那你可以不用再往下看了,直接拜拜~

基于本文聚焦谈论于 Monorepo 工程的模块化机制,因此以下的 esm 模块化机制就仅针对 Node.js 环境进行展开了,浏览器上的加载大家自行研究吧。接下来我们快速过一遍重点的基础知识!想更详细地学习,建议大家去看这门 es6 的基础 es6-Module的加载实现,或者自行去看其他详细的模块化加载基础知识。

1. packages.json

如果你更多时间聚焦在业务应用开发,那你多半不会怎么注意到 packages.json 文件(后文用 pkj 简写),或许你用得最多的就是写一下 script、看一看 dependencies,但这个文件其对于 Node 项目来说却有着决定性作用。它不仅仅包含着依赖信息、脚本,还有版本、入口等一些 Node 项目所需要的原信息。

如何查看包版本?

如果我此时问你一个问题:你怎么知道项目里的 react 安装的是什么版本?此时你会怎么回答。可能有人会说这不简单吗?看一下我当前应用的 pkj 里面的 dependencies 字段不就好了吗?

也不能说错,但是如果 pkj 里面用的版本号声明是带 ~ 或者 ^ 的,只看当前的 dependencies 是不是就不太准确了呢?如上图中如果说当前的 react 是 19.1.1 版本,那不好意思,真的错了。还有什么办法能得到真实的 react 版本呢?这时候会有人说看 lock 文件!

聪明,此时我在根目录的 lock 文件中找到了 react-demo 这个模块的 react 版本信息,19.2.0,那这个答案应该是不会错的了。那我们还有没有其他得知当前项目运行的 react 的办法呢?答案是有的。我抛一个引子,我们的项目最终运行一定是需要真实的 reactjs 的代码文件的,能找到这个文件就能知道它的版本号了!

那此时,我就去 react-demo 的 node_modules 目录下翻找,找到如下的一个 pkj:

毕竟我们最终代码运行的地方就是从这个 react 包的 pkj 中拿到的入口文件,这里标识的版本号为 19.2.0!这不也是另一种确定当前项目依赖包版本号的又一方法吗?

入口文件

既然上面我们为了找包版本都翻到 node_modules 的具体包里面去了,那 Node 是怎么找到我们写在代码中 import 的 react 的呢?

其实我们用的大部分脚手架生成的项目,pkj 里面都不会有 mainmoduleexports 等字段了,当然这也不影响我们一把梭哈写业务是吧,毕竟写应用也用不着这玩意。比如我用 vite 脚手架生成后的一个 react 应用项目的 pkj:

但如果看一下当前应用的 node_modules 下的依赖包的 pkj,多少都会发现点什么。比如我们看看 react 的:

此时我们看到了 mainexports 字段,他们分别指向一个文件路径。当然,exports 的优先级是要高于 main 的,关于 exports 的详细介,可参考:package.json 的 exports 字段。因此当我们写出 import xx from 'react' 时,毫无疑问都是从 index.js 这个入口文件中来的。

其中的入口文件长这样:

相信这些大家都很熟悉了,毕竟玩过现代化前端开发工程的基本都是这么一个路子。好了,我们简单总结下,我们项目中的依赖解析,最终都会到当前文件路径中的 node_modules 下寻找,并逐级往上,最后通过 pkj 中声明的入口找到对应的我们需要引入的依赖内容。并且我还抛出了一个小点就是找寻我们依赖包真实版本号的方式~

Monorepo 的工作空间依赖

玩过 pnpm workspace 的同学都知道它需要配置一个叫 pnpm-workspace.yml 的配置文件。比如文本用的 demo 项目:

配置完这玩意有什么用呢?当我配了 packages/** 时,当前目录下的 react-bundle、react-bundle-context、react-demo 都变成了一个独立的模块。他们各自都可以拥有自己的 pkj 文件,并且都可以独立运行 pnpm install。既然这样,那其实 react-demo 也是可以通过工作空间的特性来引用并使用工作区中的模块的,比如 react-bundle。只要我在 react-demo 的 pkj 文件中声明好依赖,便可以像使用其他 npm 包一样地使用 react-bundle 这个模块:

在我们运行完 pnpm install 后,我们来看看 react-demo 中的 node_modules 发生了什么?

如上图所示,我的整个 react-bundle 模块都出现了在 react-demo 的 node_modules 下。是不是又回到了前文提及依赖包最多的 node_modules 呢?

如果此时我再问出同样的问题: import { CompA } from '@my/react-bundle' 是怎么样导入到 CompA 的?这下你应该会熟练地打开当前项目的 node_modules 并寻找 react-bundle 的入口文件。它的 pkj 入口信息如下:

那么正好这个入口文件 index.js. 还有着我上一篇文章用到的 console.log 如下:

我们来看看结果是不是这样的,我现在把 react-demo 运行下 dev:

结果不出所料,就是它了。上面的入口是指向了打包后 dist 产物下的 index.js 文件,那么如果我把这个入口文件修改一下,是不是会有什么不同呢?比如我把入口文件改成 react-bundle 这个模块的源码入口文件 index.ts,并且输出另外的 console:

此时我们重启下 dev 并且看看 console.log 的结果如何:

结果也是不出所料,控制台上的 log 按预期发生了变化。当我们以此回顾,其实上述的工作空间中的模块的导入机制并没有什么特别的,最终都还是最基础的那套 ems 模块化导入机制。只要我们按照 node_modules、pkj和照入口文件的方式,我们依然可以梳理出来其模块到底是怎么被导入的。

我在 pnpm 的官方文档找一些相关工作空间中依赖安装的说明,这里有提到工作空间中的模块包都是通过符号链接进行整目录安装 。如 #dependenciesmetainjected

从结果来看确实是这样子的,可能细心的同学早就发现了。上述案例在 react-demo 应用中 install 了 react-bundle 这个工作空间的模块,这个处于 react-demo 的 node_modules 下的 react-bundle 跟我们源码目录的一模一样,连 react-bundle 自身的 node_modules 都不在差的,一样被"安装"进了 react-demo 中。

总结

当我们发现 Monorepo 中(pnpm workspace)同一个工程的工作空间模块被安装时,会完整地出现在宿主应用的 node_modules 目录中时(给整目录创建符号连接),Monorepo 的工作空间的模块化神秘的面具已经被我们摘掉了。它最终无非还是回到了最基础的 node_modules 依赖机制中。

当然,这里补充下 pnpm workspace 只是默认情况下的会通过符号链接来安装整目录,我们依然有其他的配置和手段可以破解这个机制,比如通过设置 dependenciesMeta.*.injected 的值为 true 来使用硬链接方式安装依赖包。所以当我们分析 monorepo 模块依赖时,还是需要具体场景具体分析,不同模块安装机制会来不同的效果,有时候没处理好可能会破坏"实例唯一性原则"导致代码报错...

那么此时会有人问这样的机制有什么作用?能给我们什么启发?因为现在我们都知道 pnpm workspace 下默认时通过对完整目录创建符号链接来实现的依赖安装,那我们在开发一些 npm 包的时候,这个机制就变得尤为重要了。

比如我们要开发 react-bundle 这个 npm 包,我们可以通过在 react-demo 中启动 vite dev 来做开发环境。因为此时的 react-bundle 时通过符号链接整目录安装进 react-demo 中的,因此我们可以实时的开发 npm 包并在 react-demo 中看到效果。

基于此,我们可以不用通过写代码->打包->发包->装包的复杂链路来开发、调试 npm 包,只需要在当前工程中即可完成上述流程。这种事放到以前也许需要自己配置 npm link 的方式才能解决,但现在我们依托于 pnpm workspace 也可以做到了。

最后我留个问题,当我们通过 react-demo 来调试 react-bundle 时,按照本文的方式我们是通过修改了 react-bundle 的 pkj 的入口配置来实现的(改为指向了 index.ts)。但是我们一般发 npm包 都是需要进行代码打包,并提供浏览器可直接运行的产物包出去,所以我们的入口配置应该得指向 dist 下的 js 文件...这时候貌似我们的 pkj 的入口字段就面临了冲突问题了,这得怎么解决?

没错,这就是下一篇我打算分享的 ------ alias 的用法解读!敬请期待!

相关推荐
晓得迷路了4 小时前
栗子前端技术周刊第 102 期 - Vite+ 正式发布、React Native 0.82、Nitro v3 alpha 版...
前端·javascript·vite
XXX-X-XXJ4 小时前
Vue Router完全指南 —— 从基础配置到权限控制
前端·javascript·vue.js
云和数据.ChenGuang4 小时前
vue钩子函数调用问题
前端·javascript·vue.js
鹏多多4 小时前
React动画方案对比:CSS动画和Framer Motion和React Spring
前端·javascript·react.js
亿元程序员4 小时前
8年游戏主程,一篇文章,多少收益?
前端
IT_陈寒4 小时前
5个Java 21新特性实战技巧,让你的代码性能飙升200%!
前端·人工智能·后端
咖啡の猫4 小时前
Vue内置指令与自定义指令
前端·javascript·vue.js
昔人'4 小时前
使用css `focus-visible` 改善用户体验
前端·css·ux
前端双越老师5 小时前
译: 构建高效 AI Agent 智能体
前端·node.js·agent