下一代前端构建工具 - Vite 2.x 源码级分析
业务背景
笔者所负责的业务 X (之后都以 “X 项目” 代表此业务)对接众多业务线,如果在业务线不断增多,而人员又无法快速补充的前提下,有必要给开发提效。
为何要给开发提效?
说到这里,有人就会问了,我 Webpack 开项目 “只要几十秒” 就能开起来,它不香吗?就算我项目再大,改一行代码热更新好几秒我也能忍受啊,大家不都是这么过来的吗?
当然,公说公有理婆说婆有理,那么我就来讲讲我的道理。
笔者每周会对接很多的需求,需求池爆棚的同时,排期表也是层峦叠嶂的。。。
有时候一堆需求突然要一起上线,而且很急,为了稳妥,你需要事先测试一下有没有问题,然后才能 PR/跑流水线啥的;还有的时候,你突然来了点小想法想要做个技术优化,这个时候,打开 X 项目,在命令行开启项目,不仅开的巨慢,而且 ctrl+c
还关不掉。。。
然后你改小小的一行,变成了这样:
当然你可以跟我说,这是你优化的问题,只要我 node 版本更新/多线程/拆包。。(这里可以 Google 搜:如何优化 Webpack 打包速度)
但是我想让你静下心来看看下面的画面:
上述项目是使用 Vite 重构之后的 X 项目,同样多的文件,同样多的依赖,可以看到第一次 2000+ ms,之后都是 600+ ms 就可以跑起来,而且即开即关,没有心理负担,快到飞起,DX 无敌。
Vite 是什么?
好,讲完了业务背景之后,我们再来看看我们今天的猪脚:“Vite”,为什么会有它,它是个啥,又做了啥,业务项目现在可以用吗?生态如何?
先找个知乎截个图:
再搬一下 Github 的仓库图:
上面的知乎、Github 链接都可打开,感兴趣的同学可以自己去看看。
接下来来回答一下这一节开篇提出的几个问题。
为什么会有 Vite ?
我想最直观的回答就是:“程序员都爱折腾吧”。
但其实是,任何一个工具/产品的诞生并流行,其实都和当下所处的时机有关。
而出发 Vite 诞生的几个必要条件我总结如下:
传统的构建工具如 Webpack/Rollup/Parcel 等都太慢了,动辄十几秒、几十秒的 ES 标准普及越来越快,现代浏览器支持了原生的 ES Module 使用,类似这样的语法 <script type="module" src="/src/main.js" />
,就可以在main.js
里面使用标准的 ES 模块语法如import/export
等,然后浏览器会根据main.js
的import
依赖,自动发起 http 请求依赖的模块,可以通过下面这个视频直观的看出来:
跨语言(基于 Go)、更快的构建工具已经诞生并趋于成熟,最直观的就是 esbuild:
可以看 esbuild 给出的 benchmark 表:
最直观的对比,相比第二名的 rollup + terser
,提升了约 100 倍,不讲武德。。。
当然第四点,也是一个比较致命的一点,已经有个现成的模板可以 “抄”,比如 snowpack 🌚,它同时是去年 2020 年 JavaScript 最具创新力的打包工具:
但是 snowpack 开发生产是一致的,都使用 esbuild + Browser Native ESM 的概念,这就导致很多浏览器尤其是低版本的 IE 是不支持这些最新的 ES 和浏览器特性的,压根就用不了,相对使用场景就比较有限,明显是一个超前于市场的产品。
而尤大最厉害的一点就是,我开发用 snowpack 的那套概念,生产打包用 Rollup 来做,还搞个开发时的插件机制也兼容 Rollup,这就厉害了,开发很快,生产也照样可以普及的用,这个应用场景就很大了,自然获得了更多的追捧,这从 Github 的 Star 就可以看出来:
snowpack
Vite
而在 2 个多月前,Vite 的 Star 还没有 snowpack 高。。。
Vite 是啥?
上面已经提到了,Vite 是一个基于浏览器原生 ESM 的开发服务器/打包工具等,特点就是一个字 “快”,用尤大的话说就是:
Vite 有多快?在 Repl.it 上从零启动一个基于 Vite 的 React 应用,浏览器页面加载完毕的时候,CRA(create-react-app)甚至还没有装完依赖。
至于 Vite 做了啥,生态如何,在业务项目中是否可以使用,我留一点悬念,留在后续讲解。(避免你看到这里就不看了,我的重头戏还没来呢。。。)
“快” 背后运行的原理是什么?(Vite 做了啥)
先把调试环境搭好
好了,说了这么多可能没什么体感,有些人可能就不满了,你个技术分享,搞了半天没有一行代码。。。
Talk is cheap,show me the code!
讲解一门技术最后的方式就是 “Learn by doing”,下面我们就以这种方式来讲解 Vite 源码。
首先初始化一个 Vite 项目:
yarn create @vitejs/app app-vue2 # 选中模板为 Vue,语言为 Javascript
cd app-vue2 && yarn
yarn dev
闪电起项目 ⚡️,不要几十秒,也不用几秒,只需 851ms,只需 851ms!
我们先将 Vite 源码拷贝到本地,然后在 app-vue2
项目中 link vite 依赖,开始调试源码:
git clone git@github.com:vitejs/vite.git
cd vite && yarn
yarn build # 构建 vite 包
cd packages/vite && yarn link
接着去到 app-vue2
下面,关闭服务器,然后执行如下命令:
yarn link vite
yarn dev
打开浏览器可以看到如下界面:
可以看到,我们的 network 面板里面加载了如下几个模块:
localhost client main.js env.js vue.js?v=92bdfa16 App.vue HelloWorld.vue App.vue?vue&type=style&index=0&lang.css HelloWorld.vue?vue&type=style&index=0&scoped=true&lang.css logo.png
对应到我们源码里面就是如下这几块:
上面的 10 个请求可以分为以下几类:
Html 代码,对应 localhost
Vite 相关的代码: client
,以及client
里面引入的env.js
用户侧 JS 相关的代码: main.js
、App.vue
、HelloWorld.vue
NPM 依赖相关的代码: vue.js?v=92bdfa16
CSS 相关的代码: App.vue?vue&type=style&index=0&lang.css
、HelloWorld.vue?vue&type=style&index=0&scoped=true&lang.css
当然你可以使用 TS、Less/Sass 诸如此类的,但是为了讲解需要,上面的内容基本是最简单也相对比较全面的了。
从上面的内容我们可以看出来,Vite 相比 Webpack/Rollup 等主要的区别如下:
起服务快 并非打包成单一的 xxx.js
文件,然后 html 文件引入使用,而是基本保持和开发时的目录结构和引用关系一致,借助浏览器对 ES Module 的支持,按需引用
接下来我们就着重从源码的角度分析这两点不同!
从 CLI 入口开始
故事的起点要从我们 app-vue2
的 yarn dev
脚本开始说起,yarn dev
实际上就是允许了 vite
命令,而 vite
命令对应到 Vite 源码中的如下位置:
packages/vite/src/node/cli.ts
cli
.command('[root]') // default command
.alias('serve')
.option('--host <host>', `[string] specify hostname`)
// ... options
.option(
'--force',
`[boolean] force the optimizer to ignore the cache and re-bundle`
)
.action(async (root: string, options: ServerOptions & GlobalCLIOptions) => {
// output structure is preserved even after bundling so require()
// is ok here
const { createServer } = await import('./server')
try {
const server = await createServer({
root,
base: options.base,
mode: options.mode,
configFile: options.config,
logLevel: options.logLevel,
clearScreen: options.clearScreen,
server: cleanOptions(options) as ServerOptions
})
await server.listen()
} catch (e) {
createLogger(options.logLevel).error(
chalk.red(`error when starting dev server:\n${e.stack}`)
)
process.exit(1)
}
})
可以看到主要就是一个基于 cac
的命令行命令,主要的过程就是从 ./server
中导入 createServer
,然后创建一个 server
,接着允许服务并监听端口,默认为 3000 。
一个 “简单” 的服务器
接下来再看看 server
文件中做了什么事情,主题逻辑代码如下:
packages/vite/src/node/server/index.ts
https://github.com/vitejs/vite/blob/30ff5a235d2a832cb45a761a03c5947460417b40/packages/vite/src/node/server/index.ts#L295
export async function createServer(
inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
const config = await resolveConfig(inlineConfig, 'serve', 'development')
// ...
const middlewares = connect() as Connect.Server
const httpServer = middlewareMode
? null
: await resolveHttpServer(serverConfig, middlewares)
// ...
const plugins = config.plugins
const container = await createPluginContainer(config, watcher)
const moduleGraph = new ModuleGraph(container)
// ...
const server: ViteDevServer = {
config: config,
middlewares,
get app() {
config.logger.warn(
`ViteDevServer.app is deprecated. Use ViteDevServer.middlewares instead.`
)
return middlewares
},
httpServer,
pluginContainer: container,
moduleGraph,
transformWithEsbuild,
transformRequest(url, options) {
return transformRequest(url, server, options)
},
transformIndexHtml: null as any,
listen(port?: number, isRestart?: boolean) {
return startServer(server, port, isRestart)
},
_optimizeDepsMetadata: null,
_ssrExternals: null,
_globImporters: {},
_isRunningOptimizer: false,
_registerMissingImport: null,
_pendingReload: null
}
server.transformIndexHtml = createDevHtmlTransformFn(server)
// apply server configuration hooks from plugins
const postHooks: ((() => void) | void)[] = []
for (const plugin of plugins) {
if (plugin.configureServer) {
postHooks.push(await plugin.configureServer(server))
}
}
// ...
// main transform middleware
middlewares.use(transformMiddleware(server))
// ...
// spa fallback
if (!middlewareMode) {
middlewares.use(
history({
logger: createDebugger('vite:spa-fallback'),
// support /dir/ without explicit index.html
rewrites: [
{
from: // $ /,
to({ parsedUrl }: any) {
createLogger('info').info(`rewrite: ${JSON.stringify(parsedUrl)}`)
const rewritten = parsedUrl.pathname + 'index.html'
if (fs.existsSync(path.join(root, rewritten))) {
return rewritten
} else {
return `/index.html`
}
}
}
]
})
)
}
// ...
if (!middlewareMode) {
// transform index.html
middlewares.use(indexHtmlMiddleware(server))
// handle 404s
middlewares.use((_, res) => {
res.statusCode = 404
res.end()
})
}
// error handler
middlewares.use(errorMiddleware(server, middlewareMode))
// ...
const runOptimize = async () => {
if (config.cacheDir) {
server._isRunningOptimizer = true
try {
server._optimizeDepsMetadata = await optimizeDeps(config)
} finally {
server._isRunningOptimizer = false
}
server._registerMissingImport = createMissingImporterRegisterFn(server)
}
}
// ...
// overwrite listen to run optimizer before server start
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
httpServer.once('listening', () => {
// update actual port since this may be different from initial value
serverConfig.port = (httpServer.address() as AddressInfo).port
})
// ...
return server
}
创建 server
的主体逻辑见上面的文件,主要做了如下几件事:
获取在起服务时需要的 config
配置,所有的配置内容都是在resolveConfig
这个函数里面处理的,包括plugins
用户插件和内建插件、cacheDir
npm 依赖预构建之后的缓存目录、在之后浏览器按需获取文件时对请求进行截获,返回相对应内容的处理函数createResolve
,以及定义在vite.config.js
里面的resolve
,包含用户自定义的一些alias
文件的处理等。
const config = await resolveConfig(inlineConfig, "serve", "development");
初始化
connect
框架生成app
实例、传给http.createServer
生成httpServer
,然后注册一系列中间件用于处理浏览器请求,包括对/
、js/css/vue
的请求等:主要使用 sirv
这个包,将/public
变为静态资源目录的servePublicMiddleware
中间件,可以通过http://localhost:3000/public/xxx
获取public
目录下的xxx
文件用于处理 js/css/vue
等请求,并返回转换后的代码的transformMiddleware
中间件用于处理 /
,并重定向到/index.html
的history
中间件
const middlewares = connect() as Connect.Server
const httpServer = middlewareMode
? null
: await resolveHttpServer(serverConfig, middlewares)
// ...
middlewares.use(servePublicMiddleware(config.publicDir))
// main transform middleware
middlewares.use(transformMiddleware(server))
// ...
middlewares.use(
history({
logger: createDebugger('vite:spa-fallback'),
// support /dir/ without explicit index.html
rewrites: [
{
from: // $ /,
to({ parsedUrl }: any) {
createLogger('info').info(`rewrite: ${JSON.stringify(parsedUrl)}`)
const rewritten = parsedUrl.pathname + 'index.html'
if (fs.existsSync(path.join(root, rewritten))) {
return rewritten
} else {
return `/index.html`
}
}
}
]
})
)
用于处理插件的 container
,它是由createPluginContainer
来创建,以及用于构建模块依赖图的moduleGraph
,它是由new ModuleGraph(container)
创建,这两个函数将在之后讲解:
const container = await createPluginContainer(config, watcher);
// ...
const moduleGraph = new ModuleGraph(container);
用于对 html
进行转换,注入一些脚本的transformIndexHtml
函数,它由createDevHtmlTransformFn
函数创建,它将会在indexHtmlMiddleware
中间件执行的过程中运行createDevHtmlTransformFn
函数中添加的devHtmlHook
,在html
文件中注入我们在localhost
network 面板中看到的<script type="module" src="/@vite/client"></script>
脚本,运行vite
相关的client
脚本内容。
server.transformIndexHtml = createDevHtmlTransformFn(server);
// ...
middlewares.use(indexHtmlMiddleware(server));
用于进行依赖预构建的优化函数 runOptimize
,用于将 npm 依赖以及用户指定的需要缓存的依赖进行打包,并缓存在node_modules/.vite
目录下,针对这些文件的 http 请求都将添加缓存。
const runOptimize = async () => {
if (config.cacheDir) {
server._isRunningOptimizer = true
try {
server._optimizeDepsMetadata = await optimizeDeps(config)
} finally {
server._isRunningOptimizer = false
}
server._registerMissingImport = createMissingImporterRegisterFn(server)
}
}
// overwrite listen to run optimizer before server start
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
httpServer.once('listening', () => {
// update actual port since this may be different from initial value
serverConfig.port = (httpServer.address() as AddressInfo).port
})
在 cli
中调用 server.listen()
后,会首先执行 container.buildStart({})
调用所有注册插件的 buildStart
钩子函数,然后运行 runOptimize
依赖预构建函数,最后是监听端口,接收来自浏览器的请求。
传说中的依赖预构建
从上面的整体代码我们可以看到,在开启服务,监听端口接收来自浏览器的请求之前,会运行插件 container
的 buildStart
钩子,进而运行所有插件的 buildStart
钩子,以及进行依赖预构建,运行 runOptimize
函数。
可以看到整个运行 Node 服务的生命周期中,都是一些基本不怎么耗时的收集 config
、注册各种中间件、初始化一些之后会用到的插件容器 container
以及模块依赖图 moduleGraph
等,其中最耗时的就是依赖预构建了,它主要将所有的 npm 依赖构建成单一的可缓存文件,也是 Vite 服务开启过程中的一个最大的时间瓶颈,因为 Vite 针对用户项目中的各种文件都是不做打包处理的,而是在浏览器运行时按需请求,并进行转换处理。
这可以看做是 Vite 在极致的起服务速度和极慢的浏览器 “首屏出图” 速度之间的一个权衡,而极慢的浏览器 “首屏出图” 速度则是我会在后文提到的 Vite 有什么 “不好” 的内容之一。
下面就来分析一下这个神奇的预构建过程。
const runOptimize = async () => {
if (config.cacheDir) {
server._isRunningOptimizer = true;
try {
server._optimizeDepsMetadata = await optimizeDeps(config);
} finally {
server._isRunningOptimizer = false;
}
server._registerMissingImport = createMissingImporterRegisterFn(server);
}
};
可以看到函数体,主要是执行 optimizeDeps
函数,返回依赖预构建之后的元数据,用于索引构建之后的文件,以及映射构建前后的文件路径,然后注册 _registerMissingImport
,用于在项目运行过程中添加新的 npm 依赖时,也能预构建到缓存目录 node_modules/.vite
下。
下面分析一下 optimizeDeps
函数:
packages/vite/src/node/optimizer/index.ts
https://github.com/vitejs/vite/blob/30ff5a235d2a832cb45a761a03c5947460417b40/packages/vite/src/node/optimizer/index.ts#L102
import { esbuildDepPlugin } from './esbuildDepPlugin'
import { ImportSpecifier, init, parse } from 'es-module-lexer'
import { scanImports } from './scan'
export async function optimizeDeps(
config: ResolvedConfig,
force = config.server.force,
asCommand = false,
newDeps?: Record<string, string> // missing imports encountered after server has started
): Promise<DepOptimizationMetadata | null> {
config = {
...config,
command: 'build'
}
const { root, logger, cacheDir } = config
const log = asCommand ? logger.info : debug
if (!cacheDir) {
log(`No cache directory. Skipping.`)
return null
}
const dataPath = path.join(cacheDir, '_metadata.json')
const mainHash = getDepHash(root, config)
const data: DepOptimizationMetadata = {
hash: mainHash,
browserHash: mainHash,
optimized: {}
}
if (!force) {
let prevData
try {
prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8'))
} catch (e) {}
// hash is consistent, no need to re-bundle
if (prevData && prevData.hash === data.hash) {
log('Hash is consistent. Skipping. Use --force to override.')
return prevData
}
}
if (fs.existsSync(cacheDir)) {
emptyDir(cacheDir)
} else {
fs.mkdirSync(cacheDir, { recursive: true })
}
let deps: Record<string, string>, missing: Record<string, string>
if (!newDeps) {
;({ deps, missing } = await scanImports(config))
} else {
deps = newDeps
missing = {}
}
// update browser hash
data.browserHash = createHash('sha256')
.update(data.hash + JSON.stringify(deps))
.digest('hex')
.substr(0, 8)
const include = config.optimizeDeps?.include
if (include) {
const resolve = config.createResolver({ asSrc: false })
for (const id of include) {
if (!deps[id]) {
const entry = await resolve(id)
if (entry) {
deps[id] = entry
} else {
throw new Error(
`Failed to resolve force included dependency: ${chalk.cyan(id)}`
)
}
}
}
}
const qualifiedIds = Object.keys(deps)
if (!qualifiedIds.length) {
writeFile(dataPath, JSON.stringify(data, null, 2))
log(`No dependencies to bundle. Skipping.\n\n\n`)
return data
}
const total = qualifiedIds.length
const maxListed = 5
const listed = Math.min(total, maxListed)
const extra = Math.max(0, total - maxListed)
const depsString = chalk.yellow(
qualifiedIds.slice(0, listed).join(`\n `) +
(extra > 0 ? `\n (...and ${extra} more)` : ``)
)
const flatIdDeps: Record<string, string> = {}
const idToExports: Record<string, ExportsData> = {}
const flatIdToExports: Record<string, ExportsData> = {}
await init
for (const id in deps) {
const flatId = flattenId(id)
flatIdDeps[flatId] = deps[id]
const entryContent = fs.readFileSync(deps[id], 'utf-8')
const exportsData = parse(entryContent) as ExportsData
for (const { ss, se } of exportsData[0]) {
const exp = entryContent.slice(ss, se)
if (/export\s+*\s+from/.test(exp)) {
exportsData.hasReExports = true
}
}
idToExports[id] = exportsData
flatIdToExports[flatId] = exportsData
}
const define: Record<string, string> = {
'process.env.NODE_ENV': JSON.stringify(config.mode)
}
for (const key in config.define) {
const value = config.define[key]
define[key] = typeof value === 'string' ? value : JSON.stringify(value)
}
const start = Date.now()
const result = await build({
entryPoints: Object.keys(flatIdDeps),
bundle: true,
keepNames: config.optimizeDeps?.keepNames,
format: 'esm',
external: config.optimizeDeps?.exclude,
logLevel: 'error',
splitting: true,
sourcemap: true,
outdir: cacheDir,
treeShaking: 'ignore-annotations',
metafile: true,
define,
plugins: [esbuildDepPlugin(flatIdDeps, flatIdToExports, config)]
})
const meta = result.metafile!
createLogger('info').info(`${JSON.stringify(meta)}`)
// the paths in `meta.outputs` are relative to `process.cwd()`
const cacheDirOutputPath = path.relative(process.cwd(), cacheDir)
for (const id in deps) {
const entry = deps[id]
data.optimized[id] = {
file: normalizePath(path.resolve(cacheDir, flattenId(id) + '.js')),
src: entry,
needsInterop: needsInterop(
id,
idToExports[id],
meta.outputs,
cacheDirOutputPath
)
}
}
writeFile(dataPath, JSON.stringify(data, null, 2))
return data
}
可以看到,上面的函数主要做了这么几件事情:
接收 config,然后 data,形如 { hash, browserHash, optimized }
,其中browserHash
主要用于浏览器获取预构建的 npm 依赖时,添加的查询字符串,用于在依赖变化时,浏览器能更新缓存,也就是我们之前看到的vue.js?v=92bdfa16
,这个92bdfa16
,主要在处理浏览器请求时,调用resolvePlugin
时,运行tryNodeResolve
函数对 npm 依赖的请求添加这个browserHash
;optimized
则是形如npmDep: { file, src, needsInterop }
的键值对,比如vue
依赖,则是如下内容:
"vue": {
"file": "/Users/bytedance/Projectes/my-projects/learning/vite/app-vue2/node_modules/.vite/vue.js",
"src": "/Users/bytedance/Projectes/my-projects/learning/vite/app-vue2/node_modules/vue/dist/vue.runtime.esm-bundler.js",
"needsInterop": false
}
如果 node_modules/.vite/_metadata.json
文件存在,且hash
相同,则表示已经构建过了,并且没有更新,则直接返回prevData
const dataPath = path.join(cacheDir, "_metadata.json");
const mainHash = getDepHash(root, config);
const data: DepOptimizationMetadata = {
hash: mainHash,
browserHash: mainHash,
optimized: {},
};
if (!force) {
let prevData;
try {
prevData = JSON.parse(fs.readFileSync(dataPath, "utf-8"));
} catch (e) {}
// hash is consistent, no need to re-bundle
if (prevData && prevData.hash === data.hash) {
log("Hash is consistent. Skipping. Use --force to override.");
return prevData;
}
}
通过 scanImports
找出需要依赖预构建的依赖,结合用户定义的需要处理的依赖config.optimizeDeps?.include
,deps 是一个对象,是依赖名到其在文件系统中的路径的映射如:{ vue: '/Users/bytedance/Projectes/my-projects/learning/vite/app-vue2/node_modules/vue/dist/vue.runtime.esm-bundler.js' }
let deps: Record<string, string>, missing: Record<string, string>;
if (!newDeps) {
({ deps, missing } = await scanImports(config));
} else {
deps = newDeps;
missing = {};
}
// ...
const include = config.optimizeDeps?.include;
if (include) {
const resolve = config.createResolver({ asSrc: false });
for (const id of include) {
if (!deps[id]) {
const entry = await resolve(id);
if (entry) {
deps[id] = entry;
} else {
throw new Error(
`Failed to resolve force included dependency: ${chalk.cyan(id)}`
);
}
}
}
}
使用 es-module-lexer
的parse
处理依赖的代码,读取其中的exportsData
,并完成依赖 id(文件路径)到exportsData
的映射,用于之后esbuild
构建时进行依赖图分析并打包到一个文件里面,其中exportsData
为这个文件里引入的模块imports
和导出的模块exports
:
const flatIdDeps: Record<string, string> = {}
const idToExports: Record<string, ExportsData> = {}
const flatIdToExports: Record<string, ExportsData> = {}
await init
for (const id in deps) {
const flatId = flattenId(id)
flatIdDeps[flatId] = deps[id]
const entryContent = fs.readFileSync(deps[id], 'utf-8')
const exportsData = parse(entryContent) as ExportsData
for (const { ss, se } of exportsData[0]) {
const exp = entryContent.slice(ss, se)
if (/export\s+*\s+from/.test(exp)) {
exportsData.hasReExports = true
}
}
idToExports[id] = exportsData
flatIdToExports[flatId] = exportsData
}
举个 es-module-lexer 例子。
使用 esbuild
进行依赖的预构建,并将构建之后的文件写入缓存目录:node_modules/.vite
,得益于 esbuild 比传统构建工具快 10-100 倍的速度,所以依赖预构建也是非常快的,且一次构建之后,后续可以缓存;
build
构建函数传入用户 vite.config.js
define 定义的环境变量,需要进行依赖预构建的文件入口 Object.keys(flatIdDeps)
等, 以及处理依赖的 esbuild 插件 esbuildDepPlugin
,这个插件主要做了以下三件事:
主要用于处理某个依赖文件及其依赖图,转换 mjs|ts|jsx|tsx|svelte|vue
等文件成为 js 代码,less|sass|scss|styl
等文件成为css
,前提是使用了相关的插件,其中mjs|ts|jsx|tsx
等是默认支持的将某个依赖的依赖图中的文件统一打包到一个 esm 文件中,如 vue
依赖,打包成一个vue.js
,或者 lodash 依赖,打包成一个lodash-es
文件,减少 http 请求数量
一个比较直观的例子就是,当我们直接使用 import { debounce } from "lodash-es"
时,浏览器会导入 600+ 文件,大概需要 1 s 多:
而经过依赖预构建之后,浏览器只需要导入一个文件,且只需 20 ms :
处理一些不兼容模块 commonjs
模块等,将它们打包成 esm 文件,比如 react 的包,使得浏览器可以使用
const define: Record<string, string> = {
"process.env.NODE_ENV": JSON.stringify(config.mode),
};
for (const key in config.define) {
const value = config.define[key];
define[key] = typeof value === "string" ? value : JSON.stringify(value);
}
const start = Date.now();
const result = await build({
entryPoints: Object.keys(flatIdDeps),
bundle: true,
keepNames: config.optimizeDeps?.keepNames,
format: "esm",
external: config.optimizeDeps?.exclude,
logLevel: "error",
splitting: true,
sourcemap: true,
outdir: cacheDir,
treeShaking: "ignore-annotations",
metafile: true,
define,
plugins: [esbuildDepPlugin(flatIdDeps, flatIdToExports, config)],
});
进行依赖预构建并写入到缓存目录之后,最后就是补充 data.optimized
内容,并将内容写入到缓存目录下的_metadata.json
用于之后进行依赖获取和走构建缓存等:
const meta = result.metafile!
createLogger('info').info(`${JSON.stringify(meta)}`)
// the paths in `meta.outputs` are relative to `process.cwd()`
const cacheDirOutputPath = path.relative(process.cwd(), cacheDir)
for (const id in deps) {
const entry = deps[id]
data.optimized[id] = {
file: normalizePath(path.resolve(cacheDir, flattenId(id) + '.js')),
src: entry,
needsInterop: needsInterop(
id,
idToExports[id],
meta.outputs,
cacheDirOutputPath
)
}
}
writeFile(dataPath, JSON.stringify(data, null, 2))
其中 needsInterop
为记录那些在依赖预构建时,使用了 commonjs 语法的依赖,如果使用了 commonjs ,那么 needsInterop
为 true
,这个属性主要用于在浏览器请求对应的依赖时(构建前是 commonjs 形式),Vite 的 importAnalysisPlugin
插件会进行依赖性导入分析,使用 transformCjsImport
函数,它会对需要预编译且为 CommonJS 的依赖导入代码进行重写。举个例子,当我们在 Vite 项目中使用 react
时:
import React, { useState, createContext } from "react";
此时 React 的导入就是 needsInterop 为 true,所以 importAnalysisPlugin 插件的会对导入 React 的代码进行重写:
import $viteCjsImport1_react from "/@modules/react.js";
const React = $viteCjsImport1_react;
const useState = $viteCjsImport1_react["useState"];
const createContext = $viteCjsImport1_react["createContext"];
之所以要进行重写的缘由是因为 CommonJS 的模块并不支持命名方式的导出,即没有 exports xxx
这样的语法,只有 exports.xxx
。所以,如果不经过插件的转化,则会看到这样的异常:
Uncaught SyntaxError: The requested module '/@modules/react.js' does not provide an export named 'useState'
最后将 data 写入的路径为 node_modules/.vite/_metadata.json
,内容如下:
{
"hash": "cd74d918",
"browserHash": "92bdfa16",
"optimized": {
"vue": {
"file": "/Users/bytedance/Projectes/my-projects/learning/vite/app-vue2/node_modules/.vite/vue.js",
"src": "/Users/bytedance/Projectes/my-projects/learning/vite/app-vue2/node_modules/vue/dist/vue.runtime.esm-bundler.js",
"needsInterop": false
}
}
}
依赖预构建总结
经过上面的分析,我们可以总结依赖预构建的几点特点:
在快速起服务和浏览器首屏出图直接的一个取舍,而得益与 esbuild 的快速构建,使得起服务快的同时,浏览器首屏出图也快,而且可以进行缓存 使得可以使用一些 (j|t)sx?
/vue
/svelte
的包成为可能针对 commonjs 等也可以进行转换使用
所以 Vite 并不是一个纯的 bundless
工具,或者说构建/编译几乎是不可或缺的内容。
一个请求的 Vite 之旅
GET localhost
实际 GET / => /index.html
讲完依赖预构建,接下来我们可以放心的讲解一个基于 Vite 的 Vue 项目的运行过程,也就是我们在 network 面板里面看到的那些请求,以及它们与项目目录里面的对应关系。
首先我们知道,在 createServer
中注册了 history
中间件,针对 /
请求,会重定向到 /index.html
,重定向之后的请求则会激活 indexHtmlMiddleware
中间件的处理:
packages/vite/src/node/server/middlewares/indexHtml.ts
下的 indexHtmlMiddleware
函数内容:
export function indexHtmlMiddleware(
server: ViteDevServer
): Connect.NextHandleFunction {
return async (req, res, next) => {
const url = req.url && cleanUrl(req.url);
// spa-fallback always redirects to /index.html
if (url?.endsWith(".html") && req.headers["sec-fetch-dest"] !== "script") {
createLogger("info").info(`html middleware`);
const filename = getHtmlFilename(url, server);
if (fs.existsSync(filename)) {
try {
let html = fs.readFileSync(filename, "utf-8");
// 这里调用 transformIndexHtml
html = await server.transformIndexHtml(url, html);
return send(req, res, html, "html");
} catch (e) {
return next(e);
}
}
}
next();
};
}
上面函数会调用 transformIndexHtml
,然后执行 packages/vite/src/node/plugins/html.ts
下的 applyHtmlTransforms
函数,执行用于给 html
注入内容的 hooks 如 [...preHooks, devHtmlHook, ...postHooks]
,并在 html
文件的 head
和 body
标签前后插入脚本。
其中 devHtmlHook
主要做的事情就是在 html 文件头部注入 <script type="module" src="/@vite/client"></script>
脚本,也就是我们看到的第一个请求 localhost
返回的内容:
而 devHtmlHook
则是在 server
中调用 createDevHtmlTransformFn
函数时注入的 Hooks,在 packages/vite/src/node/server/middlewares/indexHtml.ts
下的 createDevHtmlTransformFn
函数内容:
export function createDevHtmlTransformFn(
server: ViteDevServer
): (url: string, html: string) => Promise<string> {
const [preHooks, postHooks] = resolveHtmlTransforms(server.config.plugins)
return (url: string, html: string): Promise<string> => {
return applyHtmlTransforms(
html,
url,
getHtmlFilename(url, server),
[...preHooks, devHtmlHook, ...postHooks],
server
)
}
}
// devHtmlHook 函数
const devHtmlHook: IndexHtmlTransformHook = async (
html,
{ path: htmlPath, server }
) => {
// TODO: solve this design issue
// Optional chain expressions can return undefined by design
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
const config = server?.config!
const base = config.base || '/'
const s = new MagicString(html)
let scriptModuleIndex = -1
await traverseHtml(html, htmlPath, (node) => {
if (node.type !== NodeTypes.ELEMENT) {
return
}
// ...
html = s.toString()
return {
html,
tags: [
{
tag: 'script',
attrs: {
type: 'module',
// 这里注入 /@vite/client 脚本
src: path.posix.join(base, CLIENT_PUBLIC_PATH)
},
injectTo: 'head-prepend'
}
]
}
}
GET client
实际 GET /@vite/client
首先会走 transformMiddleware
:
export function transformMiddleware(
server: ViteDevServer
): Connect.NextHandleFunction {
const {
config: { root, logger, cacheDir },
moduleGraph
} = server
// determine the url prefix of files inside cache directory
let cacheDirPrefix: string | undefined
if (cacheDir) {
const cacheDirRelative = normalizePath(path.relative(root, cacheDir))
if (cacheDirRelative.startsWith('../')) {
// if the cache directory is outside root, the url prefix would be something
// like '/@fs/absolute/path/to/node_modules/.vite'
cacheDirPrefix = `/@fs/${normalizePath(cacheDir).replace(/ ^ //, '')}`
} else {
// if the cache directory is inside root, the url prefix would be something
// like '/node_modules/.vite'
cacheDirPrefix = `/${cacheDirRelative}`
}
}
return async (req, res, next) => {
if (req.method !== 'GET' || knownIgnoreList.has(req.url!)) {
return next()
}
const withoutQuery = cleanUrl(url)
if (
isJSRequest(url) ||
isImportRequest(url) ||
isCSSRequest(url) ||
isHTMLProxy(url)
) {
// strip ?import
url = removeImportQuery(url)
// Strip valid id prefix. This is prepended to resolved Ids that are
// not valid browser import specifiers by the importAnalysis plugin.
url = unwrapId(url)
// for CSS, we need to differentiate between normal CSS requests and
// imports
if (isCSSRequest(url) && req.headers.accept?.includes('text/css')) {
url = injectQuery(url, 'direct')
}
// check if we can return 304 early
const ifNoneMatch = req.headers['if-none-match']
if (
ifNoneMatch &&
(await moduleGraph.getModuleByUrl(url))?.transformResult?.etag ===
ifNoneMatch
) {
isDebug && debugCache(`[304] ${prettifyUrl(url, root)}`)
res.statusCode = 304
return res.end()
}
// resolve, load and transform using the plugin container
const result = await transformRequest(url, server, {
html: req.headers.accept?.includes('text/html')
})
if (result) {
const type = isDirectCSSRequest(url) ? 'css' : 'js'
const isDep =
DEP_VERSION_RE.test(url) ||
(cacheDirPrefix && url.startsWith(cacheDirPrefix))
return send(
req,
res,
result.code,
type,
result.etag,
// allow browser to cache npm deps!
isDep ? 'max-age=31536000,immutable' : 'no-cache',
result.map
)
}
}
} catch (e) {
return next(e)
}
next()
}
}
会命中 isJSRequest(url)
逻辑,进入中间件的处理过程:
对 url 进行 transformRequest
,主要的逻辑为通过pluginContainer.resolveId
获取到实际的文件位置id
,然后根据这个位置,使用pluginContainer.load
来获取对应的文件内容,如果文件内容并非浏览器可以直接使用的 esm 内容,那么就需要pluginContainer.transform
进行文件内容的转换,最后返回转换后的code
、map
以及 etag,用于缓存。
export async function transformRequest(
url: string,
{ config, pluginContainer, moduleGraph, watcher }: ViteDevServer,
options: TransformOptions = {}
): Promise<TransformResult | null> {
url = removeTimestampQuery(url)
const { root, logger } = config
const prettyUrl = isDebug ? prettifyUrl(url, root) : ''
const ssr = !!options.ssr
// resolve
const id = (await pluginContainer.resolveId(url))?.id || url
const file = cleanUrl(id)
let code: string | null = null
let map: SourceDescription['map'] = null
// load
const loadStart = isDebug ? Date.now() : 0
const loadResult = await pluginContainer.load(id, ssr)
// ...
if (typeof loadResult === 'object') {
code = loadResult.code
map = loadResult.map
} else {
code = loadResult
}
// ...
// ensure module in graph after successful load
const mod = await moduleGraph.ensureEntryFromUrl(url)
ensureWatchedFile(watcher, mod.file, root)
// transform
const transformStart = isDebug ? Date.now() : 0
const transformResult = await pluginContainer.transform(code, id, map, ssr)
if (
transformResult == null ||
(typeof transformResult === 'object' && transformResult.code == null)
) {
// no transform applied, keep code as-is
isDebug &&
debugTransform(
timeFrom(transformStart) + chalk.dim(` [skipped] ${prettyUrl}`)
)
} else {
isDebug && debugTransform(`${timeFrom(transformStart)} ${prettyUrl}`)
code = transformResult.code!
map = transformResult.map
}
if (map && mod.file) {
map = (typeof map === 'string' ? JSON.parse(map) : map) as SourceMap
if (map.mappings && !map.sourcesContent) {
await injectSourcesContent(map, mod.file)
}
}
return (mod.transformResult = {
code,
map,
etag: getEtag(code, { weak: true })
} as TransformResult)
}
pluginContainer.resolveId
在调用时,会逐个调用每个插件上的 resolveId
方法,一旦遇到 aliasPlugin
,在 config
中,曾注册过对应的 /`` ^ ``/@vite//
的 alias,此插件将用于将 /``@vite/client
替换成 CLIENT_DIR
+ /
+ client
,也就是 vite/dist/client/client
实际处于 packages/vite/src/node/config.ts
路径下的 resolvedAlias
:
// resolve alias with internal client alias
const resolvedAlias = mergeAlias(
// #1732 the CLIENT_DIR may contain $$ which cannot be used as direct
// replacement string.
// @ts-ignore because @rollup/plugin-alias' type doesn't allow function
// replacement, but its implementation does work with function values.
[{ find: / ^ /@vite//, replacement: () => CLIENT_DIR + '/' }],
config.resolve?.alias || config.alias || []
)
const resolveOptions: ResolvedConfig['resolve'] = {
dedupe: config.dedupe,
...config.resolve,
alias: resolvedAlias
}
const resolved = {
// ...
resolve: resolveOptions
// ...
}
在 packages/vite/src/node/plugins/index.ts
的 aliasPlugin
插件中作为参数传入
export async function resolvePlugins(
config: ResolvedConfig,
prePlugins: Plugin[],
normalPlugins: Plugin[],
postPlugins: Plugin[]
): Promise<Plugin[]> {
// ...
return [
isBuild ? null : preAliasPlugin(),
aliasPlugin({ entries: config.resolve.alias }),
...prePlugins,
// ...
].filter(Boolean) as Plugin[]
}
在 aliasPlugin
里面改写路径之后,会继续将改写过的路径传给下一个插件,最终进入 resolvePlugin
插件的 tryNodeResolve
函数,获取到 @fs/Users/bytedance/Projectes/my-projects/learning/vite/vite/packages/vite/dist/client/client.js
文件的路径并返回,最终通过 pluginContainer.load
获取 loadResult,然后 通过 pluginContainer.transform
获取其转换过的代码,通过 send
方法发送给浏览器,而 client.js
里面的代码主要用于与服务器进行 ws 通信来进行 hmr 热更新、以及重载页面等操作。
受限于篇幅,本文接下来的内容不再细化。
下面的所有请求,都会走一个类似上面的流程,最终发送给浏览器的代码是浏览器可以运行的代码,其中针对 Vue 文件是需要走类似
@vitejs/plugin-vue
的 plugin 的转换的,感兴趣的同学可以自行了解一下。
GET /src/main.js
实际 GET /src/main.js
GET env.js
实际 GET /@fs/Users/bytedance/Projectes/my-projects/learning/vite/vite/packages/vite/dist/client/env.js
GET vue.js?v=92bdfa16
实际 GET /node_modules/.vite/vue.js?v=92bdfa16
GET App.vue
实际 GET /src/App.vue
GET App.vue?vue&type=style&index=0&lang.css
实际 GET /src/App.vue?vue&type=style&index=0&lang.css
有什么 “不好” 的?
正如上面提到的,Vite 只对 npm 依赖进行预构建,对于用户编写的文件不进行预处理,而是通过浏览器支持的 ES Module 来进行按需读取,所以如果用户文件过多,且没有进行一定的 Code Spliting 等操作,那么可想而知,首屏是非常慢的,可以通过这个视频直观的看出来:
所以使用 Vite 的开发,对我们的首屏性能优化就提出了更高的要求,这也直接给生产下带来了一定帮助,也正是因为 Vite 是主要面向开发侧的,所以可以尽可能的用最先进的技术,如 Http2?Http3?来进行网络请求,以及更好的懒加载、缓存技术。
还有一点就是,Vue 生产内建了 Rollup 打包工具,这对原先使用 Webpack 的项目也不太友好,但得益于 Vite 社区的活跃和尤大的号召力,社区中已经有成型的基于 Webpack 来生产打包,开发使用 Vite 的解决方案:https://github.com/IndexXuan/vue-cli-plugin-vite#readme
生态如何?
Vite 拥有比较完善的生态,主要的项目如 https://github.com/vitejs/awesome-vite 在不断的更新,且 Vite 社区比较活跃,社区成员也很乐于解答问题:
discord:https://discord.com/channels/804011606160703521/804011606160703524 Github discussion:https://github.com/vitejs/vite/discussions
同时 Vite 支持多框架:React/Vue/Svelte 等。
我能用在生产项目中吗?
如果你是想从头开始一个新项目,亦或对首屏性能优化有很大的兴趣,那么建议你一定要试一试,有可能一不小心,就回不去了!
Hail OpenSource!
世界已经被开源吞噬,庆幸在这样一个商业氛围及其浓厚的今天,我们还有幸能阅读到优秀的项目源码,站在巨人的肩膀上!