本文为原创文章,引用请注明出处,欢迎大家收藏和分享💐💐
背景
前段时间用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
类型,可以在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 } })
- 首先将
entryFile
的相对路径解析出来放到relativePath
变量中保存; - 分析模块类型,给
contents
赋值。通过 esbuild 词法分析入口模块的import
和export
信息,当两个关键字都没有,判断是一个cjs
模块,生成下面格式contents;
contents += `export default require("${relativePath}");`
- 假如不满足第2步条件,则系统认为是一个
esm
模块,生成对应的contents:
contents += `import d from "${relativePath}";export default d;` // or contents += `\nexport * from "${relativePath}"`
- 解析文件扩展名,取得对应的
loader
; - 返回
loader
类型、模块内容、导入路径给esbuild打包,语法参考 esbuild onLoad result。
在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
。
欢迎大家关注本人公众号「是马非马」,一起玩耍起来!🌹🌹