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
```
載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。