我是晨霜,本篇会分享我的 toy-vite 的实现思路以及心路历程,会一步一步的从头开始实现,以及讲解为什么要这么做,希望能够对你有所帮助。
Vite 简单介绍
Vite 号称下一代前端开发与构建工具,其最主要的特点就是“快”,这一点在Logo和名称上均有体现,Vite(法语意为 "快速的",发音 /vit/
,发音同 "veet"),体验过 Vite 的人都会对 Vite 的“快”印象深刻。Vite 之所以那么“快”的最主要的原因在于在开发环境中使用了 Native ES Modules,为了探究其原理,先来看下 Native ESM。
Native ESM
写过 JavaScript 的开发人员一定会比较奇怪,为什么 JavaScript 有那么多的模块化规范, AMD、CMD、CJS、ESM 等等,究其原因在于 JavaScript 从一开始设计的时候就没有模块化这个概念,为了解决模块化的问题,人们发明了很多模块化规范,但是这也带来了另一个问题,那就是“混乱”,最终 ECMA 委员会看不下去了,在 ES6 为 JavaScript 带来了 ESM,值得一提的是, ES6 的 Native ESM 和平时在 Webpack 中写的 ESM 还是有一些区别的,主要区别在以下两点。
-
要运行 ESM 代码,script 标签必须加上 type="module" 属性。
-
import 路径必须是一个 url 或者相对路径。
例子:
// 必须添加 type="module" 才能使用 ESM
<script type="module">
// 导入的路径必须是 url 或者相对路径
import React from 'http://esm.sh/react'
</script>
Native ESM 兼容性
为了运行下面的例子,你需要准备一个兼容 Native ESM 的浏览器。下图是一些主流浏览器的兼容性列表。兼容性查询地址。
React Counter Demo
现在尝试不借用任何打包工具,直接使用 ESM 在浏览器中运行一个 React 计数器示例。
index.html
// 省略其他部分
<body>
<div id="root"></div>
<script type="module" src="./index.js"></script>
</body>
index.js
import React from "http://esm.sh/react";
import ReactDOM from "http://esm.sh/react-dom";
import htm from "http://esm.sh/htm";
const html = htm.bind(React.createElement);
const App = () => {
const [count, setCount] = React.useState(0);
return html`
<div>
<div>${count}</div>
<button onClick=${() => setCount((v) => v + 1)}>add</button>
</div>
`;
};
ReactDOM.render(html`<${App} />`, document.getElementById("root"));
注意,file 协议是不能运行的,需要开启一个 http server,这里推荐 VS Code 的 Live Server 扩展。不出意外的话,应该可以在浏览器中看见一个 div 和一个 button。
这里可能有两点令人疑惑
-
为什么从 esm.sh 导入?
简单理解 esm.sh 类似于一个 cdn,并且从上面的包已经被 esbuild 转换成 ESM 格式了。
-
htm是什么库?
htm 全称
Hyperscript Tagged Markup
,可以用它来代替 JSX,因为 JSX 不能直接运行在浏览器中,而又不想直接裸写React.createElement
,这时就可以使用 htm,它能提供类似于 JSX 的体验,但是又不需要编译,可以直接跑在浏览器中。至于模板字符串语法,感兴趣的可以直接去 MDN 查看。
是不是感觉非常神奇,没有使用任何打包工具,就在浏览器里跑起来了 React,仿佛回到了当初前端那个“刀耕火种”的年代🤣🤣🤣。
其实从这里就可以大致猜测出 Vite 的原理。
- 启动一个 http server 。
- 拦截 js 请求,编译其中浏览器不支持的语法(直接从 node_modules 导入、JSX 等)。
Vite 实现
按照上面的思路,开始尝试实现一个 toy-vite。这里先介绍下文件目录。
|-- toy-vite
|-- src // toy-vite的源代码
|-- index.js
|-- demo // 要运行的 demo
|-- index.html
|-- index.js
|-- package.json
demo 文件的内容和上述的 React Counter Demo 内容几乎一模一样,区别在于库是从 node_modules 中直接引入的,以及使用了 JSX。
index.js
// 直接从 node_modules 引入
import React from "react";
import ReactDOM from "react-dom";
// 不使用 htm 库,直接使用 JSX
return (
<div>
<button>-</button>
<span>{count}</span>
<button>+</button>
</div>
)
http server
vite 1.0 的 http server 选择了 koa 这个库,在 vite 2.0 选择了自实现,这里选择使用 express。
const express = require("express");
const path = require("path");
const fs = require("fs");
const app = express();
const demoPath = path.resolve(__dirname, "../demo");
app.use((req, res) => {
const { url } = req;
let content = fs.readFileSync(path.resolve(demoPath, "." + url)).toString();
if (url.endsWith("html")) {
res.type("html");
} else if (url.endsWith("js")) {
res.type("js");
}
res.send(content);
});
app.listen(8000);
这样一个简易的 http server 就启动好了,此时在浏览器中输入 localhost:8000/index.html
,并打开控制台,你会看到一个类似于 Uncaught SyntaxError: Unexpected token '<'
的错误,发生这个错误的原因在于浏览器不认识 JSX,那么很明显,需要编译 JSX,没错第一时间想到的就是 babel。
转换 JS
在res.type(js);
下面加上对 JSX 的处理。
content = require("@babel/core").transformSync(content, {
plugins: ["@babel/plugin-transform-react-jsx"],
}).code;
再次打开控制台查看 index.js
的返回,发现 JSX 已经被正确编译了,但是此时,界面依然没有正常渲染,现在错误变成了 Uncaught TypeError: Failed to resolve module specifier "react". Relative references must start with either "/", "./", or "../".
,还记得上文提过的吗,Native ESM 中引入的路径必须是一个 url 或者相对路径,这里对路径进行处理。
import React from "react";
// 转换为
import React from "/node_moduels/react";
写一个简单的正则来处理,在上面处理 JSX 代码的部分,加上
const regex = /from\s*"(.+)"/g;
content = content.replace(regex, `from "/node_modules/$1"`);
由于这两段代码还会用上,可以把这两段代码封装成一个transformJs
函数。
再次刷新页面,发现 react 和 react-dom 的请求已经被发送出来了,但是却给出了 500 的错误,原因在于读取 demo/node_modules/react 文件时发生了错误,因为这是个目录,因此需要对 url 为 /node_modules
开头的请求做特殊处理。在const { url } = req;
之后加上
if (url.startsWith("/node_modules")) {
res.type("js");
const pkg = JSON.parse(
fs
.readFileSync(path.resolve(demoPath, "." + url, "./package.json"))
.toString()
);
const main = pkg.main;
let content = fs
.readFileSync(path.resolve(demoPath, "." + url, main))
.toString();
content = transformJs(content);
res.send(content);
return;
}
这里一共做了两件事,首先是读取 node_modules 库中的 package.json 文件,找到其中的 main 字段,再根据 main 字段的值来索引 React 文件内容,并发送到客户端,再次打开控制台,会发现以下错误Uncaught SyntaxError: The requested module '/node_modules/react' does not provide an export named 'default'
,查看 react 请求返回的内容。
react 请求返回的内容
'use strict';
if (process.env.NODE_ENV === 'production') {
module.exports = require('./cjs/react.production.min.js');
} else {
module.exports = require('./cjs/react.development.js');
}
打开 demo/node_modules/react
目录,发现 react 只提供了 CJS 和 UMD 两种格式的包,默认使用的是 CJS 格式,所以报 does not provide an export
也就很正常啦。
到目前为止的思路其实并没有错,但是却卡在了 React 没有提供 ESM 版本的问题上。可以先跑一个提供 ESM 版本的框架来感受一下,这里选择 preact,只需要做一点小改动,就可以成功跑起来。
-
demo 中的例子改为 preact 实现
-
@babel/plugin-transform-react-jsx 配置 pragma 为 h
-
package.json main 字段替换为 module
现有的代码实现在这里,感兴趣的也可以按照上述自行修改运行,强烈建议跑一下现有实现感受一下。
现有问题
在上面的实现中尝试导入 lodash-es,发现会发出几百个 http 请求,查看 lodash-es 文件的内容
export { default as add } from './add.js';
export { default as after } from './after.js';
export { default as ary } from './ary.js';
...省略更多
这样的导出语句有几百条,每一条都对应一个 http 请求,很明显,这不合理。
那么现有实现的主要问题在于:
-
依赖库必须提供 ESM 格式,否则跑不起来
-
依赖源文件过多会导致过多的 http 请求
使用 esbuild 进行依赖预构建
还记得之前从 esm.sh 导入依赖吗,如果事先对依赖进行预打包,打包成 ESM 格式以及文件合并,就可以解决上面的问题。在启动 http server 前,我们可以使用 esbuild 进行预构建。
async function prebuild() {
// 读取 demo 中所有的依赖
const pkg = JSON.parse(
fs.readFileSync(path.resolve(demoPath, "./package.json")).toString()
);
const dependencies = Object.keys(pkg.dependencies).map((id) =>
path.resolve(demoPath, "./node_modules", id)
);
// 使用 esbuild 打包
await build({
absWorkingDir: process.cwd(),
entryPoints: dependencies,
format: "esm", // 输出格式为 ESM
bundle: true, // 打包成 bundle
splitting: true,
outdir: path.resolve(demoPath, "./node_modules", ".toyvite"),
});
}
这里直接简单的对 demo 中所有用到的依赖进行预构建,vite 中则是通过正则扫描出项目中真正使用到的依赖。执行完 prebuild 后,demo/node_modules/.toyvite
中就可以看到打包后的产物了,那么在读取依赖库时,只需要在 .toyvite
目录中读取就可以了,继续修改代码。
if (url.startsWith("/node_modules")) {
res.type("js");
const name = url.substr(url.lastIndexOf("/") + 1);
let content = fs
.readFileSync(
path.resolve(
demoPath,
"./node_modules/.toyvite/",
name.endsWith(".js") ? name : name + ".js"
)
)
.toString();
content = transformJs(content);
res.send(content);
return;
}
打开浏览器,成功跑起 React 代码。这里有一个可以继续优化的点,既然使用了 esbuild,那么在 transformJs 中,也可以直接使用 esbuild 来进行。修改 transformJs
函数中转换 JSX 的部分。
content = transformSync(content, {
jsx: "transform",
loader: "jsx",
}).code;
完整的代码可以在这里找到。
总结
可以看到,最终仅用了80行代码就让 React 成功的跑在了浏览器中,虽然这只是一个 toy ,但是依然可以用来学习 Vite 原理。学习一个项目的时候,如果直接看源码比较吃力,可以尝试实现一个 toy 版,理解原理之后,再去看源码,说不定效果会更好。
写在最后
笔者目前就职于字节跳动-抖音电商部门,目前团队在北京和上海都还有非常多的hc,感兴趣的可以投递简历到 suchangv@bytedance.com 或者加我微信 suchangvv 找我内推。 祝大家都能找到心仪的工作。