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
```

ここでは、JS の import 構文を使用するのではなく、新しい DSL を定義します。なぜなら、この種の 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 でさらにラップして、コンポーネントのエラーがアプリのクラッシュを引き起こさないようにします。

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 に変更する必要があります。なぜなら、ホスト環境には jsx/runtime が存在しないからです。

この成果物を CDN にアップロードしてテストします。

次に、以下の DLS を入力します。

```component
import=http://127.0.0.1:2333/snippets/js/components
name=MDX.Test.Card
```
読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。