分享我的toyvite的实现及心路历程

1,872 阅读3分钟

​ 我是晨霜,本篇会分享我的 toy-vite 的实现思路以及心路历程,会一步一步的从头开始实现,以及讲解为什么要这么做,希望能够对你有所帮助。

Vite 简单介绍

logo.png

​ 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 还是有一些区别的,主要区别在以下两点。

  1. 要运行 ESM 代码,script 标签必须加上 type="module" 属性。

  2. import 路径必须是一个 url 或者相对路径。

例子:

// 必须添加 type="module" 才能使用 ESM
<script type="module">
  // 导入的路径必须是 url 或者相对路径
	import React from 'http://esm.sh/react'
</script>

Native ESM 兼容性

​ 为了运行下面的例子,你需要准备一个兼容 Native ESM 的浏览器。下图是一些主流浏览器的兼容性列表。兼容性查询地址

ESM兼容性.png

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。

​ 这里可能有两点令人疑惑

  1. 为什么从 esm.sh 导入?

    简单理解 esm.sh 类似于一个 cdn,并且从上面的包已经被 esbuild 转换成 ESM 格式了。

  2. htm是什么库?

    htm 全称 Hyperscript Tagged Markup,可以用它来代替 JSX,因为 JSX 不能直接运行在浏览器中,而又不想直接裸写 React.createElement,这时就可以使用 htm,它能提供类似于 JSX 的体验,但是又不需要编译,可以直接跑在浏览器中。至于模板字符串语法,感兴趣的可以直接去 MDN 查看。

是不是感觉非常神奇,没有使用任何打包工具,就在浏览器里跑起来了 React,仿佛回到了当初前端那个“刀耕火种”的年代🤣🤣🤣。

其实从这里就可以大致猜测出 Vite 的原理。

  1. 启动一个 http server 。
  2. 拦截 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 找我内推。 祝大家都能找到心仪的工作。