{"title":"NextJS/React 加载远程组件","date_published":"2024-02-12T16:23:54.949Z","tags":["post"],"attributes":[{"trait_type":"xlog_slug","value":"nextjs-react-load-and-render-remote-component"}],"content":"## 前言\n\n写过文档的大佬们都知道 [MDX](https://mdxjs.com/) 这个东西,对原本的 Markdown 进行了扩展,可以在 Markdown 中直接使用框架组件(React,Vue 等等)。\n\n现在也有很多静态生成的博客使用 MDX 去编写博文,在博文中内嵌了 React 组件,在一些需要交互式的场景中,在传统的 Markdown 只能展示内容,而使用了组件就可以把死的文字变活。\n\nMDX 的原理是在项目构建时,解析 Markdown 抽象语法树,把引入的组件进行了编译,然后嵌入到了文章内部。\n\n而使用 MDX,就必须要引入编译时。而对于 CMS 类型的博客网站,因为内容都是动态生成的,就无法使用 MDX。\n\n那么有没有办法去想一个歪路子去实现呢。\n\n## 构想\n\n有了初步的想法之后,我们的需求就很明确了。需要在普通的 Markdown 中渲染远程组件。这里我们都以 React 为例,项目框架为 NextJS。\n\n首先我们构想一个 RemoteComponentRender,需要的逻辑如下。\n\n```excalidraw\nhttps://cdn.jsdelivr.net/npm/@innei/react-cdn-components@0.0.8/excalidraw/1-cdn-component.json\n```\n\n首先要加载远程组件,然后提取其中的组件扔到 React 里面去渲染。\n\n然后为了更简单的实现一个 Markdown 的新语法,我们利用 CodeBlock,在此基础上进行扩展。\n\n比如实现下面的语法:\n\n````markdown\n```component\nimport=https://cdn.jsdelivr.net/npm/@innei/react-cdn-components@0.0.7/dist/components/Firework.js\nname=MDX.Firework\nheight=25\n```\n````\n\n这里我们定义一个新的 DSL 而不是使用 JS 的 import 语法,因为对于此类需要对 AST 操作的编码过程会很复杂。\n\n- `import=远程 js url` 导入一个 iife 或者 umd js。\n- `component=` 从 window 上找到需要渲染的组件位置\n- 其他参数。\n\n## 实现\n\n第一步就不用说了,然后是提取组件,这里我们需要把远程组件打包为 umd 或者 iife。\n\n顺着思路走,这里就会想到远程组件需要和宿主环境下的 React 保持同一个上下文,也就是 ReactDOM 和 React 必须是单例同版本的,那么远程组件的渲染必须使用宿主的 React 和 ReactDOM。\n\n### React 附加到全局对象\n\n基于这一点,我曾设想对 next webpack 做改造,把 React/ReactDOM 进行 external,但是由于需要 Server Component 的操作,这样的修改导致直接白屏。\n\n我们的远程组件一定只会在浏览器端进行懒加载,所以,我们只需要在此之前把 React/ReactDOM 附加到浏览器侧全局对象上即可。\n\n```tsx\n// shared/Global.tsx\n'use client'\n\nimport React from 'react'\nimport ReactDOM from 'react-dom'\nimport { useIsomorphicLayoutEffect } from 'foxact/use-isomorphic-layout-effect'\n\nexport const Global = () => {\n useIsomorphicLayoutEffect(() => {\n Object.assign(window, {\n React,\n ReactDOM,\n react: React,\n reactDom: ReactDOM,\n })\n }, [])\n return null\n}\n\n```\n\n在 `app/layout.tsx` 进行引入。\n\n```tsx\nexport default async function RootLayout() {\n // ...\n\treturn \n \n\t\n}\n```\n\n这样我们就能保证在渲染远程 React 组件时候,React/ReactDOM 已经在全局对象上了。\n\n### `` 组件的实现\n\n我们想来实现一个基础版的组件。\n\n```tsx\nconst ReactComponentRender: FC = (dlsProps) => {\n const [Component, setComponent] = useState({\n component: ComponentBlockLoading,\n })\n\n useIsomorphicLayoutEffect(() => {\n loadScript(dlsProps.import)\n .then(() => {\n const Component = get(window, dlsProps.name)\n setComponent({ component: Component })\n })\n }, [dlsProps])\n\n return (\n }>\n }>\n \n \n \n )\n}\n```\n\n这个组件中通过 `loadScript` 加载远程 js 代码,然后使用 lodash 的 `get` 方法获取在 window 上的组件,最后通过 `setComponent` 去渲染到组件容器中。\n\n:::note\n上面 `loadScript` 方法可以在下面的地址参考实现。\n\nhttps://github.com/Innei/sprightly/blob/ed7f55a85a1c9e537774982188c1bcd4447b4c4b/src/lib/load-script.ts\n:::\n\n上面的例子中其实就已经完成了基本功能。为了防止 ReactComponentRender 内部报错,我们还可以对其进一步包装 ErrorBoundary 防止组件报错导致 App 崩溃。\n\n```tsx\nexport const ReactComponentRender: FC = (props) => {\n const { dls } = props\n const dlsProps = parseDlsContent(dls)\n const style: React.CSSProperties = useMemo(() => {\n if (!dlsProps.height) return {}\n const isNumberString = /^\\d+$/.test(dlsProps.height)\n return {\n height: isNumberString ? `${dlsProps.height}px` : dlsProps.height,\n }\n }, [dlsProps.height])\n return (\n }>\n \n \n \n \n )\n}\n\nconst ReactComponentRenderImpl: FC = (dlsProps) => {\n const [Component, setComponent] = useState({\n component: ComponentBlockLoading,\n })\n\n useIsomorphicLayoutEffect(() => {\n loadScript(dlsProps.import)\n .then(() => {\n const Component = get(window, dlsProps.name)\n console.log('Component', Component)\n setComponent({ component: Component })\n })\n }, [dlsProps])\n\n return (\n }>\n }>\n \n \n \n )\n}\n```\n\n### 远程组件的构建和打包\n\n上面我们已经明确了远程组件的渲染机制,现在就需要去实现组件的定义侧。这里需要明确组件的打包产物是 iife 或者 umd 格式的。并且产物的 React 应该使用 `window.React`。\n\n我们新建一个项目用于专门存放此类组件。然后使用 rollup 构建。\n\n安装需要的 rollup 插件。\n\n```bash\nnpm i -D @rollup/plugin-commonjs @rollup/plugin-node-resolve rollup/plugin-replace @rollup/plugin-typescript rollup rollup-plugin-esbuild rollup-plugin-external-globals\n```\n\n明确我们的组件都放置在 `src/components` 目录下。我们需要为每一个组件单独打包一个 iife 产物。rollup 参考配置如下:\n\n```js\n// @ts-check\nimport { readdirSync } from 'fs'\nimport path, { dirname } from 'path'\nimport { fileURLToPath } from 'url'\nimport { minify } from 'rollup-plugin-esbuild'\nimport externalGlobals from 'rollup-plugin-external-globals'\n\nimport commonjs from '@rollup/plugin-commonjs'\nimport { nodeResolve } from '@rollup/plugin-node-resolve'\nimport replace from '@rollup/plugin-replace'\nimport typescript from '@rollup/plugin-typescript'\n\nimport css from 'rollup-plugin-postcss' // 如果你需要使用 tailwindcss\n\nconst dir = 'dist'\n\n/**\n * @type {import('rollup').RollupOptions}\n */\n\nconst baseConfig = {\n plugins: [\n externalGlobals({ // 这里需要使用 externalGlobals 插件去把 react 的导入直接换成从 window 上取\n react: 'React',\n 'react-dom': 'ReactDOM',\n }),\n replace({ // 变量替换,防止打包出现 node 环境变量\n 'process.env.NODE_ENV': JSON.stringify('production'),\n preventAssignment: true,\n }),\n nodeResolve(),\n\n commonjs({ include: 'node_modules/**' }),\n typescript({\n tsconfig: './tsconfig.json',\n declaration: false,\n }),\n css({\n minimize: true,\n modules: {\n generateScopedName: '[hash:base64:5]',\n },\n }),\n\n minify(),\n ],\n\n treeshake: true,\n external: ['react', 'react-dom'], // 预防万一这里也加一下\n}\n\nconst config = readdirSync(\n path.resolve(dirname(fileURLToPath(import.meta.url)), 'src/components'),\n)\n .map((file) => {\n const name = file.split('.')[0]\n const ext = file.split('.')[1]\n if (ext !== 'tsx') return\n /**\n * @type {import('rollup').RollupOptions}\n */\n return {\n ...baseConfig,\n\n input: `src/components/${name}.tsx`,\n output: [\n {\n file: `${dir}/components/${name}.js`,\n format: 'iife',\n sourcemap: false,\n name: `MDX.${name}`,\n },\n ],\n }\n })\n .filter(Boolean)\n\nexport default config\n```\n\n现在来写一个简单的组件,包含 React Hook 的使用。如下。\n\n```tsx\n// src/components/Test.tsx\nimport React, { useState } from 'react'\n\nexport const Card = () => {\n const [count, setCount] = useState(0)\n return (\n
\n {\n setCount((c) => c + 10)\n }}\n >\n {count}\n \n
\n )\n}\n\n```\n\n编译的产物应该如下:\n\n![](https://object.innei.in/bed/2024/0212_1707751594799.png)\n\n:::warning\n这里如果编译的产物不是 `React.createElement` 而不是使用了 `jsx/runtime`,需要修改 tsconfig.ts jsx 为 `react` 而不是 `react-jsx`。因为在宿主环境中并没有 `jsx/runtime`\n:::\n\n我们可以把这个产物发到 CDN 测试一下。\n\n然后输入如下的 DLS。\n\n````markdown\n```component\nimport=http://127.0.0.1:2333/snippets/js/components\nname=MDX.Test.Card\n```\n````\n\n\n\n