[陈同学i前端] 一起学Vite|简单手写开发服务器

前言

大家好,我是陈同学,一枚野生前端开发者,感谢各位的点赞、收藏、评论

很高兴能和你一同学习~

近年来,前端领域技术更新迭代节奏较快,前端工程师们为了更好的进行项目开发、测试、构建、部署,开发出了各种各样的构建工具

像常见的WebpackRollupEsbuildVite,每一类工具都有它的特点,均致力于提高前端领域的工程化水平

而工具出现的目标是解决前端工程当中的一些影响通性问题

常见的痛点(需求点)有:模块化需求(ESM)、兼容高级语法、代码质量测试、静态资源处理、代码压缩、开发效率等

webpack独霸天下的时代,vite出现之初就以开发环境下极快的启动/构建速度而迅速博得大量前端开发者的青睐,发展至今使用体量已经逼近webpack,相信大多数开发者多少在日常的学习工作生活中都有接触过

本节我们继续进行Vite知识的学习,具体安排如下:

  • 一起学Vite|初识下一代的前端工具链
  • 一起学Vite|原来这玩意叫依赖预构建
  • 一起学Vite|实现第一个Vite插件
  • 一起学Vite|插件机制与流水线
  • 一起学Vite|HMR,你好[上]👋
  • 一起学Vite|HMR,你好[下]👋
  • 一起学Vite|模块联邦——代码共享的终极解决方案
  • 一起学Vite|简单手写开发服务器(本节)
  • 一起学Vite|简单手写打包器

本文阅读成本与收益如下:

阅读耗时:10mins

全文字数:5k+

预期效益

  • 开发服务器实现思路
  • 开发服务器具体实现

实现思路

首先我们来梳理一下实现一个Vite的开发服务器的思路,其实主要分为六个部分

  1. 初始化前端工程项目并安装依赖,设置构建脚本
  2. 开发脚手架,实现cli命令的逻辑,设置启动脚本
  3. 基于Esbuild开发依赖预构建能力
  4. 开发Vite的插件机制,两个组成部分:PluginContainerPluginContext
  5. 开发no-bundle服务的各种资源编译构建能力
  6. 开发模块热更新能力(本节不涉及)

[陈同学i前端] 一起学Vite|简单手写开发服务器

开发环境搭建 & 脚手架实现

项目初始化

首先需要为我们即将要开发的Vite项目准备一个monorepo类型的项目目录结构

# 若没有安装pnpm,先安装一下
npm install -g pnpm

# 创建一个项目目录 vite-project 并进入
mkdir vite-project && cd vite-project

# 生成一个package.json
pnpm init

# 写入pnpm-workspace.yaml内容
echo "packages:\n- 'packages/*'" >> pnpm-workspace.yaml

# 创建vite核心代码包目录
mkdir -p ./packages/mini-vite && cd ./packages/mini-vite
# 初始化核心包的package.json
pnpm init
# 为了防止【我行你不行的情况,这里安装依赖带上了版本号码】
pnpm i @types/node @types/connect @types/debug @types/fs-extra @types/resolve npm-run-all2@^6.1.1 rimraf@^3.0.2 --save-dev
pnpm i cac@^6.7.14 chokidar@^3.5.3 connect@^3.7.0 debug@^4.3.4 es-module-lexer@^1.1.0 esbuild@^0.15.15 fs-extra@^10.1.0 magic-string@^0.26.7 picocolors@^1.0.0 resolve@^1.22.1 rollup@^4.2.0 sirv@^2.0.2 ws@^8.11.0 @rollup/plugin-typescript@^11.1.5 typescript@^5.2.2

# 预先创建后面用到的目录与文件
mkdir -p ./src/node ./bin
echo "console.log('hello vite');" >> ./src/node/cli.ts
echo "{
  \"extends\": \"../../tsconfig.base.json\",
  \"include\": [\"./\"],
  \"exclude\": [\"**/__tests__\"],
  \"compilerOptions\": {
    \"lib\": [\"ESNext\", \"DOM\"],
    \"stripInternal\": true
  }
}" >>  ./src/node/tsconfig.json

cd ../../ && code .

# # 安装主项目依赖
# pnpm install @rollup/plugin-typescript@^11.1.5 npm-run-all2@^6.1.1 rimraf@^3.0.2 typescript@^5.2.2 rollup@^4.2.0 --save-dev -w
序号 包名 版本范围 描述
1 cac ^6.7.14 处理命令行参数的库
2 chokidar ^3.5.3 实时监控文件的库
3 connect ^3.7.0 简单的 HTTP 服务器框架
4 debug ^4.3.4 Node.js 和浏览器的调试工具
5 es-module-lexer ^1.1.0 将 JavaScript 模块字符串解析成 tokens 的库
6 esbuild ^0.15.15 高性能、零配置的 JavaScript 和 TypeScript bundler/minifier
7 fs-extra ^10.1.0 对 Node.js 的 fs 模块进行扩展的工具包
8 magic-string ^0.26.7 可以操作字符串的类库
9 picocolors ^1.0.0 为控制台输出提供美观且可定制的颜色方案
10 resolve ^1.22.1 根据路径解析模块路径的库
11 rollup ^4.2.0 一个 JavaScript 模块打包器
12 sirv ^2.0.2 一个快速、zero-config development server for static files
13 ws ^8.11.0 WebSocket library for Node.js
13 @rollup/plugin-typescript ^11.1.5 Rollup 的一个插件,用于将 TypeScript 文件编译成 JavaScript

说明:版本号(^6.7.14) 表示允许升级到 6.x 系列但不低于 6.7.14 的最新版本

以上我们已经安装好编写vite服务项目所需的部分依赖

接下来我们还要为项目源码构建编译写好两种配置文件

  1. tsconfig.json:包含了关于如何编译 TypeScript 源代码的信息,比如目标 JavaScript 版本、模块系统、源映射以及其它编译选项,让 Typescript 依赖包提供的 tsc 编译命令识别并按需要进行ts->js的编译输出
// packages/mini-vite/tsconfig.json
{
  // 指定此配置文件继承自其他配置文件
  "extends": "./tsconfig.base.json",
  // 指定需要包含的文件或目录
  "include": [
    "./rollup.config.ts"
  ],
  // 指定编译选项
  "compilerOptions": {
    // 启用 ECMAScript 模块和 CommonJS 模块之间的互操作性
    "esModuleInterop": true,
    // 是否生成声明文件(.d.ts)
    "declaration": false,
    // 是否解析 JSON 文件中的模块
    "resolveJsonModule": true,
  }
}

编译器公共配置文件,其它tsconfig.json通过extends字段来进行配置继承

// packages/mini-vite/tsconfig.base.json
{
  "compilerOptions": {
    // 指定编译的目标 JavaScript 版本
    "target": "ES2022",
    // 指定生成的模块系统
    "module": "ESNext",
    // 指定模块解析策略
    "moduleResolution": "bundler",
    // 启用所有严格类型检查选项
    "strict": true,
    // 生成类型定义文件
    "declaration": true,
    // 禁止子类中覆盖父类成员时使用 override 修饰符
    "noImplicitOverride": true,
    // 禁止未使用的局部变量
    "noUnusedLocals": true,
    // 启用 CommonJS 和 ES Modules 之间的互操作性
    "esModuleInterop": true,
    // 在 catch 子句中禁止使用未知类型的变量
    "useUnknownInCatchVariables": false
  }
}
  1. rollup.config.ts:Rollup的配置文件,用于预设JS模块的构建逻辑,支持各种格式如CommonJS、ESModules、UMD等
// packages/mini-vite/rollup.config.ts
import { defineConfig } from 'rollup'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import typescript from '@rollup/plugin-typescript'

const __dirname = fileURLToPath(new URL('.', import.meta.url)) // 当前目录路径,如:/Users/xxx/code/vite-project/packages/mini-vite/

const sharedNodeOptions = defineConfig({
  treeshake: {
    moduleSideEffects: 'no-external', // 只对本地模块进行 tree-shaking,不包括外部依赖
    propertyReadSideEffects: false, // 不对属性读取进行 tree-shaking
    tryCatchDeoptimization: false, // 不对 try-catch 语句进行优化
  },
  output: {
    dir: './dist',
    entryFileNames: `node/[name].js`, // 输出文件名将以 [name].js 的格式命名,其中 [name] 是入口点的名称
    chunkFileNames: 'node/chunks/dep-[hash].js', // 非入口点 chunk 文件的命名格式,[hash] 是文件内容的哈希值
    exports: 'named', // 输出文件使用命名导出
    format: 'esm', // 输出文件使用 ES 模块格式
    externalLiveBindings: false, // 保留外部依赖的实时绑定
    freeze: false, // 冻结输出文件中的对象
  },
  // 用于处理 Rollup 构建过程中的警告
  onwarn(warning, warn) {
    if (warning.message.includes('Circular dependency')) {
      return
    }
    warn(warning)
  },
})

export default defineConfig({
  // 入口文件路径映射
  input: {
    cli: path.resolve(__dirname, 'src/node/cli.ts'),
  },
  plugins: [
    // 引用rollup插件 @rollup/plugin-typescript 来处理 ts 文件
    typescript({
      tsconfig: path.resolve(__dirname, 'src/node/tsconfig.json'),
      declaration: false,
    })
  ],
  ...sharedNodeOptions,
});

编写好rollup以及typescript的配置文件后,还需要稍微改动一下packages/mini-vite/package.json内容

新增可执行的构建脚本scripts、入口文件路径main

{
  "name": "@packages/mini-vite",
  "version": "1.0.0",
  "description": "",
  "main": "./dist/index.js", // Change
  "type": "module", // Change
  // Change
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "rimraf dist && pnpm run build-bundle -w",
    "build": "rimraf dist && run-s build-bundle",
    "build-bundle": "rollup --config rollup.config.ts --configPlugin typescript"
  },
  // ...
}

到这里我们已经初步搭建好Vite开发服务器项目的开发环境

命令行执行 pnpm buildpackages/mini-vite 目录下出现了 dist 目录,则项目初始化完成

[陈同学i前端] 一起学Vite|简单手写开发服务器

脚手架实现

接下来我们进入到脚手架实现步骤,基于上面我们搭建的开发环境,我们编写的代码逻辑入口是 packages/mini-vite/src/node/cli.ts

解析命令行参数

首先使用 cac 包来处理命令行参数

import cac from "cac";

const cli = cac();
console.log('>>>cli start');
// [] 中的内容为可选参数,也就是说仅输入 `vite` 命令下会执行下面的逻辑
cli
  .command('[root]', 'start dev server')
  .alias("serve")
  .alias("dev")
  .action(async () => {
    console.log('>>>mini-vite dev action');
  }); // 注册指令回调
cli.help() // 支持 --help 查看命令
cli.parse() // 解析参数

再到 packages/mini-vite/bin 目录下新增一个 mini-vite.js 的文件

#!/usr/bin/env node

function start() {
  return import('../dist/node/cli.js')
}
start();

配置好可执行命令语句映射

[陈同学i前端] 一起学Vite|简单手写开发服务器

随后我们就可以在根目录的 node_modules 当中看到 bin 目录下有一个 mini-vite 的可执行命令

[陈同学i前端] 一起学Vite|简单手写开发服务器

验证一下命令是否正常:

# npx|一个命令行工具,它允许用户运行本地安装的Node.js模块,以及远程托管的Node.js模块(本地没有找到则拉取远程资源)
npx mini-vite

[陈同学i前端] 一起学Vite|简单手写开发服务器

加入开发服务器启动能力

现在进一步完善脚手架的能力

新增 packages/mini-vite/src/node/server/index.ts 模块

内容如下:

// packages/mini-vite/src/node/server/index.ts
// node.js中简单的 HTTP 服务器框架,koa、express也是基于这个来开发
import connect from 'connect';
// 【控制台输出】提供美观且可定制的颜色方案
import colors from 'picocolors';
// 用于读取文件
import { readFileSync } from 'node:fs';

const { version } = JSON.parse(
  readFileSync(new URL('../../package.json', import.meta.url)).toString()
);
const port = 3001;

export async function startDevServer() {
  const app = connect();
  const startTime = Date.now();
  app.listen(port, async () => {
    console.log(colors.green('🚀 Hello,恭喜你,开发服务器启动成功 🚀'));
    const startupDurationString = startTime
      ? colors.dim(
          `ready in ${colors.reset(
            colors.bold(Math.ceil(Date.now() - startTime))
          )} ms`
        )
      : '';
    console.log(
      `\n  ${colors.green(
        `${colors.bold('MINI-VITE')} v${version}`
      )}  ${startupDurationString}\n`
    );
    console.log(
      `  ${colors.green('➜')}  ${colors.bold('Local')}:   ${colors.blue(
        `http://localhost:${port}`
      )}`
    );
  });
}

接着在 cli.ts 里面引入启动开发服务器的方法

[陈同学i前端] 一起学Vite|简单手写开发服务器

在项目根目录下执行命令

# 执行 packages/mini-vite 中 package.json 声明的 build 脚本,编译新的产物
pnpm --filter mini-vite build

再次执行 npx mini-vite 查看效果

[陈同学i前端] 一起学Vite|简单手写开发服务器

到此我们已经拥有了一个能够启动本地服务器的脚手架~!

有了cli的基础之后我们开发开发 Vite 的开发服务器内部逻辑,首先需要实现的能力是 依赖预构建

依赖预构建

依赖预构建的逻辑实现:packages/mini-vite/src/node/optimizer/index.ts

// packages/mini-vite/src/node/optimizer/index.ts
export async function optimizeDeps(config: { root: string }) {
  // 1. 定位需预构建的项目工程入口文件
  // 2. 从入口文件开始扫描依赖项
  // 3. 预构建依赖
}

在启动服务器的方法里调用该方法

# packages/mini-vite/src/node/server/index.ts
import connect from 'connect';
import colors from 'picocolors';
import { readFileSync } from 'node:fs';
+ import { optimizeDeps } from '../optimizer';

const port = 3001;

// ...

export async function startDevServer() {
  const app = connect();
  const startTime = Date.now();
  app.listen(port, async () => {
+   await optimizeDeps({ root: process.cwd() });
    console.log(colors.green('🚀 Hello,恭喜你,开发服务器启动成功 🚀'));
    // ...
  });
}

定位项目工程入口文件

无论是react项目、vue项目还是其它的前端工程项目,都会有一个或多个入口文件,这里我们先假设现在我们需要用mini-vite启动的项目入口文件有一个且路径为 src/main.ts

// packages/mini-vite/src/node/optimizer/index.ts
import path from "path";
export async function optimizeDeps(config: { root: string }) {
  // 1. 定位需预构建的项目工程入口文件
+ const entry = path.resolve(config.root, "src/main.ts");
  // 2. 从入口文件开始扫描依赖项
  // 3. 预构建依赖
}

自动依赖搜寻

Vite 在启动服务器时,如果没有找到相应的缓存,将抓取业务源码,自动寻找引入的依赖(”bare import”:从 node_modules 解析),并将这些依赖作为预构建包的入口点。预构建通过 esbuild 高性能构建工具执行,所以能够让这个过程耗时非常短。

自动依赖扫描需要我们先写一个 esbuild 的插件来实现

// src/node/optimizer/scanPlugin.ts
import { Plugin } from "esbuild";
import { externalTypes } from "../constants";

export function esbuildScanPlugin(depImports: Set<string>): Plugin {
  return {
    name: 'vite:dep-scan',
    setup(build) {
      // 忽略部分后缀的文件,像.vue、.svelte等后缀的文件
      build.onResolve(
        { filter: new RegExp(`\.(${externalTypes.join("|")})$`) },
        (resolveInfo) => {
          return {
            path: resolveInfo.path,
            // 无关的资源标记 external,不让 esbuild 处理,防止 Esbuild 未知报错
            external: true,
          };
        }
      );
      // 记录需要预构建的第三方依赖
      build.onResolve(
        {
          filter: /^[\w@][^:]/,
        },
        (resolveInfo) => {
          const { path: id } = resolveInfo;
          // 推入 depImports 集合中
          depImports.add(id);
          return {
            path: id,
            external: true,
          };
        }
      );
    },
  };
}

在我们的 optimizeDeps 方法中调用,作为参数传递给 esbuild.build 方法,由 esbuild 来进行源码文件模块的遍历处理

// packages/mini-vite/src/node/optimizer/index.ts
import path from "path";
import colors from 'picocolors';
import { build } from "esbuild";
import { esbuildScanPlugin } from "./scan";

export async function optimizeDeps(config: { root: string }) {
  // 1. 定位需预构建的项目工程入口文件
  const entry = path.resolve(config.root, "src/main.ts");
  // 2. 从入口文件开始自动扫描依赖项
  const deps = new Set<string>();
  console.debug(colors.green(`scanning for dependencies...\n`))
  await build({
    entryPoints: [entry],
    bundle: true,
    write: false,
    plugins: [esbuildScanPlugin(deps)],
  });
  console.log(
  `${colors.green("需预构建的依赖项如下:")}\n${[...deps]
    .map(colors.green)
    .map((item) => `- ${item}`)
    .join("\n")}\n\n`
  );
  // 3. 预构建依赖
}

到这里我们重新编译(pnpm --filter mini-vite build)一下 mini-vite 的代码,并在 packages/ 目录下新增一个 playground 子项目(用于测试 mini-vite 的能力)

[陈同学i前端] 一起学Vite|简单手写开发服务器

当然也可以直接在 packages 目录使用 pnpm create vite playground 来初始化子项目,只需要保证项目的入口文件路径为 packages/playground/src/main.ts 即可

{
  "name": "playground",
  // ...
  "devDependencies": {
    // ...
    "mini-vite": "workspace:*" // package.json中devDependencies需要新增此项,安装当前monorepo仓库的mini-vite依赖
  }
}

修改好 package.json 文件后在 packages/playground 目录中执行一下 npx mini-vite 即可看到效果

[陈同学i前端] 一起学Vite|简单手写开发服务器

在服务器已经启动之后,如果遇到一个新的依赖关系导入,而这个依赖关系还没有在缓存中,Vite 将重新运行依赖构建进程并重新加载页面

预构建依赖

// packages/mini-vite/src/node/optimizer/index.ts
import path from "path";
import colors from 'picocolors';
import { build } from "esbuild";
import { esbuildScanPlugin } from "./scan";
import { esbuildDepPlugin } from "./esbuildDepPlugin";

export async function optimizeDeps(config: { root: string }) {
  // 1. 定位需预构建的项目工程入口文件
  const entry = path.resolve(config.root, "src/main.ts");
  // 2. 从入口文件开始扫描依赖项
  const deps = new Set<string>();
  console.debug(colors.green(`scanning for dependencies...\n`))
  await build({
    entryPoints: [entry],
    bundle: true,
    write: false,
    plugins: [esbuildScanPlugin(deps)],
  });
  console.log(
  `${colors.green("需预构建的依赖项如下:")}\n${[...deps]
    .map(colors.green)
    .map((item) => `- ${item}`)
    .join("\n")}\n\n`
  );
  // 3. 预构建依赖
  await build({
    entryPoints: [...deps],
    write: true,
    bundle: true,
    splitting: true,
    format: "esm",
    outdir: path.resolve(config.root, path.join('node_modules', '.mini-vite', 'deps')),
  });
}

最终将预构建出来的产物输出到 node_modules/.mini-vite/deps 目录当中

[陈同学i前端] 一起学Vite|简单手写开发服务器

插件机制

本系列前面的文章有讲到过Vite插件机制的设计与应用,接下来我们简单实现一下插件的能力

即分别实现 插件上下文插件容器

插件上下文

rollup官方提供的插件上下文类型声明如下:

export interface PluginContext extends MinimalPluginContext {
	addWatchFile: (id: string) => void;
	cache: PluginCache;
	emitFile: EmitFile;
	error: (error: RollupError | string, pos?: number | { column: number; line: number }) => never;
	getFileName: (fileReferenceId: string) => string;
	getModuleIds: () => IterableIterator<string>;
	getModuleInfo: GetModuleInfo;
	getWatchFiles: () => string[];
	load: (
		options: { id: string; resolveDependencies?: boolean } & Partial<PartialNull<ModuleOptions>>
	) => Promise<ModuleInfo>;
	/** @deprecated Use `this.getModuleIds` instead */
	moduleIds: IterableIterator<string>;
	parse: (input: string, options?: any) => AcornNode;
	resolve: (
		source: string,
		importer?: string,
		options?: {
			assertions?: Record<string, string>;
			custom?: CustomPluginOptions;
			isEntry?: boolean;
			skipSelf?: boolean;
		}
	) => Promise<ResolvedId | null>;
	setAssetSource: (assetReferenceId: string, source: string | Uint8Array) => void;
	warn: (warning: RollupWarning | string, pos?: number | { column: number; line: number }) => void;
}

插件上下文主要作用:在调用插件容器内任意方法时实例化一个全新的上下文对象,用于在每一个插件钩子方法(同类型如:resolveId)执行过程中共享相关数据与方法

上下文对象中的每一个方法都能够在rollup文档中找到对应说明:rollup插件上下文说明

插件容器

// packages/mini-vite/src/node/server/pluginContainer.ts
import { InputOptions, ModuleInfo, CustomPluginOptions, PartialResolvedId, SourceDescription, SourceMap, LoadResult, PluginContext } from "rollup";
export interface PluginContainer {
//   options: InputOptions;
//   getModuleInfo(id: string): ModuleInfo | null;
//   buildStart(options: InputOptions): Promise<void>;
  resolveId(
    id: string,
    importer?: string,
    options?: {
      attributes?: Record<string, string>;
      custom?: CustomPluginOptions;
      skip?: Set<Plugin>;
      ssr?: boolean;
      /**
       * @internal
       */
      scan?: boolean;
      isEntry?: boolean;
    }
  ): Promise<PartialResolvedId | null>;
  transform(
    code: string,
    id: string,
    options?: {
      inMap?: SourceDescription['map'];
      ssr?: boolean;
    }
  ): Promise<{ code: string; map?: SourceMap | { mappings: '' } | null }>;
  load(
    id: string,
    options?: {
      ssr?: boolean;
    }
  ): Promise<LoadResult | null>;
//   watchChange(
//     id: string,
//     change: { event: 'create' | 'update' | 'delete' }
//   ): Promise<void>;
//   close(): Promise<void>;
}

插件容器所提供的一般方法能够根据传入的参数依次执行当前容器内所有插件的对应钩子方法

当前容器内所有插件来源于 Vite内部提供 以及 用户编写的Vite配置文件传入

然后我们实际来实现一下插件容器:

// packages/mini-vite/src/node/server/pluginContainer.ts

// code...

export const createPluginContainer = (config: { plugins: any[] }): PluginContainer => {
  const plugins = config.plugins;
  // 插件上下文对象
  class Context implements Pick<PluginContext, 'resolve'> {
    async resolve(id: string, importer?: string) {
      let out = await pluginContainer.resolveId(id, importer);
      if (typeof out === "string") out = { id: out };
      return out as ResolvedId | null;
    }
  }
  // 插件容器
  const pluginContainer: PluginContainer = {
    // 路径映射关系转换钩子
    async resolveId(id: string, importer?: string) {
      const ctx = new Context();
      for (const plugin of plugins) {
        if (plugin.resolveId) {
          const newId = await plugin.resolveId.call(ctx, id, importer);
          if (newId) {
            id = typeof newId === "string" ? newId : newId.id;
            return { id };
          }
        }
      }
      return null;
    },
    // 模块内容加载钩子
    async load(id) {
      const ctx = new Context();
      for (const plugin of plugins) {
        if (plugin.load) {
          const result = await plugin.load.call(ctx, id);
          if (result) {
            return result;
          }
        }
      }
      return null;
    },
    // 模块内容转换钩子
    async transform(code, id) {
      const ctx = new Context();
      for (const plugin of plugins) {
        if (plugin.transform) {
          let result = null;
          if ('handler' in plugin.transform) {
            result = await plugin.transform.handler.call(ctx, code, id);
          } else {
            result = await plugin.transform.call(ctx, code, id);
          }
          if (!result) continue;
          if (typeof result === "string") {
            code = result;
          } else if (result.code) {
            code = result.code;
          }
        }
      }
      return { code };
    },
  };

  return pluginContainer;
};

resolveloadtransform三个钩子是实际工程化项目当中使用较为频繁的三个钩子

最后把插件容器加入到开发服务器的启动方法当中

// packages/mini-vite/src/node/server/index.ts
import connect from 'connect';
import colors from 'picocolors';
import { readFileSync } from 'node:fs';
import { optimizeDeps } from '../optimizer';

+ import { createPluginContainer, PluginContainer } from "./pluginContainer";

export interface ServerContext {
+  root: string;
+  pluginContainer: PluginContainer;
+  app: connect.Server;
+  plugins: Plugin[];
}

const { version } = JSON.parse(
  readFileSync(new URL('../../package.json', import.meta.url)).toString()
);

export async function startDevServer() {
  const app = connect();
  const startTime = Date.now();
+ const plugins = []; // 后面再补充
+ const pluginContainer = createPluginContainer({
+   plugins
+ });

+  const serverContext: ServerContext = {
+    root: process.cwd(),
+    app,
+    pluginContainer,
+    plugins,
+  };

+  for (const plugin of plugins) {
+    if (plugin.configureServer) {
+      await plugin.configureServer(serverContext);
+    }
+  }

  const port = 3001;
  app.listen(port, async () => {
    await optimizeDeps({ root: process.cwd() });
    console.log(colors.green('🚀 Hello,恭喜你,开发服务器启动成功 🚀'));
    const startupDurationString = startTime
      ? colors.dim(
          `ready in ${colors.reset(
            colors.bold(Math.ceil(Date.now() - startTime))
          )} ms`
        )
      : '';
    console.log(
      `\n  ${colors.green(
        `${colors.bold('MINI-VITE')} v${version}`
      )}  ${startupDurationString}\n`
    );
    console.log(
      `  ${colors.green('➜')}  ${colors.bold('Local')}:   ${colors.blue(
        `http://localhost:${port}`
      )}`
    );
  });
}

插件容器以及插件上下文到这里已经有了基本的结构

HTML入口处理 & TS 编译加载

我们知道HTML文件模块才是一个WEB页面的入口,以上我们只实现了在TS/JS模块里进行依赖预构建以及建立基础的插件机制,接下来我们要开始从入口HTML入手,解析出JS模块的路径并加载入口JS模块

HTML请求

开发服务器加载HTML的时机是用户在访问网站链接时,这个时候需要有一个中间件结合插件的使用来处理HTML的请求

// packages/vite/src/node/server/middlewares/indexHtml.ts
import { NextHandleFunction } from "connect";
import { ServerContext } from "../index";
import path from "path";
import fse from "fs-extra";
/**
 * 对HTML进行处理转换的中间件
 * @param serverContext 
 * @returns 
 */
export function indexHtmlMiddleware(
  serverContext: ServerContext
): NextHandleFunction {
  return async (req, res, next) => {
    if (req.url === "/") {
      // 取出开发服务器上下文的 root 作为项目根目录
      const { root } = serverContext;
      // Vite创建的项目默认使用项目根目录下的 index.html
      const indexHtmlPath = path.join(root, "index.html");
      // 判断是否存在
      if (await fse.pathExists(indexHtmlPath)) {
        const rawHtml = await fse.readFile(indexHtmlPath, "utf8");
        let html = rawHtml;
        // 执行用户提供 or Vite内置的插件中 transformIndexHtml 方法来对 HTML 进行自定义的修改/替换
        for (const plugin of serverContext.plugins) {
          if (plugin.transformIndexHtml) {
            html = await plugin.transformIndexHtml(html);
          }
        }
        res.statusCode = 200;
        res.setHeader("Content-Type", "text/html");
        return res.end(html);
      }
    }
    return next();
  };
}

在服务实例中应用中间件

import connect from 'connect';
import colors from 'picocolors';
import { readFileSync } from 'node:fs';
import { optimizeDeps } from '../optimizer';
import { createPluginContainer, PluginContainer } from "./pluginContainer";
import { Plugin } from "../plugin";
+ import { indexHtmlMiddleware } from './middlewares/indexHtml';

// code...

export async function startDevServer() {
  const app = connect();
  const startTime = Date.now();
  // MOCK用户提供的插件
  const plugins: Plugin[] = [];
  const pluginContainer = createPluginContainer({
    plugins
  });
  const serverContext: ServerContext = {
    root: process.cwd(),
    app,
    pluginContainer,
    plugins,
  };

+  app.use(indexHtmlMiddleware);

  for (const plugin of plugins) {
    if (plugin.configureServer) {
      await plugin.configureServer(serverContext);
    }
  }

  const port = 3001;
  app.listen(port, async () => {
    // code...
    console.log(
      `  ${colors.green('➜')}  ${colors.bold('Local')}:   ${colors.blue(
        `http://localhost:${port}`
      )}`
    );
  });
}

接着build一下mini-vite的子包 pnpm --filter mini-vite build

packages/playground 里面新增一个 index.html 文件,填入以下的内容

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>测试mini-vite</title>
</head>
<body>
    <div class="container">mini-vite来了!</div>
    <div id="app"></div>
    <script type="module" src="./src/main.ts"></script>
</body>
</html>

packages/playground 目录下执行 npx mini-vite

[陈同学i前端] 一起学Vite|简单手写开发服务器

访问地址:http://localhost:3001

[陈同学i前端] 一起学Vite|简单手写开发服务器

仔细的同学可能已经发现了,现在 main.ts 的内容并没有被正确加载并应用到页面当中

这是因为目前只做了请求HTML文件内容的逻辑,并没有实现TS内容请求的逻辑

TS编译加载

新增一个处理非HTML请求的中间件

// packages/vite/src/node/server/middlewares/transform.ts
import { NextHandleFunction } from "connect";
import { ServerContext } from "..";
import { isJSRequest, isImportRequest } from "../../utils";

export function transformMiddleware(
    serverContext: ServerContext
): NextHandleFunction {
    return async (req, res, next) => {
      if (req.method !== "GET" || !req.url) {
        return next();
      }
      const url = req.url;
      console.debug("transformMiddleware: %s", url);
      // 若是JS模块资源的请求,则执行以下逻辑
      if (isJSRequest(url) || isImportRequest(url)) {
        // 编译转化
        let result = await transformRequest(url, serverContext, {
          html: req.headers.accept?.includes('text/html'),
        });
        if (!result) {
          return next();
        }
        res.statusCode = 200;
        res.setHeader("Content-Type", "application/javascript");
        return res.end(result.code);
      }
      next();
    };
}

其中需要补充点用到的工具函数以及静态变量

// src/node/utils.ts
const escapeRegexRE = /[-/\^$*+?.()|[\]{}]/g
export function escapeRegex(str: string): string {
  return str.replace(escapeRegexRE, '\$&')
}
const knownJsSrcRE =
  /\.(?:[jt]sx?|m[jt]s|vue|marko|svelte|astro|imba|mdx)(?:$|\?)/

export const isJSRequest = (url: string): boolean => {
  url = cleanUrl(url)
  if (knownJsSrcRE.test(url)) {
    return true
  }
  if (!path.extname(url) && url[url.length - 1] !== '/') {
    return true
  }
  return false
}
export const QEURY_RE = /\?.*$/s;
export const HASH_RE = /#.*$/s;
export const cleanUrl = (url: string): string =>
  url.replace(HASH_RE, "").replace(QEURY_RE, "");

const knownTsRE = /\.(?:ts|mts|cts|tsx)(?:$|\?)/
export const isTsRequest = (url: string): boolean => knownTsRE.test(url)

const importQueryRE = /(\?|&)import=?(?:&|$)/
const trailingSeparatorRE = /[?&]$/
export const isImportRequest = (url: string): boolean => importQueryRE.test(url)

再来实现一下 transformRequest 方法

// packages/mini-vite/src/node/transformRequest.ts
import { SourceMap } from 'rollup';
import { ServerContext } from './server';
import { cleanUrl } from './utils';
export interface TransformOptions {
  html?: boolean;
}
export interface TransformResult {
  code: string;
  map?: SourceMap | { mappings: '' } | null;
  etag?: string;
  deps?: string[];
  dynamicDeps?: string[];
}
export async function transformRequest(
  url: string,
  server: ServerContext,
  options: TransformOptions = {}
): Promise<TransformResult | null> {
    const { pluginContainer } = server;
    url = cleanUrl(url);
    // 依次调用插件容器的 resolveId、load、transform 方法
    const resolvedResult = await pluginContainer.resolveId(url);
    let transformResult: TransformResult | null = null;
    if (resolvedResult?.id) {
      const loadResult = await pluginContainer.load(resolvedResult.id);
      let code = '';
      if (typeof loadResult === "object" && loadResult !== null) {
        code = loadResult.code;
      }
      if (code) {
        transformResult = await pluginContainer.transform(
          code,
          resolvedResult?.id
        );
      }
    }
    return Promise.resolve(transformResult);
}

再更新一下开发服务器启动方法:

# packages/mini-vite/src/node/server/index.ts
// code...
export async function startDevServer() {
  // code...
  app.use(indexHtmlMiddleware(serverContext));
+  app.use(transformMiddleware(serverContext));
  const port = 3001;
  app.listen(port, async () => {
    // code...
  });
}

在引入了JS模块处理的中间件后,需要提供几个插件的逻辑来做代码编译

  1. 路径解析插件:对请求的路径进行处理,若不做任何处理,浏览器在发送 /src/main.ts 请求的时候会报404的异常错误码
  2. 编译插件:将识别到的 tstsxjsjsx 模块代码编译成浏览器环境能够识别运行的代码
  3. import分析插件:将模块代码导入的第三方依赖的路径重写为预构建好的产物路径

补充文件:

import path from "path"
import os from "os";
export function slash(p: string): string {
  return p.replace(/\/g, "/");
}
export const isWindows = os.platform() === "win32";
// 规范化路径字符串
export function normalizePath(id: string): string {
  return path.posix.normalize(isWindows ? slash(id) : id);
}
// code...

路径解析插件

路径解析首先判断路径字符串是相对路径还是绝对路径

若是绝对路径,进一步判断路径对应的文件模块是否存在,若不存在则拼上项目根目录的路径再次判断

若是相对路径,通过将当前被遍历到的目标模块路径 importer 拼上相对路径再判断文件模块是否存在,若存在则返回拼接后的路径,若不存在则返回null

// packages/mini-vite/src/node/plugins/resolve.ts
import { pathExists } from "fs-extra";
import path from "path";
import resolve from "resolve";
import { ServerContext } from "../server";
import { normalizePath } from "../utils";
import { Plugin } from "../plugin";

export function resolvePlugin(): Plugin {
  let serverContext: ServerContext;
  return {
    name: 'vite:resolve',
    configureServer(server) {
      // 保存服务端上下文
      serverContext = server;
    },
    async resolveId(id: string, importer?: string) {
      // 绝对路径
      if (path.isAbsolute(id)) {
        // 路径存在,直接返回
        if (await pathExists(id)) {
          return { id };
        }
        // 路径不存在的情况下加上 root 路径前缀,支持类似 /src/main.ts 的情况
        id = path.join(serverContext.root, id);
        if (await pathExists(id)) {
          return { id };
        }
      } else if (id.startsWith('.')) {
        // 相对路径
        if (!importer) throw new Error('`importer` should not be undefined');
        const hasExtension = path.extname(id).length > 1;
        let resolvedId: string;
        // 包含文件名后缀,如 ./main.ts
        if (hasExtension) {
          resolvedId = normalizePath(
            resolve.sync(id, { basedir: path.dirname(importer) })
          );
          if (await pathExists(resolvedId)) {
            return { id: resolvedId };
          }
        } else {
          // 不包含文件名后缀,如 ./main
          // 遍历来实现自动推断文件后缀名,如:./main -> ./main.ts
          for (const extname of [".tsx", ".ts", ".jsx", "js"]) {
            try {
              const withExtension = `${id}${extname}`;
              resolvedId = normalizePath(
                resolve.sync(withExtension, {
                  basedir: path.dirname(importer),
                })
              );
              if (await pathExists(resolvedId)) {
                return { id: resolvedId };
              }
            } catch (error) {}
          }
        }
      }
      return null;
    },
  };
}

编译插件

编译插件最主要的作用是拿到通过插件容器 resolveId 钩子方法处理过的字符串路径 id 后加载模块内容并将JS/TS/JSX/TSX内容编译转换成浏览器可以执行的JS语法的代码

// packages/mini-vite/src/node/plugins/esbuild.ts
import { Loader, TransformOptions, transform } from 'esbuild';
import { cleanUrl } from '../utils';
import { Plugin } from '../plugin';
import path from 'path';

export async function transformWithEsbuild(
  code: string,
  filename: string,
  options?: TransformOptions,
  inMap?: object
) {
  let loader = options?.loader;
  if (!loader) {
    // if the id ends with a valid ext, use it (e.g. vue blocks)
    // otherwise, cleanup the query before checking the ext
    const ext = path
      .extname(/\.\w+$/.test(filename) ? filename : cleanUrl(filename))
      .slice(1);

    if (ext === 'cjs' || ext === 'mjs') {
      loader = 'js';
    } else if (ext === 'cts' || ext === 'mts') {
      loader = 'ts';
    } else {
      loader = ext as Loader;
    }
  }
  const result = await transform(code, options)
  return result;
}

export function esbuildTransformPlugin(): Plugin {
    return {
      name: "vite:esbuild",
      async load(id) {
        if (isJSRequest(id)) {
          try {
            const code = await fse.readFile(id, "utf-8");
            return code;
          } catch (e) {
            return null;
          }
        }
      },
      async transform(code, id) {
        const reg = /\.(m?ts|[jt]sx)$/;
        if (reg.test(id) || reg.test(cleanUrl(id))) {
          const result = await transformWithEsbuild(code, id, {
            target: "esnext",
            format: "esm",
            sourcemap: true,
          })
          return {
            code: result.code,
            map: result.map,
          }
        }
        return null;
      },
    }
}

import分析插件

import分析插件用于在遍历分析已经编译好的模块文件内ESM导入语句

如:

import { createPinia } from 'pinia';
import { createXXX } from 'xxx';
import { transformText } from './kit/tool';

并将导入对应的路径进行分类处理:

  • 第三方依赖路径(bare import):重写为预构建产物路径
  • 绝对路径和相对路径:需借助之前的路径解析插件进行解析
import MagicString from "magic-string";
import { Plugin } from "../plugin"
import { ServerContext } from "../server"
import { isJSRequest, normalizePath } from "../utils";
import { init, parse } from "es-module-lexer";
import path from "path";

export function importAnalysisPlugin(): Plugin {
    let serverCtx: ServerContext;
    return {
        name: 'vite:import-analysis',
        configureServer(_server) {
            serverCtx = _server
        },
        async transform(source: string, importer: string, options) {
            if (!serverCtx) return null
            // 只处理 JS 的请求
            if (!isJSRequest(importer)) return null;
            await init;
            // 解析 import 语句
            const [imports] = parse(source);
            if (!imports.length) {
                return source;
            }
            const ms = new MagicString(source);
            // 遍历每个 import 语句依次进行分析
            for (const importInfo of imports) {
                const { s: modStart, e: modEnd, n: modSource } = importInfo;
                if (!modSource) continue;
                // 将第三方依赖的路径重写到预构建产物的路径
                if (/^[\w@][^:]/.test(modSource)) {
                    const bundlePath = normalizePath(
                        path.join('/', path.join('node_modules', '.mini-vite', 'deps'), `${modSource}.js`)
                    );
                    ms.overwrite(modStart, modEnd, bundlePath);
                } else if (modSource.startsWith(".") || modSource.startsWith("/")) {
                    // 调用插件上下文的 resolve 方法,自动经过路径解析插件的处理
                    const resolved = await this.resolve(modSource, importer);
                    if (resolved) {
                        ms.overwrite(modStart, modEnd, resolved.id);
                    }
                }
            }
            return {
                code: ms.toString(),
                map: ms.generateMap(),
            };
        }
    }
}

插件作用与效果

将以上三个插件应用到开发服务器的插件容器当中:

# packages/mini-vite/src/node/server/index.ts
// code...
export async function startDevServer() {
  const app = connect();
  const startTime = Date.now();

  // 用到的插件
-  const plugins: Plugin[] = [];
+  const plugins: Plugin[] = [resolvePlugin(), esbuildTransformPlugin(), importAnalysisPlugin()];
  const pluginContainer = createPluginContainer({
    plugins
  });
  // code...
  const port = 3001;
  app.listen(port, async () => {
    // code...
  });
}

为了同时验证预构建以及插件能力,这里需要对 playground 中的代码作一定的调整

首先执行一下命令安装两个常用的第三方包 momentlodash-es

pnpm --filter playground install moment lodash-es

修改HTML文件

<!-- packages/playground/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>测试mini-vite</title>
</head>
<body>
    <div class="title">mini-vite来了!</div>
    <div class="container"></div>
    <script type="module" src="./src/main.ts"></script>
</body>
</html>

TS模块改动如下:

// packages/playground/src/main.ts
import { chunkArr, getFullDateTime } from './tool';
const element = document.querySelector('.container');
element.innerHTML = 'Hello World<br />' + getFullDateTime() + '<br />';
console.log(chunkArr(3, ['a', 'b', 'c', 'd', 'e', 'f', 'g']));

// packages/playground/src/tool.ts
import moment from 'moment';
import * as _ from 'lodash-es';
export const getFullDateTime = () => moment(Date.now()).format('YYYY-MM-DD HH:mm:ss');
export const chunkArr = 
  (size = 2, arr = ['']) => {
    return _.chunk(arr, size, null);
  };

现在再将开发服务器跑起来

访问页面验证效果:

[陈同学i前端] 一起学Vite|简单手写开发服务器

[陈同学i前端] 一起学Vite|简单手写开发服务器

附加内容:可能有同学会好奇,如果路径没有通过 importAnalysisPlugin 改成预构建依赖的路径会发生什么

[陈同学i前端] 一起学Vite|简单手写开发服务器

[陈同学i前端] 一起学Vite|简单手写开发服务器

加载不经过预构建的第三方依赖lodash会造成 请求瀑布流 现象,并且因无法保证第三方依赖的编写规范可能存在导入的模块无法使用的风险

支持CSS的引入

以上我们已经实现了基本的开发服务器能力:加载HTML模块并响应给浏览器、接收JS/TS模块资源的请求并进行路径解析+编码转换+import路径重写

目前能够满足简单编写HTML以及JS逻辑的需求

在日常的前端开发当中我们会有样式渲染的需求,接下来继续丰富能力

当想要在 main.ts 里面直接通过import引入样式

// packages/playground/src/main.ts
import './index.css';

需要让开发服务器识别这类资源后缀的请求,正确地加载成样式模块,返回响应并应用到页面当中

首先在 transformMiddleware 中间件中识别到 CSS 资源的请求时要能进入到模块加载和转换逻辑

// packages/mini-vite/src/node/server/middlewares/transform.ts
import { NextHandleFunction } from "connect";
import { ServerContext } from "..";
import { isJSRequest, isImportRequest } from "../../utils";
import { transformRequest } from "../../transformRequest";
+ import { isCSSRequest } from "../../plugins/css";

export function transformMiddleware(
    serverContext: ServerContext
): NextHandleFunction {
  return async (req, res, next) => {
    if (req.method !== "GET" || !req.url) {
      return next();
    }
    const url = req.url;
    console.debug(">>>transformMiddleware", url);
    // 若是JS模块资源的请求,则执行以下逻辑
-    if (isJSRequest(url) || isImportRequest(url)) {
+    if (isJSRequest(url) || isImportRequest(url) || isCSSRequest(url)) {
      // ...
    }
    next();
  };
}

开发一个css编译用的插件

import fse from "fs-extra";
import { Plugin } from "../plugin";

export const CSS_LANGS_RE = /\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/;
  
export const isCSSRequest = (request: string): boolean => CSS_LANGS_RE.test(request);

export function cssPlugin(): Plugin {
  return {
    name: "vite:css",
    load(id: string) {
      if (isCSSRequest(id)) {
        return fse.readFile(id, "utf-8");
      }
    },
    async transform(code: string, id: string) {
      if (!isCSSRequest(id)) {
        return null;
      }
      // 将css样式代码转成style标签并塞到页面的head下,达成样式引入的目的
      const cssModule = `
const cssStr = "${code.replace(/\n/g, "")}";
const style = document.createElement("style");
style.setAttribute("type", "text/css");
style.innerHTML = cssStr;
document.head.appendChild(style);
export default cssStr;
`.trim();
      return {
        code: cssModule,
      };
    },
  };
}

将css插件应用到开发服务器的插件容器当中

# packages/mini-vite/src/node/server/index.ts
+ import { cssPlugin } from '../plugins/css';
// code...
export async function startDevServer() {
  const app = connect();
  const startTime = Date.now();

  // 用到的插件
-  const plugins: Plugin[] = [resolvePlugin(), esbuildTransformPlugin(), importAnalysisPlugin()];
+  const plugins: Plugin[] = [resolvePlugin(), esbuildTransformPlugin(), importAnalysisPlugin(), cssPlugin()];
  const pluginContainer = createPluginContainer({
    plugins
  });
  // code...
  const port = 3001;
  app.listen(port, async () => {
    // code...
  });
}

playground 中新增 index.css 样式文件

/* packages/playground/src/index.css */
.container {
    font-size: 25px;
    padding: 15px;
    border: 1px solid #bfbfbf;
    border-radius: 10px;
}

main.ts 中加入 import './index.css';

[陈同学i前端] 一起学Vite|简单手写开发服务器

这个时候重新 build 一下 mini-vite ,访问链接看效果

[陈同学i前端] 一起学Vite|简单手写开发服务器

小结

本节我们从零到一实现了 mini-vite 的工程搭建

从最开始的开发环境搭建,建立了一个monorepo的仓库来管理项目,并安装好需要用到的依赖

接着我们开始实现开发服务器的脚手架逻辑,还原了使用vite启动开发环境时的脚手架体验

然后再按照 依赖预构建->插件机制->JS模块处理->HTML解析->CSS模块处理 的思路来逐步一边学习 Vite 开发服务器的机制,一边快速进行一定的实践

讲到最后

Vite 经过几年的发展已经成为前端构建领域的一大利器

通过学习框架的原理以及设计思路有利于每一名前端工程师的发展

希望这节的内容能够帮助到想要深入学习 Vite 的同学

感谢各位看到这里,如果你觉得本节内容还不错的话,欢迎各位的点赞、收藏、评论,大家的支持是我做内容的最大动力

本文为作者原创,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文链接,否则保留追究法律责任的权利

参考补充

Vite官方文档

Rollup官方文档

Esbuild官方文档

掘金小册

Vue3文档

原文链接:https://juejin.cn/post/7317820788547076115 作者:charlex

(0)
上一篇 2024年1月1日 上午10:47
下一篇 2024年1月1日 上午10:58

相关推荐

发表回复

登录后才能评论