远子 💖 Vina

读Vite源码

远子 •  2021年05月26日 • 1 条评论

Vite 很快

下边来创建一个 vite 项目:

npm init @vitejs/app

根据指引,输入项目名称后选择 vue 框架,输出如下:

$ npm init @vitejs/app
npx: 5 安装成功,用时 2.649 秒
✔ Project name: · hello-vite
✔ Select a framework: · vue
✔ Select a variant: · TypeScript

Scaffolding project in /Users/rmlzy/Documents/awesome/hello-vite...

Done. Now run:

  cd hello-vite
  npm install
  npm run dev

测试一下开发服务的启动速度:

npm run dev

输出如下:

$ npm run dev

> hello-vite@0.0.0 dev /Users/rmlzy/Documents/awesome/hello-vite
> vite


  vite v2.3.4 dev server running at:

  > Local: http://localhost:3000/
  > Network: use `--host` to expose

  ready in 213ms.

hello-world 项目启动开发服务耗时 213 毫秒,秒开级别的,果然很快。

vite 在 build 时没有输出耗时,通过 shell 命令打印一下时间,先加一个 npm script:

"scripts": {
    "test-build": "echo $(date) && npm run build && echo $(date)"
}

执行 npm run test-build 后的输出如下:

$ npm run test-build

> hello-vite@0.0.0 test-build /Users/rmlzy/Documents/awesome/hello-vite
> echo $(date) && npm run build && echo $(date)

2021年 5月26日 星期三 09时35分44秒 CST

> hello-vite@0.0.0 build /Users/rmlzy/Documents/awesome/hello-vite
> vue-tsc --noEmit && vite build

vite v2.3.4 building for production...
✓ 14 modules transformed.
dist/assets/logo.03d6d6da.png    6.69kb
dist/index.html                  0.47kb
dist/assets/index.83e959fa.css   0.34kb / brotli: 0.18kb
dist/assets/index.9ded90a5.js    1.51kb / brotli: 0.67kb
dist/assets/vendor.05decbbe.js   43.31kb / brotli: 15.60kb
2021年 5月26日 星期三 09时35分52秒 CST

hello-world 项目打包耗时 8 秒,速度还可以。

再用 vue-cli 创建一个同样的 Vue3 + TypeScript 的 hello-world 项目,来比对一下 vue-cli 和 vite

开发服务耗时打包耗时打包体积
vue-cli2655ms5276ms85.99kb
vite213ms8s52.32kb

可以看到 vite 的表现很强,尤其是开发服务,快速的热重载可以提升开发效率。

Vite 为什么快

先说结论:

  1. Vite 打包后的代码是 ESM 文件,少了 Babel 降级的步骤;
  2. Vite 将库代码预构建,类似 webpack 的 dll;
  3. Vite 构建源码时,类似一种懒加载的方式,只构建页面中引用到的代码。这里可以参考 Vite文档,Vite 可以保证在大型项目达到极快的热重载;
  4. 预构建时使用 esbuild,esbuild 使用 Go 编写,比纯JS的打包器快很多;
  5. 目前打包时使用 rollup,可以看到有点慢,未来会使用 esbuild;

我认为可以这么理解:

  • vite = esbuild + ESM + 优化
  • vue-cli = webpack + tsc + babel

总结一下:

  • Vite 少了 Babel 降级的步骤,速度上来一大截;
  • 用 esbuild,速度又上来一大截;
  • 预构建的机制,速度又上来一大截;

Vite 的工作流程

简单来说,启动开发服务时的流程如下:

  1. 执行 npm init @vitejs/app 初始化仓库
  2. 执行 npm run dev
  3. 执行 vite serve
  4. 创建 DevServer 实例
  5. 调用 DevServer.listen() 方法,当文件改动时,更新或者重启服务
  6. 浏览器请求源码时,Vite 转换源码通过 WebSocket 发送给浏览器,浏览器进行渲染

上边省略了大量的细节,下边通过阅读 Vite 源码来分析。

在这之前,先看一下 Vite 的功能清单,可以从 Vite 文档 中找到:

  • NPM 依赖解析和预构建
  • 模块热重载
  • 处理源码

    • TypeScript
    • Vue
    • JSX
    • CSS
    • 静态资源
    • JSON
    • Glob
    • Web Assembly
    • Web Worker
  • 构建优化

    • CSS 代码分割
    • 预加载指令生成
    • 异步 Chunk 加载优化

后边我们会挨个分析这些功能是如何实现的。

Vite 详细的工作流程

image-20210526151345203

Vite 源码中有大量的参数校验、错误判断,为了移除干扰看清整体的流程,本文的代码做了大量删减。

第 1 步:初始化一个 Vite 项目

npm init awesome-app 这种写法会下载仓库名为 create-awesome-app 的仓库。

执行 npm init @vitejs/app 时会基于 create-vite-app 初始化仓库,相当于 git clone git@github.com:vitejs/create-vite-app.git

第 2 步:启动开发服务

执行 npm run dev时将执行 package.jsonscripts 字段中的 dev 命令,即 vite serve

vite 命令指向到 hello-vite/node_modules/.bin/vite,又指向到 node_modules/vite/node/cli.js,即 vite 源码中的 cli.ts,源码大概是这样的:

import { cac } from "cac";

const cli = cac('vite');

cli
    .command("serve")
    .action(async () => {
      const { createServer } = await import("./server");
      const server = await createServer();
      await server.listen();
    });

cac 是一个创建命令行的工具,简单来说,在终端中输入命令时,cac 帮我们解析参数,然后执行一些动作。

上边代码的意思是:在执行 vite serve 命令时,走 action 回调,action 里的代码很简单:创建 Server 后执行 listen 方法。

第 3 步:创建 DevServer 实例

来看一下 createServer 这个工厂函数的定义:

async function createServer() {
  const config = await resolveConfig("serve", "development");
  const server = {
    config,
    listen(port) {
      return startServer(server, port);
    }
  };
  return server;
}

async function startServer(server, port) {
  const httpServer = require("http").createServer();
  return new Promise((resolve, reject) => {
    httpServer.listen(port, "127.0.0.1", () => {
      console.log("启动开发服务成功");
    });
    httpServer.on("error", () => {
      console.log("启动开发服务失败");
    });
  });
}

可以看到,vite 底层是通过 node 的 http 模块启动开发服务。

第 3.1 步:初始化 config

resolveConfig 方法的定义如下:

async function resolveConfig() {
  const root = "/Users/rmlzy/Documents/awesome/hello-vite";
  
  // 1. 初始化 config 对象
  const config = {
    root,
    configFile: `${root}/vite.config.ts`,
    command: "serve",
    mode: "development",
    plugins: [],
    env: {},
    cacheDir: ""
  };
  
  // 2. 读取 hello-vite/vite.config.ts 后合并到 config 对象上
  if (config.configFile) {
    const loadResult = await loadConfigFromFile(config.configFile);
    _.merge(config, loadResult);
  }
  
  // 3. 挂载 plugin
  config.plugins = await resolvePlugins();
  
  // 4. 挂载路径别名
  
  // 5. 读取 hello-vite/.env 文件后合并到 config.env 对象上
  const userEnv = loadEnv()
  config.env = {
    ...userEnv,
    MODE: mode,
    DEV: config.mode === 'development',
    PROD: config.mode === 'production'
  };
  
  // 6. 预构建生成的包默认缓存在 node_modules/.vite 目录下,开发可以指定 cacheDir
  const cacheDir = config.cacheDir || `${root}/node_modules/.vite`;
  
  return config;
}

顾名思义,resolveConfig 函数的作用是生成一份 configconfig 上包含了 DevServer 需要的各种信息,主要分这么几步:

  1. 初始化一个对象名为 config
  2. 读取用户的 vite.config.ts 后,合并到内置的 config 对象上;
  3. 挂载 plugins 到 config 对象上;
  4. 挂载解析器到 config 对象上;
  5. 挂载环境变量到 config 对象上;
  6. 挂载缓存目录到 config 对象上;
  7. 最后返回 config 对象。

有几个值得注意的地方:

首先是 loadEnv 方法:

function loadEnv() {
  const env = {};
  const envFiles = [
    `.env.${mode}.local`,
    `.env.${mode}`,
    `.env.local`,
    `.env`
  ];
  for (const file of envFiles) {
    const path = lookupFile(root, [file], true);
    if (path) {
      const parsed = dotenv.parse(fs.readFileSync(path));
      for (const [key, value] of Object.entries(parsed)) {
        if (key.startsWith("VITE_")) {
          env[key] = value;
        }
      }
    }
  }
  return env;
}

这里有两个需要注意的地方,第一个是加载 env 文件时是有优先级的,第二个是用户自定义的配置必须以 VITE_ 开头。

第 3.2 步:挂载插件

Vite 中的插件分为两种:第一种是用户通过 vite.config.ts 指定的插件,第二种是 Vite 内置的插件。

image-20210526124807737

Vite 文档中所说的 “对 TypeScript、JSX、CSS 等支持开箱即用”,说的就是 Vite 内置了一些常用的插件。

来看一下 resolvePlugins 方法:

async function resolvePlugins() {
  const isBuild = config.command === "build";
  const buildPlugins = isBuild ? (await import("../build")).resolveBuildPlugins();
  return [
      isBuild ? null : preAliasPlugin(),
      aliasPlugin(),
      ...prePlugins,
      dynamicImportPolyfillPlugin,
      resolvePlugin,
      htmlInlineScriptProxyPlugin,
      cssPlugin,
      config.esbuild !== false ? esbuildPlugin : null,
      jsonPlugin,
      wasmPlugin,
      webWorkderPlugin,
      assetPlugin,
      ...normalPlugins,
      definePlugin,
      cssPostPlugin,
      ...buildPlugins.pre,
      ...postPlugins,
      ...buildPlugins.post,
  ].filter(Boolean);
}

resolvePlugins 是一个汇总方法,将用户从 vite.config.ts 定义的 normalPlugin 和内置插件汇总到一起后挂载到 server.config.plugins 的这数组里。后边会单独介绍这些插件的用途。

第 3.3 步:挂载中间件

server 对象上还需要挂载中间件,大概如下:

async function createServer() {
  const config = await resolveConfig("serve", "development");
  
  // 挂载中间件
  const middlewares = connect();
  middlewares.use(corsMiddleware());
  middlewares.use("/__open-in-editor", launchEditorMiddleware());
  middlewares.use("/__vite_ping", viteHMRPingMiddleware());
  middlewares.use(decodeURIMiddleware());
  middlewares.use(transformMiddleware());
  middlewares.use(serveRawFsMiddleware());
  middlewares.use(serveStaticMiddleware());
  
  const server = {
    config,
    middlewares,
    listen(port) {},
    close() {}
  };
  return server;
}

const middlewares = connect(); 可以看到 middlewares 是通过 node 的 connect 模块生成的,middlewares 是个数组,后边再详细介绍。

第 3.4 步:监听文件变化

Vite 使用 chokidar 工具库监听文件的变动,当文件变化时、新增文件时、删除文件时,会走到对应的监听函数中。

async function createServer() {
  const config = await resolveConfig("serve", "development");
  
  // 挂载中间件
  const middlewares = connect();
  middlewares.use(corsMiddleware());
  middlewares.use("/__open-in-editor", launchEditorMiddleware());
  middlewares.use("/__vite_ping", viteHMRPingMiddleware());
  middlewares.use(decodeURIMiddleware());
  middlewares.use(transformMiddleware());
  middlewares.use(serveRawFsMiddleware());
  middlewares.use(serveStaticMiddleware());
  
  const watcher = chokidar.watch();
  
  const server = {
    config,
    middlewares,
    watcher,
    listen(port) {},
    close() {}
  };
  
  // 文件变化时
  watcher.on("change", file => {
    
  });
  
  // 添加文件时
  watcher.on("add", file => {
    
  });
  
  // 删除文件时
  watcher.on("unlink", file => {
    
  });
  
  return server;
}

第 3.5 步:初始化 WebSocket

监听到文件改动后,需要将变动后的源码发送到浏览器,Vite 通过 ws 与浏览器建立 WebSocket 连接。

async function createServer() {
  const config = await resolveConfig("serve", "development");
  
  // 挂载中间件
  const middlewares = connect();
  middlewares.use(corsMiddleware());
  middlewares.use("/__open-in-editor", launchEditorMiddleware());
  middlewares.use("/__vite_ping", viteHMRPingMiddleware());
  middlewares.use(decodeURIMiddleware());
  middlewares.use(transformMiddleware());
  middlewares.use(serveRawFsMiddleware());
  middlewares.use(serveStaticMiddleware());
  
  const watcher = chokidar.watch();
  
  const ws = createWebSocketServer();
  
  const server = {
    config,
    middlewares,
    watcher,
    ws,
    listen(port) {},
    close() {}
  };
  
  // 文件变化时
  watcher.on("change", file => {
    ws.send({ type: "update", updates });
  });
  
  // 添加文件时
  watcher.on("add", file => {
    ws.send({ type: "update", updates });
  });
  
  // 删除文件时
  watcher.on("unlink", file => {
    ws.send({ type: "update", updates });
  });
  
  return server;
}

通过调用 ws.send 方法将改动发送到浏览器,这里的 type 有几种情况:

  • ws.send({ type: "update", updates }):热加载
  • ws.send({ type: "full-reload" }):重启开发服务
  • ws.send({ type: "prune" }):清空
  • ws.send({ type: "error" }):出错时

ws 把信号给浏览器后,浏览器需要接受信号并执行特定的逻辑,这里的代码在 vite/src/client.ts 中:

const socket = new WebSocket("http://localhost:3000", "vite-hmr");

socket.addEventListener("message", data => {
  handleMessage(JSON.parse(data));
});

async function handleMessage(payload) {
  switch (payload.type) {
    case "connected":
      break;
    case "update":
      break;
    case "custom":
      break;
    case "full-reload":
      break;
    case "prune":
      break;
    case "error":
      break;
  }
}

第 3.6 步:处理热更新

在 watcher 监听到文件改动后,对于可热更新的文件,会以补丁的形式打到项目中:

watcher.on("change", async (file) => {
  file = normalizePath(file);
  try {
    await handleHMRUpdate(file, server);
  } catch (err) {
    ws.send({ type: "error", err });
  }
});

handleHMRUpdate 方法的定义如下:

async function handleHMRUpdate(file, server) {
  const { ws, config } = server;

  // 当 vite.config.ts 变动或者 env 文件变动时,重启 DevServer
  const isConfig = file === config.configFile;
  const isEnv = file.endsWith(".env");
  if (isConfig || isEnv) {
    await restartServer(server);
    return;
  }
  
  // 当客户端文件变动时,刷新页面
  if (file.startsWith(normalizedClientDir)) {
    ws.send({ type: "full-reload", path: "*" });
    return;
  }
  
  const hmrContext = {
    file,
    timestamp,
    modules: [],
    read: () => readModifiedFile(file),
    server,
  };
  
  // 如果插件定义了 handleHotUpdate 方法,则执行一次
  for (const plugin of config.plugins) {
    if (plugin.handleHotUpdate) {
      const filteredModules = await plugin.handleHotUpdate(hmrContext)
      if (filteredModules) {
        hmrContext.modules = filteredModules
      }
    }
  }
  
  if (!hmrContext.modules.length) {
    if (file.endsWith(".html")) {
      ws.send({ type: "full-reload", path: "*" });
    }
    return;
  }
  
  updateModules(file, hmrContext.modules, timestamp, server);
}

handleHMRUpdate 的代码很简单,主要是判断被改动的文件是否可以热加载,如果不能热加载则刷新页面或者重启服务。

热加载真正的逻辑在 updateModules 方法:

function updateModules(file, modules, timestamp, server) {
  const updates = [];
  for (const mod of modules) {
    const boundaries = new Set();
    const hasDeadEnd = propagateUpdate(mode, timestamp, boundaries);
    if (hasDeadEnd) {
      ws.send({ type: "full-reload" });
      return;
    }
    updates.push(
        ...[...boundaries].map(({ boundary, acceptedVia }) => ({
        type: `${boundary.type}-update`,
        timestamp,
        path: boundary.url,
        acceptedPath: acceptedVia.url
      }))
    );
  }
  ws.send({ type: "update", updates });
}

function propagateUpdate(node, timestamp, boundaries, currentChain = [node]) {
  if (node.isSelfAccepting) {
    boundaries.add({ boundary: node, acceptedVia: node });
    return false;
  }

  if (!node.importers.size) {
    return true;
  }
  
  for (const importer of node.importers) {
    const subChain = currentChain.concat(importer);
    
    if (importer.acceptedHmrDeps.has(node)) {
        boundaries.add({ boundary: importer, acceptedVia: node });
        continue;      
    }

    if (currentChain.includes(importer)) {
           return true;   
    }
   
    if (propagateUpdate(importer, timestamp, boundaries, subChain)) {
      return true;
    }
  }
  return false;
}

propagateUpdate 是一个递归方法,用来搜集循环依赖。

updateModules 内部将改动存储在 updates 变量,updates 是个数组,元素是一个 Set(为了去重),我们通过断点可以观察到 updates 的结构:

[
    {
        "type": "js-update",
        "timestamp": 1622010469515,
        "path": "/src/App.vue",
        "accpetedPath": "src/App.vue"
    }
]

Vite 将这份 json 通过 ws.send("update", updates) 发送到浏览器时,浏览器在接受到 update 指令时,会循环读取 updates,伪代码是这样的:

async function handleMessage(payload) {
  switch(payload.type) {
    case "update":
      payload.updates.forEach(update => {
        // 重新下载代码
        const timestamp = +new Date();
        const url = `src/App.vue?import&t=${timestamp}`;
        const newFile = axios.get(url);
        
        // 将 newFile 追加到项目中
      });
      break;
  }
}

我们稍微改动一下 src/App.vue,在控制台可以发现多了一条网络请求:

image-20210526144827345

拿到改动后文件后,Vite 是如何将新文件塞到项目里的呢?

答案是:ModuleGraph

第 3.7 步:ModuleGraph

依赖预构建

转换源码

解析 .ts 文件

解析 .vue 文件

我们假定在上边 hello-vite 这个 vue3 + typescript 的项目中有这样一个 SFC:

<template>
  <div>
    <h1>Heading</h1>
    <p>Lorem ipsum ...</p>
    <p>
      <button>Ok</button>
      <button>Cancel</button>
    </p>
  </div>
</template>

<script>
import { defineComponent } from "vue";
  
export default defineComponent({
  name: "Demo"
});
</script>

这个组件的预览效果大概是这样的:

image-20210526102321636

先看一下流程图:

解析 jsx

解析 .css/.less/.scss 文件

解析静态资源

解析 .json 文件

解析 glob

构建生产版本

部署静态站点

服务端渲染


仅有一条评论

  1. 纳纳子

    老公,图片挂了哦

我要发表看法

«-必填
«-必填,不公开