Vite 怎么能那么快?从 ES modules 开始谈起

不知道大家有没有听过 vite 这个工具,看它名字有个 v,大概就可以猜到可能跟 Vue 有关。没错,这是 Vue 的作者尤雨溪开发出来的另外一套工具,原本是想要给 VuePress 用的,但是强大之处就在于它不仅限于此。

Vite 在 GitHub 上的 About 只写了两个句子:

Native-ESM powered web dev build tool. It's fast.

如果你有体验过,就会发现真的很快。Vite 是 build tool 跟 dev server 的综合体,这篇会简单教大家使用一下 vite,然后来谈 ES modules,再来看看 vite 神奇的地方。

初探 Vite

先来大致讲一下 vite 这个工具到底是在做什么。我们可以从它的定位来看:build tool + dev server,我们先着重于后面那一块。 Dev server 就是像我们用 webpack 时会用的 webpack dev server + hot module reload,提供给我们本地开发的环境,让我们只要存档以后就会自动更新整个 app,已经是现在写前端不可或缺的工具了。

而 vite 的概念也是如此,就是提供一个「更快速的 dev server」,让我们在开发时能够使用。

接着直接带大家跑一次流程。

虽然说 vite 跟 Vue 的整合度最好,但 vite 并不是 Vue 专属的工具。例如 vite 也提供了 React 的 template。我们直接拿 React 来做示范:

npm init vite-app react-demo --template react
cd react-demo
npm install
npm run dev

就这短短四行指令,立刻让你体验 vite 的威力。第一行就是用 vite 提供的工具帮你产生一个 boilerplate 出来,之后切进文件夹里面进行开发。

成功以后 terminal 会跟你说 dev server 跑起来了,接着打开:http://localhost:3000 ,就会看到熟悉的一直转圈圈的 React:

vite 开发 React 效果图

接着我们试着打开 src/App.jsx,随意更改一些东西存档,就会看到 React app 非常快速地更新了。 Vite 无论是启动的速度还是更新的速度,都比 create-react-app 或者是 webpack dev server 快上不少。在 推特 上也有人针对这两者做了一些比较,vite 显然更有优势。

Vite 这么快的原因到底是什么?简单来说,就是 Native ES Modules。接下来,就让我们看看什么是 Native ES Modules。

Native ES Modules

在继续往下读之前,建议大家要先知道在 JavaScript 里面 module 发展的一些历史,可以先参考我之前写过的这篇文章:webpack 新手教学之浅谈模组化与 snowpack

在文章中有提到,早期在浏览器并没有原生的 module 机制,所以才会产生出各个标准,像是大家可能都有听过的 CommonJS、AMD 或是 UMD。但是这点在 ES6 的时候有了改变,因为 ES6 的规范里终于有 module 了!我们就称这个做 ES Modules,简称 ESM。ESM 的规范其实大家应该都用过,就是:

// a.js
export const PI = 3.14;

// b.js
import { PI } from './a';

只要你看到 exportimport,那八成就是 ESM 的语法。除了规范以外,更令人兴奋的是现在所有的主流浏览器都已经原生支持 ESM 了!这里有个简单的 demo 网站,打开以后打开浏览器开发者工具并切到 network 标签去,点开 index.js 和 utils.js,就会发现两个档案都是使用 ESM 的语法:

vite esm 浏览器网络展示

采用原生的 ESM 加载机制,就是 Native ESM,让浏览器来帮你处理这些 importexport

等等,我刚特地强调原生两个字,难道说还有其他东西不是原生的吗?是的,没错喔。你平常在用的 webpack 或者类似的工具,别忘了它的名称叫做「bundler」,就是要把你的 JS 档案跟 dependencies 打包在一起。尽管你在写代码的时候是用 importexport 没错,但是在输出时很有可能已经被 babel 或者是 webpack 转成 CommonJS 或是其它形式,而且外面还有再包一层来负责解析 require 这一些语法。

而这也是 webpack 这些打包工具之所以慢的原因,那就是他们需要静态分析过 app 的所有档案以及套件的相依性,然后根据这些资讯把东西包在一起,当你的档案越来越大的时候,花的时间也就自然越来越多,因为 webpack 要搞清楚到底要怎么打包。

如果我们能避开构建,不要把所有东西都包在一起的话,是不是就会快很多了?

是,这就是为什么 vite 这么快。

再探 vite

在稍早附的文章里面有提到过 snowpack,其实 snowpack 的概念与 vite 相当类似,都是采用 Native ESM 的解法。与其把东西全部打包在一起,不如好好利用浏览器,让浏览器帮你处理那些复杂的相依性。

像是 snowpack 就会把你用到的 node_modules 放到一个特定的地方让你可以引入。

接着我们可以回来看 vite,打开我们刚开始装的那个 demo 专案并且开启 devtool 然后切到 network,一目了然:

vite 浏览器网络展示

原理就跟 snowpack 蛮像的,都是使用 ESM 加载不同的 package,才会看到浏览器有这么多的请求。

点开 main.jsx,就可以看到里面的代码:

import React from '/@modules/@pika/react/source.development.js';
import ReactDOM from '/@modules/@pika/react-dom/source.development.js';
import '/src/index.css?import';
import App2 from '/src/App.jsx';
ReactDOM.render(
  /* @__PURE__ */ React.createElement(
    React.StrictMode,
    null,
    /* @__PURE__ */ React.createElement(App2, null),
  ),
  document.getElementById('root'),
);

Vite 在服务端会帮我们将代码做转换,它会把代码里的 import React from 'react' 换掉,把路径改成自己准备好的 React build。这是因为 React 官方其实目前还没有 ESM 的 build!现在大家在用的好像是种 UMD 与 CommonJS 的混合体。未来有计画要做,但可能需要一段时间,详情可参考:#11503 Formalize top-level ES exports

虽然说官方没有,但社区中已经有人自己先做出来了,所以这里用的是社区版。顺便补充一下,原本的 import React from 'react' 被称为「bare module imports」,bare 指的是后面的 react,它并不是一个档案路径。根据 Evan You 的说法,ESM 的标准里面这是未定义行为,所以要特别处理。

如果我们把前面自己试的 ESM 小范例,import { add } from './utils.js' 换成 import { add } from 'utils.js',就会出现这个错误:

Uncaught TypeError: Failed to resolve module specifier "utils.js". Relative references must start with either "/", "./", or "../".

所以一定要是 /./ 或是 ../ 开头才行。接着我们来看 App.jsx

import { createHotContext } from '/vite/client';
import.meta.hot = createHotContext('/src/App.jsx');
import RefreshRuntime from '/@react-refresh';
let prevRefreshReg;
let prevRefreshSig;
if (!window.__vite_plugin_react_preamble_installed__) {
  throw new Error(
    "vite-plugin-react can't detect preamble. Something is wrong. " +
      'See https://github.com/vitejs/vite-plugin-react/pull/11#discussion_r430879201',
  );
}
if (import.meta.hot) {
  prevRefreshReg = window.$RefreshReg$;
  prevRefreshSig = window.$RefreshSig$;
  window.$RefreshReg$ = (type, id) => {
    RefreshRuntime.register(
      type,
      '/Users/huli/Documents/lidemy/test/react-demo/src/App.jsx' + ' ' + id,
    );
  };
  window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform;
}
var _s = $RefreshSig$();

import React, { useState } from '/@modules/@pika/react/source.development.js';
import logo2 from '/src/logo.svg?import';
import '/src/App.css?import';

function App2() {
  _s();

  const [count, setCount] = useState(0);
  return /* @__PURE__ */ React.createElement(
    'div',
    {
      className: 'App',
    },
    /* @__PURE__ */ React.createElement(
      'header',
      {
        className: 'App-header',
      },
      /* @__PURE__ */ React.createElement('img', {
        src: logo2,
        className: 'App-logo',
        alt: 'logo',
      }),
      /* @__PURE__ */ React.createElement(
        'p',
        null,
        'Hello Vite + React!wwaaaa',
      ),
      /* @__PURE__ */ React.createElement(
        'p',
        null,
        /* @__PURE__ */ React.createElement(
          'button',
          {
            onClick: () => setCount((count2) => count2 + 1),
          },
          'count is: ',
          count,
        ),
      ),
      /* @__PURE__ */ React.createElement(
        'p',
        null,
        'Edit ',
        /* @__PURE__ */ React.createElement('code', null, 'App.jsx'),
        ' and save to test HMR updates.',
      ),
      /* @__PURE__ */ React.createElement(
        'a',
        {
          className: 'App-link',
          href: 'https://reactjs.org',
          target: '_blank',
          rel: 'noopener noreferrer',
        },
        'Learn React',
      ),
    ),
  );
}

_s(App2, 'oDgYfYHkD9Wkv4hrAPCkI/ev3YU=');

_c = App2;
export default App2;

var _c;

$RefreshReg$(_c, 'App2');
if (import.meta.hot) {
  window.$RefreshReg$ = prevRefreshReg;
  window.$RefreshSig$ = prevRefreshSig;

  import.meta.hot.accept();
  RefreshRuntime.performReactRefresh();
}

可以看到原本的 jsx 已经在服务端被转成了 JS,然后与 HMR(Hot Module Reload)有关的代码。如果你试着修改 source code 然后存档,会发现 network request 的网址上多了一个 timestamp:

vite timestamp

可以猜出这应该跟 cache invalidation 有关,在 reload module 的时候避免加载到旧的,所以加上 timestamp 强制重新抓取。

最后我们来看一下 CSS 的部分是怎么处理的:

import { updateStyle } from '/vite/client';
const css =
  '.App {\n  text-align: center;\n}\n\n.App-logo {\n  height: 40vmin;\n  pointer-events: none;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n  .App-logo {\n    animation: App-logo-spin infinite 20s linear;\n  }\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\n.App-link {\n  color: #61dafb;\n}\n\n@keyframes App-logo-spin {\n  from {\n    transform: rotate(0deg);\n  }\n  to {\n    transform: rotate(360deg);\n  }\n}\n\nbutton {\n  font-size: calc(10px + 2vmin);\n}\n';
updateStyle('"7ac702d2"', css);
export default css;

把 CSS 变成一个字符串,然后运行 updateStyle 这个函数。只要是在客户端加载 vite,就会自动一起加载 /vite/client,里面会处理像是 HMR 或者是加载 CSS,例如说上面的 updateStyle 就在这个档案里面。

好,写到这里其实我们大致上了解 vite 的面貌。为什么它比较快?因为 webpack 需要 bundle,可是 vite 不需要,所以它不需要把代码全都包在一起,它只需要起一个本地服务器,让你的 import 可以抓到正确的档案就好了。少了打包,速度自然快很多,这就是 Native ESM 的威力。

生产环境

只要运行 npx vite build 就可以产生生产构建,但产生的档案或许会让人失望,因为就跟 webpack 一样,是个很大的 index.js,所有代码都在里面。

这是因为生产环境是使用 rollup 构建,走传统的打包策略,就跟 webpack 没两样。原因 vite 的 docs 已经说明:

Vite does utilize bundling for production builds, because native ES module imports result in waterfall network requests that are simply too punishing for page load time in production.

跟大家解释这个问题是什么,问题就来自代码库之间的依赖。

假设使用用代码库 A,它需要去加载代码库 B,然后代码库 B 依赖于代码库 C,就这样一直互相依赖,然后相连到天边,就产生了很长一大串的依赖链。那浏览器就要等到这些套件全部都下载完成以后才能开始执行 JavaScript,这就是原文说的「waterfall network requests」,所以在生产环境上这样用的话是有问题的。

尤其是 HTTP/1.1,浏览器都会有 parallel 的上限,大部分是 5 个上下,所以如果你有 60 个 dependencies 要下载,就需要等好长一段时间。虽然说 HTTP/2 多少可以改善这问题,但若是东西太多,依然没办法。

那为什么在本地不会有问题呢?因为本地服务的下载时间几乎是 0 啊!所以这是在生产环境上面才会有的 issue。而这个问题已经有人在试着解决,例如说 pika

Pika is building a world where third-party libraries can be loaded, cached, and shared across sites

依照我的理解,就有点像是如果所有人的 ESM 都从 pika 下载,那浏览器就可以缓存这些代码库,下载过的就不需要再下载一次,速度就会快上许多。不过当然还有其他问题有待解决,例如说浏览器会提供这么多空间给你放吗?等等之类的。

结语

Vite 最近好像掀起了一股小小的炫风,在一些开源专案中都会看到有人问说是不是有机会改用 vite 作为 dev server。虽然说 snowpack 已经出来一阵子了,但是 Native ESM 的这个用法,应该是到 vite 红起来才比较广为人知。

我自己是觉得在本地开发的时候,ESM 的确是能让速度快上许多,是个很值得尝试的方向,我甚至认为这可能会是未来标准的开发方式,取代原本的构建方式。而在生产环境上面如果能解决刚刚说的 waterfall 问题,或许就能产生两种 target,一种是 target modern browser,直接用 ESM + ES6 输出,少了许多构建的时间;另一种是针对比较旧的浏览器,就走老路用 webpack 或是 rollup 等等。

Evan You 之前有跟 Adam Wathan(Tailwind CSS 的作者)录了一集 podcast,有讲到为什么会想做 vite,以及 vite 在未来发展方向或者是生产环境构建会碰到的问题等等,很推荐大家去听听看:140: Evan You - Reimagining the Modern Dev Server with Vite