React如何远程加载组件
前言
做过低代码开发的应该都遇到过组件加载的问题,一开始我们是把组件和整个项目放在同一个仓库中,打包的时候每个组件都是一个单独的chunk,这样加载的时候按需加载。但是随着项目越来越大,组件越来越多,甚至一些自定义的等等,这一套逻辑就不能支持下去。
而远程组件的好处就是,每个开发都能编写组件直接提交到一个公共的地方,那么我们的项目在获取组件列表的时候就能自动拿到以及使用。
构想
既然我们需要加载远程组件,就需要实现一个模块,需要处理从远端加载我们的组件然后进行渲染。
1.加载远端js
2.缓存组件(避免多次加载)
3.获取组件
4. 渲染
低代码通常会返回一个配置项,描述了内部有哪些组件以及他们的层级关系,这些我们不需要关注,默认我们现在有这么一个配置项。
首先要加载远程组件,然后把他放到react中渲染,类似下面的结构:
const json = {
components: [
{
"id": "remote-button-a",
"url": "https://cdn.jsdelivr.net/npm/@luke358/remote-component@latest/dist/umd/button.umd.js",
"globalName": "RemoteUI",
"exportName": "Button",
"componentProps": {
"children": "远程按钮 A"
},
"loadingText": "远程按钮 A 加载中",
"clickMessage": "Remote UMD button A is working."
},
]
}
这里我们定义了一些简单的结构。
- import 远程的js
- name 我们最终把这个组件挂载到window上,用于存取组件
实现
远程组件的构建和打包
这里主要关注组件的实现、以及通过配置远程加载组件,这里我们需要把远程组件打包为 umd 或者 iife。
我们新建一个项目来存放我们的组件。直接使用vite+react+ts脚手架搭建即可。
我们把所有的组件都放在 src/components 下。
主要的打包配置如下
import { existsSync } from "node:fs";
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import { build } from "vite";
import react from "@vitejs/plugin-react";
const rootDir = process.cwd();
const srcComponentsDir = resolve(rootDir, "src/components");
const outDir = resolve(rootDir, "dist/umd");
const entryExtensions = [".tsx", ".ts", ".jsx", ".js"];
const globalNamespace = "RemoteUI";
function toKebabCase(value) {
return value
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/[_\s]+/g, "-")
.toLowerCase();
}
async function getComponentEntries() {
const dirents = await readdir(srcComponentsDir, { withFileTypes: true });
const entries = [];
for (const dirent of dirents) {
if (!dirent.isDirectory()) {
continue;
}
const componentName = basename(dirent.name);
const entry = entryExtensions
.map((extension) => resolve(srcComponentsDir, componentName, `index${extension}`))
.find((entryPath) => existsSync(entryPath));
if (entry) {
entries.push({
componentName,
entry,
fileName: `${toKebabCase(componentName)}.umd.js`,
globalName: globalNamespace
});
}
}
if (entries.length === 0) {
throw new Error("No component entry files were found under src/components.");
}
return entries;
}
async function buildComponent(entryConfig) {
await build({
configFile: false,
plugins: [
react({
jsxRuntime: "classic"
})
],
resolve: {
alias: {
"@": resolve(rootDir, "src")
}
},
build: {
outDir,
emptyOutDir: false,
sourcemap: true,
lib: {
entry: entryConfig.entry,
name: entryConfig.globalName,
formats: ["umd"],
fileName: () => entryConfig.fileName
},
rollupOptions: {
external: ["react", "react-dom"],
output: {
extend: true,
globals: {
react: "React",
"react-dom": "ReactDOM"
}
}
}
}
});
}
async function main() {
const entries = await getComponentEntries();
await rm(outDir, { recursive: true, force: true });
await mkdir(outDir, { recursive: true });
for (const entry of entries) {
await buildComponent(entry);
}
await writeFile(
resolve(outDir, "manifest.json"),
JSON.stringify(
entries.map(({ componentName, fileName, globalName }) => ({
componentName,
fileName,
globalName,
accessPath: `${globalName}.${componentName}`
})),
null,
2
)
);
console.log(`Built ${entries.length} UMD bundle(s) into ${outDir}`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
主要关注的就是 formats, globalName和external.
我们不需要把 React 和 ReactDOM 打包进来,到时候全部都适用主应用的,所以后续需要在主应用把 React 和 ReactDOM 挂载到 window 上。
然后编写一个简单的 button 组件
import React from "react";
export interface ButtonProps {
children: React.ReactNode;
type?: "button" | "submit" | "reset";
disabled?: boolean;
onClick?: () => void;
}
const buttonStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
padding: "12px 18px",
border: "none",
borderRadius: "999px",
background:
"linear-gradient(135deg, rgba(15, 23, 42, 1) 0%, rgba(37, 99, 235, 1) 100%)",
color: "#ffffff",
fontSize: "14px",
fontWeight: 600,
cursor: "pointer",
boxShadow: "0 12px 30px rgba(37, 99, 235, 0.24)"
};
const disabledStyle: React.CSSProperties = {
opacity: 0.45,
cursor: "not-allowed",
boxShadow: "none"
};
export function Button({
children,
type = "button",
disabled = false,
onClick
}: ButtonProps) {
return (
<button
type={type}
disabled={disabled}
onClick={onClick}
style={{
...buttonStyle,
...(disabled ? disabledStyle : undefined)
}}
>
{children}
</button>
);
}
执行打包之后 "build:umd": "node ./scripts/build-umd.mjs" 就会在dist/umd 下看到一个 button.umd.js 文件。
之后把这个文件上传到 oss 或者其他地方就能进行远程加载。
远程组件的加载使用
添加React到全局对象
// main.ts
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
declare global {
interface Window {
React?: typeof React;
ReactDOM?: typeof ReactDOM;
}
}
window.React = React;
window.ReactDOM = ReactDOM;
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
这样在渲染远程组件的时候就能确保 React 能正确的使用
RemoteComponentLoader 实现
function loadRemoteScript(url: string) {
const cachedPromise = scriptPromiseCache.get(url);
if (cachedPromise) {
return cachedPromise;
}
const scriptPromise = new Promise<void>((resolve, reject) => {
const existingScript = document.querySelector<HTMLScriptElement>(`script[data-remote-src="${url}"]`);
if (existingScript) {
if (existingScript.dataset.loaded === "true") {
resolve();
return;
}
existingScript.addEventListener("load", () => resolve(), { once: true });
existingScript.addEventListener("error", () => reject(new Error(`Load failed: ${url}`)), {
once: true
});
return;
}
const script = document.createElement("script");
script.src = url;
script.async = true;
script.dataset.remoteSrc = url;
script.onload = () => {
script.dataset.loaded = "true";
resolve();
};
script.onerror = () => reject(new Error(`Load failed: ${url}`));
document.head.appendChild(script);
}).catch((error) => {
scriptPromiseCache.delete(url);
removeRemoteScript(url);
throw error;
});
scriptPromiseCache.set(url, scriptPromise);
return scriptPromise;
}
export function loadRemoteComponent<TProps extends Record<string, unknown>>(
config: RemoteComponentConfig
) {
const cacheKey = getCacheKey(config);
const resolvedComponent = resolvedComponentCache.get(cacheKey);
if (resolvedComponent) {
return Promise.resolve(resolvedComponent as RemoteComponentType<TProps>);
}
const pendingComponent = componentPromiseCache.get(cacheKey);
if (pendingComponent) {
return pendingComponent as Promise<RemoteComponentType<TProps>>;
}
const componentPromise = loadRemoteScript(config.url)
.then(() => {
const remoteComponent = resolveRemoteExport(config);
resolvedComponentCache.set(cacheKey, remoteComponent);
return remoteComponent;
})
.catch((error) => {
componentPromiseCache.delete(cacheKey);
resolvedComponentCache.delete(cacheKey);
throw error;
});
componentPromiseCache.set(cacheKey, componentPromise);
return componentPromise as Promise<RemoteComponentType<TProps>>;
}
这里主要是展示怎么通过配置加载我们远端的组件。
后续使用只需要:
const [ResolvedComponent, setResolvedComponent] = React.useState<RemoteComponentType<TProps> | null>(
useEffect(() => {
loadRemoteComponent<TProps>(config)
.then((component) => {
if (!disposed) {
setResolvedComponent(() => component);
}
})
.catch((error: Error) => {
if (!disposed) {
setLoadError(error);
}
});
return () => {
disposed = true;
};
}, [])
return <div> {ResolvedComponent ? 'loading' : <ResolvedComponent />} </div>
这里只是给了个简单的例子。
正常情况我们需要考虑组件的并发加载处理,缓存,错误捕获避免导致应用全部崩溃等一些细节问题。
具体实现代码(主要由AI生成):
github.com/luke358/tes…
安装依赖后,执行 npm run dev 可以直接看到效果