前言
Vite (法语意为 "快速的",发音 /vit/) 是一种新型前端构建工具,能够显著提升前端开发体验。它主要由两部分组成:
- 一个开发服务器,它基于 原生 ES 模块 提供了 丰富的内建功能,如速度快到惊人的 模块热更新(HMR)。
- 一套构建指令,它使用 Rollup 打包你的代码,并且它是预配置的,可以输出用于生产环境的优化过的静态资源。
随着vite2.0的发布,社区掀起了对Bundle Free新一轮的热潮。相信大家对vite都有或多或少的了解。就像Vite官方文档描述的那样,Vite实质上是由基于浏览器原生ESM的dev server和一套基于rollup的构建指令组成。所谓知其然亦要知其所以然,Vite源码解析系列旨在向大家揭示Vite内部核心原理实现,让大家对以Vite为代表的新型构建工具的设计有一个更清晰的认识,如果想要了解vite的基本用法和实践可以前往vite官方文档。下面是Vite2.0核心模块:
看到上图中的小红旗了吗?本文作为Vite源码解析系列之一,将对Vite2.0中的核心插件importAnalysis的实现进行深入解析,帮助大家更加深入的理解Vite的核心原理之一 —— 路径重写,为后续可能到来的‘Bundle Free 时代’提前进行知识铺垫。
为什么需要路径重写?
在讲路径重写之前,我们先简单聊一聊Vite这类基于ESM的构建工具为何需要进行模块的路径重写。 当我们直接将源码运行在浏览器,而不对import路径进行任何处理时,可能会出现如下错误:
很显然,这是因为我们对一些依赖的引入(例如import React from 'react')导致的,其中的react实际是来自node_modules的‘裸模块’,而浏览器很明显是无法根据这种模块名直接去读取node_modules目录来获取这类模块的。
Vite路径重写插件具体干了哪些事?
首先,路径重写首先要解决上述的模块引用的问题,同时也要实现一些必要的功能支持。下面是Vite中插件源码的注释。
/**
* Server-only plugin that lexes, resolves, rewrites and analyzes url imports.
*
* - Imports are resolved to ensure they exist on disk
*
* - Lexes HMR accept calls and updates import relationships in the module graph
*
* - Bare module imports are resolved (by @rollup-plugin/node-resolve) to
* absolute file paths, e.g.
*
* ```js
* import 'foo'
* ```
* is rewritten to
* ```js
* import '/@fs//project/node_modules/foo/dist/foo.js'
* ```
*
* - CSS imports are appended with `.js` since both the js module and the actual
* css (referenced via <link>) may go through the transform pipeline:
*
* ```js
* import './style.css'
* ```
* is rewritten to
* ```js
* import './style.css.js'
* ```
*/
通过分析源码以及相应注释,我们发现该插件主要实现了下面的功能:
- 通过
@rollup/plugin-node-resolve插件对模块进行解析,确定模块有效性。 - 模块路径重写:
import 'react' => import '/node_modules/.vite/react.js?v=xxxx' - 相对路径转为绝对路径:
import './App.tsx' => import '/src/App.tsx' - 向import路径注入特定参数t,满足hmr更新逻辑要求,并更新moduleGraph(模块关系图,viteDevServer组成部分之一,用于追踪维护模块导入关系,url与文件的映射以及热更新状态)。
- 对样式文件进行特定处理,将其转换为js文件,为后续转换做预处理。最终处理后请求得到的样式文件内容示例如下:
import { createHotContext as __vite__createHotContext } from "/@vite/client";import.meta.hot = __vite__createHotContext("/src/App.less");import { updateStyle, removeStyle } from "/@vite/client"
const id = "/Users/jerry/github-project/vite/packages/create-app/template-react-ts/src/App.less"
const css = ".App {\n text-align: center;\n}\n.App-logo {\n height: 40vmin;\n pointer-events: none;\n}\n@media (prefers-reduced-motion: no-preference) {\n .App-logo {\n animation: App-logo-spin infinite 20s linear;\n }\n}\n.App-header {\n background-color: #282c34;\n min-height: 100vh;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n font-size: calc(10px + 2vmin);\n color: white;\n}\n.App-link {\n color: #61dafb;\n}\n@keyframes App-logo-spin {\n from {\n transform: rotate(0deg);\n }\n to {\n transform: rotate(360deg);\n }\n}\nbutton {\n font-size: calc(10px + 2vmin);\n}\n"
updateStyle(id, css)
import.meta.hot.accept()
export default css
import.meta.hot.prune(() => removeStyle(id))
路径重写是如何实现的?
vite插件通用钩子
在讲解vite路径重写插件之前,我们先看看vite插件的一些通用钩子,为后面解析该插件做一个前置知识补充。
vite插件本身其实是对rollup插件基于自身特性的扩展,它沿用了rollup插件大部分钩子,同时加入了一些自身特有的钩子。如果想要详细了解vite和rollup插件开发,可以参考下rollup插件文档和vite插件api。这里我们就简单说明下路径重写插件所用到的钩子。下面是该插件部分源码,从该源码中可以看出,vite的路径重写插件主要用到了configureServer和transform这两个通用钩子。
return {
name: 'vite:import-analysis',
configureServer(_server) {
server = _server
},
async transform(source, importer, ssr) {
···
}
}
configureServer是vite插件特有的钩子,主要用于获取viteDevServer实例,然后对其进行一些自定义操作,例如添加一些自定义中间件。而在这里主要用于存储服务器实例,然后在下面的transform钩子中使用。
transform是rollup插件的通用钩子,它在每个传入模块请求时被调用,主要用于对模块的转换进行一些自定义的操作处理从而改变模块最终的转换结果。
插件总体执行流程
插件的整体执行流程如下:
下面我们将对流程中凸出标识的两块核心逻辑进行解析。
标准化模块路径函数实现
毫无疑问,得到标准化的import路径并替换原有import路径是本插件的核心。现在我们先看看这个标准化路径函数normalizeUrl是如何实现的呢?
该函数的实现分为如下几部,我们分步骤逐一进行解析:
1.判断import模块是否存在,获取import语句引入模块的真实文件路径:
const resolved = await this.resolve(url, importer)
// 解析获取失败,提示文件不存在
if (!resolved) {
this.error(
`Failed to resolve import "${url}" from "${path.relative(
process.cwd(),
importer
)}". Does the file exist?`,
pos
)
}
上面是部分实现代码,这块核心是this.resolve方法,该方法通过利用当前模块路径importer和当前模块中import语句的路径url,得到import语句引入模块的真实文件路径,该方法是的具体实现是在在resolved插件中,其底层是基于node的path.resolve 和fs.existsSync api,这里就不进行详细展开了,想要详细了解的话可以参考resolve.ts插件中的resolveId钩子。
2.判断该路径是否在项目root路径下,针对不在root下但真实存在的文件加上‘@fs’标识 上面我们拿到了import语句引入模块的真实文件路径后,就可以用这个路径进行判断,根据文件位置做出不同的处理,具体逻辑如下:
// normalize all imports into resolved URLs
// 如果文件路径的开头是以项目位置+‘/’开头的,则直接获取绝对路径
// 如果文件不是在root路径下但是确实存在于文件系统,则加上/@fs/前缀
// e.g. `import 'foo'` -> `import '/@fs/.../node_modules/foo/index.js`
if (resolved.id.startsWith(root + '/')) {
// in root: infer short absolute path from root
url = resolved.id.slice(root.length)
} else if (fs.existsSync(cleanUrl(resolved.id))) {
// exists but out of root: rewrite to absolute /@fs/ paths
url = path.posix.join(FS_PREFIX + resolved.id)
} else {
url = resolved.id
}
这是非常关键的步骤,为了方便大家理解,我这里列举几种情况供大家参考:
root:'/Users/jerry/github-project/vite/packages/create-app/template-react-ts' // 项目根目录
'./index.css' => '/src/index.css' //满足第一种情况
'./env' => '/@fs/Users/jerry/github-project/vite/packages/vite/dist/client/env.js' //满足第二种情况
'/@react-refresh' => '/@react-refresh' // 满足第三种情况
3.判断当前处理后的新url是否合法(是否以‘.’或'/'开头),若不合法,则进行合法化操作。
// if the resolved id is not a valid browser import specifier,
// prefix it to make it valid. We will strip this before feeding it
// back into the transform pipeline
if (!url.startsWith('.') && !url.startsWith('/')) {
url = `/@id/` + resolved.id.replace('\0', `__x00__`)
}
4.判断如果为相对路径,则将importer路径上的参数v也注入到它import的这个模块的路径上
// for relative js/css imports, inherit importer's version query
// do not do this for unknown type imports, otherwise the appended
// query can break 3rd party plugin's extension checks.
// 如果引入是相对路径,则向其最终的url上加入其importer的v参数
if (isRelative && !/[\?&]import\b/.test(url)) {
const versionMatch = importer.match(/[\?&](v=[\w\.-]+)\b/)
if (versionMatch) {
url = injectQuery(url, versionMatch[1])
}
}
5.注入hmr时间戳参数t,用于热更新判断
// check if the dep has been hmr updated. If yes, we need to attach
// its last updated timestamp to force the browser to fetch the most
// up-to-date version of this module.
// 注入/更新hmr参数t
try {
const depModule = await moduleGraph.ensureEntryFromUrl(url)
if (depModule.lastHMRTimestamp > 0) {
url = injectQuery(url, `t=${depModule.lastHMRTimestamp}`)
}
} catch (e) {
// it's possible that the dep fails to resolve (non-existent import)
// attach location to the missing import
e.pos = pos
throw e
}
执行重写动作
rewrite动作的实现实质上就是对特定字符片段的替换操作,vite插件中的实现主要是基于magic-string组件,对源码字符串本身按照开始和结束位置进行一系列的重写、替换、插入等操作,这里主要用到的api是overwrite,为方便大家理解下面的代码,这里给大家展示下该api的基本用法:
- s.overwrite( start, end, content[, options] )
- Replaces the characters from start to end with content. The same restrictions as s.remove() apply. Returns this. The fourth argument is optional. It can have a storeName property — if true, the original name will be stored for later inclusion in a sourcemap's names array — and a contentOnly property which determines whether only the content is overwritten, or anything that was appended/prepended to the range as well.
同时在重写规则上,通过&es-interop标识对动态import和cjs模块做了特殊处理,具体实现如下:
let s: MagicString | undefined
const str = () => s || (s = new MagicString(source))
···
// rewrite
// 如果原始路径和经过normalize的url对比有差异,则执行重写操作
if (url !== specifier) {
// for optimized cjs deps, support named imports by rewriting named
// imports to const assignments.
// 处理cjs依赖
if (resolvedId.endsWith(`&es-interop`)) {
// 去掉&es-interop标识
url = url.slice(0, -11)
if (isDynamicImport) {
// rewrite `import('package')` to expose the default directly
str().overwrite(
dynamicIndex,
end + 1,
`import('${url}').then(m => ({ ...m.default, default: m.default }))`
)
} else {
const exp = source.slice(expStart, expEnd)
// 获取cjs模块转换esm结果,然后进行重写
const rewritten = transformCjsImport(exp, url, rawUrl, index)
if (rewritten) {
str().overwrite(expStart, expEnd, rewritten)
} else {
// #1439 export * from '...'
str().overwrite(start, end, url)
}
}
} else {
// 常规esm重写
str().overwrite(start, end, isDynamicImport ? `'${url}'` : url)
}
}
待解决的问题
相信读到这里,大家对路径重写的实现已经有了基本的认识,但是本文中并没有对本插件中部分涉及的hmr逻辑,css转换逻辑等进行详细展开。这是由于这些模块在vite中有专门插件进行处理,因此不便在本文进行详细展开,这些问题在后续会有专门的系列文章进行详细讲解,大家敬请期待吧。
总结
本文对vite2.0中对于rewrite的实现进行了剖析,这块最核心的部分其实就是使用ES Module Lexer对source源码进行词法分析,从而获取import语句中的路径,利用使用resolve方法解析出源码import模块的真实文件路径,再基于该路径配合一套规则进行重写替换掉源码中写的import路径,最后输出转换后的代码。由于vite2.0现在正处于高频更新阶段,部分实现可能会有变化,不过总体实现思路不会有太大改变。虽然目前vite相比于webpack在生态和功能上还有不小的差距,但它的优势也是对webpack的致命杀器,也是非常值得一试的。