100 行代码写一个破产版 Vite
天下苦 Webpack 久矣?
几个月前尤雨溪在实现 Vite Demo 后发了一条推,让人爆笑:
Sean 是 Webpack 的核心成员,学过中文,以前非常活跃,不知为何最近两年很少为 Webpack 贡献代码。尤大的一句“感觉不会再爱了”,让 Sean 直接喊了声大哥,哈哈。
# What's Vite
很多人吹嘘 Vite 是 Webpack Killer,其实有点神化了,它(至少目前)不能替代 Webpack、也不是为了替代 Webpack,对 Vite 更贴切的描述应该是「基于 ES Module 的 Dev Server + 基于 Rollup 封装的 Bundler」——我认为它定位与核心竞争力在前半句,支持打包只是让它更好用的附带功能。
截至笔者写作之日,Vite 的最新版本为 v1.0.0-rc4
(opens new window),基础功能与 API 已趋于稳定,我试着用 100 行代码实现一个破产版的 Vite。之所以称之为破产版,是因为:
- 对文件类型支持有限
- 未实现 Vite 的核心功能 HMR(HMR 是 Vite 优秀体验的原因之一)
本文及提供的 demo 仅为阐述 Vite 如何利用 ES Module 的能力绕开开发时的打包,提高开发体验。其余模块与功能的实现,日后可能会单独写文谈谈个人的源码解析。
demo 见地址 (opens new window)。去掉空行总共 101 行 TS 代码。
# 前置知识
- ES Module(参考此处 (opens new window))
- Node / Koa
- TypsScript
- 模块调试(yarn link / npm link)
- ...
# 前期准备
- 创建一个 simple-vite 项目,指定 bin 文件
- 使用 Vite 官方的模版 (opens new window)创建一个 simple-vite-demo 项目,然后将 node script 中的
vite
替换为simple-vite
,指定 simple-vite 执行 simple-vite 的 bin,用于测试(参考link 的使用 (opens new window))
# 开始动手
Vite 使用 Koa 作为驱动 Node 的框架,各独立功能(如:资源路径重写与转发、Vue 文件的服务端解析、Esbuild 支持、HMR 等)以 Koa 中间件的方式实现,这些中间件在 Vite 内部被称为 Plugin,本文参考 Vite 的写法但仍保留 Middleware 的概念。
# 入口
import Koa, { DefaultState, DefaultContext } from 'koa'
import chalk from 'chalk'
import middlewares from './middlewares'
const app = new Koa<DefaultState, DefaultContext>()
middlewares.forEach(middleware => middleware(app))
app.listen(3000, () => {
console.log(chalk.green('[simple-vite] running in port 3000'))
})
2
3
4
5
6
7
8
9
10
11
在 3000 端口起一个 Koa 服务,将创建出的 app 作为参数传递至每一个中间件。以 staticMiddleware 为例,我们先处理一下静态资源的转发问题。
# staticMiddleware
import send from 'koa-send'
import Koa from 'koa'
export const staticMiddleware = (app: Koa): void => {
app.use(async (ctx, next) => {
await send(ctx, ctx.path, {
root: process.cwd(),
index: 'index.html',
})
await next()
})
}
2
3
4
5
6
7
8
9
10
11
12
服务端使用 koa-send 将客户端请求静态资源的相对路径转换为绝对路径。当在 simple-vite-demo 根目录下使用 simple-vite 启动一个项目时,localhost:3000 便能访问到根目录下的 index.html。
<script type="module" src="/src/main.js"></script>
/src/main.js
的脚本会以 module 形式被加载,加载完成后控制台会出现一条报错信息:
Uncaught TypeError: Failed to resolve module specifier "vue". Relative references must start with either "/", "./", or "../".
main.js 中我们直接通过 import { createApp } from 'vue'
引入 node_modules 中的 vue,'vue'
这种引入方式被称为 Bare Import,而 Bare Import 是不符合 ESM 规范的,报错信息亦表明 ES Module 的路径必须为相对路径,因此需要重写 main.js 的内容。
Vite 中,会将 Bare Import 的模块转换成 /@modules/${packageName}
的形式:
// before
import { createApp } from 'vue'
// after
import { createApp } from '/@modules/vue'
2
3
4
Simple-Vite 也照模子抄过来吧!
# rewriteMiddleware
import Koa from 'koa'
import { streamToString, isJsFile } from '../utils'
const env = process.env.NODE_ENV || 'development'
export const rewriteMiddleware = (app: Koa): void => {
app.use(async (ctx, next) => {
if (isJsFile(ctx.type)) {
const content = await streamToString(ctx.body)
ctx.body = content
.replace(/(from\s+['"])(?![\.\/])/g, '$1/@modules/')
.replace(/process\.env\.NODE_ENV/g, `"${env}"`)
return next()
}
return next()
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Vite 对文件路径的重写逻辑中,使用 path.extname
来识别文件类型,更严谨一些,我们的破产版暂且用正则匹配。当识别出 js 文件时,将二进制流转换成字符串进行替换。其中正则 /(from\s+['"])(?![\.\/])/g
表示匹配 from 后不以 .
或 /
开头的字符串,其后插入 /@modules/
。
重启 Node 服务后可观察到 main.js 的代码内容已被替换。这时留给我们的问题有二:
- 显然,项目中不存在名为
@modules
的文件夹。为了让 Node 能正确加载 package,需要找到 package 的真实路径 - main.js 中
import App from './App.vue'
能让浏览器成功加载 .vue 文件,但未做即时编译,无法识别
我们依次解决。
# loadModuleMiddleware
import Koa from 'koa'
import { join } from 'path'
const cwd = process.cwd()
export const loadModuleMiddleware = (app: Koa): void => {
app.use(async (ctx, next) => {
if ((ctx.path.startsWith('/@modules'))) {
const moduleName = ctx.path.slice(10) // 10 is length of '/@modules'
const modulePkg = require(join(cwd, 'node_modules', moduleName, 'package.json'))
ctx.path = join('/node_modules', moduleName, modulePkg.module)
return next()
}
return next()
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
loadModuleMiddleware
识别到路径是 rewriteMiddleware
重写的格式后,将 ctx.path 重定向至 /node_modules/${moduleName}/package.json
中 module
字段指定的模块入口文件。以 Vue 3 为例,Vue 3 的 package.json
中指定 "module": "dist/vue.runtime.esm-bundler.js"
,最终效果:
// before
import { createVue } from 'vue'
// after `rewriteMiddleware`
import { createVue } from '/@modules/vue'
// after `loadModuleMiddleware` redirect
import { createVue } from '/node_modules/vue/dist/vue.runtime.esm-bundler.js'
2
3
4
5
6
# vueSfcMiddleware
main.js 中引入的 App.vue
是未经编译的 Vue SFC,要为它进行即时编译需要用到 @vue/compiler-sfc
。
import Koa from 'koa'
import * as CompilerSfc from '@vue/compiler-sfc'
import { isVueSfcFile, isUndefined, streamToString, setResponseContentType } from '../utils'
export const vueSfcMiddleware = (app: Koa): void => {
app.use(async (ctx, next) => {
if (isVueSfcFile(ctx.path)) {
const content = await streamToString(ctx.body)
const { descriptor } = CompilerSfc.parse(content)
if (isUndefined(ctx.query.type)) {
const code = descriptor.script?.content
ctx.body =
`import { render as __render } from "${ctx.path}?type=template"\n` +
`${CompilerSfc.rewriteDefault(code!, '__script')} \n` +
'__script.render = __render \n' +
'export default __script'
} else if (ctx.query.type === 'template') {
ctx.body = CompilerSfc.compileTemplate(
{ source: descriptor.template?.content } as CompilerSfc.SFCTemplateCompileOptions
).code
}
setResponseContentType(ctx, 'js')
return next()
}
return next()
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
当识别到是 .vue 文件时,Vite 会做(不限于)以下两件事:
- 使用 CompilerSfc 解析 script 的内容,插入 template 的 import 语句(当 script 中引入了 CSS 还会插入 CSS 的 import 语句,此处从简只处理 script)
- 使用 CompilerSfc 解析 template 成 render 函数,返回
- 将上述即时编译的元素组装成一个组件
因为没有对样式和图片资源进行处理,所以 main.js 中引入的 CSS 及 App.vue 中引入的图片仍然会报错,我们暂且移除这部分代码。你可以具体看看 Vite 是怎么处理这些资源的,随着阅读的深入会接触到热更新的核心逻辑。
# 最后
最后我们理一下各 Middleware 的执行顺序。
// src/index.ts
import middlewares from './middlewares'
// ...
middlewares.forEach(middleware => middleware(app))
// middlewares/index
import { loadModuleMiddleware } from './loadModuleMiddleware'
import { staticMiddleware } from './staticMiddleware'
import { rewriteMiddleware } from './rewriteMiddleware'
import { vueSfcMiddleware } from './vueSfcMiddleware'
export default [
loadModuleMiddleware,
staticMiddleware,
vueSfcMiddleware,
rewriteMiddleware,
]
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
功能逻辑顺序:
- 重写 (Conten-Type 被标识为)Js 的文件中 import 的引入路径
- 如果该 JS 文件是 .vue 单文件,服务端编译后返回组件
- 将所有资源的路径转换为绝对路径
- 确保将绝对路径中能正确引入 node_modules 下的包
Koa 使用的是洋葱圈模型,因此上面代码中的 middlewares 顺序是与执行顺序相反的。
一切就绪,刷新,大功告成:
# 局限
像文章开头所说的一样,因为未实现对 css、png 等资源的加载,未实现 HMR,所以 Simple-Vite 在功能上也是残缺的,称之为破产版并不为过。如果只针对现有的代码,有什么值得优化的吗?我个人不完全地列举了一下:
- 程序的 root 写死
process.cwd()
,应该做成可配置的 process.env.NODE_ENV
也应该做成可配置的,方便测试- 需要拓展各个 Middleware 接收的参数,而不是仅仅接收 new Koa() 创建的 app 实例,这样 Middleware 可以获取更丰富的信息做更多事
- ...
这些就留到日后再优化吧,又或者,就不要优化这个破产版的了,考虑优化 Vite 给 Vite 提 PR 吧(滑稽)。