.markdown-body{word-break:break-word;line-height:1.75;font-weight:400;font-size:15px;overflow-x:hidden;color:#333}.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4,.markdown-body h5,.markdown-body h6{line-height:1.5;margin-top:35px;margin-bottom:10px;padding-bottom:5px}.markdown-body h1{font-size:30px;margin-bottom:5px}.markdown-body h2{padding-bottom:12px;font-size:24px;border-bottom:1px solid #ececec}.markdown-body h3{font-size:18px;padding-bottom:0}.markdown-body h4{font-size:16px}.markdown-body h5{font-size:15px}.markdown-body h6{margin-top:5px}.markdown-body p{line-height:inherit;margin-top:22px;margin-bottom:22px}.markdown-body img{max-width:100%}.markdown-body hr{border:none;border-top:1px solid #ddd;margin-top:32px;margin-bottom:32px}.markdown-body code{word-break:break-word;border-radius:2px;overflow-x:auto;background-color:#fff5f5;color:#ff502c;font-size:.87em;padding:.065em .4em}.markdown-body code,.markdown-body pre{font-family:Menlo,Monaco,Consolas,Courier New,monospace}.markdown-body pre{overflow:auto;position:relative;line-height:1.75}.markdown-body pre>code{font-size:12px;padding:15px 12px;margin:0;word-break:normal;display:block;overflow-x:auto;color:#333;background:#f8f8f8}.markdown-body a{text-decoration:none;color:#0269c8;border-bottom:1px solid #d1e9ff}.markdown-body a:active,.markdown-body a:hover{color:#275b8c}.markdown-body table{display:inline-block!important;font-size:12px;width:auto;max-width:100%;overflow:auto;border:1px solid #f6f6f6}.markdown-body thead{background:#f6f6f6;color:#000;text-align:left}.markdown-body tr:nth-child(2n){background-color:#fcfcfc}.markdown-body td,.markdown-body th{padding:12px 7px;line-height:24px}.markdown-body td{min-width:120px}.markdown-body blockquote{color:#666;padding:1px 23px;margin:22px 0;border-left:4px solid #cbcbcb;background-color:#f8f8f8}.markdown-body blockquote:after{display:block;content:""}.markdown-body blockquote>p{margin:10px 0}.markdown-body ol,.markdown-body ul{padding-left:28px}.markdown-body ol li,.markdown-body ul li{margin-bottom:0;list-style:inherit}.markdown-body ol li .task-list-item,.markdown-body ul li .task-list-item{list-style:none}.markdown-body ol li .task-list-item ol,.markdown-body ol li .task-list-item ul,.markdown-body ul li .task-list-item ol,.markdown-body ul li .task-list-item ul{margin-top:0}.markdown-body ol ol,.markdown-body ol ul,.markdown-body ul ol,.markdown-body ul ul{margin-top:3px}.markdown-body ol li{padding-left:6px}.markdown-body .contains-task-list{padding-left:0}.markdown-body .task-list-item{list-style:none}@media (max-width:720px){.markdown-body h1{font-size:24px}.markdown-body h2{font-size:20px}.markdown-body h3{font-size:18px}}
前言
Hi,我是 ssh,春节过去了,躁动的心也该收一收,开始新一年的学习了。我目前就职于字节跳动的 Web Infra 团队,目前团队还很缺人(尤其是北京)。
为此我组建了一个氛围特别好的招聘社群,大家在里面尽情的讨论面试相关的想法和问题,也欢迎你加入,随时投递简历给我。
在字节跳动,大家都会自发的研究社区前沿的技术,尝试接入内部的项目并寻找提升开发效率的可能性,这种追求极致敢于创新也是被鼓励的,也有不少同学已经在尝试把新一代的构建工具 Vite 和 snowpack 引入使用。
这篇文章我想简单的聊聊,让尤老师如此专注的 Vite 2.0 到底有哪些亮点。
前几天,尤雨溪在各个社交平台宣布 Vite 2.0 发布了。
看得出他对 Vite 倾注了很多感情,甚至都冷落了 Vue3,停更了两个多月。
相关的中文公告已经有翻译了,可以在尤雨溪的知乎文章:Vite 2.0 发布了中查看。
这篇文章来谈谈 Vite 2.0 的发布中,几个让我比较感兴趣的技术点。
Vite 原理
为什么会出现 Vite?在过去的 Webpack、Rollup 等构建工具的时代,我们所写的代码一般都是基于 ES Module 规范,在文件之间通过 import
和 export
形成一个很大的依赖图。
这些构建工具在本地开发调试的时候,也都会提前把你的模块先打包成浏览器可读取的 js bundle,虽然有诸如路由懒加载等优化手段,但懒加载并不代表懒构建,Webpack 还是需要把你的异步路由用到的模块提前构建好。
当你的项目越来越大的时候,启动也难免变的越来越慢,甚至可能达到分钟级别。而 HMR
热更新也会达到好几秒的耗时。
Vite 则别出心裁的利用了浏览器的原生 ES Module 支持,直接在 html 文件里写诸如这样的代码:
代码语言:javascript复制// index.html
<div id="app">div>
<script type="module">
import { createApp } from 'vue'
import Main from './Main.vue'
createApp(Main).mount('#app')
script>
复制代码
Vite 会在本地帮你启动一个服务器,当浏览器读取到这个 html 文件之后,会在执行到 import 的时候才去向服务端发送 Main.vue
模块的请求,Vite 此时在利用内部的一系列黑魔法,包括 Vue 的 template 解析,代码的编译等等,解析成浏览器可以执行的 js 文件返回到浏览器端。
这就保证了只有在真正使用到这个模块的时候,浏览器才会请求并且解析这个模块,最大程度的做到了按需加载。
用 Vite 官网上的图来解释,传统的 bundle 模式是这样的:
而基于 ESM 的构建模式则是这样的:
灰色部分是暂时没有用到的路由,甚至完全不会参与构建过程,随着项目里的路由越来越多,构建速度也不会变慢。
依赖预编译
依赖预编译,其实是 Vite 2.0 在为用户启动开发服务器之前,先用 esbuild
把检测到的依赖预先构建了一遍。
也许你会疑惑,不是一直说好的 no-bundle 吗,怎么还是走启动时编译这条路线了?尤老师这么做当然是有理由的,我们先以导入 lodash-es
这个包为例。
当你用 import { debounce } from 'lodash'
导入一个命名函数的时候,可能你理想中的场景就是浏览器去下载只包含这个函数的文件。但其实没那么理想,debounce
函数的模块内部又依赖了很多其他函数,形成了一个依赖图。
当浏览器请求 debounce
的模块时,又会发现内部有 2 个 import
,再这样延伸下去,这个函数内部竟然带来了 600 次请求,耗时会在 1s 左右。
这当然是不可接受的,于是尤老师想了个折中的办法,正好利用 Esbuild 接近无敌的构建速度,让你在没有感知的情况下在启动的时候预先帮你把 debounce
所用到的所有内部模块全部打包成一个传统的 js bundle
。
Esbuild
使用 Go 编写,并且比以 JavaScript 编写的打包器预构建依赖快 10-100 倍。
在 httpServer.listen
启动开发服务器之前,会先把这个函数劫持改写,放入依赖预构建的前置步骤,Vite 启动服务器相关代码。
// server/index.ts
const listen = httpServer.listen.bind(httpServer)
httpServer.listen = (async (port: number, ...args: any[]) => {
try {
await container.buildStart({})
// 这里会进行依赖的预构建
await runOptimize()
} catch (e) {
httpServer.emit('error', e)
return
}
return listen(port, ...args)
}) as any
而 runOptimize
相关的代码则在 Github optimizer 中。
首先会根据本次运行的入口,来扫描其中的依赖:
代码语言:javascript复制let deps: Record<string, string>, missing: Record<string, string>
if (!newDeps) {
;({ deps, missing } = await scanImports(config))
}
scanImports
其实就是利用 Esbuild
构建时提供的钩子去扫描文件中的依赖,收集到 deps
变量里,在扫描到入口文件(比如 index.html
)中依赖的模块后,形成类似这样的依赖路径数据结构:
{
"lodash-es": "node_modules/lodash-es"
}
之后再根据分析出来的依赖,使用 Esbuild
把它们提前打包成单文件的 bundle。
const esbuildService = await ensureService()
await esbuildService.build({
entryPoints: Object.keys(flatIdDeps),
bundle: true,
format: 'esm',
external: config.optimizeDeps?.exclude,
logLevel: 'error',
splitting: true,
sourcemap: true,
outdir: cacheDir,
treeShaking: 'ignore-annotations',
metafile: esbuildMetaPath,
define,
plugins: [esbuildDepPlugin(flatIdDeps, flatIdToExports, config)]
})
在浏览器请求相关模块时,返回这个预构建好的模块。这样,当浏览器请求 lodash-es
中的 debounce
模块的时候,就可以保证只发生一次接口请求了。
你可以理解为,这一步和 Webpack
所做的构建一样,只不过速度快了几十倍。
在预构建这个步骤中,还会对 CommonJS
模块进行分析,方便后面需要统一处理成浏览器可以执行的 ES Module
。
插件机制
很多同学提到 Vite,第一反应就是生态不够成熟,其他构建工具有那么多的第三方插件,提供了各种各样开箱即用的便捷功能,Vite 需要多久才能赶上呢?
Vite 从 preact 的 WMR 中得到了启发,把插件机制做成兼容 Rollup 的格式。
于是便有了这个相亲相爱的 LOGO:
目前和 vite 兼容或者内置的插件,可以查看vite-rollup-plugins。
简单的介绍一下 Rollup 插件,其实插件这个东西,就是 Rollup 对外提供一些时机的钩子,还有一些工具方法,让用户去写一些配置代码,以此介入 Rollup 运行的各个时机之中。
比如在打包之前注入某些东西,或者改变某些产物结构,仅此而已。
而 Vite 需要做的就是基于 Rollup 设计的接口进行扩展,在保证 Rollup 插件兼容的可能性的同时,再加入一些 Vite 特有的钩子和属性来扩展。
举个简单的例子,@rollup/plugin-image 可以把图片模块解析成 base64 格式,它的源码其实很简单:
代码语言:javascript复制export default function image(opts = {}) {
const options = Object.assign({}, defaults, opts)
const filter = createFilter(options.include, options.exclude)
return {
name: 'image',
load(id) {
if (!filter(id)) {
return null
}
const mime = mimeTypes[extname(id)]
if (!mime) {
// not an image
return null
}
const isSvg = mime === mimeTypes['.svg']
const format = isSvg ? 'utf-8' : 'base64'
const source = readFileSync(id, format).replace(/[rn] /gm, '')
const dataUri = getDataUri({ format, isSvg, mime, source })
const code = options.dom
? domTemplate({ dataUri })
: constTemplate({ dataUri })
return code.trim()
}
}
}
复制代码
其实就是在 load
这个钩子,读取模块时,把图片转换成相应格式的 data-uri
,所以 Vite 只需要在读取模块的时候,也去兼容执行相关的钩子即可。
虽然 Vite 很多行为和 Rollup 构建不同,但他们内部有很多相似的行为和时机,只要确保 Rollup 插件只使用了这些共有的钩子,就很容易做到插件的通用。
可以参考 Vite 官网文档 —— 插件部分
一般来说,只要一个 Rollup 插件符合以下标准,那么它应该只是作为一个 Vite 插件:
- 没有使用 moduleParsed 钩子。
- 它在打包钩子和输出钩子之间没有很强的耦合。
- 如果一个 Rollup 插件只在构建阶段有意义,则在 build.rollupOptions.plugins 下指定即可。
Vite 后面的目标应该也是尽可能和 Rollup 相关的插件生态打通,社区也会一起贡献力量,希望 Vite 的生态越来越好。
比较
和 Vite 同时期出现的现代化构建工具还有:
- Snowpack - The faster frontend build tool
- preactjs/wmr: