在之前的开发中我们主要运用 vue-cli 来搭建项目,它是基于 webpack 的脚手架工具。随着 vite 的出现,这个可以提供更快、更精简的构建工具,给我们带来了另一种开发体验。在后续的项目中我们也尝试使用 vite 做开发,整个过程十分流畅,因此也想通过对源码的分析,去深入了解 vite 及 vite 插件在项目开发中起到的作用,以及是怎么加载 vue 文件的。
目前 vite 已经更新到3.x,功能也在逐渐完善。接下来我们可以通过 vue 官方推荐的脚手架工具 create-vue,来创建一个新项目。
npm init vue@latest
可以看到在 vite.config.ts 中会有默认的基础配置,其中涉及到 vite 官方插件 @vitejs/plugin-vue ,对于处理浏览器不识别的 .vue 文件,起到关键性作用。
过程分析
我们从 npm run dev 开始,会涉及到项目启动运行的过程、浏览器访问后文件的处理、文件热更新及缓存等,沿着这条思路,去分析下 vite 项目在启动及开发的过程中都做了什么?
运行项目
当我们在终端输入 npm run dev
的时候,npm 会在当前目录的 node_modules/.bin 查找要执行的程序,当使用 npm install 安装 vite 依赖时,在 vite 的package.json 文件中配置有 bin 字段,此时就会在 node_modules 文件夹下面的 .bin 目录中复制 bin 字段链接的执行文件,它其实是命令名到本地文件名的映射,bin 文件中呈现的是 shell 脚本,去执行 vite/bin/vite.js
文件,这样做是因为命令行中并没有 vite 的命令,我们在调用执行文件时,可以不带路径,直接使用命令名来执行相对应的执行文件。
启动服务
根据 vite/bin/vite.js
路径,在 vite 源码中的 bin 目录可以下找到 vite.js 文件,会调用 start 方法,引入 dist 目录下的服务端文件,该文件为打包后的内容,具体代码可以vite/src/node/cli.ts
中查看,用于启动服务端。
在cli.ts
中,首先通过 cac (一个JavaScript库,用于构建应用的CLI),创建命令行交互,用 picocolors 来修改终端输出字符的样式,它和 chalk 的功能一样,但是体积小,速度快,满足研发过程中的需要,如果你正在开发一个库,可以试一下这两个的组合。
当在终端输入npm run dev
时,会执行 cli.action 中的回调函数:
import { cac } from 'cac'
import colors from 'picocolors'
// dev
cli
.command('[root]', 'start dev server') // default command
.alias('serve') // the command is called 'serve' in Vite's API
.alias('dev') // alias to align with the script name
...
.action(async (root: string, options: ServerOptions & GlobalCLIOptions) => {
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)
})
...
await server.listen()
...
// 输出启动信息
server.printUrls()
} catch (e) {
createLogger(options.logLevel).error(
colors.red(`error when starting dev server:\n${e.stack}`),
{ error: e }
)
process.exit(1)
}
调用 createServer 函数,启动 server 服务,实现对浏览器请求的响应,在接下来的热更新中还会继续介绍该函数。关于创建服务,在vite@2.x 开始使用 connect 创建服务,而不是 koa。启动后 vite 会先通过 esbuild 对引入的 node_modules 模块及在 vite.config.ts 中配置的 optimizeDeps 内容执行预构建。
- 因为 vite 需要将代码中 CommonJS / UMD 的依赖项转换为 ES 模块格式,并缓存入当前项目的 node_modules/.vite 中,避免重复构建
- 页面中引用的 node_modules 模块中在其内部也会有多个模块依赖存在,导致浏览器发起多个请求,影响性能,需要将构建成一个模块,比如,在 main.js 中引入的 vue、vue-router、pinia 等模块:
关于 esbuild 是一款基于
Go
语言开发的javascript
打包工具,它的构建速度是 webpack 的几十倍。
浏览器访问
由于现代浏览器已原生支持 ES 模块,可以通过 <script type="module">
的方式加载标准的 ES 模块
<script type="module">
import { createApp } from "/node_modules/.vite/vue.js?v=c260ab7b";
</script>
但是在浏览器中不支持裸模块的导入 import { createApp } from 'vue'
,在启动服务器 devServer后,需要重写引入路径,例如 /node_modules/.vite/vue.js?v=c260ab7b
,当浏览器请求资源时,劫持浏览器的 http 请求,省去打包编译的过程,只有引入的模块才会加载,其他模块则不会,实现了真正的按需加载。对于浏览器不识别的非 JavaScript 文件(jsx,css 或者 vue/svelte 组件),依靠 vite 官方插件,进行代码转换后,再返回给浏览器。例如 .vue 文件,通过 @vitejs/plugin-vue
,将 template、script、style部分,经过插件处理后在文件中 import 引入不同路径,在浏览器中,通过 type 类型区分不同请求:
热更新
在 vite 中,热更新是在原生 ESM 上执行的。当某个模块内容改变时,让浏览器去重新请求该模块,进行分解,返回给浏览器处理,不涉及编译,而 webpack 则会对模块相关依赖进行重新编译,随着项目的增大,其打包速度也会下降。
从源码中了解热更新:
在 createServer
的时候,通过 WebSocket
创建浏览器和服务器通信,使用 chokidar
监听文件的改变,当模块内容修改是,发送消息通知客户端,只对发生变更的模块重新加载。
export async function createServer(
inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
// 生成所有配置项,包括vite.config.js、命令行参数等
const config = await resolveConfig(inlineConfig, 'serve', 'development')
// 初始化connect中间件
const middlewares = connect() as Connect.Server
const httpServer = middlewareMode
? null
: await resolveHttpServer(serverConfig, middlewares, httpsOptions)
const ws = createWebSocketServer(httpServer, config, httpsOptions)
// 初始化文件监听
const watcher = chokidar.watch(path.resolve(root), {
ignored: [
'**/node_modules/**',
'**/.git/**',
...(Array.isArray(ignored) ? ignored : [ignored])
],
ignoreInitial: true,
ignorePermissionErrors: true,
disableGlobbing: true,
...watchOptions
}) as FSWatcher
// 生成模块依赖关系,快速定位模块,进行热更新
const moduleGraph: ModuleGraph = new ModuleGraph((url, ssr) =>
container.resolveId(url, undefined, { ssr })
)
// 监听修改文件内容
watcher.on('change', async (file) => {
file = normalizePath(file)
if (file.endsWith('/package.json')) {
return invalidatePackageDjianata(packageCache, file)
}
// invalidate module graph cache on file change
moduleGraph.onFileChange(file)
if (serverConfig.hmr !== false) {
try {
// 执行热更新
await handleHMRUpdate(file, server)
} catch (err) {
ws.send({
type: 'error',
err: prepareError(err)
})
}
}
})
// 主要中间件,请求文件转换,返回给浏览器可以识别的js文件
middlewares.use(transformMiddleware(server))
...
return server
}
当监听到文件内容变化 change
时,先执行 moduleGraph.onFileChange(file)
onFileChange(file: string): void {
const mods = this.getModulesByFile(file)
if (mods) {
const seen = new Set<ModuleNode>()
mods.forEach((mod) => {
this.invalidateModule(mod, seen)
})
}
}
invalidateModule(
mod: ModuleNode,
seen: Set<ModuleNode> = new Set(),
timestamp: number = Date.now()
): void {
// 修改时间戳
mod.lastInvalidationTimestamp = timestamp
// 使转换结果无效,确保下次请求时重新处理该模块
mod.transformResult = null
mod.ssrTransformResult = null
invalidateSSRModule(mod, seen)
}
接着执行handleHMRUpdate
函数,通过moduleGraph.getModulesByFile(file)
,获取需要更新的模块,调用updateModules
函数,此时会对一些文件特殊处理,比如是 .env 配置文件、html 文件等情况,ws
发送full-reload
,页面刷新。
在客户端 ws
接收到不同更新类型,执行相应操作:
async function handleMessage(payload: HMRPayload) {
switch (payload.type) {
// 通信连接
case 'connected':
...
setInterval(() => socket.send('ping'), __HMR_TIMEOUT__)
break
// 更新部分代码
case 'update':
payload.updates.forEach((update) => {
if (update.type === 'js-update') {
queueUpdate(fetchUpdate(update))
} else {
// css-update
...
})
break
// 全更新
case 'full-reload':
...
location.reload()
...
break
...
}
}
在更新部分代码时会调用fetchUpdate
函数
async function fetchUpdate({ path, acceptedPath, timestamp }: Update) {
...
if (isSelfUpdate || qualifiedCallbacks.length > 0) {
const dep = acceptedPath
const disposer = disposeMap.get(dep)
if (disposer) await disposer(dataMap.get(dep))
const [path, query] = dep.split(`?`)
try {
// import更新文件添加时间戳,浏览器发送get请求,返回新结果
const newMod: ModuleNamespace = await import(
/* @vite-ignore */
base +
path.slice(1) +
`?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${
query ? `&${query}` : ''
}`
)
moduleMap.set(dep, newMod)
} catch (e) {
warnFailedFetch(e, dep)
}
}
...
}
思考
在此有个问题,启动服务过程中,怎么利用浏览器缓存来做优化的?
vite 利用 HTTP 头来加速整个页面的重新加载,源码模块的请求会根据 304 Not Modified
进行协商缓存,而依赖模块请求则会通过 Cache-Control: max-age=31536000,immutable
进行强缓存,因此一旦被缓存它们将不需要再次请求。
关于浏览器Cache-Control
:
- max-age=xxx:缓存内容将在xxx秒后失效
- no-cache: 客户端缓存内容,是否使用缓存需要经过协商缓存来验证决定
我们来看下在createServer
时提到的 transformMiddleware
里面有哪些动作:
export function transformMiddleware(
server: ViteDevServer
): Connect.NextHandleFunction {
const {
config: { root, logger },
moduleGraph
} = server
...
// 返回一个执行函数
return async function viteTransformMiddleware(req, res, next) {
...
// 判断url,正则匹配,其中isJSRequest的正则为:
// const knownJsSrcRE = /.((j|t)sx?|mjs|vue|marko|svelte|astro)($|?)/
if (
isJSRequest(url) ||
isImportRequest(url) ||
isCSSRequest(url) ||
isHTMLProxy(url)
) {
...
// http协商缓存:
// 通过比对请求头中的if-none-match与文件的etag值,如果未变化返回 304,使用浏览器缓存,
// 否则返回一个完整响应内容,在响应头上添加新的etag值
const ifNoneMatch = req.headers['if-none-match']
// 文件内容未发生变化
if (
ifNoneMatch &&
(await moduleGraph.getModuleByUrl(url, false))?.transformResult
?.etag === ifNoneMatch
) {
isDebug && debugCache(`[304] ${prettifyUrl(url, root)}`)
res.statusCode = 304
return res.end()
}
// 文件内容发生变化
// 依赖vite插件进行解析转换,返回code
// 如果是npm依赖,会通过 Cache-Control: max-age=31536000,immutable 进行强缓存,因此一旦被缓存它们将不需要再次请求
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) || isOptimizedDepUrl(url)
return send(req, res, result.code, type, {
etag: result.etag,
// 允许浏览器缓存npm依赖
cacheControl: isDep ? 'max-age=31536000,immutable' : 'no-cache',
headers: server.config.server.headers,
map: result.map
})
}
...
next()
}
在上一步处理请求时,调用 transformRequest
函数,比如 vue 文件,会命中 @vite/plugin-vue
插件对template
、style
分别做处理
export function transformRequest(
url: string,
server: ViteDevServer,
options: TransformOptions = {}
): Promise<TransformResult | null> {
...
const request = doTransform(url, server, options, timestamp)
...
return request
}
async function doTransform(
url: string,
server: ViteDevServer,
options: TransformOptions,
timestamp: number
) {
...
// 插件命中
const id =
(await pluginContainer.resolveId(url, undefined, { ssr }))?.id || url
...
// 加载
const loadResult = await pluginContainer.load(id, { ssr })
...
// 转换
const transformResult = await pluginContainer.transform(code, id, {
inMap: map,
ssr
})
...
// 返回处理结果
// 启用协商缓存,在响应头中带上etag
const result = ssr
? await ssrTransform(code, map as SourceMap, url)
: ({
code,
map,
etag: getEtag(code, { weak: true })
} as TransformResult)
return result
}
总结
主要了解了下关于 vite 启动过程中,文件处理、热更新的执行过程,源代码中有很多细节处理,有兴趣可以去深入研究一下,在此过程中,了解了一些 npm 包及其作用,如cac
、picocolors
、chokidar
等,以及http
缓存的应用,还有发现源码中会有很多map、set
数据结构的使用,包括数据增、删、改、是否存在等,带着问题继续探索。
暂无评论内容