banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

NextJS/React 加载远程组件

前言#

写过文档的大佬们都知道 MDX 这个东西,对原本的 Markdown 进行了扩展,可以在 Markdown 中直接使用框架组件(React,Vue 等等)。

现在也有很多静态生成的博客使用 MDX 去编写博文,在博文中内嵌了 React 组件,在一些需要交互式的场景中,在传统的 Markdown 只能展示内容,而使用了组件就可以把死的文字变活。

MDX 的原理是在项目构建时,解析 Markdown 抽象语法树,把引入的组件进行了编译,然后嵌入到了文章内部。

而使用 MDX,就必须要引入编译时。而对于 CMS 类型的博客网站,因为内容都是动态生成的,就无法使用 MDX。

那么有没有办法去想一个歪路子去实现呢。

构想#

有了初步的想法之后,我们的需求就很明确了。需要在普通的 Markdown 中渲染远程组件。这里我们都以 React 为例,项目框架为 NextJS。

首先我们构想一个 RemoteComponentRender,需要的逻辑如下。

https://cdn.jsdelivr.net/npm/@innei/[email protected]/excalidraw/1-cdn-component.json

首先要加载远程组件,然后提取其中的组件扔到 React 里面去渲染。

然后为了更简单的实现一个 Markdown 的新语法,我们利用 CodeBlock,在此基础上进行扩展。

比如实现下面的语法:

```component
import=https://cdn.jsdelivr.net/npm/@innei/[email protected]/dist/components/Firework.js
name=MDX.Firework
height=25
```

这里我们定义一个新的 DSL 而不是使用 JS 的 import 语法,因为对于此类需要对 AST 操作的编码过程会很复杂。

  • import=远程 js url 导入一个 iife 或者 umd js。
  • component= 从 window 上找到需要渲染的组件位置
  • 其他参数。

实现#

第一步就不用说了,然后是提取组件,这里我们需要把远程组件打包为 umd 或者 iife。

顺着思路走,这里就会想到远程组件需要和宿主环境下的 React 保持同一个上下文,也就是 ReactDOM 和 React 必须是单例同版本的,那么远程组件的渲染必须使用宿主的 React 和 ReactDOM。

React 附加到全局对象#

基于这一点,我曾设想对 next webpack 做改造,把 React/ReactDOM 进行 external,但是由于需要 Server Component 的操作,这样的修改导致直接白屏。

我们的远程组件一定只会在浏览器端进行懒加载,所以,我们只需要在此之前把 React/ReactDOM 附加到浏览器侧全局对象上即可。

// shared/Global.tsx
'use client'

import React from 'react'
import ReactDOM from 'react-dom'
import { useIsomorphicLayoutEffect } from 'foxact/use-isomorphic-layout-effect'

export const Global = () => {
  useIsomorphicLayoutEffect(() => {
    Object.assign(window, {
      React,
      ReactDOM,
      react: React,
      reactDom: ReactDOM,
    })
  }, [])
  return null
}

app/layout.tsx 进行引入。

export default async function RootLayout() {
  // ...
	return <html>
    <Global />
	</html>
}

这样我们就能保证在渲染远程 React 组件时候,React/ReactDOM 已经在全局对象上了。

<RemoteComponentRender /> 组件的实现#

我们想来实现一个基础版的组件。

const ReactComponentRender: FC<DlsProps> = (dlsProps) => {
  const [Component, setComponent] = useState({
    component: ComponentBlockLoading,
  })

  useIsomorphicLayoutEffect(() => {
   loadScript(dlsProps.import)
      .then(() => {
        const Component = get(window, dlsProps.name)
        setComponent({ component: Component })
      })
  }, [dlsProps])

  return (
    <ErrorBoundary fallback={<ComponentBlockError />}>
      <Suspense fallback={<ComponentBlockLoading />}>
        <Component.component />
      </Suspense>
    </ErrorBoundary>
  )
}

这个组件中通过 loadScript 加载远程 js 代码,然后使用 lodash 的 get 方法获取在 window 上的组件,最后通过 setComponent 去渲染到组件容器中。

上面的例子中其实就已经完成了基本功能。为了防止 ReactComponentRender 内部报错,我们还可以对其进一步包装 ErrorBoundary 防止组件报错导致 App 崩溃。

export const ReactComponentRender: FC<ReactComponentRenderProps> = (props) => {
  const { dls } = props
  const dlsProps = parseDlsContent(dls)
  const style: React.CSSProperties = useMemo(() => {
    if (!dlsProps.height) return {}
    const isNumberString = /^\d+$/.test(dlsProps.height)
    return {
      height: isNumberString ? `${dlsProps.height}px` : dlsProps.height,
    }
  }, [dlsProps.height])
  return (
    <ErrorBoundary fallback={<ComponentBlockError style={style} />}>
      <StyleContext.Provider value={style}>
        <ReactComponentRenderImpl {...dlsProps} />
      </StyleContext.Provider>
    </ErrorBoundary>
  )
}

const ReactComponentRenderImpl: FC<DlsProps> = (dlsProps) => {
  const [Component, setComponent] = useState({
    component: ComponentBlockLoading,
  })

  useIsomorphicLayoutEffect(() => {
    loadScript(dlsProps.import)
      .then(() => {
        const Component = get(window, dlsProps.name)
        console.log('Component', Component)
        setComponent({ component: Component })
      })
  }, [dlsProps])

  return (
    <ErrorBoundary fallback={<ComponentBlockError />}>
      <Suspense fallback={<ComponentBlockLoading />}>
        <Component.component />
      </Suspense>
    </ErrorBoundary>
  )
}

远程组件的构建和打包#

上面我们已经明确了远程组件的渲染机制,现在就需要去实现组件的定义侧。这里需要明确组件的打包产物是 iife 或者 umd 格式的。并且产物的 React 应该使用 window.React

我们新建一个项目用于专门存放此类组件。然后使用 rollup 构建。

安装需要的 rollup 插件。

npm i -D @rollup/plugin-commonjs @rollup/plugin-node-resolve rollup/plugin-replace @rollup/plugin-typescript rollup rollup-plugin-esbuild rollup-plugin-external-globals

明确我们的组件都放置在 src/components 目录下。我们需要为每一个组件单独打包一个 iife 产物。rollup 参考配置如下:

// @ts-check
import { readdirSync } from 'fs'
import path, { dirname } from 'path'
import { fileURLToPath } from 'url'
import { minify } from 'rollup-plugin-esbuild'
import externalGlobals from 'rollup-plugin-external-globals'

import commonjs from '@rollup/plugin-commonjs'
import { nodeResolve } from '@rollup/plugin-node-resolve'
import replace from '@rollup/plugin-replace'
import typescript from '@rollup/plugin-typescript'

import css from 'rollup-plugin-postcss' // 如果你需要使用 tailwindcss

const dir = 'dist'

/**
 * @type {import('rollup').RollupOptions}
 */

const baseConfig = {
  plugins: [
    externalGlobals({ // 这里需要使用 externalGlobals 插件去把 react 的导入直接换成从 window 上取
      react: 'React',
      'react-dom': 'ReactDOM',
    }),
    replace({ // 变量替换,防止打包出现 node 环境变量
      'process.env.NODE_ENV': JSON.stringify('production'),
      preventAssignment: true,
    }),
    nodeResolve(),

    commonjs({ include: 'node_modules/**' }),
    typescript({
      tsconfig: './tsconfig.json',
      declaration: false,
    }),
    css({
      minimize: true,
      modules: {
        generateScopedName: '[hash:base64:5]',
      },
    }),

    minify(),
  ],

  treeshake: true,
  external: ['react', 'react-dom'], // 预防万一这里也加一下
}

const config = readdirSync(
  path.resolve(dirname(fileURLToPath(import.meta.url)), 'src/components'),
)
  .map((file) => {
    const name = file.split('.')[0]
    const ext = file.split('.')[1]
    if (ext !== 'tsx') return
    /**
     * @type {import('rollup').RollupOptions}
     */
    return {
      ...baseConfig,

      input: `src/components/${name}.tsx`,
      output: [
        {
          file: `${dir}/components/${name}.js`,
          format: 'iife',
          sourcemap: false,
          name: `MDX.${name}`,
        },
      ],
    }
  })
  .filter(Boolean)

export default config

现在来写一个简单的组件,包含 React Hook 的使用。如下。

// src/components/Test.tsx
import React, { useState } from 'react'

export const Card = () => {
  const [count, setCount] = useState(0)
  return (
    <div>
      <button
        onClick={() => {
          setCount((c) => c + 10)
        }}
      >
        {count}
      </button>
    </div>
  )
}

编译的产物应该如下:

image

这里如果编译的产物不是 React.createElement 而不是使用了 jsx/runtime,需要修改 tsconfig.ts jsx 为 react 而不是 react-jsx。因为在宿主环境中并没有 jsx/runtime

我们可以把这个产物发到 CDN 测试一下。

然后输入如下的 DLS。

```component
import=http://127.0.0.1:2333/snippets/js/components
name=MDX.Test.Card
```
加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。