前言#
寫過文檔的大佬們都知道 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>
)
}
編譯的產物應該如下:
這裡如果編譯的產物不是 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
```