React如何远程加载组件

前言

做过低代码开发的应该都遇到过组件加载的问题,一开始我们是把组件和整个项目放在同一个仓库中,打包的时候每个组件都是一个单独的chunk,这样加载的时候按需加载。但是随着项目越来越大,组件越来越多,甚至一些自定义的等等,这一套逻辑就不能支持下去。

而远程组件的好处就是,每个开发都能编写组件直接提交到一个公共的地方,那么我们的项目在获取组件列表的时候就能自动拿到以及使用。

构想

既然我们需要加载远程组件,就需要实现一个模块,需要处理从远端加载我们的组件然后进行渲染。

markdown 复制代码
1.加载远端js
2.缓存组件(避免多次加载)
3.获取组件
4. 渲染

低代码通常会返回一个配置项,描述了内部有哪些组件以及他们的层级关系,这些我们不需要关注,默认我们现在有这么一个配置项。

首先要加载远程组件,然后把他放到react中渲染,类似下面的结构:

perl 复制代码
  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 下。

主要的打包配置如下

javascript 复制代码
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, globalNameexternal.

我们不需要把 React 和 ReactDOM 打包进来,到时候全部都适用主应用的,所以后续需要在主应用把 React 和 ReactDOM 挂载到 window 上。

然后编写一个简单的 button 组件

typescript 复制代码
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到全局对象

typescript 复制代码
// 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 实现

ini 复制代码
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>>;
}

这里主要是展示怎么通过配置加载我们远端的组件。

后续使用只需要:

javascript 复制代码
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 可以直接看到效果

相关推荐
weixin_382395232 小时前
为小工厂量身打造:本地部署的物料管理系统带缺料计算
前端·制造
__zRainy__2 小时前
解决pnpm v10+不自动构建
前端·pnpm·工程化
猫猫不是喵喵.2 小时前
Vue3 Props 属性
前端·javascript·vue.js
醉城夜风~3 小时前
CSS元素显示模式(display)
前端·css
AI大模型-小华4 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子6 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅6 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni7 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学8 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端