在之前我们已经介绍了第一部分的源码分析内容:vite源码分析 — 启动 vite 在这篇文章中主要介绍了启动 vite 的过程,衱 插件机制是怎么运作的、模块是怎么存储管理的、预构建详细过程是怎么样子。本篇主要介绍浏览器开始请求一个模块的时候做了什么。本篇实例项目也是基于第一篇中的前置背景中设置的项目的。
上面是整个请求的流程图, 下面我们将一步一步的分析每一步做了什么。
浏览器访问资源
当加载的 html 有一个 module 类型的script 标签,src 是整个项目的入口 js 文件:
<script type="module" src="/src/main.js"></script>
浏览器识别该 script为 module 类型,所以发起网络请求 http://localhost:3000/src/main.js 请求文件 /src/main.js。请求来到了vite 创建的 connect 服务,然后类似于 koa 将请求 url 开始经过层层 中间件处理, 其中对于 js 比较重要的也比较难的中间件就是 transformMiddleware 这个中间件。
源码位置:vite/v2.4.1/packages/vite/src/node/server/middlewares/transform.ts
先是对请求的 url 做一些处理, 去掉 t、import 这些 query,然后去掉 __x00__ 前缀, 得到比较纯净的 url。
判断是否缓存
接下来要判断是否直接利用缓存,这一般对于访问 node_modules 的模块都是有浏览器强缓存的。判断强缓存的源码如下:
const ifNoneMatch = req.headers['if-none-match']
if (
ifNoneMatch &&
(await moduleGraph.getModuleByUrl(url))?.transformResult?.etag ===
ifNoneMatch
) {
isDebug && debugCache(`[304] ${prettifyUrl(url, root)}`)
res.statusCode = 304
return res.end()
}
通过 url 从 模块图 中获取该模块,在第一篇文章中介绍了, 每一个模块都有一个与 urlToModuleMap 对象的索引,所以访问模块非常的高效。获取模块的 etag, 这个 etag 是在创建模块的时候生成的后续会介绍到。如果浏览器请求头中的 etag 与模块缓存中的 etag 相同,则说明还可以复用缓存直接返回 304 状态码, 否则的话就需要后续的模块请求的过程。
请求模块 — transformRequest
在之前判断没有缓存或者缓存不可用的时候,会重新请求模块的过程。
再一次判断缓存
const module = await moduleGraph.getModuleByUrl(url)
const cached =
module && (ssr ? module.ssrTransformResult : module.transformResult)
if (cached) {
return cached
}
从模块缓存中通过 url 获取该模块,如果该模块存在,说明虽然浏览器请求的模块已经很旧了,但是在文件更新的时候或者 hmr 的时候该模块又重新构建了一遍缓存了新的模块, 所以可以直接返回该新模块了。
通过 url 获取模块的 resolveId
const id = (await pluginContainer.resolveId(url))?.id || url
没有缓存则需要进入创建模块并且添加到模块缓存的过程了。 首先需要通过 url 获取到模块的 resolveId也就是模块 id。 这一步主要是执行的插件 container 的 resolveId 方法,这个方法在上一篇中已经介绍的大概就是按照插件的顺序依次的执行插件的 resolveId 方法,然后第一个得到 id 的直接返回。具体来说有两个插件执行了该方法:
vite:pre-alias 插件
源代位置:vite/v2.4.1/packages/vite/src/node/plugins/preAlias.ts
const bareImportRE = /^[\w@](?!.*:\/\/)/
export function tryOptimizedResolve(
id: string,
server: ViteDevServer
): string | undefined {
const cacheDir = server.config.cacheDir
// metadata.json 文件
const depData = server._optimizeDepsMetadata
if (cacheDir && depData) {
const isOptimized = depData.optimized[id]
if (isOptimized) {
return (
isOptimized.file +
`?v=${depData.browserHash}${
isOptimized.needsInterop ? `&es-interop` : ``
}`
)
}
}
}
resolveId(id, _, __, ssr) {
// 如果是一个第三方模块 比如 vue lodash-es
if (!ssr && bareImportRE.test(id)) {
return tryOptimizedResolve(id, server)
}
}
上面是该插件处理 resolveId 的源码, 如果该 url 是一个 单词或者@开头的地址 其实就是一个第三方模块,那么会执行 tryOptimizedResolve方法, 该方法主要是从 缓存目录 也就是.vite 中获取预构建生成的 metadata.json 文件, 从该文件中获取 该第三方模块的 file 地址,然后 通过 file 、browerHash query 以及是否是 cjs query组成了第三方模块的 vite resolveId。 所以说如果代码中有
import { createApp } from "vue";
那么得到的 resolveId 应该是 /[root]/node_modules/.vite/vue.js.
vite:resolve 插件
// ……
// 以 / 开头,绝对路径
if (asSrc && id.startsWith('/')) {
const fsPath = path.resolve(root, id.slice(1))
if ((res = tryFsResolve(fsPath, options))) {
return res
}
}
// ……
// 以 . 开头 相对路径
if (id.startsWith('.')) {
// 相对于被导入文件的路径回去该模块的绝对路径
const basedir = importer ? path.dirname(importer) : process.cwd()
const fsPath = path.resolve(basedir, id)
const normalizedFsPath = normalizePath(fsPath)
const pathFromBasedir = normalizedFsPath.slice(basedir.length)
// 如果获取到的相对于 root 是 node_nodules 包内的, 这个是指通过 相对路径直接访问的 node_modules 文件一般不这么做
if (pathFromBasedir.startsWith('/node_modules/')) {
// normalize direct imports from node_modules to bare imports, so the
// hashing logic is shared and we avoid duplicated modules #2503
const bareImport = pathFromBasedir.slice('/node_modules/'.length)
if (
(res = tryNodeResolve(
bareImport,
importer,
options,
targetWeb,
server,
ssr
)) &&
res.id.startsWith(normalizedFsPath)
) {
return res
}
}
// 非 node_modules 文件内的则回去绝对路径
if ((res = tryFsResolve(fsPath, options))) {
isDebug && debug(`[relative] ${chalk.cyan(id)} -> ${chalk.dim(res)}`)
const pkg = importer != null && idToPkgMap.get(importer)
if (pkg) {
idToPkgMap.set(res, pkg)
return {
id: res,
moduleSideEffects: pkg.hasSideEffects(res)
}
}
return res
}
}
对于该插件处理通常的 js 文件的请求,对于绝对路径的请求,直接返回以 root 目录为根的绝对路径地址。对于相对路径的请求, 则相对于被导入的文件地址获取绝对地址后,得到以 root 目录为根的绝对路径。
比如对于main 文件中的:
import App from "./App.jsx";
我们得到:
import App from "/src/App.jsx";
tryNodeResolve
const resolve = require('resolve');
function tryNodeResolve(
id,
importer,
options,
targetWeb,
server,
ssr
) {
const { root, dedupe, isBuild } = options
const deepMatch = id.match(/^([^@][^/]*)/|^(@[^/]+/[^/]+)//);
const pkgId = deepMatch ? deepMatch[1] || deepMatch[2] : id;
let basedir
if (dedupe && dedupe.includes(pkgId)) {
basedir = root
} else if (
importer &&
path.isAbsolute(importer) &&
fs.existsSync(cleanUrl(importer))
) {
basedir = path.dirname(importer)
} else {
basedir = root
}
const pkg = resolvePackageData(pkgId, basedir)
if (!pkg) {
return
}
let resolved = deepMatch
? resolveDeepImport(id, pkg, options, targetWeb)
: resolvePackageEntry(id, pkg, options, targetWeb)
if (!resolved) {
return
}
return { id: resolved };
};
export function resolvePackageData(
id,
basedir
) {
try {
// https://www.npmjs.com/package/resolve
const pkgPath = resolve.sync(`${id}/package.json`, basedir)
return loadPackageData(pkgPath)
} catch (e) {
}
};
function loadPackageData(pkgPath) {
const data = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
const pkgDir = path.dirname(pkgPath)
const { sideEffects } = data
let hasSideEffects
if (typeof sideEffects === 'boolean') {
hasSideEffects = () => sideEffects
} else if (Array.isArray(sideEffects)) {
hasSideEffects = createFilter(sideEffects, null, { resolve: pkgDir })
} else {
hasSideEffects = () => true
}
const pkg = {
dir: pkgDir, // [root]/node_modules/vue
data,
hasSideEffects,
webResolvedImports: {},
nodeResolvedImports: {},
setResolvedCache(key: string, entry: string, targetWeb: boolean) {
if (targetWeb) {
pkg.webResolvedImports[key] = entry
} else {
pkg.nodeResolvedImports[key] = entry
}
},
getResolvedCache(key: string, targetWeb: boolean) {
if (targetWeb) {
return pkg.webResolvedImports[key]
} else {
return pkg.nodeResolvedImports[key]
}
}
}
return pkg
}
当id 为 vue 调用 tryNodeResolve 时候, 会使用 id vue 调用 resolvePackageData 方法,根据 id 与 baseDir 调用 resolve 这个第三方库, 对于 id vue, baseDir 为 项目根目录来说, 得到的 pkg.dir 为 [root]/node_modules/vue,然后调用 resolveDeepImport 方法, 该方法主要目的就是读取vue 模块中的 package.json,得到 main、module 入口文件信息,然后返回正确的路径 [root]/node_modules/vue/dist/vue.runtime.esm-bundler.js
通过 resolveId 获取 code
const loadResult = await pluginContainer.load(id, ssr)
这里对于js 文件来说就是使用 fs.readFile 模块读取的文件内容然后返回。load 和 resolveId 模块一样也是插件的容器的执行钩子, 会按照插件的顺序依次的执行每一个插件的 load 方法, 如果有内容返回则结束直接返回。
通过 code transform 获取最终的代码
const transformResult = await pluginContainer.transform(code, id, map, ssr)
在 transformRequest文件中,传入 load 获得的 code 代码,以及 resolveId 获得的 id,进入 插件容器的 transform 阶段, 在该阶段会依次的按照顺序执行多个插件每一个插件的结果是下一个插件的入参, 经过所有的插件处理后返回最终的加工后的 code。 我们这里比较重要的插件是 vite:import-analysis 插件,该插件主要是为了替换code 中那些导入模块的路径的。具体如下:
vite:import-analysis
源码位置:vite/v2.4.1/packages/vite/src/node/plugins/importAnalysis.ts
流程大概如上图所示, 这个部分就不粘贴源代码了流程比较复杂用流程图代替:
首先使用 esbuild 的 parser 模块解析code,得到该模块的依赖项 imports。 如果该模块没有依赖则直接返回 code,不需要后续的依赖处理,否则需要对每一个 import 都要处理。
对于每一个 import,导入路径去掉 base 部分,通常 base 都是 / , 然后通过之前介绍的 pluginContainer.resolveId(url) 获取到依赖模块的resolveId, 然后将该 id 转成能够写入到文件的 url, 如果该 id 以 / 开头 (因为 resoveId 都是绝对路径,需要吧 root 部分路径去掉)所以去掉 root 路径部分。然后 moduleGraph.ensureEntryFromUrl(url)。
在 ensureEntryFromUrl 中,如果 模块缓存没有该 url 的缓存, 则创建一个 module, 然后通过 url 获取到 resolveId、
file 添加到urlToModuleMap、idToModuleMap 、fileToModulesMap 映射中, 如果存在则直接返回该模块的对象。方便后续重写 code etag 等信息。
然后将 import XXX from "XXX"重新写 form 后的路径引用改写成 得到的url, 返回重写的 code 代码。
重置 module,响应内容
return (mod.transformResult = {
code,
map,
etag: getEtag(code, { weak: true })
} as TransformResult)
经过上述 resoveId、load、transform 插件钩子的执行,最终得到了解析后的 code、map, 并且我们还获得了 模块缓存多种对应的 module 对象, 那么我们将重置该 module 的 transformResult 属性。重置 code、map、根据 code 内容重新生成一个 etag,然后响应内容。
export function send(
req: IncomingMessage,
res: ServerResponse,
content: string | Buffer,
type: string,
etag = getEtag(content, { weak: true }),
cacheControl = 'no-cache',
map?: SourceMap | null
): void {
if (req.headers['if-none-match'] === etag) {
res.statusCode = 304
return res.end()
}
res.setHeader('Content-Type', alias[type] || type)
res.setHeader('Cache-Control', cacheControl)
res.setHeader('Etag', etag)
// inject source map reference
if (map && map.mappings) {
if (isDebug) {
content += `\n/*${JSON.stringify(map, null, 2).replace(
/\*\//g,
'*\\/'
)}*/\n`
}
content += genSourceMapString(map)
}
res.statusCode = 200
return res.end(content)
}
function genSourceMapString(map: SourceMap | string | undefined) {
if (typeof map !== 'string') {
map = JSON.stringify(map)
}
return `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(
map
).toString('base64')}`
}
具体响应的内容就是根据文件的类型设置 content-type、设置 etag,对于 cache-control 来说如果是 node_modules 模块也就是预构建中扫描的模块则 为 max-age=31536000,immutable 对于普通的模块 缓存规则为 no-cache。设置 200 响应码 返回文件内容, 只不过在文件内容末尾将 sourcemap 内容添加到末尾,这样子可以提高性能吧。至此文件请求处理流程就结束了。