Vite是下一代的前端开发与构建工具,为什么称为下一代?根本原因在于其基于原生ES Module。在目前的前端工程化生态中,webpack、rollup、esbuild等非常流行,而Vite真是构建在一些流行的技术上。Vite的出现实际上是前端模块规范发展到现在自然出现的产物,它并不是首个基于ES Module的构建工具,还有一些同类型的工具例如Snowpack、@web/dev-server等等。
Vite是基于ES Module的构建工具,可与webpack做对比来学习了解。从构建工具的角度来看前端发展,目前可以简单分为三个阶段:
前端项目越来越大,你会发现webpack等传统构建工具的项目启动速度、HMR速度是越来越慢,Vite实际上就是来解决这两个问题的。基于ES Module,Vite是如何加快开发阶段项目启动和更新呢?
Vite将应用模块分为两类:依赖和源码:
- 依赖是指第三方模块即node_modules中模块
- 源码是指非node_modules中本项目源代码
在项目启动时,Vite使用esbuild来预编译代码中使用的依赖包,但是不会对源码做任何处理,一方面得益于Go语言编写的esbuild的编译速度,这个过程相对webpack的JS语言编写的acorn编译非常快,另一方面减少编译的代码量,从而大大提高了项目的启动速度。并且在预编译后还添加了缓存机制,避免再次启动的不必要编译。
在开发过程中,Vite才开始对源码进行必要的转换和动态更新。这个过程是按需进行的,只有当浏览器需要相关模块源码时才会执行,这个过程Vite会通过相关机制来实现。按需更新保证了在开发阶段HMR的速度问题,从而带来良好的开发体验。
实际上Vite背后的原理主要就是这两点实现的逻辑处理,当然还涉及到一些不同规范模块转换、CSS分割、ESM特点导致的其他兼容处理等等一系列工作。
vite官方提供了创建基于vite的项目开发的脚手架create-vite-app,其内置常见的MVVM框架的项目模板,例如vue、vue + typescript、react等。
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
}
基于Vite 2.6.7版本创建的,开发环境的命令直接就是执行vite命令。在源码中vite命令对应的逻辑总结如下:
// vite/bin/vite.js
// --debug调试参数,会在终端打印出vite相关细节
const debugIndex = process.argv.findIndex((arg) => /^(?:-d|--debug)$/.test(arg))
if (debufIndex > 0) { // 相关逻辑 }
// --profile统计分析参数,会生成profile文件
const profileIndex = process.argv.indexOf('--profile')
if (profileIndex > 0) {
// 相关逻辑
} else {
require('../dist/node/cli')
}
vite命令最核心的逻辑是执行相关目录下的cli文件,该文件逻辑主要如下:
const cac = require('cac)
...
const cli = cac('vite')
...
// dev命令
cli
.command('[root]') // default command
.alias('serve') // the command is called 'serve' in Vite's API
.alias('dev') // alias to align with the script name
.action(async() => {
// 命令对应的逻辑
})
// build命令
cli
.command('build [root]')
.action(async() => {
// 命令对应的逻辑
})
// preview命令
cli
.command('preview [root]')
.action(async() => {
// 命令对应的逻辑
})
实际上就是使用cac库来创建CLI工具,定义了dev、build、preview命令具体的执行逻辑。项目启动和开发阶段更新都是在dev命令的逻辑下面,其action逻辑实际上主要就是创建服务器并进行监听:
const { createServer } = await import('./server')
const server = await createServer({
root,
base: options.base,
mode: options.mode,
configFile: options.config,
logLevel: options.logLevel,
clearScreen: options.clearScreen,
server: cleanOptions(options)
})
if (!server.httpServer) {
throw new Error('HTTP server not available')
}
await server.listen()
依赖预构建逻辑都在创建服务器的相关文件中,即调用node/server下文件对应的createServer函数来创建开发服务器。
createServer函数主要的逻辑主要以下几点:
在Vite源码中相关配置对应的逻辑是通过中间件来实现的:
// 默认内置的中间件
const middlewares = connect() as Connect.Server
中间件实际上是Node的Connect(中间件框架)的实例, 通过Connect框架来实现应用自定义中间件或服务器。在源码中针对一些配置项应用相关内置的中间件,这里简单列举一些:
// request timer
if (process.env.DEBUG) {
middlewares.use(timeMiddleware(root))
}
// cors (enabled by default)
const { cors } = serverConfig
if (cors !== false) {
middlewares.use(corsMiddleware(typeof cors === 'boolean' ? {} : cors))
}
// proxy
const { proxy } = serverConfig
if (proxy) {
middlewares.use(proxyMiddleware(httpServer, config))
}
// base
if (config.base !== '/') {
middlewares.use(baseMiddleware(server))
}
其中一些重要的中间件有:
该中间件逻辑如下:
export function servePublicMiddleware(dir: string): Connect.NextHandleFunction {
const serve = sirv(dir, sirvOptions)
return function viteServePublicMiddleware(req, res, next) {
// 跳过相关静态资源的请求 skip import request and internal requests `/@fs/ /@vite-client` etc
if (isImportRequest(req.url!) || isInternalRequest(req.url!)) {
return next()
}
serve(req, res, next)
}
}
sirv是一个轻量级的中间件,用于处理对静态资源的请求,具体使用可以去看相关npm sirv文档。
// serve static files under /public
// this applies before the transform middleware so that these files are served
// as-is without transforms.
if (config.publicDir) {
middlewares.use(servePublicMiddleware(config.publicDir))
}
publicDir默认是public,作为静态资源服务的文件夹,该文件夹下内容不会被编译转换。serveStaticMiddleware和serveRawFsMiddleware中间件虽然处理不同的静态资源获取的情况,但是底层都是使用sirv来实现的,这里就不展开说明了。
该中间件的应用是有前提条件的,即:
if (!middlewareMode || middlewareMode === 'html') {
middlewares.use(spaFallbackMiddleware(root))
}
middlewareMode支持布尔值和字符串,该属性是serve配置项:
以中间件模式创建 Vite 服务器。(不含 HTTP 服务器)
- ‘ssr’ 将禁用 Vite 自身的 HTML 服务逻辑,因此你应该手动为 index.html 提供服务。
- ‘html’ 将启用 Vite 自身的 HTML 服务逻辑。
当该属性值是ssr和是布尔值true是等价, 该属性可以不配置默认该值是false。spaFallbackMiddleware中间件实际上是针对index.html文件访问地址的处理:
import history from 'connect-history-api-fallback'
export function spaFallbackMiddleware(
root: string
): Connect.NextHandleFunction {
const historySpaFallbackMiddleware = history({
// support /dir/ without explicit index.html
rewrites: [
{
from: /\/$/,
to({ parsedUrl }: any) {
const rewritten =
decodeURIComponent(parsedUrl.pathname) + 'index.html'
if (fs.existsSync(path.join(root, rewritten))) {
return rewritten
} else {
return `/index.html`
}
}
}
]
})
SPA项目使用History API的路由方式当刷新时对于不匹配的路由会找不到对应的文件,使用connect-history-api-fallback来解决相关文件,vite在该中间中对于访问index.html的一些路径支持。
if (!middlewareMode || middlewareMode === 'html') {
// transform index.html
middlewares.use(indexHtmlMiddleware(server))
}
该中间的逻辑就是转换index.html的内容,应用针对htm内容的一些插件操作html对应的文本内容生成最终的内容,逻辑简单概括两步:
开发阶段的CSS插入、组件插入等等都是在此中间件执行的。
vite重写了Node服务器listen方法来在服务器启动完成前执行预编译动作,具体逻辑如下:
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)
}
}
if (!middlewareMode && httpServer) {
let isOptimized = false
// overwrite listen to run optimizer before server start
const listen = httpServer.listen.bind(httpServer)
httpServer.listen = (async (port: number, ...args: any[]) => {
if (!isOptimized) {
try {
await container.buildStart({})
await runOptimize()
isOptimized = true
} catch (e) {
httpServer.emit('error', e)
return
}
}
return listen(port, ...args)
}) as any
} else {
await container.buildStart({})
await runOptimize()
}
从上面逻辑可知,核心逻辑是调用optimizeDeps,而该方法逻辑简单总结如下几点:
收集依赖就要有入口,默认情况下,Vite 会抓取你的 index.html 来检测需要预构建的依赖项。如果指定了config.optimizeDeps.entries、 build.rollupOptions.input,Vite 将转而去抓取这些入口点。
获取到入口之后就调用esbuild的build API来操作文件系统中文件,具体代码如下:
await Promise.all(
entries.map((entry) =>
build({
absWorkingDir: process.cwd(),
write: false,
entryPoints: [entry],
bundle: true,
format: 'esm',
logLevel: 'error',
plugins: [...plugins, plugin],
...esbuildOptions
})
)
从入口文件开始遍历所有文件找到符合要求的import语句,获取对应的依赖。并不是所有import语句对应的模块都是依赖,这个过程会筛选符合要求的import语句,具体是通过esbuildScanPlugin生成的插件逻辑来决定的。esbuildScanPlugin插件会定义相关的过滤条件,这边就涉及到esbuild插件相关知识。
具体的扫描过程相对复杂繁琐,不过有一个非常重要的工具库es-module-lexer,该库用于ES模块语法词法分析。
当获取到符合要求的依赖后,就逐个对依赖进行编译,并输出到缓存目录.vite中,这个过程涉及到esbuild、es-module-lexer相关使用。
Vite预编译过程从源码来看还是非常清晰的,其中涉及到esbuild、es-module-lexer、fast-glob等相关库的使用逻辑相对又非常复杂,涉及到编译原理。作为可用于实际开发的工具,其细节还是非常多的,本文仅仅在流程上大概梳理下预编译阶段的主要过程点。预编译过程整体如下:
开发服务器创建过程逻辑主要如下:
合并配置文件和相关默认值生成最终的配置对象
根据不同的配置添加不同的中间件,用于在更新阶段做相关处理
重写开发服务器listen方法执行预编译方法,主要就是调用optimizeDeps方法
- 创建.vite缓存目录并创建相关编译的依赖文件等等
- 从入口文件扫描收集依赖,入口文件默认是index.html,可通过config.optimizeDeps.entries、 build.rollupOptions.input来指定,优先级大于默认的
- 使用esbuild预编译相关依赖为ESM格式,所有依赖都是编译成ES Module规范的,依赖可能是IIFE、CommonJs等
使用chokidar库创建监听器来监听相关文件变动执行不同的逻辑以及创建webscoket连接