从 Vue 构建模块你可以学到什么?
最近在给公司内部低代码平台进行升级,原本是单一仓库,里面有各种模块,例如:
- utils
- view-render
- form-render
- fetch
- form-design
- ...
它们最终给其他产品线使用的时候是通过 Vue Cli 打包成一个 umd 的 js 文件,但是这样会带来一系列问题,所以就准备调整成 monopore 的形式,讲上面的模块拆分成一个个的单体仓库,以及后续版本发布也会基于 npm 的形式来管理。
但是首先摆在眼前的就是对 packages 下目录的包要如何进行构建分发,这里调研了下最终参考 Vue 的构建过程。
这篇文章不介绍 Vue 是如何构建整个流程的,只是介绍在构建中可以收获什么知识点。
node:
前缀
打开 scripts/build.js 文件,映入眼帘的就是 node:
前缀名称。
你可能很好奇,之前使用 node 包的时候都是直接引入呀,为什么现在还出现了这种语法。
其实这个是 node18 引入的特性,简单来说就是 node18 引入了一些新模块,例如 test,但是 test 这个 npm 上已经有了,这样的话就会有歧义,到底你要使用 node 的包还是 npm 别人发布的呢?
所以就添加了 node:
的前缀
import test from "node:test";
import assert from "node:assert";
test("synchronous passing test", (t) => {
// This test passes because it does not throw an exception.
assert.strictEqual(1, 1);
});
其次,因为 node:
是官方的前缀,也可以一定程度避免安全性问题,例如可能会有一个包叫做 expres
它跟 express
非常接近,可能会存在误导性。
还有也会带来性能上的提升,有了 node:
前缀可以避免查找 node_modules
目录这一过程,直接加载内置模块。
补充阅读
- https://nodejs.org/en/blog/announcements/v18-release-announce
- https://stateful.com/blog/node-18-prefix-only-modules
控制并发数量
在 build 过程中,如果一个个顺序调用会导致构建时间太长了,就需要有一个机制来管理并发数量
/**
* Runs iterator function in parallel.
* @template T - The type of items in the data source
* @param {number} maxConcurrency - The maximum concurrency.
* @param {Array<T>} source - The data source
* @param {(item: T) => Promise<void>} iteratorFn - The iteratorFn
* @returns {Promise<void[]>} - A Promise array containing all iteration results.
*/
async function runParallel(maxConcurrency, source, iteratorFn) {
/**@type {Promise<void>[]} */
const ret = [];
/**@type {Promise<void>[]} */
const executing = [];
for (const item of source) {
const p = Promise.resolve().then(() => iteratorFn(item));
ret.push(p);
if (maxConcurrency <= source.length) {
const e = p.then(() => {
executing.splice(executing.indexOf(e), 1);
});
executing.push(e);
if (executing.length >= maxConcurrency) {
await Promise.race(executing);
}
}
}
return Promise.all(ret);
}
这个函数会在 buildAll
调用
async function buildAll(targets) {
await runParallel(cpus().length, targets, build);
}
targets 简单来说就是 packages 目录下的一系列包名
targets: string[]
这个函数会通过 await
特性来调整并发数量,简单概括一下流程
- 执行 for of 循环
- 判断 source 是否大于 maxConcurrency
- 如果大于则进入 if 循环中
这里补充一下 if 是如何工作的
if (maxConcurrency <= source.length) {
// 微任务队列,会在本次宏任务结束后清空
const e = p.then(() => {
executing.splice(executing.indexOf(e), 1);
});
executing.push(e);
// 如果 executing 数量大于了 maxConcurrency 等待 executing 执行
// Promise.race 作用是只要有一个任务变化,就返回最先变动的promise,这里利用了这个机制,任务变化就让它往下面走
if (executing.length >= maxConcurrency) {
await Promise.race(executing);
}
}
- Promise.all 执行完成全部任务
整体流程就是这样,可以理解 all 会一次性并发掉所有 maxConcurrency 数量的任务,如果 source 任务数量大于 maxConcurrency 则会在 if 语句中剔除掉
祖师爷就是祖师爷,写的很短小精剪
execa
这里并没有直接调用 rollup 的 api 来完成构建,我觉得这点也可以说下
buildAll
这个函数用到了 cpus().length
,基于 cpu 的核心数量来构建任务。
每次 execa 都会创建一个子进程,可以最大程度利用机器性能,虽然没有通过多进程的形式来构建,但是多进程的创建也是一笔花销。
await execa(
"rollup",
[
"-c",
"--environment",
[
`COMMIT:${commit}`,
`NODE_ENV:${env}`,
`TARGET:${target}`,
formats ? `FORMATS:${formats}` : ``,
prodOnly ? `PROD_ONLY:true` : ``,
sourceMap ? `SOURCE_MAP:true` : ``,
]
.filter(Boolean)
.join(","),
],
{ stdio: "inherit" }
);
其次 stdio: "inherit"
的意思是进程将共享当前进程的标准输入、输出和错误流。也就是说,子进程的输出会直接显示在当前进程的控制台上,输入也会直接来自当前进程的控制台。
这样我们就会看到 rollup 的打包的信息生成在控制台中。
require
require 是 cjs 的导入模块标准,它不同于 esm 会同步加载模块。但是有的时候也会想使用 require,例如加载一个 json 文件,如果不通过 require 就需要通过 import()
的语法了,import()
是一个异步 api,所以就会带来一些问题。
再来看 Vue 是怎么处理的。
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
通过 createRequire
创建了 require,这样就可以在 esm 模块中导入 cjs 的模块了。
const pkg = require(`${pkgDir}/package.json`);
例如在这里就直接读取了对应 packages 下的 package.json 文件了
rm
node 默认的 fs.rm 只会删除一层目录,如果目录里面嵌套目录就会导致删除不了。
因此会有一系列包,但是 node 现在支持删除嵌套目录了,使用方法也很简单,通过 recursive
选项。
if (!formats && existsSync(`${pkgDir}/dist`)) {
await fs.rm(`${pkgDir}/dist`, { recursive: true });
}
这里也可以聊一下 existsSync
这个 api,它会判断对应的文件或者目录是否存在,是一个同步语法,虽然在 node 中提倡使用异步语法,但是有一些情况使用同步语法更合适。
最后
如果内容有错误地方欢迎指出。