初探 Vite

638 阅读7分钟

背景 ⌛

公司项目中使用到了 vue 作为前端开发框架,在现阶段中, vue2 的使用程度已趋近成熟,所以想着能够往深层次去探索,继而打算 vue2 向 vue3 进行过渡,在这期间做的技术探索。以下是基于 4 月 29 日开发的 Vite 2.x 版本的探索。

image.png



什么是 Vite❓

Vite 是一种前端构建工具,提供开箱即用的配置,同时具有高度扩展性

  • 一个开发服务器,基于原生 ES 模块提供了丰富的内建功能。
  • 一套构建指令,使用 Rollup 打包代码,并且它是预配置的


☕ Vite 初体验

命令

 使用 NPM:

npm init @vitejs/app

使用 yarn:

yarn create @vitejs/app

支持的预设模版包括:

  • vanilla
  • vue
  • vue-ts
  • react
  • react-ts
  • preact
  • preact-ts
  • lit-element
  • lit-element-ts
  • svelte
  • svelte-ts

生成目录结构

使用了 react-ts 的模版生成项目,基本目录结构如下:

src/
  |- main.tsx
  |- index.css
  |- ...
|- index.html
|- package.json
|- tsconfig.json
|- vite.config.ts
|- ...

使用了 vue-ts 的模版生成项目,基本目录结构如下:

src/
  assets/
    |- ...
  components/
    |- ...
  |- main.ts
  |- App.vue
  |- ...
|- index.html
|- package.json
|- tsconfig.json
|- vite.config.ts
|- ...



为什么是 Vite❓

✨ 极快服务器启动

Vite 通过将应用模块区分为 依赖源码 两类,改进了服务器的启动时间。

  • 依赖 大多在开发期间不会发生变动,Vite 将会使用 esbulid 预构建依赖,比以 JavaScript 编写的打包器预构建依赖快 10-100 倍。

  • 源码 一些不是 JavaScript 的文件,需要进行转换,如:jsx,并且不是所有模块都需要同时被加载。让浏览器负责打包程序的部分工作,只有在浏览器请求源码时进行转换并按需提供。

请求源码 (来源 Vite 官网)


✨ 加快模块更新

编辑某个文件后需要重新构建,这个时候不应该是构建整个包,而是对单个文件进行动态模块热重载,并且在重构完成后无需重载页面。

Vite 精准的使已编辑模块与最近的 HMR 边界之间的链失效,使 HMR 模块加快更新。

Vite 提供了一套原生的 ESM 的 HMR API,并且提供了第一优先级的 HMR 集成 Vue 单文件组件(SFC), React Fast Refresh, Prefresh


✨ NPM 依赖解析和预构建

在原生 ES 引入中不支持裸模块导入:

import { someMethod } from "my-dep";

上面的导入会将在浏览器中抛出一个错误,Vite 会执行一下操作:

  1. 预构建该模块,并将 CommonJS / UMD 转换为 ESM 格式
  2. 重写导入为合法的 URL,如:/node_modules/.vite/my-dep.js?v=f3sf2ebd 以便浏览器正确导入

依赖预构建利用缓存机制:

  • 浏览器缓存,HTTP 请求缓存模块,已缓存的模块不需要重新请求。
  • 文件系统缓存,将预构建的依赖缓存到 node_modules/.vite,只有当以下任一步骤发生变化才进行预编译构建
    • package.json 中 dependencies 列表
    • 包管理器 lockfile
    • vite.config.js 配置变化

✨ 构建优化

  • 对动态导入的 Polyfill,浏览器对原生 ESM 动态导入和 type='module' script 块的支持,存在差异性。

  • CSS 代码分割,将一个异步 chunk 模块中用到的 css 代码抽取出到一个 CSS 文件中,并在 chunk 加载完成时通过 link 载入。

  • 异步 chunk 加载优化,Vite 会在预加载步骤中重写代码,将 chunk A 中导入的 chunk C 同时获取到,优化跟踪所有直接导入,消除额外的网络往返。

    异步chunk导入 (来源 Vite 官网)


举个 🌰

当你用 import debounce from 'lodash/debounce' ,理想中的场景就是浏览器只加载这个函数的文件。

image.png

但由于 debounce 内部又依赖了 3 个模块:isObjectnowtoNubmer,而这 3 个模块又有其他的依赖,总共会引入 14 个模块

          debounce
         /   |   \
  isObject  now  toNumber
             ...

每个模块都独立,如果不进行预构建的话,意味着会带来 14 次请求。

// vite.config.ts
{
  ...,
  optimizeDeps: {
    exclude: ["lodash"]
  }
}

image.png

从上面可以看出,开启依赖预构建,依赖模块将合并成一个 bundle.js,并将 CommonJS 或 UMD 发布的依赖项转换为 ESM

image.png

再看看 react 与 react-dom 两个模块,有相同依赖的模块已经被抽离到单独的 chunk.js

image.png

image.png


✨ TypeScript 支持

开箱即可用的引入 .ts 文件。使用 esbuild 将 TypeScript 翻译到 JavaScript,约是 tsc 速度的 20~30 倍,同时 HMR 更新反映到浏览器的时间小于 50ms。



☕ 浅谈 Vite 缓存与热更新

启动开发服务器的过程:

  1. 启动 Vite 服务器,支持使用 http,https,http2 等服务启动,同时创建 ws 服务

服务器缓存:协商缓存与强缓存,进行模块的缓存

// 判断是否属于 依赖包 或者 已存在缓存文件夹中,如果是依赖包则进行强缓存
const isDep =
  DEP_VERSION_RE.test(url) ||
  (cacheDirPrefix && url.startsWith(cacheDirPrefix));
send(
  req,
  res,
  result.code,
  type,
  result.etag,
  isDep ? "max-age=31536000,immutable" : "no-cache",
  result.map
);

设置浏览器返回的响应头部中携带缓存字段

if (req.headers["if-none-match"] === etag) {
  res.statusCode = 304;
  return res.end();
}
res.setHeader("Content-Type", alias[type] || type);
res.setHeader("Cache-Control", cacheControl);
res.setHeader("Etag", etag);
  1. 拦截服务器启动,在服务器启动之前,会先执行依赖预构建方法 runOptimize,利用 esbuild 进行构建,缓存在 config.cacheDir 中,默认为 node_modules/.vite

image.png

image.png

image.png

  1. chokidar 插件监听文件变化,可以在配置中设置忽略变化的文件列表。

  2. 通过 createPluginContainer 生成模块管理器,并缓存模块,生成 moduleGraph,其中有三个存储映射表:

urlToModuleMap: new Map<string, ModuleNode>
idToModuleMap: new Map<string, ModuleNode>
fileToModulesMap: new Map<string, Set<ModuleNode>>
  1. ws 会一直轮询,当模块发生变化的时候,ws 会接收到更新操作
{
  "type": "update",
  "updates": [
    {
      "type": "js-update",
      "acceptedPath": "/src/App.tsx",
      "path": "/src/App.tsx",
      "timestamp": 1619524827966
    }
  ]
}

HMR 热更新源码

开发环境下,在启动服务器时将 client.ts 注入到入口文件

// node/server/index.ts
server.transformIndexHtml = createDevHtmlTransformFn(server);

// node/server/middlewares/indexHtml.ts
const devHtmlHook: IndexHtmlTransformHook = async (
  html,
  { path: htmlPath, server }
) => {
  // ...
  return {
    html,
    tags: [
      {
        tag: "script",
        attrs: {
          type: "module",
          src: path.posix.join(base, CLIENT_PUBLIC_PATH),
        },
        injectTo: "head-prepend",
      },
    ],
  };
};

// node/plugins/html.ts
function injectToHead(
  html: string,
  tags: HtmlTagDescriptor[],
  prepend = false
) {
  const tagsHtml = serializeTags(tags);
  if (prepend) {
    // inject after head or doctype
    for (const re of headPrependInjectRE) {
      if (re.test(html)) {
        return html.replace(re, `$&\n${tagsHtml}`);
      }
    }
  } else {
    // inject before head close
    if (headInjectRE.test(html)) {
      return html.replace(headInjectRE, `${tagsHtml}\n$&`);
    }
  }
  // if no <head> tag is present, just prepend
  return tagsHtml + `\n` + html;
}

监听文件变化后,先更新模块依赖关系,然后执行热更新

// node/server/index.ts
watcher.on("change", async (file) => {
  // ...
  moduleGraph.onFileChange(file); // 更新模块依赖关系

  if (serverConfig.hmr !== false) {
    // ...
    await handleHMRUpdate(file, server); // 执行热更新
    // ...
  }
});

// node/server/moduleGraph.ts
// 文件变化时更新模块的依赖
onFileChange(file: string): void {
  const mods = this.getModulesByFile(file)
  if (mods) {
    const seen = new Set<ModuleNode>()
    mods.forEach((mod) => {
      this.invalidateModule(mod, seen)
    })
  }
}

// node/server/hmr.ts
function handleHMRUpdate(
  file: string,
  server: ViteDevServer
) {
  // 判断全更新或者部分模块更新
  // 判断是否是 配置文件 或 配置依赖文件 或 环境变量文件,则直接重启服务器
  if (isConfig || isConfigDependency || isEnv) {
    await restartServer(server)
    return
  }
  // ...
  if (!hmrContext.modules.length) {
    // html file cannot be hot updated
    if (file.endsWith('.html')) {
      // 当前模块没有依赖其他模块,则进行全更新
      ws.send({
        type: 'full-reload',
        path: config.server.middlewareMode
          ? '*'
          : '/' + normalizePath(path.relative(config.root, file))
      })
    }
    return
  }
  // 进行文件的依赖模块的更新
  updateModules(shortFile, hmrContext.modules, timestamp, server)
}

执行热更新,并将更新模块信息通过 ws 推送给客户端

function updateModules(
  file: string,
  modules: ModuleNode[],
  timestamp: number,
  { config, ws }: ViteDevServer
) {
  // ...
  for (const mod of modules) {
    // ...

    // 判断模块是否过期需要全更新,通过递归查询模块下的子模块是否出现更新
    const hasDeadEnd = propagateUpdate(mod, timestamp, boundaries);
    if (hasDeadEnd) {
      ws.send({
        type: "full-reload",
      });
      return;
    }

    updates.push(
      ...[...boundaries].map(({ boundary, acceptedVia }) => ({
        type: `${boundary.type}-update` as Update["type"],
        timestamp,
        path: boundary.url,
        acceptedPath: acceptedVia.url,
      }))
    );
  }
  // ...
  // 将更新模块通过 ws 发送给客户端
  ws.send({ type: "update", updates });
}

文尾

以上是笔者在初次探索 Vite 的时候的一些点,如有错误,还请各位指出。

Vite 还有很多知识点未探索到,尤大大等大佬开发的 Vite 是真的强大,还有很多地方可以探索的。它的插件 API、 HMR API、SSR 等,都是后续的探索点。与实际项目开发的结合,这也是我下一步探索的方向,尝试着将小项目进行升级迁移。探索新知识并运用在实际开发中,这也是笔者一直想做的事。