跳到主要内容

官方模拟版

2023年06月11日
柏拉文
越努力,越幸运

一、实现


备注: 基于插件机制,来实现 Vite 的核心编译能力。

1.1 HTML 入口加载

首先要考虑的就是入口 HTML 如何编译和加载的问题,这里我们可以通过一个服务中间件,配合插件机制来实现。具体而言,你可以新建 src/node/server/middlewares/indexHtml.ts,内容如下:

import path from "path";
import { ServerContext } from "../index";
import { NextHandleFunction } from "connect";
import { pathExists, readFile } from "fs-extra";

export function indexHtmlMiddleware(
serverContext: ServerContext
): NextHandleFunction {
return async (req, res, next) => {
if (req.url === "/") {
const { root } = serverContext;
const indexHtmlPath = path.join(root, "index.html");
if (await pathExists(indexHtmlPath)) {
const rawHtml = await readFile(indexHtmlPath, "utf8");
let html = rawHtml;
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();
};
}

然后在服务中应用这个中间件:

// src/node/server/index.ts
……
import { indexHtmlMiddleware } from "./middlewares/indexHtml";
……

export async function startDevServer() {
……
app.use(indexHtmlMiddleware(serverContext));

app.listen(3000, async () => {
……
});
}

test 项目下新建 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 App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

接下来通过 pnpm dev 启动项目,然后访问http://localhost:3000,从网络面板中你可以查看到 HTML 的内容已经成功返回:

Preview

不过当前的页面并没有任何内容,因为 HTML 中引入的 TSX 文件并没有被正确编译。接下来,我们就来处理 TSX 文件的编译工作。

1.2 JS/TS/JSX/TSX 编译

首先新增一个中间件 src/node/server/middlewares/transform.ts,内容如下:

import createDebug from 'debug';
import { ServerContext } from '../index';
import { NextHandleFunction } from 'connect';
import { isJSRequest, cleanUrl } from '../../utils';

const debug = createDebug('dev');

export async function transformRequest(
url: string,
serverContext: ServerContext
) {
const { pluginContainer } = serverContext;
url = cleanUrl(url);
const resolvedResult = await pluginContainer.resolveId(url);
let transformResult;
if (resolvedResult?.id) {
let code = await pluginContainer.load(resolvedResult.id);
if (typeof code === 'object' && code !== null) {
code = code.code;
}
if (code) {
transformResult = await pluginContainer.transform(
code as string,
resolvedResult?.id
);
}
}
return transformResult;
}

export function transformMiddleware(
serverContext: ServerContext
): NextHandleFunction {
return async (req, res, next) => {
if (req.method !== 'GET' || !req.url) {
return next();
}
const url = req.url;
debug('transformMiddleware: %s', url);
if (isJSRequest(url)) {
// 核心编译函数
let result = await transformRequest(url, serverContext);
if (!result) {
return next();
}
if (result && typeof result !== 'string') {
result = result.code;
}
res.statusCode = 200;
res.setHeader('Content-Type', 'application/javascript');
return res.end(result);
}

next();
};
}

同时,我们也需要补充如下的工具函数和常量定义:

// src/node/utils.ts
import os from 'os';
import path from 'path';
import { QUERY_RE, HASH_RE, JS_TYPES_RE } from './constants';

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);
}

export const isJSRequest = (id: string): boolean => {
id = cleanUrl(id);
if (JS_TYPES_RE.test(id)) {
return true;
}
if (!path.extname(id) && !id.endsWith('/')) {
return true;
}
return false;
};

export const cleanUrl = (url: string): string =>
url.replace(HASH_RE, '').replace(QUERY_RE, '');
// src/node/constants.ts

export const HASH_RE = /#.*$/s;
export const QUERY_RE = /\?.*$/s;
export const BARE_IMPORT_RE = /^[\w@][^:]/;
export const JS_TYPES_RE = /\.(?:j|t)sx?$|\.mjs$/;
export const DEFAULT_EXTERNALS = ['.tsx', '.ts', '.jsx', 'js'];
export const PRE_BUNDLE_DIR = path.join('node_modules', '.m-vite');

从如上的核心编译函数 transformRequest 可以看出,Vite 对于 JS/TS/JSX/TSX 文件的编译流程主要是依次调用插件容器的如下方法:

  • resolveId

  • load

  • transform

其中会经历众多插件的处理逻辑,那么,对于 TSX 文件的编译逻辑,也分散到了各个插件当中,具体来说主要包含以下的插件:

  • 路径解析插件

  • Esbuild 语法编译插件

  • import 分析插件

1.3 实现路径解析插件

当浏览器解析到如下的标签时:

<script type="module" src="/src/main.tsx"></script>

会自动发送一个路径为 /src/main.tsx 的请求,但如果服务端不做任何处理,是无法定位到源文件的,随之会返回 404 状态码:

Preview

因此,我们需要开发一个路径解析插件,对请求的路径进行处理,使之能转换真实文件系统中的路径。你可以新建文件src/node/plugins/resolve.ts,内容如下:

import path from 'path';
import resolve from 'resolve';
import { Plugin } from '../plugin';
import { pathExists } from 'fs-extra';
import { ServerContext } from '../server/index';
import { DEFAULT_EXTERNALS } from '../constants';
import { cleanUrl, normalizePath } from '../utils';

export function resolvePlugin(): Plugin {
let serverContext: ServerContext;
return {
name: 'm-vite:resolve',
configureServer(s) {
serverContext = s;
},
async resolveId(id: string, importer?: string) {
if (path.isAbsolute(id)) {
if (await pathExists(id)) {
return { id };
}
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;
if (hasExtension) {
resolvedId = normalizePath(
resolve.sync(id, { basedir: path.dirname(importer) })
);
if (await pathExists(resolvedId)) {
return { id: resolvedId };
}
}
else {
for (const extname of DEFAULT_EXTERNALS) {
try {
const withExtension = `${id}${extname}`;
resolvedId = normalizePath(
resolve.sync(withExtension, {
basedir: path.dirname(importer)
})
);
if (await pathExists(resolvedId)) {
return { id: resolvedId };
}
} catch (e) {
continue;
}
}
}
}
return null;
}
};
}

这样对于 /src/main.tsx,在插件中会转换为文件系统中的真实路径,从而让模块在 load 钩子中能够正常加载(加载逻辑在 Esbuild 语法编译插件实现)。

接着我们来补充一下目前缺少的常量:

// src/node/constants.ts
export const DEFAULT_EXTERNALS = ['.tsx', '.ts', '.jsx', 'js'];

1.4 Esbuild 语法编译插件

这个插件的作用比较好理解,就是将 JS/TS/JSX/TSX 编译成浏览器可以识别的 JS 语法,可以利用 EsbuildTransform API 来实现。你可以新建src/node/plugins/esbuild.ts文件,内容如下:

import path from 'path';
import esbuild from 'esbuild';
import { Plugin } from '../plugin';
import { readFile } from 'fs-extra';
import { isJSRequest } from '../utils';

export function esbuildTransformPlugin(): Plugin {
return {
name: 'm-vite:esbuild-transform',
async load(id) {
if (isJSRequest(id)) {
try {
const code = await readFile(id, 'utf-8');
return code;
} catch (e) {
return null;
}
}
},
async transform(code, id) {
if (isJSRequest(id)) {
const extname = path.extname(id).slice(1);
const { code: transformedCode, map } = await esbuild.transform(code, {
target: 'esnext',
format: 'esm',
sourcemap: true,
loader: extname as 'js' | 'ts' | 'jsx' | 'tsx'
});
return {
code: transformedCode,
map
};
}
return null;
}
};
}

1.5 import 分析插件

在将 TSX 转换为浏览器可以识别的语法之后,是不是就可以直接返回给浏览器执行了呢?显然不是,我们还考虑如下的一些问题:

  • 对于第三方依赖路径(bare import),需要重写为预构建产物路径

  • 对于绝对路径和相对路径,需要借助之前的路径解析插件进行解析

好,接下来,我们就在 import 分析插件中一一解决这些问题:

// 新建 src/node/plugins/importAnalysis.ts
import path from 'path';
import resolve from 'resolve';
import { Plugin } from '../plugin';
import { pathExists } from 'fs-extra';
import MagicString from 'magic-string';
import { init, parse } from 'es-module-lexer';
import { ServerContext } from '../server/index';
import {
BARE_IMPORT_RE,
PRE_BUNDLE_DIR,
DEFAULT_EXTERNALS
} from '../constants';
import { cleanUrl, isJSRequest, normalizePath,getShortName } from '../utils';

export function importAnalysisPlugin(): Plugin {
let serverContext: ServerContext;
return {
name: 'm-vite:import-analysis',
configureServer(s) {
serverContext = s;
},
async transform(code: string, id: string) {
if (!isJSRequest(id)) {
return null;
}
await init;
const [imports] = parse(code);
const ms = new MagicString(code);

const resolve = async (id: string, importer?: string) => {
const resolved = await serverContext.pluginContainer.resolveId(
id,
normalizePath(importer)
);
if (!resolved) {
return;
}
let resolvedId = `/${getShortName(resolved.id, serverContext.root)}`;
return resolvedId;
};

for (const importInfo of imports) {
const { s: modStart, e: modEnd, n: modSource } = importInfo;
if (!modSource) continue;
if (BARE_IMPORT_RE.test(modSource)) {
const bundlePath = normalizePath(
path.join('/', PRE_BUNDLE_DIR, `${modSource}.js`)
);
ms.overwrite(modStart, modEnd, bundlePath);
} else if (modSource.startsWith('.') || modSource.startsWith('/')) {
const resolved = await resolve(modSource, id);
if (resolved) {
ms.overwrite(modStart, modEnd, resolved);
}
}
}

return {
code: ms.toString(),
map: ms.generateMap()
};
}
};
}

1.6 注册以上三个插件

现在,我们便完成了 JS 代码的 import 分析工作。接下来,我们把上面实现的三个插件进行注册:

// src/node/plugin/index.ts
import { Plugin } from '../plugin';
import { resolvePlugin } from './resolve';
import { esbuildTransformPlugin } from './esbuild';
import { importAnalysisPlugin } from './importAnalysis';

export function resolvePlugins(): Plugin[] {
return [resolvePlugin(), esbuildTransformPlugin(), importAnalysisPlugin()];
}

当然,我们需要注册 transformMiddleware 中间件,在 src/node/server/index.ts 中增加代码如下:

import connect from 'connect';
import { Plugin } from "../plugin";
import { blue, green } from 'picocolors';
import { resolvePlugins } from '../plugins';
import { optimize } from '../optimizer/index';
import { transformMiddleware } from './middlewares/transform';
import { indexHtmlMiddleware } from "./middlewares/indexHtml";
import { createPluginContainer, PluginContainer } from '../pluginContainer';

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

export async function startDevServer() {
const app = connect();
const root = process.cwd();
const startTime = Date.now();

const plugins = resolvePlugins();
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);
}
}


app.use(transformMiddleware(serverContext));
app.use(indexHtmlMiddleware(serverContext));

app.listen(3000, async () => {
await optimize(root);

console.log(
green('🚀 No-Bundle 服务已经成功启动!'),
`耗时: ${Date.now() - startTime}ms`
);
console.log(`> 本地访问路径: ${blue('http://localhost:3000')}`);
});
}

1.8 CSS 编译插件

首先,我们可以看看项目中 CSS 代码是如何被引入的:

// test/src/main.tsx
import "./index.css";
import App from './App';
import React from 'react';
import ReactDOM from 'react-dom';

ReactDOM.render(<App />, document.getElementById('root'));

为了让 CSS 能够在 no-bundle 服务中正常加载,我们需要将其包装成浏览器可以识别的模块格式,也就是 JS 模块,其中模块加载和转换的逻辑我们可以通过插件来实现。当然,首先我们需要在 transform 中间件中允许对 CSS 的请求进行处理,代码如下:

// src/node/server/middlewares/transform.ts
// 需要增加的导入语句
import { isJSRequest, cleanUrl,isCSSRequest } 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;
debug('transformMiddleware: %s', url);
if (isJSRequest(url) || isCSSRequest(url)) {
// 核心编译函数
let result = await transformRequest(url, serverContext);
if (!result) {
return next();
}
if (result && typeof result !== 'string') {
result = result.code;
}
res.statusCode = 200;
res.setHeader('Content-Type', 'application/javascript');
return res.end(result);
}

next();
};
}

然后我们来补充对应的工具函数:

// src/node/utils.ts
export const isCSSRequest = (id: string): boolean =>
cleanUrl(id).endsWith(".css");

现在我们来开发 CSS 的编译插件,你可以新建src/node/plugins/css.ts文件,内容如下:

import { Plugin } from '../plugin';
import { readFile } from 'fs-extra';

export function cssPlugin(): Plugin {
return {
name: 'm-vite:css',
load(id) {
if (id.endsWith('.css')) {
return readFile(id, 'utf-8');
}
},
async transform(code, id) {
if (id.endsWith('.css')) {
const jsContent = `
const css = "${code.replace(/\n/g, '')}";
const style = document.createElement("style");
style.setAttribute("type", "text/css");
style.innerHTML = css;
document.head.appendChild(style);
export default css;
`.trim();
return {
code: jsContent
};
}
return null;
}
};
}

这个插件的逻辑比较简单,主要是将封装一层 JS 样板代码,将 CSS 包装成一个 ES 模块,当浏览器执行这个模块的时候,会通过一个 style 标签将 CSS 代码作用到页面中,从而使样式代码生效。

接着我们来注册这个 CSS 插件:

// src/node/plugins/index.ts
import { cssPlugin } from './css';
import { Plugin } from '../plugin';
import { resolvePlugin } from './resolve';
import { esbuildTransformPlugin } from './esbuild';
import { importAnalysisPlugin } from './importAnalysis';

export function resolvePlugins(): Plugin[] {
return [
resolvePlugin(),
esbuildTransformPlugin(),
importAnalysisPlugin(),
cssPlugin()
];
}

现在,你可以通过 pnpm dev 来启动 test 项目,不过在启动之前,需要保证 TSX 文件已经引入了对应的 CSS 文件, 在启动项目后,打开浏览器进行访问,可以看到样式已经正常生效。

1.9 静态资源加载

在完成 CSS 加载之后,我们现在继续完成静态资源的加载。以 test 项目为例,我们来支持 svg 文件的加载。首先,我们看看 svg 文件是如何被引入并使用的:

import React from 'react';
import logo from './logo.svg';

function App() {
return (
<div>
<img src={logo} />
</div>
);
}

export default App;

站在 no-bundle 服务的角度,从如上的代码我们可以分析出静态资源的两种请求:

  • import 请求: 如 import logo from "./logo.svg"

  • 资源内容请求: 如 img 标签将资源 url 填入 src,那么浏览器会请求具体的资源内容

因此,接下来为了实现静态资源的加载,我们需要做两手准备: 对静态资源的 import 请求返回资源的 url;对于具体内容的请求,读取静态资源的文件内容,并响应给浏览器。

首先处理 import 请求,我们可以在 TSXimport 分析插件中,给静态资源相关的 import 语句做一个标记:

// src/node/plugins/importAnalysis.ts

import path from 'path';
import resolve from 'resolve';
import { Plugin } from '../plugin';
import { pathExists } from 'fs-extra';
import MagicString from 'magic-string';
import { init, parse } from 'es-module-lexer';
import { ServerContext } from '../server/index';
import {
BARE_IMPORT_RE,
PRE_BUNDLE_DIR,
DEFAULT_EXTERNALS
} from '../constants';
import { cleanUrl, isJSRequest, normalizePath,getShortName } from '../utils';

export function importAnalysisPlugin(): Plugin {
let serverContext: ServerContext;
return {
name: 'm-vite:import-analysis',
configureServer(s) {
serverContext = s;
},
async transform(code: string, id: string) {
if (!isJSRequest(id)) {
return null;
}
await init;
const [imports] = parse(code);
const ms = new MagicString(code);

const resolve = async (id: string, importer?: string) => {
const resolved = await serverContext.pluginContainer.resolveId(
id,
normalizePath(importer)
);
if (!resolved) {
return;
}
let resolvedId = `/${getShortName(resolved.id, serverContext.root)}`;
return resolvedId;
};

for (const importInfo of imports) {
const { s: modStart, e: modEnd, n: modSource } = importInfo;
if (!modSource) continue;

if (modSource.endsWith('.svg')) {
const resolvedUrl = await resolve(modSource, id);
ms.overwrite(modStart, modEnd, `${resolvedUrl}?import`);
continue;
}

if (BARE_IMPORT_RE.test(modSource)) {
const bundlePath = normalizePath(
path.join('/', PRE_BUNDLE_DIR, `${modSource}.js`)
);
ms.overwrite(modStart, modEnd, bundlePath);
} else if (modSource.startsWith('.') || modSource.startsWith('/')) {
const resolved = await resolve(modSource, id);
if (resolved) {
ms.overwrite(modStart, modEnd, resolved);
}
}
}

return {
code: ms.toString(),
map: ms.generateMap()
};
}
};
}

编译后的 App.tsx 内容如下:

Preview

接着浏览器会发出带有 ?import 后缀的请求,我们在 transform 中间件进行处理:

// src/node/server/middlewares/transform.ts
// 需要增加的导入语句
import {
isJSRequest,
cleanUrl,
isCSSRequest,
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;
debug('transformMiddleware: %s', url);
if (isJSRequest(url) || isCSSRequest(url) || isImportRequest(url)) {
// 核心编译函数
let result = await transformRequest(url, serverContext);
if (!result) {
return next();
}
if (result && typeof result !== 'string') {
result = result.code;
}
res.statusCode = 200;
res.setHeader('Content-Type', 'application/javascript');
return res.end(result);
}

next();
};
}

然后补充对应的工具函数:

// src/node/utils.ts

export function isImportRequest(url: string): boolean {
return url.endsWith('?import');
}

export function removeImportQuery(url: string): string {
return url.replace(/\?import$/, '');
}

export function getShortName(file: string, root: string) {
return file.startsWith(root + '/') ? path.posix.relative(root, file) : file;
}

此时,我们就可以开发静态资源插件了。新建src/node/plugins/assets.ts,内容如下:

import { Plugin } from "../plugin";
import { ServerContext } from "../server";
import { pathExists, readFile } from "fs-extra";
import { cleanUrl, getShortName, normalizePath, removeImportQuery } from "../utils";

export function assetPlugin(): Plugin {
let serverContext: ServerContext;

return {
name: "m-vite:asset",
configureServer(s) {
serverContext = s;
},
async load(id) {
const cleanedId = removeImportQuery(cleanUrl(id));
const resolvedId = `/${getShortName(normalizePath(id), serverContext.root)}`;

if (cleanedId.endsWith(".svg")) {
return {
code: `export default "${resolvedId}"`,
};
}
},
};
}

接着来注册这个插件:

// src/node/plugins/index.ts
import { cssPlugin } from './css';
import { Plugin } from '../plugin';
import { assetPlugin } from "./assets";
import { resolvePlugin } from './resolve';
import { esbuildTransformPlugin } from './esbuild';
import { importAnalysisPlugin } from './importAnalysis';

export function resolvePlugins(): Plugin[] {
return [
resolvePlugin(),
esbuildTransformPlugin(),
importAnalysisPlugin(),
cssPlugin(),
assetPlugin(),
];
}

OK,目前我们处理完了静态资源的 import 请求,接着我们还需要处理非 import 请求,返回资源的具体内容。我们可以通过一个中间件来进行处理:

// src/node/server/middlewares/static.ts
import sirv from "sirv";
import { NextHandleFunction } from "connect";
import { isImportRequest } from "../../utils";

export function staticMiddleware(root: string): NextHandleFunction {
const serveFromRoot = sirv(root, { dev: true });
return async (req, res, next) => {
if (!req.url) {
return;
}
if (isImportRequest(req.url)) {
return;
}
serveFromRoot(req, res, next);
};
}

然后在服务中注册这个中间件:

// src/node/server/index.ts
import connect from 'connect';
import { Plugin } from "../plugin";
import { blue, green } from 'picocolors';
import { resolvePlugins } from '../plugins';
import { optimize } from '../optimizer/index';
import { staticMiddleware } from "./middlewares/static";
import { transformMiddleware } from './middlewares/transform';
import { indexHtmlMiddleware } from "./middlewares/indexHtml";
import { createPluginContainer, PluginContainer } from '../pluginContainer';

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

export async function startDevServer() {
const app = connect();
const root = process.cwd();
const startTime = Date.now();

const plugins = resolvePlugins();
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);
}
}


app.use(transformMiddleware(serverContext));
app.use(indexHtmlMiddleware(serverContext));
app.use(staticMiddleware(serverContext.root));

app.listen(3000, async () => {
await optimize(root);

console.log(
green('🚀 No-Bundle 服务已经成功启动!'),
`耗时: ${Date.now() - startTime}ms`
);
console.log(`> 本地访问路径: ${blue('http://localhost:3000')}`);
});
}

现在,你可以通过pnpm dev启动 playground 项目,在浏览器中访问,可以发现 svg 图片已经能够成功显示了

二、测试


备注: 基于插件机制,来实现 Vite 的核心编译能力。在test项目下执行pnpm dev,在浏览器里面访问http://localhost:3000,你可以在网络面板中发现 main.tsx 的内容以及被编译为下面这样:

Preview

同时,页面内容也能被渲染出来了