前言
因为公司目前是「React」技术栈,所以很久没有关注「Vue」的动态,最近看了一下尤雨溪 - 聊聊 Vue.js 3.0 Beta 官方直播[1], 看到快结束的时候大佬推荐了一个他自己写的"小玩意": 「Vite」
「Vite」: 法语: "快"的意思,它是一个「http」服务器,作用就是不需要使用「webpack」打包就可以开发应用,而且支持超级快速的「热更新」的功能(因为「webpack」的热更新需要重新打包)
突然就对这个非常感兴趣,而且这可能是未来的一种趋势,所以在项目不是太庞大的时候去了解一下它的内部原理就会更加简单一些。而且「阅读他人优秀开源项目源码的过程可以学到很多平时工作学不到甚至看不到的东西」
ps: 这篇文章只对如何不用「webpack」开发项目做尽可能详细的解析理解,至于热更新则在下一篇文章阐述,「Vite」版本为「1.0.0-rc.4」
创建项目
首先按照以下步骤做好项目的调试准备工作:
- 打开 Vite GitHub[2], 拷贝项目到本地。
- 根目录创建「example」文件夹,执行 「yarn create vite-app <project-name>」 创建一个基础的脚手架「并安装依赖」
- 在 「example/package.json」中添加以下命令
{
"scripts": {
"dev": "node ../dist/node/cli.js",
"dev:debug": "set DEBUG=* & node ../dist/node/cli.js" // 启动 debug
}
}
- 在「根目录」以及「example」分别执行 「yarn dev」, 在用浏览器打开「http://localhost:3000」就会看到以下页面
前置知识
「Vite」重度依赖「module sciprt」的特性,因此需要提前做下功课,参考:JavaScript modules 模块 - MDN。[3]
「module sciprt」允许在浏览器中直接运行原生支持模块
<script type="module">
// index.js可以通过export导出模块,也可以在其中继续使用import加载其他依赖
import App from './index.js'
</script>
当遇见「import」依赖时,会直接发起「http」请求对应的模块文件,但是并不支持如「import { createApp } from 'vue'」 这样的引用,具体怎么支持它下面会说
开始
启动服务器
打开项目就知道「Vite」是使用「Koa」创建「http」服务器, 从「src/node/cli.js」开始
const server = require('./server').createServer(options)
而转到 「server/index.js」 下,通过「中间件」的方式给它加入其它的处理, 这里展示跟本文相关的几个中间件
const context = { app };
const resolvedPlugins = [
moduleRewritePlugin, //解析js
htmlRewritePlugin, //解析 html
moduleResolvePlugin, //获取依赖加载内容
vuePlugin, //解析vue文件
cssPlugin, //解析css
serveStaticPlugin //静态配置
];
resolvedPlugins.forEach((m) => m && m(context))
静态配置
定位到「server/serverPluginServeStatic.ts」文件,为了默认解析「index.html」的以及其他静态文件的内容,添加了一些配置,比较简单,直接贴代码:
const send = require('koa-send')
...
app.use(require('koa-static')(root))
app.use(require('koa-static')(path.join(root, 'public')))
...
// root指向 example 目录
await send(ctx, `index.html`, { root })
重写引入库名称
上面说过「module sciprt」不支持「import { createApp } from 'vue'」, 而对于这样「npm」库的应用,「Vite」内部做了特殊处理,首先打开我们启动的项目
原本的入口文件是这样的
import { createApp } from 'vue'
import App from './App.vue'
import './index.css'
createApp(App).mount('#app')
而经过处理后会转换成:
可以看到 vue 转成了 「/@modules/vue.js」, 下面来说一下这一块经历的过程:
- 首先根据上面返回的 index.html 文件,转到「server/serverPluginHtml.ts」(只保留重要代码)
app.use(async (ctx, next) => {
...
// 判断返回的时候是 .html
if (ctx.response.is('html') && ctx.body) {
const importer = ctx.path
// 获取 html 内容
const html = await readBody(ctx.body)
// 是否有缓存
if (rewriteHtmlPluginCache.has(html)) {
...
} else {
if (!html) return
// 重点:重写 .html 文件内容
ctx.body = await rewriteHtml(importer, html)
}
return
}
})
const scriptRE = /(<script\b[^>]*>)([\s\S]*?)<\/script>/gm
async function rewriteHtml(importer: string, html: string) {
html = html!.replace(scriptRE, (matched, openTag, script) => {
// 如果 script 不是 src 引入的, 则直接执行 替换 js 引入的操作:rewriteImports
if (script) {
return `${openTag}${rewriteImports(
root,
script,
importer,
resolver
)}</script>`
}
})
return injectScriptToHtml(html, devInjectionCode)
}
- 根据上面的代码,如果「script module」是使用「src」引入,则转到 「serverPluginModuleRewrite.ts」, 这块很简单,也是直接调用「rewriteImports」方法进行重写
app.use(async (ctx, next) => {
...
const publicPath = ctx.path
if (
ctx.body &&
ctx.response.is('js') && ...
) {
ctx.body = rewriteImports(
root,
content!,
importer,
resolver,
ctx.query.t
)
}
}
rewriteImports
这里是整个逻辑的重点,主要是重写 「import」引入,原理就是使用 「es-module-lexer」 解析 「import」, 再通过正则匹配符合条件的引入,之后进行重写
import MagicString from 'magic-string'
import { init as initLexer, parse as parseImports } from 'es-module-lexer'
export function rewriteImports(...) {
// 解析 import
imports = parseImports(source)[0]
...
const s = new MagicString(source)
for (let i = 0; i < imports.length; i++) {
const { s: start, e: end, d: dynamicIndex } = imports[i]
// id 即为引入的库名称,这里可以当做是 vue
let id = source.substring(start, end)
...
// 这里 resolveImport 的作用就是 添加 /@modules/
const resolved = resolveImport(...)
}
// 重写 .js
s.overwrite(
start,
end,
hasLiteralDynamicId ? `'${resolved}'` : resolved
)
}
resolveImport
这里关于 「resolveImport」 要说一下一个细节:「在应用中即使不安装 vue 依赖也是可以执行的」,因为「Vite」已经默认安装了「vue」,这里有个特殊的处理
const bareImportRE = /^[^\/\.]/
// 这里 id == vue
if (bareImportRE.test(id)) {
id = `/@modules/${resolveBareModuleRequest(root, id, importer, resolver)}`
}
export function resolveBareModuleRequest(...): string {
const optimized = resolveOptimizedModule(root, id)
if (optimized) {
return path.extname(id) === '.js' ? id : id + '.js'
}
}
- 「resolveOptimizedModule」会查找「package.json」中是否安装依赖并缓存起来。「optimized」返回「true」
- 如果未安装则在「vite」的「node_modules」中查找入口文件,并缓存 & 返回
解析代码
上文中修改了引入的 js 文件后会再次发起请求,这时候就转到「serverPluginModuleResolve」中间件下, 主要就是找到相关代码文件位置,读取并返回
const moduleRE = /^\/@modules\//
// 获取 vue 各个模块代码对应的文件路径
const vueResolved = resolveVue(root)
app.use(async (ctx, next) => {
if (!moduleRE.test(ctx.path)) {
return next()
}
// 去掉 /@modules
const id = decodeURIComponent(ctx.path.replace(moduleRE, ''))
// 读取文件并返回
const serve = async (id: string, file: string, type: string) => {
...
await ctx.read(file)
return next()
}
// isLocal 表示项目是否安装 vue
if (!vueResolved.isLocal && id in vueResolved) {
return serve(id, (vueResolved as any)[id], 'non-local vue')
}
...
}
处理 Vue 文件
如何处理 「.vue」 文件也是这个项目的重点,这次转到「serverPluginVue」中间件下面,先看一下 「.vue」 处理后的样子
import HelloWorld from '/src/components/HelloWorld.vue'
const __script = {
name: 'App',
components: {
HelloWorld
}
}
import "/src/App.vue?type=style&index=0"
import {render as __render} from "/src/App.vue?type=template"
__script.render = __render
__script.__hmrId = "/src/App.vue"
__script.__file = "XXX\\vite\\example\\src\\App.vue"
export default __script
这里可以看出,把原本一个 「.vue」 的文件拆成了三个请求(分别对应 script、style 和 template) ,浏览器会先收到包含「script」的 「App.vue」 的响应,然后解析到 「template」 和 「style」 的路径后,会再次发起 「HTTP」 请求来请求对应的资源,此时 「Vite」 对其拦截并再次处理后返回相应的内容。
主要是是根据 URL 的 query 参数来做不同的处理(简化分析如下):
const query = ctx.query
// 如果没有 query 的 type,比如直接请求的 /App.vue
if (!query.type) {
// 这里首先对 script 做了处理,之后生成 template 和 stytle 链接
const { code, map } = await compileSFCMain(
descriptor,
filePath,
publicPath,
root
)
ctx.body = code
ctx.map = map
return etagCacheCheck(ctx)
}
if (query.type === 'template') {
// 生成 render function 并返回
const { code, map } = compileSFCTemplate({ ... })
ctx.body = code
ctx.map = map
return etagCacheCheck(ctx)
}
if (query.type === 'style') {
// 生成 css 文件
const result = await compileSFCStyle(...)
ctx.type = 'js'
ctx.body = codegenCss(`${id}-${index}`, result.code, result.modules)
return etagCacheCheck(ctx)
}
小结
总体看下来主要的逻辑理解起来并不复杂,但可能有很多细节的操作需要仔细琢磨,主要就分为以下几步操作
- script module 引入
- imports 替换
- 解析引入路径并再次请求 -> 返回
虽然「Vite」目前还不能大规模的生产环境推广,但现在一直在以惊人的速度迭代着,未来也很有可能是一种大趋势。不管怎么说,在项目还未过度复杂的情况下快速阅读代码并了解作者的设计思想和意图,对自己也是一个巨大的提升
参考资料
[1]尤雨溪 - 聊聊 Vue.js 3.0 Beta 官方直播: https://m.bilibili.com/video/BV1Tg4y1z7FH
[2]Vite GitHub: https://github.com/vitejs/vite
[3]JavaScript modules 模块: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Modules