前言
在create-vue快速生成项目,到底是怎么做的一文中,我们只讲了通过npm create vue@latest
来创建一个项目,并生成以下指令:
但是并没有讲到后面pnpm dev
,也就是npm run dev
,这里需要对vite
本地服务器有一定足够的了解之后,才能去讲,并且vite
在开发环境中使用的是esbuild
,生产环境中用的是rollup
、那么本章就讲一讲vite
的指令解析、esbuild
、rollup
相关。
vite是如何来判断环境的呢
在项目的package.json
里面会有这些指令。
// 只讲dev和build、preview,其他的操作先删除
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
},
- 执行
npm run dev
之后,vite
会创建一个本地服务器。 - 执行
npm run build
之后,vite
会进行文件打包。 - 执行
npm run preview
之后,vite
会....
不知道vite
会干嘛,那就来读一下源码:
我们直接跳到
function start() {
return import("../dist/node/cli.js");
}
这个start
函数才是vite
的核心入口函数,在这个函数中通过require
导入了cli.js
文件,在这个文件里面创建了cli
对象,这个对象长这样:
通过cli
对象注册dev
命令,和build
、preview
命令
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
...
cli
.command('build [root]', 'build for production')
...
cli
.command('preview [root]', 'locally preview production build')
...
// 通过cli注册的其他命令,请查看源码
下面分别来讲一下,他们的action
回调:
cli.command('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, options) => {
// 处理options
filterDuplicateOptions(options);
// 导入createServer函数,创建一个本地服务器
const { createServer } = await import('./server')
// 创建server对象
try {
const server = await createServer({
root,
base: options.base,
mode: options.mode,
configFile: options.config,
logLevel: options.logLevel,
clearScreen: options.clearScreen,
optimizeDeps: { force: options.force },
server: cleanOptions(options),
});
// 创建失败,就抛错
if (!server.httpServer) {
throw new Error('HTTP server not available');
}
// 监听端口,启动服务
await server.listen();
const info = server.config.logger.info;
// vite的启动时间
const viteStartTime = global.__vite_start_time ?? false;
// 计算并展示,vite启动服务所耗费时间
const startupDurationString = viteStartTime
? picocolorsExports.dim(
`ready in ${picocolorsExports.reset(picocolorsExports.bold(Math.ceil(
performance.now() - viteStartTime)))} ms`)
: '';
// 打印
info(`\n ${picocolorsExports.green(`$
{picocolorsExports.bold('VITE')} v${VERSION}`)}
${startupDurationString}\n`, { clear: !server.config.logger.hasWarned });
// 打印
server.printUrls();
// VITE v4.1.3 ready in 1157 ms
// ➜ Local: http://localhost:5173/
// ➜ Network: use --host to expose
// ➜ press h to show help
// 处理简单指令,就比如在控制台按r就重新启动服务,具体的命令如下:
// 为了好看,下面扩展简化了
bindShortcuts(server, {...});
}
catch (e) {
const logger = createLogger(options.logLevel);
logger.error(picocolorsExports.red(`error when starting dev server:\n${e.stack}`), {
error: e,
});
stopProfiler(logger.info);
process.exit(1);
}
});
扩展:上面简单的指令如下所示,能够按照指令,对服务进行重启、打印url
、打开浏览器、清空控制台、终止进程等操作。
let BASE_SHORTCUTS = [
{
key: 'r',
description: 'restart the server',
async action(server) {
await server.restart()
}
},
{
key: 'u',
description: 'show server url',
action(server) {
server.config.logger.info('')
server.printUrls()
}
},
{
key: 'o',
description: 'open in browser',
action(server) {
const url = server.resolvedUrls?.local[0]
if (!url) {
server.config.logger.warn('No URL available to open in browser')
return
}
openBrowser(url, true, server.config.logger)
}
},
{
key: 'c',
description: 'clear console',
action(server) {
server.config.logger.clearScreen('error')
}
},
{
key: 'q',
description: 'quit',
async action(server) {
await server.close().finally(() => process.exit())
}
}
]
cli.command('build')
cli
.command('build [root]', 'build for production')
.(...)
.action(async (root, options) => {
// 处理options
filterDuplicateOptions(options);
// 引入build函数,进行打包
const { build } = await import('./build')
// 调用cleanOptions函数,删除掉打包配置一些属性
const buildOptions = cleanOptions(options);
// 调用build函数,打包
try {
await build({
root,
base: options.base,
mode: options.mode,
configFile: options.config,
logLevel: options.logLevel,
clearScreen: options.clearScreen,
optimizeDeps: { force: options.force },
build: buildOptions,
});
}
catch (e) {
createLogger(options.logLevel).error(picocolorsExports.red(`
error during build:\n${e.stack}`), { error: e });
process.exit(1);
}
finally {
// 最后在控制台输出打包的结果
stopProfiler((message) => createLogger(options.logLevel).info(message));
}
});
cli.command('preview')
cli
.command('preview [root]', 'locally preview production build')
.(...)
.action(async (root, options) => {
// 处理options
filterDuplicateOptions(options);
// 导入preview函数
const { preview } = await import('./preview')
// 执行preview函数
try {
const server = await preview({
root,
base: options.base,
configFile: options.config,
logLevel: options.logLevel,
mode: options.mode,
build: {
outDir: options.outDir,
},
preview: {
port: options.port,
strictPort: options.strictPort,
host: options.host,
https: options.https,
open: options.open,
},
});
// 打印,与dev类似
server.printUrls();
}
catch (e) {
createLogger(options.logLevel).error(
picocolorsExports.red(`error when starting preview server:\n${e.stack}`), { error: e });
process.exit(1);
}
finally {
// 最后在控制台输出打包的结果
stopProfiler((message) => createLogger(options.logLevel).info(message));
}
});
根据上面的三个指令,我们大致清楚了这些内容:
dev
命令,通过createServer
函数创建了一个服务器,再注册了一些指令来辅助开发。build
命令,通过build
函数,进行打包。preview
命令,通过preview
函数,开启一个本地服务器,模拟production
环境。
vite本地服务器
我们在上面看到了通过createServer
来创建一个vite
本地服务器,服务器长这样:
并且vite
服务器是借助于中间件connect
服务器实现的:
const middlewares = connect() as Connect.Server
const httpServer = middlewareMode
? null
: await resolveHttpServer(serverConfig, middlewares, httpsOptions)
resolveHttpServer
通过connect
中间件,传入resolveHttpServer
,创建一个web
服务器
export async function resolveHttpServer(
{ proxy }: CommonServerOptions,
app: Connect.Server,
httpsOptions?: HttpsServerOptions,
): Promise<HttpServer> {
if (!httpsOptions) {
const { createServer } = await import('node:http')
return createServer(app)
}
// 通过http模块,创建代理服务器
if (proxy) {
const { createServer } = await import('node:https')
return createServer(httpsOptions, app)
} else {
const { createSecureServer } = await import('node:http2')
return createSecureServer( // http2
{
// Manually increase the session memory to prevent 502 ENHANCE_YOUR_CALM
// errors on large numbers of requests
maxSessionMemory: 1000,
...httpsOptions,
allowHTTP1: true,
},
// @ts-expect-error TODO: is this correct?
app,
) as unknown as HttpServer
}
}
我们可以看到,viteDevServer
上挂载了很多静态方法与属性,其中listen
方法也被挂在了上面,之后通过调用server.listen()
方法启动服务器,说白了就是connect.listen()
。
async listen(port?: number, isRestart?: boolean) {
await startServer(server, port) // 启动服务器
if (httpServer) {
// 搞到启动的url
server.resolvedUrls = await resolveServerUrls(
httpServer,
config.server,
config,
)
// 根据简短指令与config的配置,是否自动打开浏览器
if (!isRestart && config.server.open) server.openBrowser()
}
return server
},
在createServer
函数中,还创建了一个ws
服务来做HMR
。
...
const ws = createWebSocketServer(httpServer, config, httpsOptions)
具体的流程可以参照前端构建工具vite进阶系列(五) -- vite的热更新(HMR)机制的实践与原理,当然了,connect
本地服务器,你可以认为它是一种express
服务,因为express
就是在connect
的上层做了封装而已。
vite与esbuild
在开发环境中,vite
是怎么去要求esbuild
能够做vite
具体想做的事情呢?因为在vite
中很多地方都用到了esbuild
,比如预编译、转换TypeScript
、转换Jsx
、Tsx
等,那么这里我们再来深入了解一下预编译,你也可以查看前端构建工具vite进阶系列(二) -- vite的依赖预构建与配置文件相关处理,为什么要做依赖预解析。预构建的入口是initDepsOptimizer
函数,我们一起来看看吧。
initDepsOptimizer
在initDepsOptimizer
函数里面,调用createDepsOptimizer
创建了一个依赖分析器,并且通过loadCachedDepOptimizationMetadata
获取了上一次预构建的产物cachedMetadata
。
// 获取预构建的依赖,元信息
const cachedMetadata = loadCachedDepOptimizationMetadata(config, ssr)
loadCachedDepOptimizationMetadata
export function loadCachedDepOptimizationMetadata(
config: ResolvedConfig,
ssr: boolean,
force = config.optimizeDeps.force,
asCommand = false,
): DepOptimizationMetadata | undefined {
const log = asCommand ? config.logger.info : debug
// Before Vite 2.9, dependencies were cached in the root of the cacheDir
// For compat, we remove the cache if we find the old structure
if (fs.existsSync(path.join(config.cacheDir, '_metadata.json'))) {
emptyDir(config.cacheDir)
}
const depsCacheDir = getDepsCacheDir(config, ssr)
if (!force) { // 强制重新预构建
let cachedMetadata: DepOptimizationMetadata | undefined
try {
// 读取_metadata.json信息
const cachedMetadataPath = path.join(depsCacheDir, '_metadata.json')
cachedMetadata = parseDepsOptimizerMetadata(
fs.readFileSync(cachedMetadataPath, 'utf-8'),
depsCacheDir,
)
} catch (e) {}
// 读取_metadata.json文件中的hash与当前hash比较
// 一致则不需要重新预构建
if (cachedMetadata && cachedMetadata.hash === getDepHash(config, ssr)) {
log('Hash is consistent. Skipping. Use --force to override.')
return cachedMetadata
}
} else {
config.logger.info('Forced re-optimization of dependencies')
}
// 不一致则重新预构建
fs.rmSync(depsCacheDir, { recursive: true, force: true })
}
_metadata.json
{
"hash": "bbce761b", // 预构建之后生成的hash
"browserHash": "b92f51d8", // 每个文件后面会带上
"optimized": {
"pinia": {
"src": "../../pinia/dist/pinia.mjs",
"file": "pinia.js",
"fileHash": "763eaad0",
"needsInterop": false
},
"vue": {
"src": "../../vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "fc2e66ef",
"needsInterop": false
},
"vue-router": {
"src": "../../vue-router/dist/vue-router.mjs",
"file": "vue-router.js",
"fileHash": "edbaad58",
"needsInterop": false
}
},
"chunks": {
"chunk-5OBJFL24": {
"file": "chunk-5OBJFL24.js"
},
"chunk-3NMN3MUW": {
"file": "chunk-3NMN3MUW.js"
}
}
}
在此文件中,记录了预解析之后的源文件路径,并且会以强缓存的形式缓存这些依赖。 预构建流程在源码中分为两部分:
- 第一种是:通过命令行
vite optimize
来手动预解析,调用optimizeDeps
函数。
optimizeDep
export async function optimizeDeps(
config: ResolvedConfig, // 接收一个 ResolvedConfig 类型的配置对象作为参数
force = config.optimizeDeps.force, // 默认情况下,force 参数的值等于 config.optimizeDeps.force 的值
asCommand = false, // 默认情况下,asCommand 参数为 false
): Promise<DepOptimizationMetadata> { // 返回一个 Promise 对象,其泛型类型为 DepOptimizationMetadata
const log = asCommand ? config.logger.info : debug // 根据 asCommand 的值确定日志输出函数
// 根据配置文件中的 command 和 build.ssr 判断是否开启 SSR
const ssr = config.command === 'build' && !!config.build.ssr
// 读取上一次预构建的产物
const cachedMetadata = loadCachedDepOptimizationMetadata(
config,
ssr,
force,
asCommand,
)
if (cachedMetadata) { // 如果缓存中已存在该对象,直接返回
return cachedMetadata
}
const deps = await discoverProjectDependencies(config).result // 获取当前项目的依赖项
const depsString = depsLogString(Object.keys(deps)) // 生成依赖项的日志输出字符串
log(colors.green(`Optimizing dependencies:\n ${depsString}`)) // 输出日志
// 根据配置文件中手动指定的依赖项,手动添加到 deps 对象中
await addManuallyIncludedOptimizeDeps(deps, config, ssr)
// 将 deps 对象转换成 DiscoveredDependencyInfo 类型的对象
const depsInfo = toDiscoveredDependencies(config, deps, ssr)
// 打包依赖,返回打包结果
const result = await runOptimizeDeps(config, depsInfo).result
await result.commit() // 将优化结果写入缓存
return result.metadata // 返回优化结果的 metadata 属性,即 DepOptimizationMetadata 对象
}
- Vite 在
optimizeDeps
函数中调用loadCachedDepOptimizationMetadata
函数,读取上一次预构建的产物,如果产物存在,则直接return
。 - 如果不存在则调用
discoverProjectDependencies
对依赖进行扫描,获取到项目中的所有依赖,并返回一个deps
。 - 然后通过
toDiscoveredDependencies
函数把依赖包装起来,再通过runOptimizeDeps
进行依赖打包。 - 返回
metadata
产物。
- 第二种是:在
createServer
函数中,调用initDepsOptimizer
->createDepsOptimizer
->loadCachedDepOptimizationMetadata
->discoverProjectDependencies
->toDiscoveredDependencies
->runOptimizeDeps
这个流程进行预构建的,上面已经分析过了,不再复述。
上面两种其实流程都一样,只是optimize
函数,抽离了createServer
函数的部分预构建的逻辑。那我们继续来看一下discoverProjectDependencies
是怎么获取到deps
的。
discoverProjectDependencies
通过scanImports
来扫描依赖,而scanImports
则是通过esbuild
插件esbuildScanPlugin
来工作的。
export function discoverProjectDependencies(config: ResolvedConfig): {
cancel: () => Promise<void>
result: Promise<Record<string, string>>
} {
// scanImports中调用prepareEsbuildScanner注册插件esbuildScanPlugin
const { cancel, result } = scanImports(config)
return {
cancel,
result: result.then(({ deps, missing }) => {
const missingIds = Object.keys(missing) // 缺失依赖
if (missingIds.length) {
throw new Error(
`The following dependencies are imported but could not be resolved:\n\n ${missingIds
.map(
(id) =>
`${colors.cyan(id)} ${colors.white(
colors.dim(`(imported by ${missing[id]})`),
)}`,
)
.join(`\n `)}\n\nAre they installed?`,
)
}
return deps // 返回deps
}),
}
}
scanImports
scanImports
会通过computeEntries
方法获取入口文件,其中核心方法就是entries = await globEntries('**/*.html', config)
。
export function scanImports(config: ResolvedConfig): {
cancel: () => Promise<void>
result: Promise<{
deps: Record<string, string>
missing: Record<string, string>
}>
} {
// Only used to scan non-ssr code
const start = performance.now()
const deps: Record<string, string> = {}
const missing: Record<string, string> = {}
let entries: string[]
const scanContext = { cancelled: false }
// 获取入口文件
const esbuildContext: Promise<BuildContext | undefined> = computeEntries(
config,
).then((computedEntries) => {
entries = computedEntries // 赋值
if (!entries.length) {
...
}
// 取消就return
if (scanContext.cancelled) return
debug(`Crawling dependencies using entries:\n ${entries.join('\n ')}`)
return prepareEsbuildScanner(config, entries, deps, missing, scanContext)
})
}
// 省略一部分代码...
computeEntries
函数里面调用globEntries
获取***.html
入口文件。globEntries
使用了fast-glob
库来读取目录,我以前讲过,戳 >>> 前端构建工具vite进阶系列(四) -- 插件系统让vite变得更强大
prepareEsbuildScanner
async function prepareEsbuildScanner(
config: ResolvedConfig, // Vite 的配置项
entries: string[], // 用于构建的入口文件路径列表
deps: Record<string, string>, // 依赖关系的映射表
missing: Record<string, string>, // 缺失的依赖关系的映射表
scanContext?: { cancelled: boolean }, // 扫描上下文
): Promise<BuildContext | undefined> { // 返回一个 esbuild 构建环境
// 创建插件容器
const container = await createPluginContainer(config)
// 如果扫描上下文被取消了,直接返回
if (scanContext?.cancelled) return
// 生成一个 esbuild 扫描插件
const plugin = esbuildScanPlugin(config, container, deps, missing, entries)
// 获取 esbuild 的插件和构建选项
const { plugins = [], ...esbuildOptions } =
config.optimizeDeps?.esbuildOptions ?? {}
// 调用 esbuild 的 context 函数,返回依赖图上下文对象
return await esbuild.context({
absWorkingDir: process.cwd(),
write: false,// 不输出文件,因为第一次调用esbuild的时候只是做一个扫描
stdin: {
// 拿到入口,拼成js字符串
contents: entries.map((e) => `import ${JSON.stringify(e)}`).join('\n'),
loader: 'js',
},
bundle: true,
format: 'esm',
logLevel: 'silent',
plugins: [...plugins, plugin],
...esbuildOptions,
})
扩展:为什么要使用插件:
- 因为
esbuild
不认识html
,所以需要插件来处理。 esbuild
插件大致如下图用法:
import * as esbuild from 'esbuild'
let envPlugin = {
name: 'env',
setup(build) {
// Intercept import paths called "env" so esbuild doesn't attempt
// to map them to a file system location. Tag them with the "env-ns"
// namespace to reserve them for this plugin.
// 路径重写
build.onResolve({ filter: /^env$/ }, args => ({
path: args.path,
namespace: 'env-ns',
}))
// Load paths tagged with the "env-ns" namespace and behave as if
// they point to a JSON file containing the environment variables.
// 内容处理
build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
contents: JSON.stringify(process.env),
loader: 'json',
}))
},
}
// 使用
await esbuild.build({
entryPoints: ['app.js'],
bundle: true,
outfile: 'out.js',
plugins: [envPlugin],
})
esbuild
在读取文件的时候就会调用一次扫描插件esbuildScanPlugin
,插件里面根据build.onResolve
、build.onLoad
来选择性处理文件,比如处理html
文件他就会走到这里:
这里的resolve
就是处理路径的核心方法,我们一起来看一下。
resolve
const seen = new Map<string, string | undefined>() // 路径映射表
const resolve = async (
id: string, // 相对或者绝对路径,或者三方模块路径
// 相对路径 : ./
// 决对路径 : /
// 三方包路径(裸模块):'vue'
importer?: string, // 引用者
options?: ResolveIdOptions,
) => {
// 路径处理
const key = id + (importer && path.dirname(importer))
if (seen.has(key)) { // 去重
return seen.get(key)
}
// 调用container.resolveId处理路径补全。
// container 为插件容器,在createServer函数里面创建
// 此时会遍历执行vite插件,并挂到container上,以供后续调用
const resolved = await container.resolveId(
id,
importer && normalizePath(importer),
{
...options,
scan: true,
},
)
const res = resolved?.id
seen.set(key, res)
return res // 返回处理好的路径
}
resolveId
我们可以通过container
找到resolveId
插件,它长这样:
在这个插件里面可以看到对relative
、absolute
、bare
的处理,比如文件所在项目的根目录为root:'/Users/mac/Desktop/my-project'
absolute
比如import Function from '/Module1'
,则路径会被处理成:
import Function from '/Module1'
=>import Function from '/Users/mac/Desktop/my-project/Module'
relative
对于relative
有两种,请看代码:
// 如果模块被人引用,则基础路径为引用者,否则就是npm run xx 的当前目录
const basedir = importer ? path.dirname(importer) : process.cwd()
比如引用者的路径为importerPath:'/Users/mac/Desktop/my-project/importerPath'
,则路径会被处理成:
import Function from '/Module2'
=>/Users/mac/Desktop/my-project/importerPath/Module2'
bare module
bare module
也就是第三方模块,我们以react
为例,关于这个的路径处理是这样的。-
先去
node_modules
里面去找到react
路径,记录为:path=node_module/react/
。 -
找到
react
路径下的package.json
文件中的exports
字段的default
属性,如果没有则会去找module
字段。 -
拼接
path
与exports
或者module
组成完整路径。
-
比如import React from 'react'
,则路径会被处理成:
import React from 'react'
=>import React from '/Users/mac/Desktop/my-project/node_module/react/index.js'
路径处理完毕之后,就会return
出去,就会被onLoad
接收,就像这面这样:
在onLoad
函数中,就会读取html
内容:
let raw = fs.readFileSync(path, 'utf-8')
读取html
内容之后,就会匹配出script
的src
属性,找到js
模块,就会触发build.OnResolve
解析js
模块,从而找到模块中的裸模块
,也就是第三方依赖。
扩展:这里分享几个实用的正则,感兴趣的可以看一下:
const scriptModuleRE =
/(<script\b[^>]+type\s*=\s*(?:"module"|'module')[^>]*>)(.*?)<\/script>/gis
export const scriptRE = /(<script(?:\s[^>]*>|>))(.*?)<\/script>/gis
export const commentRE = /<!--.*?-->/gs
const srcRE = /\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i
const typeRE = /\btype\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i
const langRE = /\blang\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i
const contextRE = /\bcontext\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i
找到了第三方模块之后调用runOptimizeDeps
进行打包,我们来看一下源码:
runOptimizeDeps
export function runOptimizeDeps(
resolvedConfig: ResolvedConfig,
depsInfo: Record<string, OptimizedDepInfo>,
ssr: boolean = resolvedConfig.command === 'build' &&
!!resolvedConfig.build.ssr,
): {
...
// 获取.vite/deps路径
const depsCacheDir = getDepsCacheDir(resolvedConfig, ssr)
const processingCacheDir = getProcessingDepsCacheDir(resolvedConfig, ssr)
// 先处理一下.vite/deps目录
if (fs.existsSync(processingCacheDir)) {
// 存在就置空
emptyDir(processingCacheDir)
} else {
// 不存在就创建
fs.mkdirSync(processingCacheDir, { recursive: true })
}
// 写入package.json 指定ESM规范
writeFile(
path.resolve(processingCacheDir, 'package.json'),
JSON.stringify({ type: 'module' }),
)
// 获得元数据
const metadata = initDepsOptimizerMetadata(config, ssr)
// 写入browserHash
metadata.browserHash = getOptimizedBrowserHash(
metadata.hash,
depsFromOptimizedDepInfo(depsInfo),
)
// 省略一部分代码
const start = performance.now() // 记录时间
// 调用prepareEsbuildOptimizerRun里面的esbuild.context打包
const preparedRun = prepareEsbuildOptimizerRun(
resolvedConfig,
depsInfo,
ssr,
processingCacheDir,
optimizerContext,
)
// 省略了一些代码 ...
prepareEsbuildOptimizerRun
prepareEsbuildOptimizerRun
函数做了几件事
- 先把
deps
拍平成一维数组。 - 调用
build.context
进行打包。 - 输出打包的映射对象
result
,对象长这样:
- 遍历
outputs
,把打包结果输出到processingCacheDir
,也就是.vite/deps
。
const dataPath = path.join(processingCacheDir, '_metadata.json')
writeFile(
dataPath, // 写入元数据
stringifyDepsOptimizerMetadata(metadata, depsCacheDir),
)
- 通过
esbuild
插件扫描和esbuild.context
打包,最后得到的优化依赖的结果会被缓存起来,并且写到了node_module/.vite/deps
目录下。
此过程就是预解析的过程,esbuild
是一个打包器,在开发阶段全权依赖于它。
esbuild处理TypeScript、Jsx、Tsx
esbuild
内置了对TypeScript
和JSX
/TSX
的原生支持,无需安装额外的插件或配置。当esbuild
发现一个文件的扩展名是.ts
,.tsx
,.jsx
或者.mjs
时,会自动启用TypeScript
和JSX
/TSX
支持,并将其转换为JavaScript
。
在转换过程中,esbuild
会首先进行预处理(preprocessing
)和解析(parsing
),然后进行类型检查(type-checking
),最后将其转换为JavaScript
代码。
需要注意的是,esbuild
的TypeScript
和JSX
/TSX
支持还没有完全与标准的TypeScript
和JSX
/TSX
语法保持一致,尤其是在一些高级特性和边缘情况下可能会有一些不一致的行为。因此在使用esbuild
进行TypeScript
和JSX
/TSX
编译时,比如:
babel-plugin-macros
,它是一个Babel
插件,允许你使用JavaScript
代码来生成代码,这种技术被称为“宏”。使用宏可以提供更好的开发体验,减少样板代码并提高代码可读性。
在使用esbuild
时,babel-plugin-macros
是不支持的,因为它是一个Babel
插件,而esbuild
不会执行Babel
插件。在这种情况下,你需要找到其他解决方案,或者使用其他工具来替代esbuild
。
- 另一个例子是
WebAssembly
,esbuild
在处理WebAssembly
时,需要依赖一个第三方插件esbuild-wasm
。如果你在使用esbuild
时需要处理WebAssembly
,那么你需要确保已经安装了这个插件,并且已经配置好了esbuild
的相关设置。
vite与rollup
这是vite官方文档的原话,其实你可以理解,为什么vite在开发环境用esbuild
,就是因为快,有多快?看下图:
但是在生产环境,对代码质量的要求更高,而不是更快。那关于打包是调用的build函数,来扒一扒它的源码。
build
export async function build(
inlineConfig: InlineConfig = {},
): Promise<RollupOutput | RollupOutput[] | RollupWatcher> {
// 从inlineConfig和vite.config.json里面解析依赖,并合并
const config = await resolveConfig(
inlineConfig,
'build',
'production',
'production',
)
// 获取构建配置
const options = config.build
// 判断是否需要生成 SSR 相关的代码
const ssr = !!options.ssr
// 如果是构建库,则获取库构建相关的配置
const libOptions = options.lib
// 在控制台打印,正在构建...
config.logger.info(
colors.cyan(
`vite v${VERSION} ${colors.green(
`building ${ssr ? `SSR bundle ` : ``}for ${config.mode}...`,
)}`,
),
)
// 定义 resolve 函数用于解析路径
const resolve = (p: string) => path.resolve(config.root, p)
// 获取入口文件
const input = libOptions
? options.rollupOptions?.input ||
(typeof libOptions.entry === 'string'
? resolve(libOptions.entry)
: Array.isArray(libOptions.entry)
? libOptions.entry.map(resolve)
: Object.fromEntries(
Object.entries(libOptions.entry).map(([alias, file]) => [
alias,
resolve(file),
]),
))
: typeof options.ssr === 'string'
? resolve(options.ssr)
: options.rollupOptions?.input || resolve('index.html')
// 如果是SSR并且入口文件是html就抛错
if (ssr && typeof input === 'string' && input.endsWith('.html')) {
throw new Error(
`rollupOptions.input should not be an html file when building for SSR. ` +
`Please specify a dedicated SSR entry.`,
)
}
// 获取输出目录
const outDir = resolve(options.outDir)
// 如果是 SSR 构建,则在插件中注入 ssr 参数
const plugins = (
ssr ? config.plugins.map((p) => injectSsrFlagToHooks(p)) : config.plugins
) as Plugin[]
// 获取用户配置的 external
const userExternal = options.rollupOptions?.external
let external = userExternal
// 如果构建 SSR,且配置了 legacy.buildSsrCjsExternalHeuristics,则需要对 external 进行处理
if (ssr && config.legacy?.buildSsrCjsExternalHeuristics) {
external = await cjsSsrResolveExternal(config, userExternal)
}
// 如果启用了依赖优化,则初始化依赖优化
if (isDepsOptimizerEnabled(config, ssr)) {
await initDepsOptimizer(config)
}
// 配置 RollupOptions
const rollupOptions: RollupOptions = {
context: 'globalThis',
preserveEntrySignatures: ssr
? 'allow-extension'
: libOptions
? 'strict'
: false,
cache: config.build.watch ? undefined : false,
...options.rollupOptions,
input,
plugins,
external,
onwarn(warning, warn) {
onRollupWarning(warning, warn, config)
},
}
...
// 定义bundle.js
let bundle: RollupBuild | undefined
try {
const buildOutputOptions = (output: OutputOptions = {}): OutputOptions => {
// 省略了一些判断...
const ssrNodeBuild = ssr && config.ssr.target === 'node'
const ssrWorkerBuild = ssr && config.ssr.target === 'webworker'
const cjsSsrBuild = ssr && config.ssr.format === 'cjs'
const format = output.format || (cjsSsrBuild ? 'cjs' : 'es')
const jsExt =
ssrNodeBuild || libOptions
? resolveOutputJsExtension(format, getPkgJson(config.root)?.type)
: 'js'
return { // 生成buildOutputOptions对象
dir: outDir,
// Default format is 'es' for regular and for SSR builds
format,
exports: cjsSsrBuild ? 'named' : 'auto',
sourcemap: options.sourcemap, // 启用sourceMap
name: libOptions ? libOptions.name : undefined, // name命名规则
generatedCode: 'es2015', // 目标代码版本呢
// 文件名、chunks名,生成规则
entryFileNames: ssr
? `[name].${jsExt}`
: libOptions
? ({ name }) =>
resolveLibFilename(libOptions, format, name, config.root, jsExt)
: path.posix.join(options.assetsDir, `[name]-[hash].${jsExt}`),
chunkFileNames: libOptions
? `[name]-[hash].${jsExt}`
: path.posix.join(options.assetsDir, `[name]-[hash].${jsExt}`),
assetFileNames: libOptions
? `[name].[ext]`
: path.posix.join(options.assetsDir, `[name]-[hash].[ext]`),
inlineDynamicImports:
output.format === 'umd' ||
output.format === 'iife' ||
(ssrWorkerBuild &&
(typeof input === 'string' || Object.keys(input).length === 1)),
...output,
}
}
// 多入口打包处理、或者单入口打包处理
const outputs = resolveBuildOutputs(
options.rollupOptions?.output,
libOptions,
config.logger,
)
const normalizedOutputs: OutputOptions[] = []
if (Array.isArray(outputs)) {
// 如果打包结果有多个
for (const resolvedOutput of outputs) {
// 就获取单个的打包结果
normalizedOutputs.push(buildOutputOptions(resolvedOutput))
}
} else {
// 否则直接拿到输出对象
normalizedOutputs.push(buildOutputOptions(outputs))
}
// 获取到输出路径,取到dir,一般是dist
const outDirs = normalizedOutputs.map(({ dir }) => resolve(dir!))
// 用rollup监听文件变化
if (config.build.watch) {
config.logger.info(colors.cyan(`\nwatching for file changes...`))
const resolvedChokidarOptions = resolveChokidarOptions(
config,
config.build.watch.chokidar,
)
const { watch } = await import('rollup')
const watcher = watch({
...rollupOptions,
output: normalizedOutputs,
watch: {
...config.build.watch,
chokidar: resolvedChokidarOptions,
},
})
// 监听打包开始、结束、异常
watcher.on('event', (event) => {
if (event.code === 'BUNDLE_START') {
config.logger.info(colors.cyan(`\nbuild started...`))
if (options.write) {
// 校验输出目录规则
prepareOutDir(outDirs, options.emptyOutDir, config)
}
} else if (event.code === 'BUNDLE_END') {
event.result.close()
// 计算打包耗时
config.logger.info(colors.cyan(`built in ${event.duration}ms.`))
} else if (event.code === 'ERROR') {
// 异常,调用outputBuildError,输出异常信息
outputBuildError(event.error)
}
})
return watcher
}
// 通过rollup打包生成文件
const { rollup } = await import('rollup')
bundle = await rollup(rollupOptions)
if (options.write) {
// 校验输出目录规则
prepareOutDir(outDirs, options.emptyOutDir, config)
}
const res = []
for (const output of normalizedOutputs) {
// 通过bundle = await rollup(rollupOptions),返回一个Promise对象
// 根据write或者generate指令,相应的对输出文件dist目录写入,和对象代码的生成
res.push(await bundle[options.write ? 'write' : 'generate'](output))
}
return Array.isArray(outputs) ? res : res[0]
} catch (e) {
// 异常报错
outputBuildError(e)
throw e
} finally {
// 打包结束。终止进程
if (bundle) await bundle.close()
}
}
build
函数,就通过找查入口文件,交给rollup
进行打包,并且监听每个文件的变化和打包开始,结束,异常三种情况,然后通过prepareOutDir
函数,进行输出目录的校验,之后遍历执行每个bundle
,交给rollup
执行,通过控制指令(write
或者generate
)来控制文件写入或者代码生成,把包含Promise的结果放进res
数组里面并返回。
prepareOutDir
function prepareOutDir(
outDirs: string[], // 接收一个字符串数组参数,表示输出目录
emptyOutDir: boolean | null, // 表示是否清空输出目录,为布尔值或 null
config: ResolvedConfig, // 表示已解析的配置
) {
const nonDuplicateDirs = new Set(outDirs) // 创建一个 Set 对象,用于存储不重复的输出目录
let outside = false // 是否有输出目录位于项目根目录外
if (emptyOutDir == null) { // 如果未指定是否清空输出目录
for (const outDir of nonDuplicateDirs) { // 遍历不重复的输出目录
if (
fs.existsSync(outDir) && // 判断输出目录是否存在
!normalizePath(outDir).startsWith(config.root + '/') // 判断输出目录是否在项目根目录下
) {
// 如果输出目录不在项目根目录下,发出警告,并将 outside 设为 true
config.logger.warn(
colors.yellow(
`\n${colors.bold(`(!)`)} outDir ${colors.white(
colors.dim(outDir),
)} is not inside project root and will not be emptied.\n` +
`Use --emptyOutDir to override.\n`,
),
)
outside = true
break // 跳出循环
}
}
}
for (const outDir of nonDuplicateDirs) { // 再次遍历不重复的输出目录
if (!outside && emptyOutDir !== false && fs.existsSync(outDir)) {
// 如果没有输出目录位于项目根目录外,且不禁止清空输出目录,且输出目录存在
const skipDirs = outDirs
.map((dir) => {
const relative = path.relative(outDir, dir) // 获取当前目录到其他目录的相对路径
if (
relative && // 相对路径非空
!relative.startsWith('..') && // 相对路径不以 '..' 开头
!path.isAbsolute(relative) // 相对路径不是绝对路径
) {
return relative // 返回相对路径
}
return '' // 返回空字符串
})
.filter(Boolean) // 过滤掉空字符串
emptyDir(outDir, [...skipDirs, '.git']) // 清空输出目录(除了指定的目录)
}
if (
config.build.copyPublicDir && // 如果配置中指定了 copyPublicDir
config.publicDir && // public 目录存在
fs.existsSync(config.publicDir) // public 目录存在于磁盘中
) {
copyDir(config.publicDir, outDir) // 复制 public 目录到输出目录
}
}
}
vite preview
vite preview
真的没什么好讲的,就是通过node
的http
模块,在本地启动一个http
服务器,然后对dist
目录文件路径处理,能够在新开的端口访问到项目,模拟production
环境而已。
vite的跨域配置
能看这篇文章的人,我默认为你对跨域概念以及跨域的常见解决办法都掌握了,这里就讲一下vite
中跨域是怎么做的。先来看vite给我们做的一个优化:
一般的用fetch请求一个接口,如果接口是崩的,那么就会报错,比如:
// 没有后端,也没有mock
fetch('/api').then(res=>{
console.log(res)
})
竟然没有报错?404 not found
?这就是vite
给我们做的优化,当请求的地址没有带baseUrl
的时候,他默认给你补全localhost
,这算啥优化啊? emmmm...
如果使用了代理,现在我们来尝试一下访问百度的首页。
export default defineConfig({
server:{
proxy:{
'/api':{
target:"https://www.baidu.com",
changeOrigin:true,
rewrite:(path)=>path.replace(/^\/api/, '')
},
...
}
}
})
效果:
能加载出来,但是css
可能缺失了...,同样的,在生产环境你这个代理服务器就不能使用啦,关于server的更多配置,请查看vite开发服务器文档。
总结
这篇文章讲了vite
在开发环境与打包的一些指令解析,还有一些流程,和跨域的一些配置,这篇文章比较偏向源码型的,当然可能还有一些没有顾到的点,在后续可能会进行补充。下一章 >>> 前端构建工具vite进阶系列(九) -- 总结与展望