探索Vite开发服务核心工具之:预优化(Pre-Bundling)
背景
前段时间用Vite2.x
造了个Vue3的个人项目,在Vite的加持下,无论是项目冷启动、热更新和构建,比起webpack速度都提升n00%(n≥10)以上,怀着强烈的好奇心,就把Vite的源码搞了下来学习下,顺便水篇文章方便以后重温???。
认识构建工具的开发服务「Dev server」
开发服务是指开发者在本地开发前端项目时,构建工具会额外启动的一个本地服务。如执行npm run dev
命令后,框架执行服务启动操作,启动完成后,你就能通过浏览器输入http://localhost:xxxx
("xxxx"为端口号)看到自己开发的页面了。
OK,咋们聊下Vite
的本地服务。。。它思路设计还是很独特的,Vite也是通过这种机制取得更高效的处理速度和更好的开发体验!
为了做对比,先看下传统bundle打包方式服务启动方式,以webpack为例。
Webpack的开发服务
在项目冷启动时,webpack
会通过entry
入口文件逐层检查文件的依赖,例如有3个ts文件:
// a.ts
export const a = 1;
// b.ts
export const b = 2;
// sum.ts
import { a } from './a.ts';
import { b } from './b.ts';
export default sum() => a + b;
// bundle打包后大致是这样的
// bundle.js
const a = 1;
const b = 2;
const sum = function() {
return a + b;
}
export default sum;
为了方便理解,上面是简略代码,但可以看出来,webpack
在成功启动开发服务前,要收集所有依赖后打包成一个bundle.js
文件。这种打包方法能有效整合模块之间的依赖关系,统一输出,减少资源加载数量。
但也是是有短板的:一是服务的启动需要前置依赖组件打包完成,当组件越来越多且复杂后,项目的启动时间会越来越长(几十秒甚至几分钟);二是在热更新项目时,哪怕使用HRM方式去diff文件差异,修改后的效果也需要几秒钟才能在浏览器中反映出来。如此循环往复,迟钝的反馈会极大地影响开发者的开发效率和幸福感。。
Vite的开发服务
下面是引用官方对Vite开发服务的解析。
Vite 通过在一开始将应用中的模块区分为 依赖 和 源码 两类,改进了开发服务器启动时间。
- 依赖 大多为在开发时不会变动的纯 JavaScript。一些较大的依赖(例如有上百个模块的组件库)处理的代价也很高。依赖也通常会存在多种模块化格式(例如 ESM 或者 CommonJS)。
Vite 将会使用 esbuild 预构建依赖。Esbuild 使用 Go 编写,并且比以 JavaScript 编写的打包器预构建依赖快 10-100 倍。 - 源码 通常包含一些并非直接是 JavaScript 的文件,需要转换(例如 JSX,CSS 或者 Vue/Svelte 组件),时常会被编辑。同时,并不是所有的源码都需要同时被加载(例如基于路由拆分的代码模块)。
Vite 以 原生 ESM 方式提供源码。这实际上是让浏览器接管了打包程序的部分工作:Vite 只需要在浏览器请求源码时进行转换并按需提供源码。根据情景动态导入代码,即只在当前屏幕上实际使用时才会被处理。
通俗来讲,执行npm run dev
命令后,Vite先把本地server启动,之后通过项目的入口收集依赖,把没有提供esm格式的依赖和内部大量的依赖提前打包,这个过程成为:**预优化(Pre-Bundling)**。预优化后在页面需要加载依赖时,通过http方式把资源请求回来,做到了真正的按需加载。
关于如何实现预优化,正是下面要详述部分。
Vite1.0和2.0预优化工具差异
Vite至此发布了2个大版本。其实,Vite1.0和2.0预优化还是有很大差异的。
按开发者的描述: Vite2.0 在底层使用了 http + connect模块来代替 1.0 中的 koa 框架的一些能力。并且预优化的工具也由 rollup 的 commonjs 插件 替换为 esbuild ,这两个关键点的优化,使得执行效率大幅增加。
大家可以感受一下esbuild带来的速度加成
比起rollup,esbuild能有如此表现主要得益于它的底层原理:
- js是单线程串行,esbuild是新开一个进程,然后多线程并行执行;
- esbuild用go语法编写,go是纯机器码,执行速度比JIT快;
- 免去 AST 抽象语法树生成,优化了构建流程。
Vite预优化
事不宜迟,直接把Vite源码clone下来「github地址」,在packages/vite/src/node/server/index.ts
找到server启动函数:createServer
,在这里可以找到预优化的入口optimizeDeps
方法:
export async function createServer(
inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
// 此处省略好长代码...
const runOptimize = async () => {
if (config.cacheDir) {
server._isRunningOptimizer = true
try {
server._optimizeDepsMetadata = await optimizeDeps(
config,
config.server.force || server._forceOptimizeOnRestart
)
} finally {
server._isRunningOptimizer = false
}
server._registerMissingImport = createMissingImporterRegisterFn(server)
}
}
// 此处又省略好长代码...
return server
}
接下来我们到packages/vite/src/node/optimizer/index.ts
找到optimizeDeps
方法的定义:
export async function optimizeDeps(
config: ResolvedConfig,
force = config.server.force,
asCommand = false,
newDeps?: Record<string, string>, // missing imports encountered after server has started
ssr?: boolean
): Promise<DepOptimizationMetadata | null> {
// 省略好长代码...
const result = await build({
absWorkingDir: process.cwd(),
entryPoints: Object.keys(flatIdDeps),
bundle: true,
format: 'esm',
target: config.build.target || undefined,
external: config.optimizeDeps?.exclude,
logLevel: 'error',
splitting: true,
sourcemap: true,
outdir: cacheDir,
ignoreAnnotations: true,
metafile: true,
define,
plugins: [
...plugins,
esbuildDepPlugin(flatIdDeps, flatIdToExports, config, ssr)
],
...esbuildOptions
})
// 省略好长代码...
}
build()
来源esbuild
的构建方法,至于里面的参数大家有兴趣可以到插件官网查阅build-api。
这里面要讲的,pulgins
中包含了esbuildDepPlugin
插件,这个插件是 Vite 在 esbuild 打包中最核心的逻辑,这插件的工作流程如下。
特定格式文件external
首先,插件对特定格式文件进行 external 处理,因为这些文件不会在esbuild阶段进行处理,所以要提前把它们找出并解析。其中externalTypes
是Array<fileType>
类型,可以在packages/vite/src/node/optimizer/esbuildDepPlugin.ts
找到它的定义。
// externalize assets and commonly known non-js file types
build.onResolve(
{
filter: new RegExp(`\\.(` + externalTypes.join('|') + `)(\\?.*)?$`)
},
async ({ path: id, importer, kind }) => {
const resolved = await resolve(id, importer, kind)
if (resolved) {
return {
path: resolved,
external: true
}
}
}
)
解析不同的模块
开发作者将打包模块分为两种:入口模块 和 依赖模块。
入口模块:直接 import 的模块或者通过 include 制定的模块,如:
import Vue from 'vue';
依赖模块:入口模块自身的依赖,也就是 dependencies
function resolveEntry(id: string) {
const flatId = flattenId(id)
if (flatId in qualified) {
return {
path: flatId,
namespace: 'dep'
}
}
}
build.onResolve(
{ filter: /^[\\w@][^:]/ },
async ({ path: id, importer, kind }) => {
if (moduleListContains(config.optimizeDeps?.exclude, id)) {
return {
path: id,
external: true
}
}
// ensure esbuild uses our resolved entries
let entry: { path: string; namespace: string } | undefined
// if this is an entry, return entry namespace resolve result
if (!importer) {
if ((entry = resolveEntry(id))) return entry
// check if this is aliased to an entry - also return entry namespace
const aliased = await _resolve(id, undefined, true)
if (aliased && (entry = resolveEntry(aliased))) {
return entry
}
}
// use vite's own resolver
const resolved = await resolve(id, importer, kind)
if (resolved) {
if (resolved.startsWith(browserExternalId)) {
return {
path: id,
namespace: 'browser-external'
}
}
if (isExternalUrl(resolved)) {
return {
path: resolved,
external: true
}
}
return {
path: path.resolve(resolved)
}
}
}
)
为了方便大家理解,这里写段伪代码,每个依赖打包前都执行以下逻辑:
if 入口模块
将模块解析为namespace='dep'的处理流程
else
if 为browser-external模块
将模块解析为namespace='browser-external'的处理流程
if 以http(s)引入的模块
将模块解析为外部引用模块
else
直接解析路径
对namespace
为dep
的依赖打包
完成模块分类后,接下来要对dep模块进行解析打包。
build.onLoad({ filter: /.*/, namespace: 'dep' }, ({ path: id }) => {
const entryFile = qualified[id]
let relativePath = normalizePath(path.relative(root, entryFile))
if (
!relativePath.startsWith('./') &&
!relativePath.startsWith('../') &&
relativePath !== '.'
) {
relativePath = `./${relativePath}`
}
let contents = ''
const data = exportsData[id]
const [imports, exports] = data
if (!imports.length && !exports.length) {
// cjs
contents += `export default require("${relativePath}");`
} else {
if (exports.includes('default')) {
contents += `import d from "${relativePath}";export default d;`
}
if (
data.hasReExports ||
exports.length > 1 ||
exports[0] !== 'default'
) {
contents += `\\nexport * from "${relativePath}"`
}
}
let ext = path.extname(entryFile).slice(1)
if (ext === 'mjs') ext = 'js'
return {
loader: ext as Loader,
contents,
resolveDir: root
}
})
首先将的相对路径解析出来放到变量中保存;
分析模块类型,给赋值。通过 esbuild 词法分析入口模块的 和 信息,当两个关键字都没有,判断是一个 模块,生成下面格式contents;
假如不满足第2步条件,则系统认为是一个 模块,生成对应的contents:
解析文件扩展名,取得对应的;
返回类型、模块内容、导入路径给esbuild打包,语法参考 。
在2和3步中,
contents
保存都是模块的相对路径(也就是第1步的relativePath
),这样做可以让程序生成正确的cache文件目录结构。
对namespace
为browser-external
的依赖打包
build.onLoad(
{ filter: /.*/, namespace: 'browser-external' },
({ path: id }) => {
return {
contents:
`export default new Proxy({}, {
get() {
throw new Error('Module "${id}" has been externalized for ` +
`browser compatibility and cannot be accessed in client code.')
}
})`
}
}
)
兼容yarn pnp
环境
if (isRunningWithYarnPnp) {
build.onResolve(
{ filter: /.*/ },
async ({ path, importer, kind, resolveDir }) => ({
// pass along resolveDir for entries
path: await resolve(path, importer, kind, resolveDir)
})
)
build.onLoad({ filter: /.*/ }, async (args) => ({
contents: await require('fs').promises.readFile(args.path),
loader: 'default'
}))
}
总结
总的看下来,在预优化这块,Vite
先对依赖进行模块分类,针对不同类型resolved它们的引用路径,之后启动esbuild
打包输出ES Module,最后通过http network拉取资源,使资源组装到应用上,完成按需加载。
写在最后
至此,Vite2.0的预优化部分基本讲完了,由于时间仓促,在某些细节讲解可能略显粗糙,但主流程大致如此,细节之处等以后有空再慢慢补齐??。
另外,以后有时间再搞一篇Vite的渲染机制,当资源请求回来后,如何从原始形态渲染成最终的js|ts
、css
、vue template
。