官方模拟版
一、实现
备注: 基于插件机制,来实现 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
的内容已经成功返回:
不过当前的页面并没有任何内容,因为 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
状态码:
因此,我们需要开发一个路径解析插件,对请求的路径进行处理,使之能转换真实文件系统中的路径。你可以新建文件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
语法,可以利用 Esbuild
的 Transform 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
请求,我们可以在 TSX
的 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 (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
内容如下:
接着浏览器会发出带有 ?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
的内容以及被编译为下面这样:
同时,页面内容也能被渲染出来了