普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月28日首页

React如何远程加载组件

作者 前端fun
2026年3月28日 16:13

前言

做过低代码开发的应该都遇到过组件加载的问题,一开始我们是把组件和整个项目放在同一个仓库中,打包的时候每个组件都是一个单独的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, globalNameexternal.

我们不需要把 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 可以直接看到效果

❌
❌