Vite: 实现 no-bundle 开发服务 (2)

概述

基于前文 Vite: 实现 no-bundle 开发服务 (1) 我们基于下面的导图继续实现 no-bundle 构建服务

接下来我们需要完成如下的模块:

  • CSS 编译插件
  • 静态资源加载插件
  • 模块依赖图开发,并在 transform 中间件中接入
  • HMR 服务端代码开发
  • HMR 客户端代码开发

CSS 编译插件

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

// playground/src/main.tsx
import "./index.css";

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

// src/node/server/middlewares/transform.ts
// 需要增加的导入语句
+ import { 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);// transform JS request- if (isJSRequest(url)) {+ if (isJSRequest(url) || isCSSRequest(url)) {// 后续代码省略}next();};
}

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

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

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

import { readFile } from "fs-extra";
import { Plugin } from "../plugin";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")) {// 包装成 JS 模块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";
export function resolvePlugins(): Plugin[] {return [// 省略前面的插件
+ cssPlugin(),];
}

现在,你可以通过 pnpm dev 来启动 playground 项目,不过在启动之前,需要保证 TSX
文件已经引入了对应的 CSS 文件:

// playground/src/main.tsx
import "./index.css";
// playground/src/App.tsx
import "./App.css";

在启动项目后,打开浏览器进行访问,可以看到样式已经正常生效

静态资源加载

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

// playground/src/App.tsx
import logo from "./logo.svg";
function App() {return (<img className="App-logo" src={logo} alt="" />)
}

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

  • import 请求。如 import logo from “./logo.svg” 。
  • 资源内容请求。如 img 标签将资源 url 填入 src,那么浏览器会请求具体的资源内容。

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

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

// src/node/plugins/importAnalysis.ts
async transform(code, id) {// 省略前面的代码for (const importInfo of imports) {const { s: modStart, e: modEnd, n: modSource } = importInfo;if (!modSource) continue;
+	 // 静态资源
+	 if (modSource.endsWith(".svg")) {
+		 // 加上 ?import 后缀
+		 const resolvedUrl = path.join(path.dirname(id), modSource);
+		 ms.overwrite(modStart, modEnd, `${resolvedUrl}?import`);
+		 continue;}}
}

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

在这里插入图片描述
接着浏览器会发出带有 ?import 后缀的请求,我们在 transform 中间件进行处理:

// src/node/server/middlewares/transform.ts
// 需要增加的导入语句
+ import { 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);// transform JS request
- if (isJSRequest(url) || isCSSRequest(url)) {
+ if (isJSRequest(url) || isCSSRequest(url) || isImportRequest(url)) {// 后续代码省略}next();};
}

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

// src/node/utils.ts
export function isImportRequest(url: string): boolean {return url.endsWith("?import");
}

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

import {Plugin
} from "../plugin";
import {cleanUrl,removeImportQuery
} from "../utils";
export function assetPlugin(): Plugin {return {name: "m-vite:asset",async load(id) {const cleanedId = removeImportQuery(cleanUrl(id));// 这里仅处理 svgif (cleanedId.endsWith(".svg")) {return {// 包装成一个 JS 模块code: `export default "${cleanedId}"`,};}},};
}

接着来注册这个插件:

// src/node/plugins/index.ts
+ import { assetPlugin } from "./assets";
export function resolvePlugins(): Plugin[] {return [// 省略前面的插件
+ assetPlugin(),];
}

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

// src/node/server/middlewares/static.ts
import {NextHandleFunction
} from "connect";
import {isImportRequest
} from "../../utils";
// 一个用于加载静态资源的中间件
import sirv from "sirv";
export function staticMiddleware(): NextHandleFunction {const serveFromRoot = sirv("/", {dev: true});return async (req, res, next) => {if (!req.url) {return;}// 不处理 import 请求if (isImportRequest(req.url)) {return;}serveFromRoot(req, res, next);};
}

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

// src/node/server/index.ts
// 需要添加的引入语句
+ import { staticMiddleware } from "./middlewares/static";
export async function startDevServer() {// 前面的代码省略
+ app.use(staticMiddleware());app.listen(3000, async () => {// 省略实现});
}

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

其实不光是 svg 文件,几乎所有格式的静态资源都可以按照如上的思路进行处理:

  • 通过加入 ?import 后缀标识 import 请求,返回将静态资源封装成一个 JS 模块,即 export default xxx 的形式,导出资源的真实地址。
  • 对非 import 请求,响应静态资源的具体内容,通过 Content-Type 响应头告诉浏览
    器资源的类型(这部分工作 sirv 中间件已经帮我们做了)。

模块依赖图开发

模块依赖图在 no-bundle 构建服务中是一个不可或缺的数据结构,一方面可以存储各个模块的信息,用于记录编译缓存,另一方面也可以记录各个模块间的依赖关系,用于实现HMR。

接下来我们来实现模块依赖图,即 ModuleGraph 类,新建 src/node/ModuleGraph.ts ,内容如下

import {PartialResolvedId,TransformResult
} from "rollup";
import {cleanUrl
} from "./utils";
export class ModuleNode {// 资源访问 urlurl: string;// 资源绝对路径id: string | null = null;importers = new Set < ModuleNode > ();importedModules = new Set < ModuleNode > ();transformResult: TransformResult | null = null;lastHMRTimestamp = 0;constructor(url: string) {this.url = url;}
}
export class ModuleGraph {// 资源 url 到 ModuleNode 的映射表urlToModuleMap = new Map < string, ModuleNode > ();// 资源绝对路径到 ModuleNode 的映射表idToModuleMap = new Map < string, ModuleNode > ();constructor(private resolveId: (url: string) => Promise < PartialResolvedId | null >) {}getModuleById(id: string): ModuleNode | undefined {return this.idToModuleMap.get(id);}async getModuleByUrl(rawUrl: string): Promise < ModuleNode | undefined > {const {url} = await this._resolve(rawUrl);return this.urlToModuleMap.get(url);}async ensureEntryFromUrl(rawUrl: string): Promise < ModuleNode > {const {url,resolvedId} = await this._resolve(rawUrl);// 首先检查缓存if (this.urlToModuleMap.has(url)) {return this.urlToModuleMap.get(url) as ModuleNode;}// 若无缓存,更新 urlToModuleMap 和 idToModuleMapconst mod = new ModuleNode(url);mod.id = resolvedId;this.urlToModuleMap.set(url, mod);this.idToModuleMap.set(resolvedId, mod);return mod;}async updateModuleInfo(mod: ModuleNode,importedModules: Set < string | ModuleNode >) {const prevImports = mod.importedModules;for (const curImports of importedModules) {const dep =typeof curImports === "string" ?await this.ensureEntryFromUrl(cleanUrl(curImports)) :curImports;if (dep) {mod.importedModules.add(dep);dep.importers.add(mod);}}// 清除已经不再被引用的依赖for (const prevImport of prevImports) {if (!importedModules.has(prevImport.url)) {prevImport.importers.delete(mod);}}}// HMR 触发时会执行这个方法invalidateModule(file: string) {const mod = this.idToModuleMap.get(file);if (mod) {// 更新时间戳mod.lastHMRTimestamp = Date.now();mod.transformResult = null;mod.importers.forEach((importer) => {this.invalidateModule(importer.id!);});}}private async _resolve(url: string): Promise < {url: string;resolvedId: string} > {const resolved = await this.resolveId(url);const resolvedId = resolved ? .id || url;return {url,resolvedId};}
}

对于代码细节这里也不再赘述。接着我们看看如何将这个 ModuleGraph 接入到目前的架构中。

首先在服务启动前,我们需要初始化 ModuleGraph 实例:

// src/node/server/index.ts
+ import { ModuleGraph } from "../ModuleGraph";export interface ServerContext {root: string;pluginContainer: PluginContainer;app: connect.Server;plugins: Plugin[];
+ moduleGraph: ModuleGraph;
}
export async function startDevServer() {
+ const moduleGraph = new ModuleGraph((url) => pluginContainer.resolveId(url));const pluginContainer = createPluginContainer(plugins);const serverContext: ServerContext = {root: process.cwd(),app,pluginContainer,plugins,+ moduleGraph};
}

然后在加载完模块后,也就是调用插件容器的 load 方法后,我们需要通过 ensureEntryFromUrl 方法注册模块:

// src/node/server/middlewares/transform.ts
let code = await pluginContainer.load(resolvedResult.id);
if (typeof code === "object" && code !== null) {code = code.code;
}
+ const { moduleGraph } = serverContext;
+ mod = await moduleGraph.ensureEntryFromUrl(url);

当我们对 JS 模块分析完 import 语句之后,需要更新模块之间的依赖关系:

// src/node/plugins/importAnalysis.ts
export function importAnalysis() {return {transform(code: string, id: string) {// 省略前面的代码
+     const { moduleGraph } = serverContext; 
+     const curMod = moduleGraph.getModuleById(id) !; 
+     const importedModules = new Set < string > ();for (const importInfo of imports) {// 省略部分代码if (BARE_IMPORT_RE.test(modSource)) {// 省略部分代码
+         importedModules.add(bundlePath);} else if (modSource.startsWith(".") || modSource.startsWith("/")) {const resolved = await resolve(modSource, id);if (resolved) {ms.overwrite(modStart, modEnd, resolved); +importedModules.add(resolved);}}} 
+     moduleGraph.updateModuleInfo(curMod, importedModules);// 省略后续 return 代码}}
}

现在,一个完整的模块依赖图就能随着 JS 请求的到来而不断建立起来了。另外,基于现在的模块依赖图,我们也可以记录模块编译后的产物,并进行缓存。让我们回到transform 中间件中:

export async function transformRequest(url: string,serverContext: ServerContext
) {const {moduleGraph,pluginContainer} = serverContext;url = cleanUrl(url); 
+ let mod = await moduleGraph.getModuleByUrl(url); 
+ if (mod && mod.transformResult) {
+   return mod.transformResult; 
+ }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;}mod = await moduleGraph.ensureEntryFromUrl(url);if (code) {transformResult = await pluginContainer.transform(code as string,resolvedResult ? .id);}} 
+ if (mod) {
+    mod.transformResult = transformResult;
+ }return transformResult;
}

在搭建好模块依赖图之后,我们把目光集中到最重要的部分——HMR 上面。

HMR 服务端

HMR 在服务端需要完成如下的工作:

  • 创建文件监听器,以监听文件的变动
  • 创建 WebSocket 服务端,负责和客户端进行通信
  • 文件变动时,从 ModuleGraph 中定位到需要更新的模块,将更新信息发送给客户端

首先,我们来创建文件监听器:

// src/node/server/index.ts
import chokidar, { FSWatcher } from "chokidar";
export async function startDevServer() {const watcher = chokidar.watch(root, {ignored: ["**/node_modules/**", "**/.git/**"],ignoreInitial: true,});
}

接着初始化 WebSocket 服务端,新建 src/node/ws.ts ,内容如下:

import connect from "connect";
import { red } from "picocolors";
import {WebSocketServer,WebSocket
} from "ws";
import {HMR_PORT
} from "./constants";
export function createWebSocketServer(server: connect.Server): {send: (msg: string) => void;close: () => void;
} {let wss: WebSocketServer;wss = new WebSocketServer({port: HMR_PORT});wss.on("connection", (socket) => {socket.send(JSON.stringify({type: "connected"}));});wss.on("error", (e: Error & {code: string}) => {if (e.code !== "EADDRINUSE") {console.error(red(`WebSocket server error:\n${e.stack || e.message}`));}});return {send(payload: Object) {const stringified = JSON.stringify(payload);wss.clients.forEach((client) => {if (client.readyState === WebSocket.OPEN) {client.send(stringified);}});},close() {wss.close();},};
}

同时定义 HMR_PORT 常量:

// src/node/constants.ts
export const HMR_PORT = 24678;

接着我们将 WebSocket 服务端实例加入 no-bundle 服务中:

// src/node/server/index.ts
export interface ServerContext {root: string;pluginContainer: PluginContainer;app: connect.Server;plugins: Plugin[];moduleGraph: ModuleGraph; 
+  ws: { send: (data: any) => void;close: () => void }; 
+  watcher: FSWatcher;
}export async function startDevServer() {
+  // WebSocket 对象
+  const ws = createWebSocketServer(app);// // 开发服务器上下文const serverContext: ServerContext = {root: process.cwd(),app,pluginContainer,plugins,moduleGraph,
+    ws,
+    watcher};
}

下面我们来实现当文件变动时,服务端具体的处理逻辑,新建 src/node/hmr.ts :

import {ServerContext
} from "./server/index";
import {blue,green
} from "picocolors";
import {getShortName
} from "./utils";
export function bindingHMREvents(serverContext: ServerContext) {const {watcher,ws,root} = serverContext;watcher.on("change", async (file) => {console.log(`${blue("[hmr]")} ${green(file)} changed`);const {moduleGraph} = serverContext;// 清除模块依赖图中的缓存await moduleGraph.invalidateModule(file);// 向客户端发送更新信息ws.send({type: "update",updates: [{type: "js-update",timestamp: Date.now(),path: "/" + getShortName(file, root),acceptedPath: "/" + getShortName(file, root),}, ],});});
}

注意补充一下缺失的工具函数:

// src/node/utils.ts
export function getShortName(file: string, root: string) {return file.startsWith(root + "/") ? path.posix.relative(root, file) : file;
}

接着我们在服务中添加如下代码:

// src/node/server/index.ts
+ import { bindingHMREvents } from "../hmr";
// 开发服务器上下文
const serverContext: ServerContext = {root: process.cwd(),app,pluginContainer,plugins,moduleGraph,ws,watcher,
};
+ bindingHMREvents(serverContext);

HMR 客户端

HMR 客户端指的是我们向浏览器中注入的一段 JS 脚本,这段脚本中会做如下的事情:

  • 创建 WebSocket 客户端,用于和服务端通信
  • 在收到服务端的更新信息后,通过动态 import 拉取最新的模块内容,执行 accept更新回调
  • 暴露 HMR 的一些工具函数,比如 import.meta.hot 对象的实现

首先我们来开发客户端的脚本内容,你可以新建 src/client/client.ts 文件,然后在 tsup.config.ts 中增加如下的配置:

import { defineConfig } from "tsup";
export default defineConfig({entry: {index: "src/node/cli.ts",
+   client: "src/client/client.ts",},
});

注: 改动 tsup 配置之后,为了使最新配置生效,你需要在 mini-vite 项目中执行 pnpm start 重新进行构建。

客户端脚本的具体实现如下:

// src/client/client.ts
console.log("[vite] connecting...");// 1. 创建客户端 WebSocket 实例
// 其中的 __HMR_PORT__ 之后会被 no-bundle 服务编译成具体的端口号
const socket = new WebSocket(`ws://localhost:__HMR_PORT__`, "vite-hmr");// 2. 接收服务端的更新信息
socket.addEventListener("message", async ({ data }) => {handleMessage(JSON.parse(data)).catch(console.error);
});// 3. 根据不同的更新类型进行更新
async function handleMessage(payload: any) {switch (payload.type) {case "connected":console.log(`[vite] connected.`);// 心跳检测setInterval(() => socket.send("ping"), 1000);break;case "update":// 进行具体的模块更新payload.updates.forEach((update: Update) => {if (update.type === "js-update") {// 具体的更新逻辑,后续来开发}});break;}
}

关于客户端具体的 JS 模块更新逻辑和工具函数的实现,你暂且不用过于关心。我们先把这段比较简单的 HMR 客户端代码注入到浏览器中,首先在新建 src/node/plugins/clientInject.ts ,内容如下:

import {CLIENT_PUBLIC_PATH,HMR_PORT
} from "../constants";
import {Plugin
} from "../plugin";
import fs from "fs-extra";
import path from "path";
import {ServerContext
} from "../server/index";
export function clientInjectPlugin(): Plugin {let serverContext: ServerContext;return {name: "m-vite:client-inject",configureServer(s) {serverContext = s;},resolveId(id) {if (id === CLIENT_PUBLIC_PATH) {return {id};}return null;},async load(id) {// 加载 HMR 客户端脚本if (id === CLIENT_PUBLIC_PATH) {const realPath = path.join(serverContext.root,"node_modules","mini-vite","dist","client.mjs");const code = await fs.readFile(realPath, "utf-8");return {// 替换占位符code: code.replace("__HMR_PORT__", JSON.stringify(HMR_PORT)),};}},transformIndexHtml(raw) {// 插入客户端脚本// 即在 head 标签后面加上 <script type="module" src="/@vite/client"></script>// 注: 在 indexHtml 中间件里面会自动执行 transformIndexHtml 钩子return raw.replace(/(<head[^>]*>)/i,`$1<script type="module" src="${CLIENT_PUBLIC_PATH}"></script>`);},};
}

同时添加相应的常量声明:

// src/node/constants.ts
export const CLIENT_PUBLIC_PATH = "/@vite/client";

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

// src/node/plugins/index.ts
+ import { clientInjectPlugin } from './clientInject';
export function resolvePlugins(): Plugin[] {return [
+  clientInjectPlugin()// 省略其它插件]
}

需要注意的是, clientInject 插件最好放到最前面的位置,以免后续插件的 load 钩子干扰客户端脚本的加载。

接下来你可以在 playground 项目下执行 pnpm dev ,然后查看页面,可以发现控制台出现了如下的 log 信息:

在这里插入图片描述

OK,接下来我们就来继续完善客户端脚本的具体实现。

值得一提的是,之所以我们可以在代码中编写类似 import.meta.hot.xxx 之类的方法,是因为 Vite 帮我们在模块最顶层注入了 import.meta.hot 对象,而这个对象由createHotContext 来实现,具体的注入代码如下所示:

import { createHotContext as __vite__createHotContext } from "/@vite/client";
import.meta.hot = __vite__createHotContext("/src/App.tsx");

下面我们在 import 分析插件中做一些改动,实现插入这段代码的功能:

import { init, parse } from "es-module-lexer";
import {BARE_IMPORT_RE,CLIENT_PUBLIC_PATH,PRE_BUNDLE_DIR,
} from "../constants";
import {cleanUrl,
+ getShortName,isJSRequest,
} from "../utils";
import MagicString from "magic-string";
import path from "path";
import { Plugin } from "../plugin";
import { ServerContext } from "../server/index";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) || isInternalRequest(id)) {return null;}await init;const importedModules = new Set<string>();const [imports] = parse(code);const ms = new MagicString(code);
+     const resolve = async (id: string, importer?: string) => {
+       const resolved = await this.resolve(
+         id,
+         importer
+       );
+       if (!resolved) {
+         return;
+       }
+       const cleanedId = cleanUrl(resolved.id);
+       const mod = moduleGraph.getModuleById(cleanedId);
+       let resolvedId = `/${getShortName(resolved.id, serverContext.root)}`;
+       if (mod && mod.lastHMRTimestamp > 0) {
+         resolvedId += "?t=" + mod.lastHMRTimestamp;
+       }
+       return resolvedId;
+     };const { moduleGraph } = serverContext;const curMod = moduleGraph.getModuleById(id)!;for (const importInfo of imports) {const { s: modStart, e: modEnd, n: modSource } = importInfo;if (!modSource || isInternalRequest(modSource)) continue;// 静态资源if (modSource.endsWith(".svg")) {// 加上 ?import 后缀const resolvedUrl = path.join(path.dirname(id), modSource);ms.overwrite(modStart, modEnd, `${resolvedUrl}?import`);continue;}// 第三方库: 路径重写到预构建产物的路径if (BARE_IMPORT_RE.test(modSource)) {const bundlePath = path.join(serverContext.root,PRE_BUNDLE_DIR,`${modSource}.js`);ms.overwrite(modStart, modEnd, bundlePath);importedModules.add(bundlePath);} else if (modSource.startsWith(".") || modSource.startsWith("/")) {
+         const resolved = await resolve(modSource, id);if (resolved) {ms.overwrite(modStart, modEnd, resolved);importedModules.add(resolved);}}}// 只对业务源码注入
+    if (!id.includes("node_modules")) {
+      // 注入 HMR 相关的工具函数
+      ms.prepend(
+       `import { createHotContext as __vite__createHotContext } from "${CLIENT_PUBLIC_PAT
+         `import.meta.hot = __vite__createHotContext(${JSON.stringify(
+           cleanUrl(curMod.url)
+          )});`
+      );
+   }moduleGraph.updateModuleInfo(curMod, importedModules);return {code: ms.toString(),map: ms.generateMap(),};},};
}

接着启动 playground,打开页面后你可以发现 import.meta.hot 的实现代码已经被成功插入:

在这里插入图片描述

现在,我们回到客户端脚本的实现中,来开发 createHotContext 这个工具方法:

interface HotModule {id: string;callbacks: HotCallback[];
}
interface HotCallback {deps: string[];fn: (modules: object[]) => void;
}
// HMR 模块表
const hotModulesMap = new Map <string, HotModule> ();
// 不在生效的模块表
const pruneMap = new Map <string, (data: any) => void | Promise < void >> ();
export const createHotContext = (ownerPath: string) => {const mod = hotModulesMap.get(ownerPath);if (mod) {mod.callbacks = [];}function acceptDeps(deps: string[], callback: any) {const mod: HotModule = hotModulesMap.get(ownerPath) || {id: ownerPath,callbacks: [],};// callbacks 属性存放 accept 的依赖、依赖改动后对应的回调逻辑mod.callbacks.push({deps,fn: callback,});hotModulesMap.set(ownerPath, mod);}return {accept(deps: any, callback ? : any) {// 这里仅考虑接受自身模块更新的情况// import.meta.hot.accept()if (typeof deps === "function" || !deps) {acceptDeps([ownerPath], ([mod]) => deps && deps(mod));}},// 模块不再生效的回调// import.meta.hot.prune(() => {})prune(cb: (data: any) => void) {pruneMap.set(ownerPath, cb);},};
};

在 accept 方法中,我们会用 hotModulesMap 这张表记录该模块所 accept 的模块,以及
accept 的模块更新之后回调逻辑。

接着,我们来开发客户端热更新的具体逻辑,也就是服务端传递更新内容之后客户端如何
来派发更新。实现代码如下:

async function fetchUpdate({path,timestamp
}: Update) {const mod = hotModulesMap.get(path);if (!mod) return;const moduleMap = new Map();const modulesToUpdate = new Set < string > ();modulesToUpdate.add(path);await Promise.all(Array.from(modulesToUpdate).map(async (dep) => {const [path, query] = dep.split(`?`);try {// 通过动态 import 拉取最新模块const newMod = await import(path + `?t=${timestamp}${query ? `&${query}` : ""}`);moduleMap.set(dep, newMod);} catch (e) {}}));return () => {// 拉取最新模块后执行更新回调for (const {deps,fn} of mod.callbacks) {fn(deps.map((dep: any) => moduleMap.get(dep)));}console.log(`[vite] hot updated: ${path}`);};
}

现在,我们可以来初步测试一下 HMR 的功能,你可以暂时将 main.tsx 的内容换成下面这样:

import React from "react";
import ReactDOM from "react-dom";
import "./index.css";const App = () => <div>hello 123123</div>;
ReactDOM.render(<App />, document.getElementById("root"));// @ts-ignore
import.meta.hot.accept(() => {ReactDOM.render(<App />, document.getElementById("root"));
});

启动 playground,然后打开浏览器,可以看到如下的文本:

在这里插入图片描述
同时,当你再次刷新页面,看到的仍然是最新的页面内容。这一点非常重要,之所以能达到这样的效果,是因为我们在文件改动后会调用 ModuleGraph 的 invalidateModule方法,这个方法会清除热更模块以及所有上层引用方模块的编译缓存:

// 方法实现
invalidateModule(file: string) {const mod = this.idToModuleMap.get(file);if (mod) {mod.lastHMRTimestamp = Date.now();mod.transformResult = null;mod.importers.forEach((importer) => {this.invalidateModule(importer.id!);});}
}

这样每次经过 HMR 后,再次刷新页面,渲染出来的一定是最新的模块内容。当然,我们也可以对 CSS 实现热更新功能,在客户端脚本中添加如下的工具函数:

const sheetsMap = new Map();
export function updateStyle(id: string, content: string) {let style = sheetsMap.get(id);if (!style) {// 添加 style 标签style = document.createElement("style");style.setAttribute("type", "text/css");style.innerHTML = content;document.head.appendChild(style);} else {// 更新 style 标签内容style.innerHTML = content;}sheetsMap.set(id, style);
}
export function removeStyle(id: string): void {const style = sheetsMap.get(id);if (style) {document.head.removeChild(style);}sheetsMap.delete(id);
}

紧接着我们调整一下 CSS 编译插件的代码:

import { readFile } from "fs-extra";
import { CLIENT_PUBLIC_PATH } from "../constants";
import { Plugin } from "../plugin";
import { ServerContext } from "../server";
import { getShortName } from "../utils";export function cssPlugin(): Plugin {let serverContext: ServerContext;return {name: "m-vite:css",configureServer(s) {serverContext = s;},load(id) {if (id.endsWith(".css")) {return readFile(id, "utf-8");}},// 主要变动在 transform 钩子中async transform(code, id) {if (id.endsWith(".css")) {// 包装成 JS 模块const jsContent = `import { createHotContext as __vite__createHotContext } from "${CLIENT_PUBLIC_PATH}";import.meta.hot = __vite__createHotContext("/${getShortName(id, serverContext.root)}");import { updateStyle, removeStyle } from "${CLIENT_PUBLIC_PATH}"const id = '${id}';const css = '${code.replace(/\n/g, "")}';updateStyle(id, css);import.meta.hot.accept();export default css;import.meta.hot.prune(() => removeStyle(id));`.trim();return {code: jsContent,};}return null;},};
}

最后,你可以重启 playground 项目,本地尝试修改 CSS 代码,可以看到类似如下的热更新效果:

在这里插入图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/866438.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

泰国内部安全行动司令部数据泄露

BreachForums 论坛的一名成员宣布发生一起重大数据泄露事件&#xff0c;涉及泰国内部安全行动司令部 (ISOC)&#xff0c;该机构被称为泰国皇家武装部队的政治部门。 目前&#xff0c;我们无法准确确认此次泄露的真实性&#xff0c;因为该组织尚未在其网站上发布有关该事件的任…

数据库管理-第217期 Oracle的高可用-02(20240704)

数据库管理217期 2024-07-04 数据库管理-第217期 Oracle的高可用-02&#xff08;20240704&#xff09;1 GDS简介2 GDS架构2.1 全局数据服务池2.2 全局数据服务域2.3 全局服务管理2.4 全局数据服务目录2.5 Oracle通知服务 3 GDS简图3.1 负载均衡3.2 只读服务失败转移3.3 多主复制…

项目基础知识

1.JDBC编程和MySQL数据库 数据库的连接&#xff08;以前写qq项目时的代码&#xff09; package com.wu.Util; import java.sql.*; public class JDBCUtil {private static JDBCUtil jdbcUtil null;private JDBCUtil() {}public static JDBCUtil getJdbcUtil() {if (jdbcUtil…

剧本杀小程序:助力商家发展,提高游戏体验

近几年&#xff0c;剧本杀游戏已经成为了当下年轻人娱乐的游戏社交方式。与其他游戏相比&#xff0c;剧本杀游戏具有强大的社交性&#xff0c;玩家在游戏中既可以推理玩游戏&#xff0c;也可以与其他玩家交流互动&#xff0c;提高玩家的游戏体验感。 随着互联网的发展&#xf…

Vue通过Key管理状态

Vue通过Key管理状态 Vue 默认按照“就地更新”的策略来更新&#xff0c;通过 v-for 渲染的元素列表。当数据项的顺序改变时&#xff0c;Vue 不会随之移动 DOM 元素的顺序&#xff0c;而是就地更新每个元素&#xff0c;确保它们在原本指定的索引位置上渲染。为了给 Vue 一个提示…

VIO(Virtual_Input_Output) IP 使用笔记

VIO&#xff08;Virtual Input/Output&#xff09;IP 核&#xff0c;即虚拟输入输出 IP&#xff0c;可以通过调试界面模拟 IO 的变化&#xff0c;这可以在板子没有按键等外设、或外设不足的情况下&#xff0c;来模拟外部输入。然而网上关于 VIO 的教程都说的不是很清楚&#xf…

html高级篇

1.2D转换 转换&#xff08;transform&#xff09;你可以简单理解为变形 移动&#xff1a;translate 旋转&#xff1a;rotate 缩放&#xff1a;sCale 移动&#xff1a;translate 1.移动具体值 /* 移动盒子的位置&#xff1a; 定位 盒子的外边距 2d转换移动 */div {width…

【python】OpenCV—Nighttime Low Illumination Image Enhancement

文章目录 1 背景介绍2 代码实现3 原理分析4 效果展示5 附录np.ndindexnumpy.ravelnumpy.argsortcv2.detailEnhancecv2.edgePreservingFilter 1 背景介绍 学习参考来自&#xff1a;OpenCV基础&#xff08;24&#xff09;改善夜间图像的照明 源码&#xff1a; 链接&#xff1a…

Linux 文件系统以及日志管理

一、inode 与block 1. inode 与block详解 在文件存储硬盘上&#xff0c;硬盘的最小存储单位叫做“扇区”&#xff0c;每个为512字节。 操作系统读取硬盘的时候&#xff0c;不会一个个扇区地读取&#xff0c;这样效率太低&#xff0c;而是一次性连续读取多个扇区,即一次性读取…

PyMuPDF 操作手册 - 09 API - Page属性方法和简短说明

文章目录 一、Page属性方法和简短说明一、Page属性方法和简短说明 https://pymupdf.readthedocs.io/en/latest/page.html Method/Attribute属性方法Short Description简短说明Page.add_caret_annot()仅限 PDF:添加插入符号注释Page.add_circle_annot()仅限 PDF:添加圆圈…

微服务粒度难题:找到合适的微服务大小

序言 在微服务架构风格中&#xff0c;微服务通常设计遵循SRP&#xff08;单一职责原则&#xff09;&#xff0c;作为一个独立部署的软件单元&#xff0c;专注于做一件事&#xff0c;并且做到极致。作为开发人员&#xff0c;我们常常倾向于在没有考虑为什么的情况下尽可能地将服…

头歌资源库(20)最大最小数

一、 问题描述 二、算法思想 使用分治法&#xff0c;可以将数组递归地分割成两部分&#xff0c;直到数组长度为1或2。然后比较这两部分的最大、次大、次小、最小数&#xff0c;最终得到整个数组中的最大两个数和最小两个数。 算法步骤如下&#xff1a; 定义一个函数 findMinM…

Java - 程序员面试笔记记录 实现 - Part3

4.1 线程与进程 线程是程序执行的最小单元&#xff0c;一个进程可以拥有多个线程&#xff0c;各个线程之间共享程序的内存空间以及一些进程级资源&#xff0c;但拥有自己的栈空间。 4.3 Java 多线程 方法一&#xff1a;继承 Thread 类&#xff0c;重写 run 方法&#xff1b;…

二分法查找有序表的通用算法(可查链表,数组,字符串...等等)

find_binary函数 注意事项&#xff1a; &#xff08;1&#xff09;你设计的迭代器模板中必须有using value_type T&#xff0c;且有加减运算功能&#xff0c;其本上能与C标准库std中一样。 &#xff08;2&#xff09;集合必须是有序的。 下面是函数代码&#xff1a; /// &…

一次建表语句触发的ORA-600报错分析

​ 某次在客户Oracle数据库执行一条建表语句时&#xff0c;报出ORA-600错误。 报错代码如下&#xff1a; ORA-00600: 内部错误代码, 参数: [rwoirw: check ret val], [], [], [], [], [], [], [], [], [], [], [] 相关的建表语句如下&#xff1a; ​ 在报错发生后&#xff0c;…

Android studio开发入门教程详解(复习)

引言 本文为个人总结Android基础知识复习笔记。如有不妥之处&#xff0c;敬请指正。后续将持续更新更多知识点。 文章目录 引言UITextView文本基本用法实际应用常用属性和方法 Button按钮处理点击事件 EditText输入框基本属性高级特性 ImageView图片ImageView的缩放模式 Prog…

Qt中udp指令,大小端,帧头帧尾实际示例

前言 虽然QT中&#xff0c;udp发送和接收&#xff0c;其实非常简单&#xff0c;但是实际工作中&#xff0c;其实涉及到帧头帧尾&#xff0c;字节对齐&#xff0c;以及大小端序的问题。比如网络中&#xff0c;正规的一般都是大端序&#xff0c;而不是小端序&#xff0c;大多数的…

不到 5 元的随身 WiFi 刷 Debian 系统 做轻量家庭服务器

本文首发于只抄博客,欢迎点击原文链接了解更多内容。 前言 前不久在某宝均价 5 元买了两个随身 WiFi,拆机看了看丝印都是 MSM8916 ,正好是红米 2 同款的骁龙 410 的芯片,可以刷个 Debian 当作家庭服务器来跑一些轻量的服务。 不过手气不是很好,两个都是 512M + 4G 的配置…

单机安装基于LNMP结构的WordPress网站 web与数据库服务分离

网站的类型&#xff1a; Jave:LNMT PHP:LNMP Python: LNMU 项目部署&#xff1a; 1.项目的类型&#xff08;项目的开发语言&#xff09; 2.项目运营平台的技术选择 3.尽快让项目运行起来 all in one部署 4. 架构的优化 配置ansible管理环境 配置nginx 配置数据库服务…

day11_homework_need2submit

Homework 编写—个将ts或mp4中视频文件解码到yuv的程序 yuv数据可以使用如下命令播放: ffplay -i output yuv-pix_fmt yuv420p-s 1024x436 要求: ffmpeg解析到avpacket并打印出pts和dts字段完成解码到avframe并打印任意字段完成yuv数据保存 // teminal orders on bash cd ex…