banner
innei

innei

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

NextJS/React Load Remote Components

Preface#

Those who have written documentation know about MDX, which extends the original Markdown and allows the use of framework components (like React, Vue, etc.) directly within Markdown.

Many static-generated blogs now use MDX to write posts, embedding React components within the articles. In scenarios that require interactivity, traditional Markdown can only display content, while using components can bring static text to life.

The principle of MDX is to parse the Markdown abstract syntax tree during project build time, compile the imported components, and then embed them into the article.

Using MDX requires introducing it at build time. However, for CMS-type blog sites, since the content is dynamically generated, MDX cannot be used.

So, is there a way to think outside the box to achieve this?

Concept#

With a preliminary idea in place, our requirements became clear. We need to render remote components in regular Markdown. Here, we will take React as an example, with the project framework being NextJS.

First, we envision a RemoteComponentRender with the following logic.

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

First, we need to load the remote component and then extract the component to render it in React.

To implement a new Markdown syntax more simply, we will utilize CodeBlock and expand upon it.

For example, we can implement the following syntax:

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

Here, we define a new DSL instead of using the JS import syntax, as coding processes that require AST manipulation can be quite complex.

  • import=remote js url imports an IIFE or UMD js.
  • component= finds the location of the component to be rendered on the window.
  • Other parameters.

Implementation#

The first step is straightforward, and then we need to extract the component. Here, we need to package the remote component as UMD or IIFE.

Following this line of thought, we realize that the remote component needs to maintain the same context as the React in the host environment, meaning that ReactDOM and React must be singletons of the same version. Therefore, rendering the remote component must use the host's React and ReactDOM.

Attaching React to the Global Object#

Based on this, I once envisioned modifying Next's webpack to make React/ReactDOM external. However, due to the need for Server Component operations, such modifications led to a blank screen.

Our remote components will only be lazily loaded on the browser side, so we only need to attach React/ReactDOM to the browser's global object beforehand.

// 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
}

Import it in app/layout.tsx.

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

This way, we can ensure that when rendering remote React components, React/ReactDOM are already on the global object.

Implementation of <RemoteComponentRender /> Component#

We want to implement a basic version of the component.

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>
  )
}

In this component, we load the remote js code using loadScript, then use lodash's get method to retrieve the component from the window, and finally render it in the component container using setComponent.

The example above has already completed the basic functionality. To prevent errors within ReactComponentRender, we can further wrap it in an ErrorBoundary to prevent component errors from crashing the 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>
  )
}

Building and Packaging Remote Components#

We have clarified the rendering mechanism of remote components, and now we need to implement the definition side of the components. It is important to clarify that the packaged product of the components should be in IIFE or UMD format. Moreover, the React used in the product should be window.React.

We will create a new project specifically for storing such components and use Rollup for building.

Install the necessary Rollup plugins.

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

We will place all our components in the src/components directory. We need to package each component separately into an IIFE product. The Rollup configuration is as follows:

// @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' // If you need to use tailwindcss

const dir = 'dist'

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

const baseConfig = {
  plugins: [
    externalGlobals({ // Here we need to use the externalGlobals plugin to replace the import of react directly from the window
      react: 'React',
      'react-dom': 'ReactDOM',
    }),
    replace({ // Variable replacement to prevent packaging with node environment variables
      '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'], // Just in case, we add this here as well
}

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

Now let's write a simple component that uses React Hooks, as follows.

// 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>
  )
}

The compiled product should look like this:

image

If the compiled product does not use React.createElement but instead uses jsx/runtime, you need to modify the tsconfig.ts jsx to react instead of react-jsx. This is because the host environment does not have jsx/runtime.

We can upload this product to a CDN for testing.

Then input the following DLS.

```component
import=http://127.0.0.1:2333/snippets/js/components
name=MDX.Test.Card
```
Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.