你是否曾经因为项目过于复杂,每次启动需要等待半天而烦恼?你是否曾为修改一次代码,浏览器迟迟不更新而愤怒?别担心!使用 Vite ,这些问题都能解决。
简介
Vite(读音类似于[weɪt],法语,快的意思),一个基于浏览器原生 ES Modules 的开发服务器。其主要包含两部分:
- 一个开发服务器,它基于原生 ES 模块提供了 丰富的内建功能,如速度快到惊人的模块热更新。
- 一套构建指令,它使用 Rollup 打包你的代码,并且它是预配置的,可以输出用于生产环境的优化过的静态资源。
前置概念
ES Module
ES Module 是浏览器自身支持的模块系统,目前大多数主流浏览器均已支持。
要使用 ES Module 时,script 标签需要带上 type="module"
的标识。
<script type="module" src="https://juejin.cn/index.ts"></script>
复制代码
浏览器会把 import
语法作为一次请求处理,以下图为例,import App from './App.ts'
执行时,浏览器会根据相对路径请求 App.ts
这个文件来获得对应的模块内容。
ESBuild
ESBuild 是一款 js 打包工具,支持 babel、压缩等功能,他的特点是快(比 rollup 等工具会快上几十倍)!
为什么是 Vite
相比于旧的开发模式(使用 webpack 等编译打包工具),使用 Vite 会有更好的开发体验,主要体现在以下几个方面:
- 更快的启动——启动阶段除了依赖的预编译以外,不会进行任何其他编译操作
- 更快的编译——使用 ESBuild 进行编译
- 更快的热更新——每次更新不必分析依赖,重新打包 bundle.js
源码分析
Vite 的 github 仓库地址:github.com/vitejs/vite
启动 API
Vite 项目在启动时,只是执行了 vite
这个指令,定位到 packages/vite/src/node/cli.ts
文件,可以看出 Vite 只是根据用户传入的参数执行了 createServer
这个 API。
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()
复制代码
createServer
的代码位于 packages/vite/src/node/server/index.ts
中。
配置整合
首先会将用户相关的配置做一个整合,赋值给 config
这个变量。
const config = await resolveConfig(inlineConfig, 'serve', 'development')
复制代码
resolveConfig
将读取配置文件中的配置项,与传入的参数做合并。
if (configFile !== false) {
const loadResult = await loadConfigFromFile(
configEnv,
configFile,
config.root,
config.logLevel
)
if (loadResult) {
config = mergeConfig(loadResult.config, config)
configFile = loadResult.path
configFileDependencies = loadResult.dependencies
}
}
复制代码
可以通过 vite --config xxx.ts
来指定配置文件,不然会依次寻找根目录下的 vite.config.js | vite.config.mjs | vite.config.ts
作为默认配置文件。
if (configFile) {
resolvedPath = path.resolve(configFile)
} else {
const jsconfigFile = path.resolve(configRoot, 'vite.config.js')
if (fs.existsSync(jsconfigFile)) {
resolvedPath = jsconfigFile
}
if (!resolvedPath) {
const mjsconfigFile = path.resolve(configRoot, 'vite.config.mjs')
if (fs.existsSync(mjsconfigFile)) {
resolvedPath = mjsconfigFile
}
}
if (!resolvedPath) {
const tsconfigFile = path.resolve(configRoot, 'vite.config.ts')
if (fs.existsSync(tsconfigFile)) {
resolvedPath = tsconfigFile
}
}
}
if (!resolvedPath) {
debug('no config file found.')
return null
}
复制代码
读取到的配置文件内容与传入的 config 做深合并,并保存下配置文件路径。
config = mergeConfig(loadResult.config, config)
configFile = loadResult.path
复制代码
取出配置内容中的插件,按照插件类型进行排序。
const [prePlugins, normalPlugins, postPlugins] = sortUserPlugins(
rawUserPlugins
)
const userPlugins = [...prePlugins, ...normalPlugins, ...postPlugins]
复制代码
执行插件的 config 钩子方法。
for (const p of userPlugins) {
if (p.config) {
const res = await p.config(config, configEnv)
if (res) {
config = mergeConfig(config, res)
}
}
}
复制代码
这里需要注意,config 钩子是在整合插件之后执行的,所以我们自己写插件的时候如果在 config 钩子中处理 plugins 这个字段是不会生效的
将用户配置的插件与 Vite 默认插件做整合,默认包含的插件可以到 packages/vite/src/node/plugins/index.ts
中的 resolvePlugins
方法查看。
(resolved.plugins as Plugin[]) = await resolvePlugins(
resolved,
prePlugins,
normalPlugins,
postPlugins
)
复制代码
到这一步配置项基本就不会再发生改变了,此时会执行插件中的 configResolved
钩子。
await Promise.all(userPlugins.map((p) => p.configResolved?.(resolved)))
复制代码
启动服务
拿到配置后,先初始化 http
与 websocket
服务,http
主要用于启动本地服务器,websocket
主要用于开发阶段热更新。
const middlewares = connect() as Connect.Server
const httpServer = middlewareMode
? null
: await resolveHttpServer(serverConfig, middlewares)
const ws = createWebSocketServer(httpServer, config)
复制代码
接着会加载一系列的中间件,此处暂不做详细赘述。
middlewares.use(proxyMiddleware(httpServer, config))
// ...
middlewares.use(baseMiddleware(server))
//...
middlewares.use('/__open-in-editor', launchEditorMiddleware())
//...
middlewares.use('/__vite_ping', (_, res) => res.end('pong'))
//...
middlewares.use(decodeURIMiddleware())
//...
middlewares.use(servePublicMiddleware(config.publicDir))
//...
middlewares.use(transformMiddleware(server))
//...
middlewares.use(serveRawFsMiddleware())
//...
middlewares.use(serveStaticMiddleware(root, config))
//...
middlewares.use(indexHtmlMiddleware(server))
//...
复制代码
packages/vite/src/node/server/index.ts
中的 startServer
用于启动服务,端口号 port
默认 3000
,默认启动 host
为 127.0.0.1
。
async function startServer(
server: ViteDevServer,
inlinePort?: number,
isRestart: boolean = false
): Promise<ViteDevServer> {
//...
return new Promise((resolve, reject) => {
//...
httpServer.listen(port, options.host, () => {
//...
});
});
}
复制代码
httpServer.listen
方法经过了重新,执行后会执行所有插件的 buildStart
钩子以及 runOptimize
依赖的预打包。
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
复制代码
依赖预打包
做依赖预打包的的原因主要是以下 2 点:
-
CommonJS 和 UMD 兼容性: 开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将作为 CommonJS 或 UMD 发布的依赖项转换为 ESM。
-
Vite 将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能。
依赖收集
Vite 会默认寻找项目中的 html 文件作为 entry,如果想要修改也可以在配置文件中设置 optimizeDeps.entries
或者 build.rollupOptions?.input
。
const explicitEntryPatterns = config.optimizeDeps?.entries
const buildInput = config.build.rollupOptions?.input
if (explicitEntryPatterns) {
entries = await globEntries(explicitEntryPatterns, config)
} else if (buildInput) {
const resolvePath = (p: string) => path.resolve(config.root, p)
if (typeof buildInput === 'string') {
entries = [resolvePath(buildInput)]
} else if (Array.isArray(buildInput)) {
entries = buildInput.map(resolvePath)
} else if (isObject(buildInput)) {
entries = Object.values(buildInput).map(resolvePath)
} else {
throw new Error('invalid rollupOptions.input value.')
}
} else {
entries = await globEntries('**/*.html', config)
}
复制代码
根据 entries
执行一次 esbuild 的编译:
const plugin = esbuildScanPlugin(config, container, deps, missing, entries)
await Promise.all(
entries.map((entry) =>
build({
write: false,
entryPoints: [entry],
bundle: true,
format: 'esm',
logLevel: 'error',
plugins: [...plugins, plugin],
...esbuildOptions
})
)
)
复制代码
相比于生产环境的的 build ,此处会多一个 esbuildScanPlugin
的 esbuild
插件来收集依赖。
function esbuildScanPlugin(
config: ResolvedConfig,
container: PluginContainer,
depImports: Record<string, string>,
missing: Record<string, string>,
entries: string[]
): Plugin {
// ...
return {
name: 'vite:dep-scan',
setup(build) {
// ...
}
}
}
复制代码
如果 entry 是个 html 文件,会将其中的 script 标签抽出改造成 js 形式的入口文件,例如 html 文件中有如下 script 标签:
<script src="/main.tsx" />
复制代码
那么将会被改造成:
import "/main.tsx";
export default {};
复制代码
实现代码
build.onLoad(
{ filter: htmlTypesRE, namespace: 'html' },
async ({ path }) => {
let raw = fs.readFileSync(path, 'utf-8')
const isHtml = path.endsWith('.html')
const regex = isHtml ? scriptModuleRE : scriptRE
regex.lastIndex = 0
let js = ''
let loader: Loader = 'js'
let match
while ((match = regex.exec(raw))) {
const [, openTag, htmlContent, scriptContent] = match
const content = isHtml ? htmlContent : scriptContent
const srcMatch = openTag.match(srcRE)
if (srcMatch) {
const src = srcMatch[1] || srcMatch[2] || srcMatch[3]
js += `import ${JSON.stringify(src)}\n`
} else if (content.trim()) {
js += content + '\n'
}
}
if (!js.includes(`export default`)) {
js += `\nexport default {}`
}
return {
loader,
contents: js
}
}
)
复制代码
每次编译时,都会检查编译目标的路径,如果是来自 node_modules
或者设置了 optimizeDeps.include
,都会存储到 depImports
中,准备后期预打包。
if (resolved.includes('node_modules') || include?.includes(id)) {
if (OPTIMIZABLE_ENTRY_RE.test(resolved)) {
depImports[id] = resolved
}
return externalUnlessEntry({ path: id })
} else {
return {
path: path.resolve(resolved)
}
}
复制代码
这里有个小技巧,如果在开发阶段引用了某个包,显示这个包里面导出的变量不存在,大概率是因为这个包没有被预编译,可以在 optimizeDeps.include
里头加上这个包名,可以暂时解决问题
依赖预打包
通过依赖分析,我们已经拿到了需要进行预打包的 npm 包。
{ deps, missing } = await scanImports(config)
复制代码
将转换后的依赖入口作为 esbuild 的 entryPoints 进行打包,输出的结果会放置在 node_modules/.vite
目录下:
const result = await build({
entryPoints: Object.keys(flatIdDeps),
bundle: true,
format: 'esm',
external: config.optimizeDeps?.exclude,
logLevel: 'error',
splitting: true,
sourcemap: true,
outdir: cacheDir,
treeShaking: 'ignore-annotations',
metafile: true,
define,
plugins: [
...plugins,
esbuildDepPlugin(flatIdDeps, flatIdToExports, config)
],
...esbuildOptions
})
复制代码
其中最关键的点在 esbuildDepPlugin
这个插件,其主要工作是生成依赖的入口内容:
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}"`
}
}
复制代码
如果依赖的包内容是 cjs 模式,会将其转换成 ES6 形式,假设依赖的某个包叫 func
, 其入口内容如下:
// func
const func = () => { console.log('Hello') };
module.exports = func;
复制代码
那么经过转换后,真实打包的入口内容变成:
export default require("./node_modules/func/index.js");
复制代码
如果是 ES6 的模块,则编译的入口内容只是将其引入并导出,同样以 func
为例:
// func
const func = () => { console.log('Hello') };
export default func;
复制代码
转换后真实的打包入口内容:
import d from "./node_modules/func/index.js";
export default d;
复制代码
防止二次预打包
二次启动的时候,会根据项目 package.json
的内容生成 contentHash
,将生成的 hash 值与上一次打包生成的 node_modules/.vite/_metadata.json
中的 hash 进行对比,如果没发生变化则不会打包:
// 根据 package.json 内容生成 hash
function getDepHash(root: string, config: ResolvedConfig): string {
// ...
let content = lookupFile(root, lockfileFormats) || ''
// ...
return createHash('sha256').update(content).digest('hex').substr(0, 8)
}
复制代码
比较 hash 变化:
const dataPath = path.join(cacheDir, '_metadata.json')
const mainHash = getDepHash(root, config)
const data: DepOptimizationMetadata = {
hash: mainHash,
browserHash: mainHash,
optimized: {}
}
// ...
prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8'))
if (prevData && prevData.hash === data.hash) {
log('Hash is consistent. Skipping. Use --force to override.')
return prevData
}
复制代码
总结
Vite 项目在启动阶段主要流程大致如下: