1、安装esbuild插件
powershell
npm i --save-dev esbuild
# 或
yarn add -D esbuild
2、编写client.tsx,生成web component入口文件
typescript
import React from "react";
import { hydrateRoot } from "react-dom/client";
import { XXXXExportApp } from "./XXXXExportApp";
import type { XXXXExportData } from "../XXXX";
declare global {
interface Window {
__XXXX_INITIAL_DATA__?: XXXXExportData;
}
}
const root = document.getElementById("XXXX-root");
const initialData = window.__XXXX_INITIAL_DATA__;
if (root && initialData) {
hydrateRoot(root, <XXXXExportApp data={initialData} />);
}
3、编写XXXXExportApp.tsx,该文件为导入内容文件,尽量导入原本页面组件,避免维护两套代码
其中 PageContent 使用和页面内容共用,有些代码需要处理,避免导出报错
typescript
import React from "react";
import type { XXXXExportData } from "../XXXX";
import PageContent from "../PageContent/PageContent";
interface Props {
data: XXXXExportData;
}
export const XXXXExportApp: React.FC<Props> = ({ data }) => {
return (
<div className="page">
<div className="muted" style={{ marginBottom: 12 }}>
<h1>{data.title}</h1>
{data.exportTimeLabel}: {data.generatedAt}
</div>
<PageContent {...data.pageContent} />
</div>
);
};
4、编写esbuild配置脚本,用于导出web component
将页面组件打包成 iife 格式的 web component
typescript
import { build } from "esbuild";
import { lessLoader } from 'esbuild-plugin-less';
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const entry = path.resolve(__dirname, "xxxx/export/client.tsx");
const outfile = path.resolve(__dirname, "xxxx.js");
await build({
entryPoints: [entry],
outfile,
bundle: true,
format: "iife",
platform: "browser",
target: ["es2019"],
minify: true,
jsx: "automatic",
sourcemap: false,
// 关键:不要 external react/react-dom/echarts
external: [],
plugins: [
lessLoader(), // 直接启用插件,less解析插件
],
loader: { // 图片转base64配置
'.png': 'dataurl', // 将所有 .png 导入转为 Base64
'.jpg': 'dataurl',
'.svg': 'dataurl',
},
define: {
"process.env.NODE_ENV": '"production"',
"process.env.__NEXT_IMAGE_OPTS": '"null"',
"process.env": "{}",
"process": "{}",
},
// 可选:让全局 this/window 语义更稳
globalName: "XXXXExportClient",
});
console.log(outfile);
5、在package.json 添加构建脚本,执行构建命令
javascript
{
...
"scripts": {
...
"build:xx-client": "node scripts/xxxx-export-client.mjs"
},
...
}
powershell
npm run build:xx-client
# 或
yarn build:xx-client
6、编写导出函数
typescript
import React from "react";
import { renderToString } from "react-dom/server";
import { XXXXExportApp } from "./export/XXXXExportApp";
import { PageContentProps } from "./PageContent/PageContent";
export interface XXXXExportData {
locale: "zh" | "en";
title: string;
exportTimeLabel: string;
generatedAt: string;
messages: Record<string, string>;
pageContent: PageContentProps;
}
const serializeSafe = (value: unknown): string => {
// 防止 </script> 提前闭合 + 常见 XSS 字符
return JSON.stringify(value)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
};
export const exportXXXXHtml = async (data: XXXXExportData): Promise<string> => {
async function getInlineClientJsFromRemote(url: string): Promise<string> {
const res = await fetch(url, { method: "GET" });
if (!res.ok) {
throw new Error(`Fetch client js failed: ${res.status} ${res.statusText}`);
}
const jsText = await res.text();
// 防止内联 script 被意外闭合
return jsText.replace(/<\/script>/gi, "<\\/script>");
};
async function getInlineClientCssFromRemote(url: string): Promise<string> {
const res = await fetch(url, { method: "GET" });
if (!res.ok) throw new Error(`load css failed: ${res.status}`);
const cssText = await res.text();
const globalCssText = Array.from(document.styleSheets).map((s) => {
try {
return Array.from((s as CSSStyleSheet).cssRules).map((r) => r.cssText).join("\n");
} catch {
return ""; // 跨域样式表会抛错
}
}).join("\n");
return cssText.replace(/<\/style>/gi, "<\\/style>") + globalCssText;
}
// 获取页面脚本及页面/全局样式
const inlineClientJs = await getInlineClientJsFromRemote(`${window.location.origin}/xxxx-export-client.js`);
const inlineClientCss = await getInlineClientCssFromRemote(`${window.location.origin}/xxxx-export-client.css`);
const appHtml = renderToString(React.createElement(MemoryAnalysisExportApp, { data, }));
const initialData = serializeSafe(data);
// 有些ui组件需要导入cdn插件才能上线功能保留
return `<!doctype html>
<html lang="${data.locale}" data-theme="dark" theme-mode="dark" style="color-scheme: dark;">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${data.title}</title>
<script src="https://cdn.jsdelivr.net/npm/tdesign-react@1.15.8/dist/tdesign.min.js"></script>
<style>
.muted { color: #ffffffd9; margin-bottom: 12px; margin: 12px; }
.muted h1 { margin-bottom: 8px; font-size: 24px; }
</style>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tdesign-react@1.15.8/dist/tdesign.min.css">
<style>${inlineClientCss}</style>
</head>
<body>
<div id="memory-analysis-root">${appHtml}</div>
<script>window.__XXXX_INITIAL_DATA__ = ${initialData};</script>
<!-- 这里替换成你真实构建产物 -->
<script type="module">${inlineClientJs}</script>
</body>
</html>`;
};