NextJS/React 加载远程组件
前言
写过文档的大佬们都知道 MDX 这个东西,对原本的 Markdown 进行了扩展,可以在 Markdown 中直接使用框架组件(React,Vue 等等)。
现在也有很多静态生成的博客使用 MDX 去编写博文,在博文中内嵌了 React 组件,在一些需要交互式的场景中,在传统的 Markdown 只能展示内容,而使用了组件就可以把死的文字变活。
MDX 的原理是在项目构建时,解析 Markdown 抽象语法树,把引入的组件进行了编译,然后嵌入到了文章内部。
而使用 MDX,就必须要引入编译时。而对于 CMS 类型的博客网站,因为内容都是动态生成的,就无法使用 MDX。
那么有没有办法去想一个歪路子去实现呢。
构想
有了初步的想法之后,我们的需求就很明确了。需要在普通的 Markdown 中渲染远程组件。这里我们都以 React 为例,项目框架为 NextJS。
首先我们构想一个 RemoteComponentRender,需要的逻辑如下。
首先要加载远程组件,然后提取其中的组件扔到 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
去渲染到组件容器中。
上面 loadScript
方法可以在下面的地址参考实现。
上面的例子中其实就已经完成了基本功能。为了防止 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>
)
}
编译的产物应该如下:
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
```
这个组件可交互了,那么任务就完成一半了。
组件内使用 Tailwindcss
远程组件内部要想使用 TailwindCSS 经过我的测试,常规方法并不能很好的适应,在关闭 TailwindCSS 内置的 preflight 样式之外,其他的生成的样式也同样会污染到宿主环境的 CSS。哪怕宿主环境同样也是用了默认的 TailwindCSS 样式,在 CSS 的层叠顺序上,后来附加的 CSS className 的优先级同样会在宿主相同的 className 优先级更高,那么就会导致原本的 @media 可能会失效导致宿主样式崩坏。
这里我们使用 PostCSS + TailwindCSS + CSS Module 的组合。这样确保生成的 className 是唯一的并且不影响宿主的样式还能吃到宿主暗黑模式的福利。要是用 ShadowElement 去作 CSS 隔离的话,黑暗模式就比较难搞了。
首先引入 PostCSS。然后配置 TailwindCSS
// rollup.config.js
import css from 'rollup-plugin-postcss'
plugins: [
css({
minimize: true,
modules: {
generateScopedName: '[hash:base64:5]',
},
}),
]
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class', '[data-theme="dark"]'],
content: ['src/**/*.{ts,tsx}', './storybook/**/*.{ts,tsx}'],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
},
}
在组件中,使用 module.css
的导入。
编写 css 文件使用 @apply
修饰符。
这里不能导入 TailwindCSS 的 layer。也就是下面这些,如果你任何一个地方导入了包涵这些的 CSS,那么生成的 className 还是会污染宿主的样式。
@tailwind base;
@tailwind components;
@tailwind utilities;
比如在 Firework.tsx 中我使用这样定义 module.css
.root {
@apply flex w-full justify-center;
}
.button {
@apply rounded-lg bg-white px-4 py-1 text-sm font-medium text-red-400 ring-1 ring-zinc-200 hover:bg-zinc-50 dark:bg-black dark:text-red-600 dark:ring-zinc-800 dark:hover:bg-neutral-950;
}
.firework {
@apply fixed inset-0 bg-black duration-200;
&.show {
@apply z-[9998] opacity-100;
}
&.hide {
@apply pointer-events-none z-[-99] opacity-0;
}
}
.overlay {
@apply fixed inset-0 z-[9999];
&.hide {
@apply hidden;
}
}
那么,生成的 class 为:
可见,所有的 tw 变量都只在每个 class 上定义,而不是在全局上定义。而且 className 是唯一的。并且暗黑模式也会生效。
OK,到此就完结了,感谢阅读到这里。
最后祝大家新年快乐。对了,下面这个可交互组件就是使用本文介绍的方法实现的。