引言

大多数同学说到 Vite ,会下意识的反应出 “快”、“noBundle”等关键词。

那么,为什么 Vite 相较于 Webpack、Rollup 之类的会更加快,亦或是大多数同学以为 Vite 是 “noBundle” 又是否正确呢?

接下来,这篇文章和咱们一同来浅显易懂 Vite 中的中心的“预构建”进程。

文章中 vite 版别为最新的 5.0.0-beta.18

预构建

概念

既然说到预构建,那么预构建究竟是一个什么样的概念呢?

了解 Vite 的朋友可能清楚,Vite 在开发环境中存在一个优化依靠预构建(Dependency Pre-Bundling)的概念。

简略来说,所谓依靠预构建指的是在 DevServer 发动之前,Vite 会扫描运用到的依靠然后进行构建,之后在代码中每次导入(import)时会动态地加载构建过的依靠这一进程,

也许大多数同学关于 Vite 的认知更多是 No Bundle,但上述的依靠预构建进程确实像是 Bundle 的进程。

简略来说,Vite 在一开端将运用中的模块区分为依靠源码两类:

  • 依靠部分更多指的是代码中运用到的第三方模块,比方 vuelodashreact 等。

    Vite 将会运用esbuild在运用发动时关于依靠部分进行预构建依靠。

  • 源码部分比方说往常咱们书写的一个一个 jsjsxvue 等文件,这部分代码会在运转时被编译,并不会进行任何打包。

    Vite 以原生 ESM办法供给源码。这实践上是让浏览器接管了打包程序的部分作业:Vite 只需求在浏览器恳求源码时进行转化并按需供给源码。依据情形动态导入代码,即只在当前屏幕上实践运用时才会被处理。

咱们在文章中接下来要聊到的依靠预构建,其实更多是针关于第三方模块的预构建进程。

什么是预构建

咱们在运用 vite 发动项目时,仔细的同学会发现项目 node_modules 目录下会额定添加一个 node_modules/.vite/deps 的目录:

浅显易懂 Vite5 中依靠预构建

这个目录便是 vite 在开发环境下预编译的产品。

项目中的依靠部分ahooksantdreact 等部分会被预编译成为一个一个 .js 文件。

一同,.vite/deps 目录下还会存在一个 _metadata.json

浅显易懂 Vite5 中依靠预构建

_metadata.json 中存在一些特别特点:

  • hash
  • browserHash
  • optimized
  • chunks

简略来说 vite 在预编译时会关于项目中运用到的第三方依靠进行依靠预构建,将构建后的产品存放在 node_modules/.vite/deps 目录中,比方 ahooks.jsreact.js 等。

一同,预编译阶段也会生成一个 _metadata.json 的文件用来保存预编译阶段生成文件的映射联系(optimized 字段),方便在开发环境运转时重写依靠途径。

上边的概念咱们也不需求过于在意,现在不清楚也没联系。咱们只需求清楚,依靠预构建的进程简略来说便是生成 node_modules/deps 文件即可。

为什么需求预构建

那么为什么需求预构建呢?

首要第一点,咱们都清楚 Vite 是依据浏览器 Esmodule 进行模块加载的办法。

那么,关于一些非 ESM 模块规范的第三方库,比方 react。在开发阶段,咱们需求凭借预构建的进程将这部分非 esm 模块的依靠模块转化为 esm 模块。然后在浏览器中进行 import 这部分模块时也能够正确识别该模块语法。

别的一个方面,相同是因为 Vite 是依据 Esmodule 这一特性。在浏览器中每一次 import 都会发送一次恳求,部分第三方依靠包中可能会存在许多个文件的拆分然后导致发起多次 import 恳求。

比方 lodash-es 中存在超过 600 个内置模块,当咱们履行 import { debounce } from 'lodash' 时,假如不进行预构建浏览器会一同发出 600 多个 HTTP 恳求,这无疑会让页面加载变得显着缓慢。

正式经过依靠预构建,将 lodash-es 预构建成为单个模块后仅需求一个 HTTP 恳求就能够处理上述的问题。

依据上述两点,Vite 中正是为了模块兼容性以及功用这两方面大的原因,所以需求进行依靠预构建。

思路导图

那么,预构建究竟是怎样样的进程?咱们先来看一幅关于依靠预构建的思维导图

浅显易懂 Vite5 中依靠预构建

在开端后续的内容之前,咱们先来简略和咱们聊聊这张图中描述的各个关键进程。

  1. 调用 npm run dev(vite) 发动开发服务器。 首要,当咱们在 vite 项目中首次发动开发服务器时,默许状况下(未指定 build.rollupOptions.input/optimizeDeps.entries 状况下),Vite 抓取项目目录下的一切的(config.root).html文件来检测需求预构建的依靠项(疏忽了node_modulesbuild.outDir__tests__coverage)。

通常状况下,单个项目咱们仅会运用单个 index.html 作为进口文件。

  1. 剖析 index.html 进口文件内容。 其次,当首次运转发动指令后。Vite 会寻觅到进口 HTML 文件后会剖析该进口文件中的 <script> 标签寻觅引进的 js/ts 资源(图中为 /src/main.ts)。

  2. 剖析 /src/main.ts 模块依靠 之后,会进入 /src/main.ts 代码中进行扫描,扫描该模块中的一切 import 导入语句。这一步主要会将依靠分为两种类型然后进行不同的处理办法:

    • 关于源码中引进的第三方依靠模块,比方 lodashreact 等第三方模块。Vite 会在这个阶段将导入的第三方依靠的进口文件地址记录到内存中,简略来说比方当碰到 import antd from 'antd'时 Vite 会记录 { antd: '/Users/19Qingfeng/Desktop/vite/vite-use/node_modules/antd/es/index.js' },一同会将第三方依靠当作外部(external)进行处理(并不会递归进入第三方依靠进行扫描)。
    • 关于模块源代码,就比方咱们在项目中编写的源代码。Vite 会顺次扫描模块中一切的引进,关于非第三方依靠模块会再次递归进入扫描。
  3. 递归剖析非第三方模块中的依靠引用 一同,在扫描完结 /src/main.ts 后,Vite 会关于该模块中的源码模块进行递归剖析。这一步会从头进行第三进程,仅有不同的是扫描的为 /src/App.tsx

    终究,经过上述进程 Vite 会从进口文件动身扫描出项目中一切依靠的第三方依靠,一同会存在一份类似于如下的映射联系表:

       {
            "antd": {
                // key 为引进的第三方依靠名称,value 为该包的进口文件地址
                "src": "/Users/19Qingfeng/Desktop/vite/vite-use/node_modules/antd/es/index.js"
            }// ...
        }
    
  4. 出产依靠预构建产品

    经过上述的进程,咱们现已生成了一份源码中一切关于第三方导入的依靠映射表。 终究,Vite 会依据这份映射表调用 EsBuild 关于扫描出的一切第三方依靠进口文件进行打包。将打包后的产品存放在 node_modules/.vite/deps 文件中。 比方,源码中导入的 antd 终究会被构建为一个单独的 antd.js 文件存放在 node_modules/.vite/deps/antd.js 中。

简略来说,上述的 5 个进程便是 Vite 依靠预构建的进程。

浅显易懂 Vite5 中依靠预构建

有些同学可能会猎奇,预构建生成这样的文件怎样运用呢?

这个问题其实和这篇文章联系并不是很大,本篇文章中着重点更多是和让咱们了解预构建是在做什么以及是怎样完成的进程。

简略来说,预构建关于第三方依靠生成 node_modules/.vite/deps 资源后。在开发环境下 vite 会“阻拦”一切的 ESM 恳求,将源码中关于第三方依靠的恳求地址重写为咱们预构建之后的资源产品,比方咱们在源码中编写的 antd 导入:

浅显易懂 Vite5 中依靠预构建

终究在开发环境下 Vite 会将关于第三方模块的导入途径从头为:

浅显易懂 Vite5 中依靠预构建

其实 import { Button } from '/node_modules/.vite/deps/antd.js?v=09d70271' 这个地址正是咱们将 antd 在预构建阶段经过 Esbuild 在 /node_modules/.vite/deps 生成的产品。

至于 Vite 在开发环境下是怎么重写这部分第三方导入的地址这件事,咱们会在下一篇关于完成 Vite 的文章会和咱们详细解说。

简略完成

上边的进程咱们关于 Vite 中的预构建进行了简略的流程整理。

经过上述的章节咱们了解了预构建的概念,以及预构建究竟的大致进程。

接下来,我会用最简略的代码来和咱们一同完成 Vite 中预构建这一进程。

因为源码的分支 case 比较繁琐,简略扰乱咱们的思路。所以,咱们先完成一个精简版的 Vite 开端入手巩固咱们的思路,终究咱们在循序渐进一步一步阅览源码。

搭建开发环境

工欲善其事,必先利其器。在着手开发之前,让咱们先花费几分钟来略微整理一下开发目录。

这儿,我创立了一个 vite简略目录结构

.
├── README.md              Reamdme 说明文件
├── bin                    
│   └── vite               环境变量脚本文件        
├── package.json           
└── src                    源码目录
    ├── config.js          读取装备文件
    └── server             服务文件目录
        ├── index.js       服务进口文件
        └── middleware     中间件目录文件夹

创立了一个简略的目录文件,一同在 bin/vitepackage.json 中的 bin 字段进行相关:

#!/usr/bin/env node
console.log('hello custom-vite!');
{
  "name": "custom-vite",
  // ...
  "bin": {
    "custom-vite": "./bin/vite"
  },
  // ...
}

关于 bin 字段的效果这儿我就不再赘述了,此刻当咱们在本地运转 npm link 后,在控制台履行 custom-vite 就会输出 hello custom-vite!:

浅显易懂 Vite5 中依靠预构建

编写开发服务器

接下来,让咱们按照思维导图的次序一步一步来。

在运转 vite 指令后需求发动一个开发服务器用来承载运用项目(发动目录下)的 index.html 文件作为进口文件,那么咱们就从编译一个开发服务器开端。

首要,让咱们先来修正 Vite 指令的进口文件 /bin/vite:

#!/usr/bin/env node
import { createServer } from '../src/server';
(async function () {
  const server = await createServer();
  server.listen('9999', () => {
    console.log('start server');
  });
})();

上边的 /bin/vite 文件中,咱们从 /src/server 中引进了一个 createServer 创立开发服务器的办法。

随后,运用了一个自履行的函数调用该 createServer 办法,一同调用 server.listen 办法将开发服务器发动到 9999 端口。

// /src/server/index.js
import connect from 'connect';
import http from 'node:http';
import staticMiddleware from './middleware/staticMiddleware.js';
import resolveConfig from '../config.js';
/**
 * 创立开发服务器
 */
async function createServer() {
  const app = connect(); // 创立 connect 实例
  const config = await resolveConfig(); // 模仿装备清单 (类似于 vite.config.js)
  app.use(staticMiddleware(config)); // 运用静态资源中间件
  const server = {
    async listen(port, callback) {
      // 发动服务
      http.createServer(app).listen(port, callback);
    }
  };
  return server;
}
export { createServer }

咱们 /src/server/index.js 中界说了一个创立根服务器的办法: createServer

createServer 中首要咱们经过 connect 模块装备 nodejs http 模块创立了一个支撑中间件体系的运用服务。

connectnodejs http 模块供给了中间件的扩展支撑,Express 4.0 之前的中间件模块便是依据 connect 来完成的。

之后,咱们在 createServer 办法中经过 resolveConfig 办法来模仿读取一些必要的装备特点(该办法类似于从运用自身获取 vite.config.js 中的装备):

// src/utils.js
/**
 * windows 下途径适配(将 windows 下途径的 // 变为 /)
 * @param {*} path
 * @returns
 */
function normalizePath(path) {
  return path.replace(/\/g, '/');
}
export { normalizePath };
// /src/config.js
import { normalizePath } from './utils.js';
/**
 * 加载 vite 装备文件
 * (模仿)
 */
async function resolveConfig() {
  const config = {
    root: normalizePath(process.cwd()) // 仅界说一个项目根目录的回来
  };
  return config;
}
export default resolveConfig;

能够看到在 resolveConfig 中咱们模仿了一个 config 目标进行回来,此刻 config 目标是一个固定的途径:为调用 custom-vite 指令的 pwd 途径。

关于 root 装备项的效果,能够参考 Vite Doc Config,咱们接下来会用该字段匹配的途径来寻觅项目根进口文件(index.html)的地点地址。

初始化装备文件后,咱们再次调用 app.use(staticMiddleware(config)); 为服务运用了静态资源目录的中间件,保证运用 custom-vite 的目录下的静态资源在服务上的可拜访性。

import serveStatic from 'serve-static';
function staticMiddleware({ root }) {
  return serveStatic(root);
}
export default staticMiddleware;

上边咱们运用了 serve-static 作为中间件来供给创立服务的静态资源功用。

此刻,当咱们在恣意项目中运用 custom-vite 指令时 terminal 中打印出:

浅显易懂 Vite5 中依靠预构建

一同,咱们在浏览器中输入 localhost:9999 即可拜访到咱们依据运用到的项目创立的服务。

这一步,咱们经过自己编写的 custom-vite 现已拥有一键发动开发环境的功用。

寻觅/解析 HTML 文件

在调用 custom-vite 指令现已能够发动一个简略的开发服务器后,接下来咱们就要开端为发动的开发服务器来填充对应的功用了。

了解过 Vite 的朋友都清楚,Vite 中的进口文件和其他沟通工具不同的是:vite 中是以 html 文件作为进口文件的。比方,咱们新建一个简略的项目:

.
├── index.html
├── main.js
├── ./module.js
└── package.json
// index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  Hello vite use
  <script type="module" src="/main.js"></script>
</body>
</html>
{
  "name": "custom-vite-use",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "vite",
    "test": "echo "Error: no test specified" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "react": "^18.2.0",
    "vite": "^5.0.4"
  }
}
const a = '1';
export { a };
import react from 'react';
import { a } from './module.js';
console.log(a);
console.log(react, 'react');

我已在该项目中安装了 reactvite,咱们先来看看关于上边这个简略的项目原始的 vite 表现怎么。

此刻咱们在该项目目录下运转 npm run dev 指令,等待服务发动后拜访 localhost:5173

页面上会展现 index.html 的内容:

浅显易懂 Vite5 中依靠预构建

一同,浏览器控制台中会打印:

浅显易懂 Vite5 中依靠预构建

当然,也会打印 1。因为 module.js 是我在后续为了满足递归流程补上来的模块所以这儿的图我就不弥补了,咱们了解即可~

一同咱们观察浏览器 network 恳求:

浅显易懂 Vite5 中依靠预构建

network 中的恳求次序别离为 index.html => main.js => react.js,这儿咱们先专心预构建进程疏忽其他的恳求以及 react.js 后边的查询参数。

当咱们翻开 main.js 查看 sourceCode 时,会发现这个文件中关于 react 的引进现已彻底更换了一个途径:

浅显易懂 Vite5 中依靠预构建

很奇特比照,前边咱们说过 vite 在发动开发服务器时关于第三方依靠会进行预构建的进程。这儿,/node_modules/.vite/deps/react.js 正是发动开发服务时 react 的预构建产品。

咱们来翻开源码目录查看下:

浅显易懂 Vite5 中依靠预构建

浅显易懂 Vite5 中依靠预构建

一切都和咱们上述说到过的进程看上去是那么的类似对吧。

发动开发服务器时,会首要依据 index.html 中的脚本剖析模块依靠,将一切项目中引进的第三方依靠(这儿为 react) 进行预构建。

将构建后的产品存储在 .vite/deps 目录中,一同将映射联系保存在 .vite/deps/_metadata.json 中,其中 optimized 目标中的 react 表明原始依靠的进口文件而 file 则表明经过预构建后生成的产品(两者皆为相对途径)。

之后,简略来说咱们只要在开发环境下判别假如恳求的文件名命中 optimized 目标的 key 时(这儿为 react)则直接预构建进程中生成的文件 (file 字段对应的文件途径即可)。

接下来,咱们就测验在咱们自己的 custom-vite 中来完成这一进程。

首要,让咱们从寻觅 index.html 中动身:

// /src/config.js
import { normalizePath } from './utils.js';
import path from 'path';
/**
 * 加载 vite 装备文件
 * (模仿)
 */
async function resolveConfig() {
  const config = {
    root: normalizePath(process.cwd()),
    entryPoints: [path.resolve('index.html')] // 添加一个 entryPoints 文件
  };
  return config;
}
export default resolveConfig;

首要,咱们来修正下之前的 /src/config.js 为模仿的装备文件添加一个 entryPoints 进口文件,该文件表明 custom-vite 进行构建时的进口文件,即项目中的 index.html 文件。

// /src/server/index.js
import connect from 'connect';
import http from 'node:http';
import staticMiddleware from './middleware/staticMiddleware.js';
import resolveConfig from '../config.js';
import { createOptimizeDepsRun } from '../optimizer/index.js';
/**
 * 创立开发服务器
 */
async function createServer() {
  const app = connect();
  const config = await resolveConfig();
  app.use(staticMiddleware(config));
  const server = {
    async listen(port, callback) {
      // 发动服务之前进行预构建
      await runOptimize(config);
      http.createServer(app).listen(port, callback);
    }
  };
  return server;
}
/**
 * 预构建
 * @param {*} config
 */
async function runOptimize(config) {
  await createOptimizeDepsRun(config);
}
export { createServer };

上边咱们关于 /src/server/index.jscreateServer 办法进行了修正,在 listen 发动服务之前添加了 runOptimize 办法的调用。

所谓 runOptimize 办法正是在发动服务之前的预构建函数。能够看到在 runOptimize 中递归调用了一个 createOptimizeDepsRun 办法。

接下来,咱们要完成这个 createOptimizeDepsRun 办法。这个办法的中心思路正是咱们期望凭借 Esbuild 在发动开发服务器前关于整个项目进行扫描,寻觅出项目中一切的第三方依靠进行预构建。

让咱们新建一个 /src/optimizer/index.js 文件:

import { scanImports } from './scan.js';
/**
 * 剖析项目中的第三方依靠
 * @param {*} config
 */
async function createOptimizeDepsRun(config) {
   // 经过 scanImports 办法寻觅项目中的一切需求预构建的模块
  const deps = await scanImports(config);
  console.log(deps, 'deps');
}
export { createOptimizeDepsRun };
// /src/optimizer/scan.js
import { build } from 'esbuild';
import { esbuildScanPlugin } from './scanPlugin.js';
/**
 * 剖析项目中的 Import
 * @param {*} config
 */
async function scanImports(config) {
  // 保存扫描到的依靠(咱们暂时还未用到)
  const desImports = {};
  // 创立 Esbuild 扫描插件(这一步是中心)
  const scanPlugin = await esbuildScanPlugin();
  // 凭借 EsBuild 进行依靠预构建
  await build({
    absWorkingDir: config.root, // esbuild 当前作业目录
    entryPoints: config.entryPoints, // 进口文件
    bundle: true, // 是否需求打包第三方依靠,默许 Esbuild 并不会,这儿咱们声明为 true 表明需求
    format: 'esm', // 打包后的格式为 esm
    write: false, // 不需求将打包的成果写入硬盘中
    plugins: [scanPlugin] // 自界说的 scan 插件
  });
  // 之后的内容咱们略微在讲,咱们先专心于上述的逻辑
}
export { scanImports };

能够看到在 /src/optimizer/scan.jsscanImports 办法终究调用了 esbuild build 的 build 办法进行构建。

正是在这一部分构建中,咱们运用了自己界说的 scanPlugin Esbuild Plugin 进行扫描项目依靠,那么 esbuildScanPlugin 又是怎么完成的呢?

// /src/optimizer/scanPlugin.js
import nodePath from 'path';
import fs from 'fs-extra';
const htmlTypesRe = /(.html)$/;
const scriptModuleRe = /<scripts+type="module"s+src="(.+?)">/;
function esbuildScanPlugin() {
  return {
    name: 'ScanPlugin',
    setup(build) {
      // 引进时处理 HTML 进口文件
      build.onResolve({ filter: htmlTypesRe }, async ({ path, importer }) => {
        // 将传入的途径转化为肯定途径 这儿简略先写成 path.resolve 办法
        const resolved = await nodePath.resolve(path);
        if (resolved) {
          return {
            path: resolved?.id || resolved,
            namespace: 'html'
          };
        }
      });
      // 当加载命名空间为 html 的文件时
      build.onLoad({ filter: htmlTypesRe, namespace: 'html' }, async ({ path }) => {
        // 将 HTML 文件转化为 js 进口文件
        const htmlContent = fs.readFileSync(path, 'utf-8');
        console.log(htmlContent, 'htmlContent'); // htmlContent 为读取的 html 字符
        const [, src] = htmlContent.match(scriptModuleRe);
        console.log('匹配到的 src 内容', src); // 获取匹配到的 src 途径:/main.js
        const jsContent = `import ${JSON.stringify(src)}`;
        return {
          contents: jsContent,
          loader: 'js'
        };
      });
    }
  };
}
export { esbuildScanPlugin };

简略来说,Esbuild 在进行构建时会对每一次 import 匹配插件的 build.onResolve 钩子,匹配的规矩中心为两个参数,别离为:

  • filter: 该字段能够传入一个正则表达式,Esbuild 会为每一次导入的途径与该正则进行匹配,假如共同则以为经过,不然则不会进行该钩子。
  • namespace: 每个模块都有一个相关的命名空间,默许每个模块的命名空间为 file (表明文件体系),咱们能够显现声明命名空间规矩进行匹配,假如共同则以为经过,不然则不会进行该钩子。

不了解 Esbuild 相关装备和 Plugin 开发的同学能够优先移步 Esbuild 官网手册进行简略的查阅。

上述的 scanPlugin 的中心思路为:

  • 当运转 build 办法时,首要进口文件地址会进入 ScanPlugnonResolve 钩子。

此刻,因为 filter 的正则匹配为后缀为 .html,并不存在 namespace(默以为 file)。则此刻,index.html 会进入 ScanPluginonResolve 钩子中。

build.onResolve 中,咱们先将传入的 path 转化为磁盘上的肯定途径,将 html 的肯定途径进行回来,一同修正进口 html 的 namespace 为自界说的 html

需求留意的是假如同一个 import (导入)假如存在多个 onResolve 的话,会按照代码编写的次序进行次序匹配,假如某一个 onResolve 存在回来值,那么此刻就不会往下持续履行其他 onResolve 而是会进行到下一个阶段(onLoad),Esbuild 中其他 hook 也同理。

  • 之后,因为咱们在 build.onResolve 中关于进口 html 文件进行了阻拦处理,在 onLoad 钩子中仍然进行匹配。

onLoad 钩子中咱们的 filter 规矩相同为 htmlTypesRe,一同添加了匹配 namespacehtml 的导入。

此刻,咱们在上一个 onResove 回来的 namspacehtml 的进口文件会进行该 onLoad 钩子。

build.onLoad 该钩子的主要效果加载对应模块内容,假如 onResolve 中回来 contents 内容,则 Esbuild 会将回来的 contents 作为内容进行后续解析(并不会对该模块进行默许加载行为解析),不然默许会为 namespacefile 的文件进行 IO 读取文件内容。

咱们在 build.onlod 钩子中,首要依据传入的 path 读取进口文件的 html 字符串内容取得 htmlContent

之后,咱们依据正则关于 htmlContent 进行了截取,获取 <script type="module" src="https://juejin.im/main.js />" 中引进的 js 资源 /main.js

此刻,虽然咱们的进口文件为 html 文件,但是咱们经过 EsbuildPlugin 的办法从 html 进口文件中截取到了需求引进的 js 文件。

之后,咱们拼装了一个 import "/main.js"jsContentonLoad 钩子函数中进行回来,一同声明该资源类型为 js

简略来说 Esbuild 中内置部分文件类型,咱们在 pluginonLoad 钩子中经过回来的 loader 关键字来告知 Esbuild 接下来运用哪种办法来识别这些文件。

此刻,Esbuil 会关于回来的 import "/main.js" 当作 JavaScript 文件进行递归处理,这样也就达成了咱们解析 HTML 文件的意图。

咱们来回过头略微总结下,之所以 Vite 中能够将 HTML 文件作为进口文件。

其实正是凭借了 Esbuild 插件的办法,在发动项目时运用 Esbuild 运用 HTML 作为进口文件之后运用 Plugin 截取 HTML 文件中的 script 脚本地址回来,然后寻觅到了项目真实的进口 js 资源进行递归剖析。

递归解析 js/ts 文件

上边的章节,咱们在 ScanPlugin 中别离编写了 onResolve 以及 onLoad 钩子来剖析进口 html 文件。

其实,ScanPlugin 的效果并不只是如此。这部分,咱们会持续完善 ScanPlugin 的功用。

咱们现已能够经过 HTML 文件寻觅到引进的 /main.js 了,那么接下来天然咱们需求对 js 文件进行递归剖析寻觅项目中需求被依靠预构建的一切模块。

递归寻觅需求被预构建的模块的思路相同也是经过 Esbuild 中的 Plugin 机制来完成,简略来说咱们会依据上一步转化得到的 import "/main.js" 导入来进行递归剖析。

关于 /main.js 的导入语句会分为以下两种状况别离进行不同的处理:

  • 关于 /main.js 中的导入的源码部分会进入该部分进行递归剖析,比方 /main.js 中假如又引进了另一个源码模块 ./module.js 那么此刻会持续进入 ./module.js 递归这一进程。

  • 关于 /main.js 中导入的第三方模块会经过 Esbuild 将该模块符号为 external ,然后记录该模块的进口文件地址以及导入的模块名。

比方 /main.js 中存在 import react from 'react',此刻首要咱们会经过 Esbuild 疏忽进入该模块的扫描一同咱们也会记录代码中依靠的该模块相关信息。

符号为 external 后,esbuild 会以为该模块是一个外部依靠不需求被打包,所以就不会进入该模块进行任何扫描,换句话到碰到第三方模块时并不会进入该模块进行依靠剖析。

解析来咱们首要来一步一步来晚上上边的代码:

// src/optimizer/scan.js
/**
 * 剖析项目中的 Import
 * @param {*} config
 */
async function scanImports(config) {
  // 保存依靠
  const depImports = {};
  // 创立 Esbuild 扫描插件
  const scanPlugin = await esbuildScanPlugin(config, depImports);
  // 凭借 EsBuild 进行依靠预构建
  await build({
    absWorkingDir: config.root,
    entryPoints: config.entryPoints,
    bundle: true,
    format: 'esm',
    write: false,
    plugins: [scanPlugin]
  });
  return depImports;
}

首要,咱们先为 scanImports 办法添加一个 depImports 的回来值。

之后,咱们持续来完善 esbuildScanPlugin 办法:

import fs from 'fs-extra';
import { createPluginContainer } from './pluginContainer.js';
import resolvePlugin from '../plugins/resolve.js';
const htmlTypesRe = /(.html)$/;
const scriptModuleRe = /<scripts+type="module"s+src="(.+?)">/;
async function esbuildScanPlugin(config, desImports) {
  // 1. Vite 插件容器体系
  const container = await createPluginContainer({
    plugins: [resolvePlugin({ root: config.root })],
    root: config.root
  });
  const resolveId = async (path, importer) => {
    return await container.resolveId(path, importer);
  };
  return {
    name: 'ScanPlugin',
    setup(build) {
      // 引进时处理 HTML 进口文件
      build.onResolve({ filter: htmlTypesRe }, async ({ path, importer }) => {
        // 将传入的途径转化为肯定途径
        const resolved = await resolveId(path, importer);
        if (resolved) {
          return {
            path: resolved?.id || resolved,
            namespace: 'html'
          };
        }
      });
      // 2. 额定添加一个 onResolve 办法来处理其他模块(非html,比方 js 引进)
      build.onResolve({ filter: /.*/ }, async ({ path, importer }) => {
        const resolved = await resolveId(path, importer);
        if (resolved) {
          const id = resolved.id || resolved;
          if (id.includes('node_modules')) {
            desImports[path] = id;
            return {
              path: id,
              external: true
            };
          }
          return {
            path: id
          };
        }
      });
      // 当加载命名空间为 html 的文件时
      build.onLoad(
        { filter: htmlTypesRe, namespace: 'html' },
        async ({ path }) => {
          // 将 HTML 文件转化为 js 进口文件
          const htmlContent = fs.readFileSync(path, 'utf-8');
          const [, src] = htmlContent.match(scriptModuleRe);
          const jsContent = `import ${JSON.stringify(src)}`;
          return {
            contents: jsContent,
            loader: 'js'
          };
        }
      );
    }
  };
}
export { esbuildScanPlugin };

esbuildScanPlugin 办法新增了 createPluginContainerresolvePlugin 两个办法的引进:

// src/optimizer/pluginContainer.js
import { normalizePath } from '../utils.js';
/**
 * 创立 Vite 插件容器
 * Vite 中正是自己完成了一套所谓的插件体系,能够完美的在 Vite 中运用 RollupPlugin。
 * 简略来说,插件容器更多像是完成了一个所谓的 Adaptor,这也便是为什么 VitePlugin 和 RollupPlugin 能够相互兼容的原因
 * @param plugin 插件数组
 * @param root 项目根目录
 */
async function createPluginContainer({ plugins }) {
  const container = {
    /**
     * ResolveId 插件容器办法
     * @param {*} path
     * @param {*} importer
     * @returns
     */
    async resolveId(path, importer) {
      let resolved = path;
      for (const plugin of plugins) {
        if (plugin.resolveId) {
          const result = await plugin.resolveId(resolved, importer);
          if (result) {
            resolved = result.id || result;
            break;
          }
        }
      }
      return {
        id: normalizePath(resolved)
      };
    }
  };
  return container;
}
export { createPluginContainer };
// src/plugins/resolve.js
import os from 'os';
import path from 'path';
import resolve from 'resolve';
import fs from 'fs';
const windowsDrivePathPrefixRE = /^[A-Za-z]:[/\]/;
const isWindows = os.platform() === 'win32';
// 裸包导入的正则
const bareImportRE = /^(?![a-zA-Z]:)[w@](?!.*://)/;
/**
 * 这个函数的效果便是寻觅模块的进口文件
 * 这块咱们简略写,源码中多了 exports、imports、main、module、yarn pnp 等等之类的判别
 * @param {*} id
 * @param {*} importer
 */
function tryNodeResolve(id, importer, root) {
  const pkgDir = resolve.sync(`${id}/package.json`, {
    basedir: root
  });
  const pkg = JSON.parse(fs.readFileSync(pkgDir, 'utf-8'));
  const entryPoint = pkg.module ?? pkg.main;
  const entryPointsPath = path.join(path.dirname(pkgDir), entryPoint);
  return {
    id: entryPointsPath
  };
}
function withTrailingSlash(path) {
  if (path[path.length - 1] !== '/') {
    return `${path}/`;
  }
  return path;
}
/**
 * path.isAbsolute also returns true for drive relative paths on windows (e.g. /something)
 * this function returns false for them but true for absolute paths (e.g. C:/something)
 */
export const isNonDriveRelativeAbsolutePath = (p) => {
  if (!isWindows) return p[0] === '/';
  return windowsDrivePathPrefixRE.test(p);
};
/**
 * 寻觅模块地点肯定途径的插件
 * 既是一个 vite 插件,也是一个 Rollup 插件
 * @param {*} param0
 * @returns
 */
function resolvePlugin({ root }) {
  // 相对途径
  // window 下的 /
  // 肯定途径
  return {
    name: 'vite:resolvePlugin',
    async resolveId(id, importer) {
      // 假如是 / 开头的肯定途径,一同前缀并不是在该项目(root) 中,那么 vite 会将该途径当作肯定的 url 来处理(拼接项目地点前缀)
      // /foo -> /fs-root/foo
      if (id[0] === '/' && !id.startsWith(withTrailingSlash(root))) {
        const fsPath = path.resolve(root, id.slice(1));
        return fsPath;
      }
      // 相对途径
      if (id.startsWith('.')) {
        const basedir = importer ? path.dirname(importer) : process.cwd();
        const fsPath = path.resolve(basedir, id);
        return {
          id: fsPath
        };
      }
      // drive relative fs paths (only windows)
      if (isWindows && id.startsWith('/')) {
        // 相同为相对途径
        const basedir = importer ? path.dirname(importer) : process.cwd();
        const fsPath = path.resolve(basedir, id);
        return {
          id: fsPath
        };
      }
      // 肯定途径
      if (isNonDriveRelativeAbsolutePath(id)) {
        return {
          id
        };
      }
      // bare package imports, perform node resolve
      if (bareImportRE.test(id)) {
        // 寻觅包地点的途径地址
        const res = tryNodeResolve(id, importer, root);
        return res;
      }
    }
  };
}
export default resolvePlugin;

这儿咱们来一步一步剖析上述添加的代码逻辑。

首要,咱们为 esbuildScanPlugin 额定添加了一个 build.onResolve 来匹配恣意途径文件。

关于进口的 html 文件,他会匹配咱们最开端 filter 为 htmlTypesRe 的 onResolve 勾子来处理。而关于上一步咱们从 html 文件中处理完结后的进口 js 文件(/main.js),以及 /main.js 中的其他引进,比方 ./module.js 文件并不会匹配 htmlTypesRe 的 onResolve 钩子则会持续走到咱们新增的 /.*/ 的 onResolve 钩子匹配中。

仔细的朋友们会留意到上边代码中,咱们把之前 onResolve 钩子中的 path.resolve 办法变成了 resolveId(path, importer) 办法。

所谓的 resolveId 则是经过在 esbuildScanPlugin 中首要创立了一个 pluginContainer 容器,之后声明的 resolveId 办法正是调用了咱们创立的 pluginContainer 容器的 resolveId 办法。(src/optimizer/pluginContainer.js)。

咱们要了解 pluginContainer 的概念,首要要清楚在 Vite 中实践上在开发环境会运用 Esbuild 进行预构建在出产环境上运用 Rollup 进行打包构建。

通常,咱们会在 vite 中运用一些 vite 自身的插件也能够直接运用 rollup 插件,这正是 pluginContainer 的效果。

Vite 中会在进行文件转译时经过创立一个所谓的 pluginContainer 然后在 pluginContainer 中运用一个类似于 Adaptor 的概念。

它会在开发/出产环境下关于文件的导入调用 pluginContainer.resolveId 办法,而 pluginContainer.resolveId 办法则会顺次调用装备的 vite 插件/Rollup 插件的 ResolveId 办法。

其实你会发现 VitePlugin 和 RollupPlugin 的结构是十分类似的,仅有不同的是 VitePlugin 会比 RollupPlugin 多了一些额定的生命周期(钩子)以及相关 context 特点。

当然,开发环境下关于文件的转译(比方 tsxvue 等文件的转译)正是经过 pluginContainer 来完结的,这篇文章重点在于预构建的进程所以咱们先不关于其他方面进行拓宽。

上述 esbuildScanPlugin 会回来一个 Esbuild 插件,然后咱们在 Esbuild 插件的 build.onResolve 钩子中实践调用的是 pluginContainer.resolveId 来处理。

其实这便是相当于咱们在 Esbuild 的预构建进程中调用了 VitePlugin。

一同,咱们在调用 createPluginContainer 办法时传入了一个默许的 resolvePlugin,所谓的 resolvePlugin 留意是一个 Vite 插件

resolvePlugin(src/plugins/resolve.js) 的效果便是经过传入的 path 以及 importer 获取去引进模块在磁盘上的肯定途径。

源码中 resolvePlugin 鸿沟处理较多,比方虚拟导入语句的处理,yarn pnp、symbolic link 等一系列鸿沟场景处理,这儿我略微做了简化,咱们清楚该插件是一个内置插件用来寻觅模块肯定途径的即可。

天然,在当调用 custom-vite 指令后:

  • 首要会创立 pluginContainer ,这个容器是 vite 内置完成的插件体系。

  • 之后,Esbuild 会关于进口 html 文件进行处理调用 scanPlugin 的第一个 onResolve 钩子。

  • 在第一个 onResolve 钩子因为 html 会匹配 htmlTypesRe 的正则所以进入该钩子。该 onResolve 办法会调用 Vite 插件容器(pluginContainer)的 resolvedId 办法,经过 Esbuild 插件的 onResolve 来调用 Vite 插件的 ResolveId 办法,然后取得 html 进口文件的肯定途径。

  • 之后在 Esbuild 的 onLoad 办法中截取该 html 中的 script 标签上的 src 作为模块回来值(js 类型)交给 Esbuild 持续处理(import "/main.js")。

  • 在之后,Esbuild 会处理 "/main.js" 的引进,因为第一个 onResolve 现已不匹配所以会进入第二个 onResolve 钩子,此刻会进行相同的进程调用 VitePlugin 取得该模块在磁盘上的肯定途径。

咱们会判别回来的途径是否包括 node_modules,假如包括则以为它是一个第三方模块依靠。

此刻,咱们会经过 esBuild 将该模块符号为 external: true 疏忽进行该模块内部进行剖析,一同在 desImports 中记录该模块的导入名以及肯定途径。

假如为一个非第三方模块,比方 /main.js 中引进的 ./module.js,那么此刻咱们会经过 onResolve 回来该模块在磁盘上的肯定途径。

Esbuild 会持续进入插件的 onLoad 进行匹配,因为 onLoad 的 filter 以及 namesapce 均为 htmlTypesRe 所以并不匹配,默许 Esbuild 会在文件体系中寻觅该文件地址依据文件后缀名称进行递归剖析。

这样,终究就达到了咱们想要的成果。当咱们在 vite-use(测验项目中) 调用 custom-vite 指令,会发现控制台中会打印:

浅显易懂 Vite5 中依靠预构建

此刻 depImports 中现已记录了咱们在源码中引进的第三方依靠。

生成预构建产品

上边的进程咱们凭借 Esbuild 以及 scanPlugin 现已能够在发动 Vite 服务之前完结依靠扫描取得源码中的一切第三方依靠模块。

接下来咱们需求做的,正是关于刚刚获取到的 deps 目标中的第三方模块进行构建输出经过预构建后的文件以及一份财物清单 _metadata.json 文件。

首要,咱们先关于 src/config.js 装备文件进行简略的修正:

import { normalizePath } from './utils.js';
import path from 'path';
import resolve from 'resolve';
/**
 * 寻觅地点项目目录(实践源码中该函数是寻觅传入目录地点最近的包相关信息)
 * @param {*} basedir
 * @returns
 */
function findNearestPackageData(basedir) {
  // 原始发动目录
  const originalBasedir = basedir;
  const pckDir = path.dirname(resolve.sync(`${originalBasedir}/package.json`));
  return path.resolve(pckDir, 'node_modules', '.custom-vite');
}
/**
 * 加载 vite 装备文件
 * (模仿)
 */
async function resolveConfig() {
  const config = {
    root: normalizePath(process.cwd()),
    cacheDir: findNearestPackageData(normalizePath(process.cwd())), // 添加一个 cacheDir 目录
    entryPoints: [path.resolve('index.html')]
  };
  return config;
}
export default resolveConfig;

咱们关于 config.js 中的 config 装备进行了修正,简略添加了一个 cacheDir 的装备目录。

这个目录是用于当生成预构建文件后的存储目录,这儿咱们固定写死为当前项目地点的 node_modules 下的 .custom-vite 目录。

之后,咱们在回到 src/optimizer/index.js 中稍做修正:

// src/optimizer/index.js
import path from 'path';
import fs from 'fs-extra';
import { scanImports } from './scan.js';
import { build } from 'esbuild';
/**
 * 剖析项目中的第三方依靠
 * @param {*} config
 */
async function createOptimizeDepsRun(config) {
  const deps = await scanImports(config);
  // 创立缓存目录
  const { cacheDir } = config;
  const depsCacheDir = path.resolve(cacheDir, 'deps');
  // 创立缓存目标 (_metaData.json)
  const metadata = {
    optimized: {}
  };
  for (const dep in deps) {
    // 获取需求被依靠预构建的目录
    const entry = deps[dep];
    metadata.optimized[dep] = {
      src: entry, // 依靠模块进口文件(相对途径)
      file: path.resolve(depsCacheDir, dep + '.js') // 预编译后的文件(肯定途径)
    };
  }
  // 将缓存文件写入文件体系中
  await fs.ensureDir(depsCacheDir);
  await fs.writeFile(
    path.resolve(depsCacheDir, '_metadata.json'),
    JSON.stringify(
      metadata,
      (key, value) => {
        if (key === 'file' || key === 'src') {
          // 留意写入的是相对途径
          return path.relative(depsCacheDir, value);
        }
        return value;
      },
      2
    )
  );
  // 依靠预构建
  await build({
    absWorkingDir: process.cwd(),
    define: {
      'process.env.NODE_ENV': '"development"'
    },
    entryPoints: Object.keys(deps),
    bundle: true,
    format: 'esm',
    splitting: true,
    write: true,
    outdir: depsCacheDir
  });
}
export { createOptimizeDepsRun };

src/optimizer/index.js 中,之前咱们现现已过 scanImports 办法拿到了 deps 目标:

{
  react: '/Users/ccsa/Desktop/custom-vite-use/node_modules/react/index.js'
} 

然后,咱们冲 config 目标中拿到了 depsCacheDir 拼接上 deps 目录,得到的是存储预构建资源的目录。

一同创立了一个名为 metadata 的目标,遍历生成的 deps 为 metadata.optimize 顺次赋值,经过 for of 循环后一切需求经过依靠预构建的资源悉数存储在 metadata.optimize 目标中,这个目标的结构如下:

{
  optimized: {
    react: {
      src: "/Users/ccsa/Desktop/custom-vite-use/node_modules/react/index.js",
      file: "/Users/ccsa/Desktop/custom-vite-use/node_modules/.custom-vite/deps/react.js",
    },
  },
}

需求留意的是,咱们在内存中存储的 optimize 悉数为肯定途径,而写入硬盘时的途径悉数为相对途径。

之后相同咱们运用 Esbuild 再次对应项目中的一切第三方依靠进行构建打包。不过不同的是这一步咱们符号 write:true 是需求将构建后的文件写入硬盘中的。

完结上述进程后,咱们再次在运用到的项目中 custom-vite-use 中运转 custom-vite 指令:

浅显易懂 Vite5 中依靠预构建

浅显易懂 Vite5 中依靠预构建

此刻,咱们现已完成了一个简易版别的 Vite 预构建进程。

之后,发动开发服务器后 Vite 实践会在开发服务器中关于第三方模块的恳求进行阻拦然后回来预构建后的资源。

至于 Vite 是怎么阻拦第三方资源以及在是怎么在 ESM 源生模块下是怎么处理 .vue/.ts/.tsx 等等之类的模块转译我会在后续的 Vite 文章中和咱们持续进行揭密。

文章中的代码你能够在这儿找到。

Vite 源码

上边的章节中咱们现已自己完成了一个简易的 Vite 预构建进程,接下来我会用上述预构建的进程和源码进行逐个对照。

Cli 指令文件

Vite 源码结构为 monorepo 结构,这儿咱们只是关怀 vite 目录即可。

首要,Vite 目录下的 /pakcages/vite/bin/vite.js 文件是作为项目 cli 进口文件。

实践当运转 vite 指令时会履行该文件,履行该文件会经过以下调用链:

  1. 履行 /vite/src/node/cli.ts 文件处理一系列指令行参数。

  2. 处理结束后再次调用 /vite/src/node/server/index.ts 创立开发服务器。

createServer 办法

当运转一次 Vite 指令后会履行到 /vite/src/node/server/index.ts 中的 createServer 办法。

实践 createServer 就和我咱们上述的 createServer 代表的含义是共同的,都是在开发环境下发动开发服务器。

浅显易懂 Vite5 中依靠预构建

实践上大多数流程和咱们上述的代码思路是共同的,比方resolveConfig 以及 serveStaticMiddleware 之类。

依靠预构建

在 createServer 办法的下半部分中,咱们能够看到:

浅显易懂 Vite5 中依靠预构建

  1. container.buildStart

所谓 container.buildStart 正是咱们之前说到过的 vite 内部有一套自己的插件容器。vite 正是经过这一套插件容器来处理开发形式和出产形式的区别。

container 插件容器会完成一套和 Rollup 如出一辙的插件 API,所以 Rollup Plugin 相同也能够经过 container Api 在开发形式下调用。

天然,出产形式下自身就运用 Rollup 进行构建,所以能够完成出产百分百的插件兼容。

  1. initDepsOptimizer

initDepsOptimizer 正是在发动开发服务器之前进行依靠预构建的中心办法。

initDepsOptimizer

initDepsOptimizer 会调用 createDepsOptimizer 办法。

浅显易懂 Vite5 中依靠预构建
createDepsOptimizer 办法在开发形式下(!isBuild):

浅显易懂 Vite5 中依靠预构建

discoverProjectDependencies 正如姓名那样,这个办法是发现项目中的第三方依靠(依靠扫描)。

discoverProjectDependencies 内部会调用 scanImports 办法:

浅显易懂 Vite5 中依靠预构建

编辑器左边部分为 scanImports 办法,他会回来 prepareEsbuildScanner 办法的回来值。

而 prepareEsbuildScanner 正是和咱们上述思路共同的依靠扫描:凭借 Esbuild 以及 esbuildScanPlugin 扫描项目中的第三方依靠。

终究 createDepsOptimizer 办法中会用 deps 保存 discoverProjectDependencies 办法的项目中扫描到的一切第三方依靠。

浅显易懂 Vite5 中依靠预构建

这儿有两点需求留意。

  1. 首要 discoverProjectDependencies 寻觅到的 react 实践地址是一个 "/Users/ccsa/Desktop/custom-vite-use/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js" 的值,这是因为安装依靠时我运用的是 pnpm ,而Vite 中关于 Symbolic link 有处理,而咱们上边的代码比较简易并没有处理 Symbolic link。

  2. 下图中能够看到 prepareEsbuildScanner 办法又创立了一个 pluginContainer。

浅显易懂 Vite5 中依靠预构建

Vite 中的 pluginContianer 并不是一个单例,Vite 中会多次调用 createPluginContainer 创立多个插件容器。

在 prepareEsbuildScanner 在与预构建进程相同会创立一个插件容器,这正是咱们上述简易版 Vite 中创立的插件容器。

这儿咱们只要理解 pluginContainer 在 vite 中不是一个单例即可,后续在编译文件的文章中咱们会着重来学习 pluginContainer 的概念。

runOptimizeDeps

上述的进程 Vite 现已能够经过 discoverProjectDependencies 拿到项目中的需求进行预构建的文件。

之后,createDepsOptimizer 办法中会运用 prepareKnownDeps 办法处理拿到的依靠(添加 hash 等):

浅显易懂 Vite5 中依靠预构建

然后将 prepareKnownDeps 回来的 knownDeps 交给 runoptimizeDeps 进行处理:

浅显易懂 Vite5 中依靠预构建

runOptimizeDeps 办法内部会调用 prepareEsbuildOptimizerRun

prepareEsbuildOptimizerRun 办法正是运用 EsBuild 关于前一步扫描生成的依靠进行预构建的办法:

浅显易懂 Vite5 中依靠预构建

当 context 准备结束后,prepareEsbuildOptimizerRun 会调用 rebuild 办法进行打包(生成预构建产品):

浅显易懂 Vite5 中依靠预构建

当 rebuild 运转结束后,咱们会发现 node_modules 下的预构建文件也会生成了:

浅显易懂 Vite5 中依靠预构建

Vite 源码中关于鸿沟处理的 case 特别说,实话说笔者也并没有逐行阅览。

这儿的源码部分更多是想起到一个抛砖引玉的效果,期望咱们能够在了解预构建的基础思路后能够跟从源码自己手动 debugger 调试一下。

结尾

Vite 中依靠预构建截止这儿现已给咱们共享结束了,期望文章中的内容能够帮助到咱们。

之后我仍会在专栏中共享关于 Vite 中其他进阶内容,比方 Vite 开发环境下的文件转译、热重载以及怎么在出产环境下的调用进程。

有爱好的小伙伴能够重视我的专栏浅显易懂 Vite,咱们假如关于文章中的内容有疑惑咱们也能够在评论区持续沟通。