前言
在前一篇文章的末尾,我们提了以下两个问题:
每次引入第三方依赖,都要引入依赖对应的esm模块代码,而有些依赖并有esm版本的文件,比如lodash、react,并且每次都要添加代码去指向
产生了请求依赖,如果使用了lodash-es,将会产生几百条请求
本文将使用预构建来解决这两个问题。
本文的代码将基于上一篇文章的分支开始写:github.com/blankzust/v… ,同学们也可以基于这个分支的代码编写本章的功能。
何为预构建
预构建只完成以下两项职责:
- 提前将依赖转换为esm模块
// 转换前的demo cjs代码
function sum(...args) {
return args.reduce((before, next) => before + next);
}
module.exports = sum;
//转换后的demo esm代码
const esm$1 = { exports: {} }
(function (module, exports) {
function sum(...args) {
return args.reduce((before, next) => before + next);
}
module.exports = sum;
})(esm$1, esm$1.exports);
var esm = esm$1.exports;
export { esm as default };
- 合并包的二次依赖
// 转换前demo代码
import a from 'a';
import b from 'b'
export {a, b}
// 合并后的代码
function a() {...}
function b() {...}
export {a,b}
上面两个示例代码是简单的转换,真实的转换还需要考虑更多细节,比如:
- 如何处理
__dirname
- 如何处理
require(dynamicString)
- 如何处理 CommonJS 中的编程逻辑。
- 如何生成source-map
本文不会去实现一个完善的转换方法,而是使用esbuild来帮我们完成这个动作。
Esbuild-速度惊人的bundler
esbuild性能如何?
esbuild vs rollup vs webpack 下载量变化
bundle性能比较:连续bundle 10次 Three.js库的所用的时间,使用默认设置,包括压缩代码和制作source-map。
jsx编译为js性能比较:详细比较方案见datastation.multiprocess.io/blog/2021-1…
可以发现:在bundle的速度上,esbuild全面碾压其他基于node的bundle库。
esbuild为啥那么快?
-
esbuild底层使用go语言,比起node,性能强劲得多,比起rust,心智负担少很多,且自带优秀的调度器。
-
Esbuild 选择重写包括 js、ts、jsx、css 等语言在内的转译工具,所以它更能保证源代码在编译步骤之间的结构一致性,比如在 Webpack 中使用 babel-loader 处理 JavaScript 代码时,可能需要经过多次数据转换:
- Webpack 读入源码,此时为字符串形式
- Babel 解析源码,转换为 AST 形式
- Babel 将源码 AST 转换为低版本 AST
- Babel 将低版本 AST generate 为低版本源码,字符串形式
- Webpack 解析低版本源码
- Webpack 将多个模块打包成最终产物
源码需要经历
string => AST => AST => string => AST => string
,在字符串与 AST 之间反复横跳。而 Esbuild 重写大多数转译工具之后,能够在多个编译阶段共用相似的 AST 结构,尽可能减少字符串到 AST 的结构转换,提升内存使用效率。
esbuild 插件机制
esbuild只提供了四种钩子:onStart
、onEnd
、onResolve
、onLoad
,同时也是插件可以定义的生命周期方法。
如何写一个插件&如何使用
function customPlugin() {
return {
name: "keyword-replacer", // 插件名称
setup(build) {
build.onStart({ args }) { /**额外的初始化操作*/}
build.onResolve(
{ filter: /.*/ },
async (args) => {
// 处理依赖路径相关代码
// 或者生成新的filter
// ...
}
)
build.onLoad(
{ filter: /.*/ },
async (args) => {
// 处理依赖对应文件内容
}
)
build.onStart({ args }) { /**额外的收尾操作*/}
}
}
}
// 使用插件示例
const esbuild = require('esbuild');
const customPlugin = require('customPlugin');
esbuild.build({
entryPoints: ['index.js'],
outdir: 'dist',
bundle: true,
plugins: [customPlugin()],
}).catch(() => process.exit(1));
Esbuild收集依赖插件
本章将写一个收集所有依赖的插件
为什么要收集所有依赖?
因为我们的目标是将每一个依赖都单独bundle成一个文件,防止产生二次依赖。
安装依赖
pnpm i esbuild
插件入口
// server/index.js
// express服务器启动时,开启预构建
#!/usr/bin/env node
const express = require('express')
const { vueMiddleware } = require('../middleware')
const app = express()
const root = process.cwd();
const path = require('path');
const prebundle = require('../prebundle');
app.use(vueMiddleware())
app.use(express.static(path.join(root, './demo')))
app.listen(3003, async () => {
+ await prebundle(path.join(root, './demo'));
console.log('server running at http://localhost:3003')
})
// prebundle.js: 预构建插件的使用入口
const path = require('path');
const { build } = require('esbuild');
// 即将要编写的扫描依赖插件
const scanPlugin = require('./scan-plugin');
module.exports = async (root) => {
// 1.确定入口,这里暂定为index.html
const entryHtml = path.resolve(root, './index.html');
// 2.从入口处扫描依赖
const deps = new Set();
await build({
absWorkingDir: root,
entryPoints: [entryHtml],
bundle: true,
write: false, // 不用输出文件,只做扫描
plugins: [scanPlugin(deps)]
})
// 3.打印出需要预构建的依赖
console.log(
`"需要构建的依赖")}:\n${
[...deps].map(item => ' ' + item).join('\n')}`
)
}
// vite内的写法:入口以import ${entryPath}的方式引入
const path = require('path');
const { build } = require('esbuild');
const scanPlugin = require('./scan-plugin');
module.exports = async (root) => {
// 1.确定入口,这里暂定为index.html
const entryHtml = path.resolve(root, './index.html');
+ const js = `import "${entryHtml}"`
// 2.从入口处扫描依赖
const deps = new Set();
await build({
absWorkingDir: root,
- entryPoints: [entryHtml],
+ stdin: {
+ contents: js,
+ loader: 'js'
+ },
bundle: true,
write: false, // 不用输出文件,只做扫描
plugins: [scanPlugin(deps)]
})
// 3.打印出需要预构建的依赖
console.log(
`"需要构建的依赖")}:\n${
[...deps].map(item => ' ' + item).join('\n')}`
)
}
上述代码中入口是写死的,在vite内是从vite.config.ts定义的entry字段取的
类html文件的内容处理
vite默认支持的类html文件为: html
、vue
、svelte
、astro
(一种新兴的类 html 语法)四种后缀的入口文件。咋们这里只处理html
和vue
目标
将发生以下示例代码的转换
<!-- html变化 -->
- <div id="app"></div>
- <script type="module" src="/test.js">
- <script type="module">
+ import '/test.js'
import Vue from 'vue';
import App from './App.vue'
new Vue({
render: h => h(App)
}).$mount('#app')
- </script>
<!-- vue变化 -->
- <template>
- <div>{{ msg }}</div>
- </template>
- <script>
import { test } from './test.js';
export default {
data() {
return {
msg: 'Hi from the Vue file!'
}
}
}
- </script>
- <style scoped>
- div {
- color: blue;
- }
- </style>
代码实现
// scan-plugin.js
// 匹配<script type='module'>
const scriptModuleRE =
/(<script\b[^>]+type\s*=\s*(?:"module"|'module')[^>]*>)(.*?)<\/script>/gis
// 匹配<script></script>标签
const scriptRE = /(<script(?:\s[^>]*>|>))(.*?)<\/script>/gis
// 匹配<!-- -->注释标签
const commentRE = /<!--.*?-->/gs
// 匹配类html文件格式
const htmlTypesRE = /.(html|vue|svelte|astro)$/;
// 匹配<script lang='xxx' />中的lang属性
const langRE = /\blang\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i;
// 匹配<script type='xxx' />中的lang属性(vue)
const typeRE = /\btype\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i;
// 匹配<script src='xxx' />中的src属性
const srcRE = /\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i;
const scanPlugin = (deps) => {
return {
name: 'm-vite:scan-deps-plugin',
setup(build) {
// 扫描.html或.vue的模块,并将相对路径转换为绝对路径
build.onResolve(
{ filter: htmlTypesRE },
(resolveInfo) => {
const { path, importer } = resolveInfo;
// 判断路由是否为相对路径./或者../
const isAbsolutePath = path.startsWith('./') || path.startsWith('../')
return {
path: isAbsolutePath ? join(dirname(importer), path) : path,
namespace: 'html'
}
}
)
build.onLoad(
{ filter: htmlTypesRE, namespace: 'html' },
async ({ path }) => {
let raw = fs.readFileSync(path, 'utf-8')
raw = raw.replace(commentRE, '<!---->')
const isHtml = path.endsWith('.html')
// html文件匹配<script type='module'
// 非html文件匹配<script
const regex = isHtml ? scriptModuleRE : scriptRE
regex.lastIndex = 0
let js = ''
// let scriptId = 0
let match;
while ((match = regex.exec(raw))) {
const [, openTag, content] = match
const typeMatch = openTag.match(typeRE)
const type =
typeMatch && (typeMatch[1] || typeMatch[2] || typeMatch[3])
const langMatch = openTag.match(langRE)
const lang =
langMatch && (langMatch[1] || langMatch[2] || langMatch[3])
if (
type &&
!(
type.includes('javascript') ||
type.includes('ecmascript') ||
type === 'module'
)
) {
continue
}
const srcMatch = openTag.match(srcRE)
if (srcMatch) {
const src = srcMatch[1] || srcMatch[2] || srcMatch[3]
js += `import ${JSON.stringify(src)}\n`
} else if (content.trim()) {
js += content + '\n'
}
}
if (!path.endsWith('.vue') || !js.includes('export default')) {
js += '\nexport default {}'
}
return {
loader: 'js',
contents: js,
resolveDir: dirname(path), // 定义生成的内容模块的直属地址
}
},
)
}
}
}
至此已经完成类html文件内容的转换。
记录依赖
在获得传统的js代码之后,我们可以很轻松的通过以下流程来扫描每一个bare import依赖
bare import 指的是无/、./、../这些定位标识的字符串,比如import Vue from 'vue'就是一个bare import
代码实现
const EXTERNAL_TYPES = [
"css",
"less",
"sass",
"scss",
"styl",
"stylus",
"pcss",
"postcss",
"vue",
"svelte",
"marko",
"astro",
"png",
"jpe?g",
"gif",
"svg",
"ico",
"webp",
"avif",
]
const BARE_IMPORT_RE = /^[\w@][^:]/;
const scanPlugin = (deps) => {
return {
name: 'm-vite:scan-deps-plugin',
setup(build) {
// ... 之前的类html转换操作
// 过滤资源模块依赖
build.onResolve(
{ filter: new RegExp(`\\.(${EXTERNAL_TYPES.join('|')})$`) },
(resolveInfo) => {
return {
path: resolveInfo.path,
external: true, // 表示此模块不会在其他钩子内再次扫描到
}
}
)
// 记录符合bare import正则的依赖
build.onResolve(
{ filter: BARE_IMPORT_RE },
(resolveInfo) => {
const { path: id } = resolveInfo;
// 依赖推入 deps 集合中
deps.add(id);
return {
path: id,
}
}
)
// ...
}
}
}
效果
测试代码:
执行后,终端打印出:
"需要构建的依赖":
vue
lodash-es
符合预期
Esbuild bundle
我们已经获取了需要bundle的依赖,将其作为入口进行bundle即可
await esbuild.build({
absWorkingDir: root,
entryPoints: [...deps],
format: 'esm',
bundle: true,
splitting: true,
outdir: path.resolve(process.cwd(), './node_modules/__m-vite')
})
注:vite2.0在这部分还写了一个代理模块逻辑,用来处理esbuild旧版本非扁平化打包产物的问题,如果你还想学习这一块内容,可以看这篇文章:juejin.cn/book/705006…
// 旧版esbuild的默认打包产物
node_modules/.vite
├── _metadata.json
├── vue
│ └── dist
│ └── vue.runtime.esm-bundler.js
// 新版esbuild默认打包产物
node_modules/.vite
├── _metadata.json
├── vue.js
效果
执行npm run dev
发现node_modules下面多了以下文件
node_modules/.vite
├── lodash-es.js
├── vue.js
至此,依赖的预构建已经完成.
重构loadPkg
重构前
还记得之前我们写的那个loadPkg方法吗?其目的是为了加载依赖的esm版本代码
async function loadPkg(pkgName) {
if (pkgName === 'vue') {
const res = await readSource('/../node_modules/vue/dist/vue.esm.browser.min.js');
return res;
} else {
}
}
但是每添加一种依赖,就需要写上一行判断,不仅非常不友好,而且遇到有二次依赖关系的模块,会产生大量的请求链,以lodash-es为例
async function loadPkg(pkgName) {
if (pkgName === 'vue') {
const res = await readSource('/../node_modules/vue/dist/vue.esm.browser.min.js');
return res;
} else if (pkgName === 'lodash-es') {
const res = await readSource('/../node_modules/lodash-es/lodash.js');
return res;
}
}
如下图所示产生了300多条请求,且除了lodash-es之外的二次依赖并没有合适的处理器,导致一直处于pending状态
重构后
在loadPkg的时候直接读取预构建生成的文件即可
// 加载依赖的esm版本代码
async function loadPkg(pkgName) {
const res = await readSource(`/../node_modules/__m-vite/${pkgName}.js`)
return res;
}
现象如下:
只产生了5条请求,和之前的300多条天壤之别,且页面能正常显示:
尝试使用一下lodash-es的功能:
<!-- App.vue -->
<script>
import { test } from './test.js';
test();
export default {
data() {
return {
msg: 'Hi from the Vue file! 1+1=' + sum(1, 1)
}
}
}
</script>
//test.js
import { sum } from 'lodash-es'
export function test() {
console.log(sum([1, 2, 3]));
}
刷新页面,发现终端打印出6
,符合预期。
小结
本文基于尤大的vue-dev-server,向vite的no-bundle开发服务器改造又进了一步。本文通过高性能bundle工具esbuild,在启动开发服务器时,提前扫描并预构建了代码中的依赖。还有以下问题待解决:
- 每一次访问都要去编译vue文件,每一次启动服务器都需要重新bundle每一个依赖,需要引入缓存机制。
- 缺少hmr
- 缺少插件机制
- ...
这些缺陷将在后续章节中一一解决,下个章节,我们将引入插件机制。
链接文档
- 本文源码:github.com/blankzust/v…
- esbuild官方文档:esbuild.docschina.org/api/
- vite源码仓库:github.com/vitejs/vite
- 如何调试vite源码:juejin.cn/post/717616…
- 本文涉及的vite源码文件:
- /packages/vite/src/node/optimizer/scan.ts
- /packages/vite/src/node/optimizer/optimizer.ts
- /packages/vite/src/node/optimizer/esbuildDepPlugin.ts
- /packages/vite/src/node/optimizer/index.ts