你是否曾经因为项目过于复杂,每次启动需要等待半天而烦恼?你是否曾为修改一次代码,浏览器迟迟不更新而愤怒?别担心!使用 Vite ,这些问题都能解决。
简介
Vite(读音类似于[weɪt],法语,快的意思),一个基于浏览器原生 ES Modules 的开发服务器。其主要包含两部分:
- 一个开发服务器,它基于原生 ES 模块提供了 丰富的内建功能,如速度快到惊人的模块热更新。
- 一套构建指令,它使用 Rollup 打包你的代码,并且它是预配置的,可以输出用于生产环境的优化过的静态资源。
前置概念
ES Module
ES Module 是浏览器自身支持的模块系统,目前大多数主流浏览器均已支持。
要使用 ES Module 时,script 标签需要带上 type="module" 的标识。
<script type="module" src="/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 项目在启动阶段主要流程大致如下:
招聘!!!
字节跳动互娱基础架构团队招人啦!北京、深圳、杭州都有岗位!
我们是谁
字节成立最早的前端架构团队,目前规模最大,做的最专业,手里直接有大几百人的前端业务团队,产品 DAU 上亿级别,每天不用和 PM、UI 撕逼,有良好的技术氛围,业界大牛云集,团队成员都能获得相对好的技术成长。
平时工作
负责抖音、抖音火山版、直播等业务大规模复杂业务场景的前端架构设计、实现和优化
- 负责PC、H5、Hybrid、App Native、BFF、RPC等一种或几种技术场景的架构;
- 制定开发规范,工程化体系搭建及优化,提升开发效率、质量和性能,保障业务稳定运行;
- 发现现有流程及架构的问题,并持续进行优化;
- 解决业务遇到的技术痛点和难点;
- 跟进业内前沿技术,保证团队技术的先进性。
职位要求
- 本科及以上学历,计算机及相关专业;计算机基础扎实,熟悉数据结构、网络等;
- 有一定的架构和方案设计能力及经验,具备一定的方案沟通和推动能力;
- 对后端技术有一定了解,熟悉一门后端语言(java/go等);
- 对前端工程化(例如构建方面:webpack、rollup等)、Nodejs、渲染框架(例如react或vue等)、中后台搭建系统等至少其一有一定深度的实践经验者优先;
- 有大型网站架构经验者优先;有较高的技术热情和积极性者优先。
加分项
- 参与或主导过优秀的开源项目;
- 有优秀的技术博文、博客。
有意者可以添加我微信说明来意: