大致运转流程
大致内容直接用一张PPT图来大致描绘。下文会附上各个阶段的代码解释及外链,所以最后在看吧
构建过程
依赖预构建
步骤
- 依赖搜寻替换:将裸模块(bare import)路径替换为相对路径
why: 浏览器无法识别ES裸模块导入,(本地可以是因为Node.js环境)
- Commonjs/UMD模块。转译成ES模块。基于esbuild
- 聚合常用ES多文件库,如lodash-es。否则会存在几百个文件一起请求
// before
import React from 'react' // 非相对路径,所以无法识别
// after
import __vite__cjsImport0_react from "/node_modules/.vite/react.js?v=432aac16"
依赖预构建 - 源码解读
预编译入口函数
Github链接: 构建入口
// vite1.0基于koa架构开发,但是在vite2.0改用httpserver。。充分的利用已有工具
if (!middlewareMode && httpServer) {
const listen = httpServer.listen.bind(httpServer)
// 重写listen,确保server启动之前执行。
httpServer.listen = (async (port, ...args) => {
try {
// vite plugin集合体,统一触发buildStart生命周期调用
await container.buildStart({})
await runOptimize() // 预构建
} catch (e) {}
return listen(port, ...args)
})
} else {
....
}
预编译核心函数
Github链接:预编译入口
步骤:
- 缓存对比,决定是否重新构建
- 扫描入口,获取文件依赖Map。deps 形如: {"lodash-es": "node_modules/lodash-es"}
- 基于 es-module-lexer(ES文件词法解析)打平依赖Map。
- 调用 esbuild 编译,并将缓存写入_metadata.json
// 预编译主函数入口
function optimizeDeps( config, force=config.server.force,asCommand=false,newDeps?) {
...
----------------------------------------------------------------------------
/** 第一步: 比较上次预构建信息(_metadata.json)并决定是否重新构建 */
----------------------------------------------------------------------------
// 前后比对hash,相同则直接返回
if (prevData && prevData.hash === data.hash) {
return prevData
}
...
----------------------------------------------------------------------------
/** 第二步: 扫描源码,或根据参数newDeps,获取依赖 */
----------------------------------------------------------------------------
// newDeps参数是在服务启动后加入依赖时传入的依赖信息。
let deps
if (!newDeps) {
// 借助esbuild扫描源码,获取依赖
;({ deps, missing } = await scanImports(config))
} else {
deps = newDeps
missing = {}
}
...
----------------------------------------------------------------------------
/** 第三步: 利用es-module-lexer扁平化嵌套的源码依赖 */
----------------------------------------------------------------------------
// 扁平化依赖
await init
for (const id in deps) {
flatIdDeps[flatId] = deps[id]
const entryContent = fs.readFileSync(deps[id], 'utf-8')
const exportsData = parse(entryContent) as ExportsData
...
}
...
----------------------------------------------------------------------------
/** 第四步: 解析用户依赖优化配置,调用esbuild构建文件,并存入cacheDir */
----------------------------------------------------------------------------
// 加入用户指定的esbuildOptions
const { plugins = [], ...esbuildOptions } =
config.optimizeDeps?.esbuildOptions ?? {}
// 调用esbuild.build打包文件
const result = await build({
...
entryPoints: Object.keys(flatIdDeps),// 入口
format: 'esm',// 打包成esm模式
external: config.optimizeDeps?.exclude,// 剔除exclude文件
outdir: cacheDir,// 输出地址
})
// 重新写入_metadata.json
for (const id in deps) {
const entry = deps[id]
data.optimized[id] = {
file: normalizePath(path.resolve(cacheDir, flattenId(id) + '.js')),
src: entry,
}
}
writeFile(dataPath, JSON.stringify(data, null, 2))
return data
}
静态资源处理
浏览器虽然支持了ES Module,但是webpack告诉我们: Import anything u want,奈何浏览器看不懂import css、img
HTML模版解析
Github链接:入口
- 所有http请求,会经过createDevHtmlTransformFn函数统一处理
- vite内置插件vite:css-post 将css文件转译成
/** createServer函数 */
server.transformIndexHtml = createDevHtmlTransformFn(server)
/** createDevHtmlTransformFn函数 */
const [preHooks, postHooks] = resolveHtmlTransforms(server.config.plugins) // 插件钩子函数前后处理
css
官方描述:导入 .css 文件将会把内容插入到 标签中,同时也带有 HMR 支持。也能够以字符串的形式检索处理后的、作为其模块默认导出的 CSS
步骤:
- server获取资源请求URL
- 判断是否为css文件,若是则通过Vite内置预处理插件:vite:css 修改返回样式文件如下
- 将修改后的文件,返回浏览器
/** 转译后的css文件,其实是js执行逻辑 */
import { createHotContext as __vite__createHotContext } from "/@vite/client";
import.meta.hot = __vite__createHotContext("/src/App.scss"); // HMR
import { updateStyle, removeStyle } from "/@vite/client"
const id = ".../src/App.scss"
const css = "XXXX"
updateStyle(id, css)
// 下面都是HMR相关,下面介绍,先不用关注
import.meta.hot.accept()
export default css
import.meta.hot.prune(() => removeStyle(id))
/** 其中updateStyle函数,主要功能如下。通过js方式将css文件写入head */
updateStyle() {
...
const style = new CSSStyleSheet()
style = document.createElement('style')
style.setAttribute('type', 'text/css')
style.innerHTML = content // content由esbuild处理得到
document.head.appendChild(style)
...
}
scss、less、stylus等样式文件
快速跳转看源码:传送门
相较于css处理,多了一步基于sass、less等三方库的语法转译工作,其余步骤与css处理方式完全一致,甚至官网上写着
没有必要为它们安装特定的 Vite 插件
# .scss and .sass
npm install -D sass
JSX、TS、TSX
基于esbuild将Ts文件转译成js,是否有些好奇,为什么每次发起资源请求后,在执行编译却依旧能这么快?esbuild到底是什么魔鬼?
- Go语言编写,编译型语言机器可以直接执行。而JS属于解释型语言
- Go语言,并发无忧,且线程之间共享内存。JS只能序列化传递,反序列化读取
根据 esbuild 的作者的测试,垃圾回收机制似乎将 JavaScript 的工作线程的并行处理能力减少了一半,可能是因为你的一半 CPU 核心忙于为另一半收集垃圾
- 相比于TSC,esbuild只进行了3次AST遍历的次数,减少内存占用。
其它资源
如图片、json则直接通过URL导入
插件体系设计
Vite 可以使用插件进行扩展,这得益于 Rollup 优秀的插件接口设计和一部分 Vite 独有的额外选项。这意味着 Vite 用户可以利用 Rollup 插件的强大生态系统,同时根据需要也能够扩展开发服务器和 SSR 功能。
前提:其中蓝色标为Vite自定义的hook(5个)、红色标为Rollup内置Hook(7个)。且从左到右分别是执行的顺序
仅个人观点:Vite借助Rollup插件体系丰富自己的生态。官方解释Rollup更适合ES Module
- 生产环境构建使用ESModule并不合理,实际效果可能不如Bundle,所以需要借助目前主流的一些构建工具。如Rollup、webpack等。
- 配置简单、构建体积更小,加上目标就是替代webpack。所以选择Rollup并不奇怪
插件实现伪代码
-
resolveConfig:插件解析,分类,初始化PluginContainer插件管理模块
-
resolvePlugins:加入Vite内置的插件,如vite:css 等
-
createPluginContainer:插件统一出口,执行对应hook,则将预分类后插件所有钩子函数按顺序执行
async function resolveConfig(inlineConfig, ...args) {
let config = inlineConfig; // 配置参数对象
...
// 既然有flattern plugins数组,那就可以随意发挥[[pulginA,pulginB],pulginC]
// 筛选应用apply设置应用场景(serve|build)的插件
const rawUserPlugins = (config.plugins || []).flat().filter((p) => {
return p && (!p.apply || p.apply === command);
});
// sortUserPlugins函数根据enforce字段对插件进行排序,enforce可以填pre、 post
const [prePlugins, normalPlugins, postPlugins] = sortUserPlugins(rawUserPlugins);
// 执行plugin.config hook,可再次设置配置参数
const userPlugins = [...prePlugins, ...normalPlugins, ...postPlugins];
for (const p of userPlugins) {
if (p.config) {
const res = await p.config(config, configEnv);
if (res) {
config = mergeConfig(config, res);
}
}
}
// 利用createPluginContainer函数创建一个用于特殊场景的内部解析器,比如解析css @import ,预构建依赖等
const createResolver = (options) => {
return async (id, importer, aliasOnly) => {
let container = await createPluginContainer(...args);
return (await container.resolveId(id, importer))?.id;
};
};
// 最终参数配置对象
const resolved = {
// ...其他配置
plugins: userPlugins,
createResolver,
};
// resolvePlugins函数添加vite内部插件,使完成各功能开箱即用。
// resolvePlugins函数往下会讲解,源码:https://github.com/vitejs/vite/blob/5745a2e8072cb92d647662dc387e7f12b2841cab/packages/vite/src/node/plugins/index.ts#L18
resolved.plugins = await resolvePlugins(resolved, prePlugins, normalPlugins, postPlugins);
// 执行plugin.configResolved hook
await Promise.all(userPlugins.map((p) => p.configResolved?.(resolved)));
// 省略以下
}
plugin container
export async function createPluginContainer({ plugins, rollupOptions }: ResolvedConfig) {
//省略部分代码
...
const container = {
// 注意,options是一个立即调用函数,你的插件的options hook可以直接是配置对象,也可以是返回配置对象的函数
options: await (async () => {
for (const plugin of plugins) {
if (!plugin.options) continue;
options = (await plugin.options(...args)) || options;
}
})(),
async buildStart() {
await Promise.all(
plugins.map((plugin) => {
if (plugin.buildStart) {
return plugin.buildStart.call(new Context(plugin), ...args);
}
}),
);
},
// resolveId
async resolveId(...args) {
const ctx = new Context();
for (const plugin of plugins) {
if (!plugin.resolveId) continue;
const result = await plugin.resolveId.call(ctx, ...args);
if (!result) continue;
}
},
// load
async load(...args) {
for (const plugin of plugins) {
if (!plugin.load) continue;
const result = await plugin.load.call(ctx, ...args);
}
},
// transform
async transform(...args) {
const ctx = new TransformContext(...args);
for (const plugin of plugins) {
result = await plugin.transform.call(ctx, ...args);
}
},
watchChange(...args) {
const ctx = new Context();
for (const plugin of plugins) {
if (!plugin.watchChange) continue;
plugin.watchChange.call(ctx, ...args);
}
},
// buildEnd && closeBundle
async close() {
if (closed) return;
const ctx = new Context();
await Promise.all(plugins.map((p) => p.buildEnd && p.buildEnd.call(ctx)));
await Promise.all(plugins.map((p) => p.closeBundle && p.closeBundle.call(ctx)));
closed = true;
},
};
return container;
}
HMR
释义
- hot module refresh:监听文件变化,重新编译文件并刷新整个页面
- hot module replacement: 模块热替换,只更新对应文件
hmr过程
- 创建一个websocket服务端。(createWebSocketServer函数)
- 创建一个ws client文件(/@vite/client),并在html中引入。(devHtmlHook)
HTML代码中能看到:
- 服务端监听文件变化,发送websocket消息,告诉客户端变化类型,变化文件等。(chokidar)
- 客户端接受到消息,根据消息内容决定重新刷新页面还是重新加载变化文件,并执行预设的hmr hook函数。( 如上文css处理中介绍到的updateStyle )
/** 入口文件 createServer */
if (!middlewareMode || middlewareMode === 'html') {
// transform index.html
middlewares.use(indexHtmlMiddleware(server))
...
}
/** 文件位置:src/node/server/middlewares/indexHtml.ts */
const devHtmlHook: IndexHtmlTransformHook = async (
html,
{ path: htmlPath, server, originalUrl }
) => {
// 处理一些缓存标识位等信息
...
return {
html,
tags: [
{
tag: 'script',
attrs: {
type: 'module',
// CLIENT_PUBLIC_PATH就是/@vite/client
src: path.posix.join(base, CLIENT_PUBLIC_PATH)
},
injectTo: 'head-prepend'
}
]
}
}
客户端响应逻辑
- 根据type,判断刷新页面、还是更新文件
- 若css等资源文件,则直接修改link href
- 若JS等执行文件。则先执行hook。包含旧文件注销(disposer)。然后通过import加载文件
// @vite/client
socket.addEventListener('message', async ({ data }) => {
handleMessage(JSON.parse(data))
})
// CSS等资源文件
if(css) {
const el = (
[].slice.call(
document.querySelectorAll(`link`)
)
).find((e) => e.href.includes(path))
if (el) {
const newPath = `${path}${
path.includes('?') ? '&' : '?'
}t=${timestamp}`
el.href = new URL(newPath, el.href).href
}
}
// JS等执行文件
if(js) {
...
await Promise.all(
Array.from(modulesToUpdate).map(async (dep) => {
const disposer = disposeMap.get(dep)
// 注销旧文件的影响
if (disposer) await disposer(dataMap.get(dep))
const [path, query] = dep.split(`?`)
try {
const newMod = await import(
/* @vite-ignore */
base +
path.slice(1) +
`?import&t=${timestamp}${query ? `&${query}` : ''}`
)
moduleMap.set(dep, newMod)
} catch (e) {
warnFailedFetch(e, dep)
}
})
)
}
HMR对比Webpack
- 依赖关系的建立:Webpack 在 browser 运行时记录,vite 在服务侧编译时记录;这和构建策略有关,打包后的资源全量的加载,按需使用
- Module 更新:Webpack 直接替换本地缓存的模块(即删除掉)。而 vite 是直接请求新的模块内容并使用新的模块。
- Webpack 编译流程前置,vite 编译流程后置且按需编译;
- Webpack 使用 JSONP 请求新的编译生成的模块。vite 直接使用 ESM import 动态加载发生变更的模块内容。读取 export 导出的内容。
源码小结
- 有限的人力,放在刀刃上。
- 比如:Vite成功的桥接各种前端工具(sass、esbuild、es-module-lexer)
- Vite2.0放弃使用koa,转而投入Http怀抱。让浏览器来判断资源是否缓存,如依赖通过设置max-age = 31536000, immutable
- 诸如静态资源处理这块,与webpack的思路基本一致。充分借鉴
现在
性能
原先,由于TCP启动慢、浏览器不支持并发的原因,所以性能优化中重要的一步就是合并请求。所以出现了webpack等打包工具。目前由于http2、浏览器模块化的发展是否一劳永逸?
数据来源于:Performance recommendations
前提:300个模块。对比压缩前后,耗时
尽管原生 ESM 现在得到了广泛支持,但由于嵌套导入会导致额外的网络往返,在生产环境中发布未打包的 ESM 仍然效率低下(即使使用 HTTP/2)。为了在生产环境中获得最佳的加载性能,最好还是将代码进行 tree-shaking、懒加载和 chunk 分割(以获得更好的缓存)。 - 引用自Vite官网
本地测试
入口文件导入50个独立ts文件(每个文件仅20行),相比于脚手架Demo项目启动,耗时增长91%(基于http1.1)
Http2.0是否能加速
本地基于Http1.1传输,是否因为浏览器6个Tcp连接限制导致效率不行呢? - 文章主题为Vite,所以移除Http发展历程介绍,仅保留结论
结论:本地开发中确实提速不少。但是由于笔者目前的项目属于多SPA的大型应用,首屏访问需要的文件都是千级别。所以尽管快,实际首屏资源请求耗时依旧接近1min,这对于开发者来说有些unbearable
那么实际生产上的效果如何呢?但是笔者并没有在生产环境使用Vite,只能通过一些文章以偏概全
- 目前浏览器实现上,对高度模块化的web app并没有做的很好。仅传输客户端未缓存的文件有点困难
web servers’ and browsers’ implementations are not currently optimized towards highly-modularized web app use cases. It’s hard to only push the resources that the user doesn’t already have cached - Javascript module-use http/2
- 因为TCP的拥塞控制(congestion control)。使得单链接的Http/2很难比Http/1.1快一大截。甚至在低速丢包率较高的情况下(无需担心,通常有线网络下丢包率在0.01%),表现还不如6个连接的Http/1.1。 而且事实上意外的发现。HTTP/2 目前部署在浏览器和服务器中,在大多数情况下通常与 HTTP/1.1 一样快或略快
HTTP/2 as it is currently deployed in browsers and servers is typically as fast or slightly faster than HTTP/1.1 in most conditions. This is in my opinion partly because websites got better at optimizing for HTTP/2 - HOL blocking in HTTP/2 over TCP
其他工具
- 思路与Vite基本一致,且Vite的预构建也是受到了snowpack v1的启发
- 除去官网所说的差异点 ,个人使用上能够发现,Vite已经融合Rollup作为生产打包工具,所有的功能开箱即用。但是Snowpack需要开发者自己搭建引入,有些支离破碎的体验感(这块体感来源于2020年10月V2版本,可能有些久了)
- A tool for making JavaScript code run faster.
- 将运行时损耗的性能,提前到编译阶段将代码进行等价转换,其内置了一个完整的JS解释器,在一个独立环境(prepack js环境 而非 当前Node环境)中执行代码。
- 基于上面的环境,延展宿主环境与prepack执行环境交换 related issues
- 不过多介绍,感兴趣可以点击上方链接
写在最后
- Vite期望完全替换Webpack:
对这个问题,笔者按目前的基建速度并不太乐观。或许是中小型项目能够很快捷的借助Vite进行开发。但是对于大型应用来说,JS Module方式带来的首屏场景下,大量资源请求造成的网络往返耗时令开发者无法接受(实测:巨型项目基于Vite启动需要请求3500+文件,即使用上Http2.0首屏加载依旧耗时80秒)。但是对于大型应用来说抛去了开发环境的快捷。那Vite还具有什么优势呢?
- Vite在非巨石应用的开发环境下启动速度毋庸置疑,借助了ESBuild、ES module特性具有盖过webpack的风头。但是在生产环境构建上,基于Rollup打包。。。差距还好
- HTTP2.0甚至3.0的升级,协议层面在积极解决传输效率问题。硬件层面带宽的拓展也会不断加速。都会使得Es module会越来越主流,个人也希望新技术能够带来更多的可能性。