Fork me on GitHub

参考react-live,如何实现代码在线预览?

目前很多平台都支持嵌入 CodeSandbox、StackBlitz 等第三方在线代码运行平台,仅仅通过一个 iframe 标签就可以完成,这个当然是很方便的,不过有时候我们只是想展示一些轻量级代码,再加上经常网络抽风,还需要我们维护一堆仓库,不知道哪天自己手抖就删了,导致文章中 demo 效果无法展示。那么有没有可能我们自己实现这种在线运行的功能呢,答案当然是可以。

应该提供哪些主要功能 🤔

编辑器组件应该与预览组件分离,给予用户只使用编辑器的机会,单独使用编辑器时就是一个普通的代码高亮展示组件,所以提供两个单独组件。为了方便我们代码中编辑器组件与预览组件跨组件交换数据,我们再额外提供一个 Provider 组件,该组件负责为编辑器组件与预览组件包裹一个 ContextProvider,我们的配置都通过 Provider 组件传入,大致写法结构如下:

1
2
3
4
<Provider language='jsx' defaultCode='一些 jsx 代码' {...rest}>
<Editor />
<Preview />
</Provider>
  • Provider 组件
    • ContextProvider 的再包装,用于 Editor 与 Preview 的跨组件通讯
    • 负责传入参数配置
  • Editor 组件
    • 支持代码高亮
    • 代码更新后同步到 Context 中
  • Preview 组件
    • 从 Context 中读取最新代码
    • 需支持 react 组件预览
    • 除此之外,我还需要支持原生 html 的预览

Provider 组件

上边说 Provider 负责参数的统一传入,让我们看一下具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import { useControllableValue } from 'ahooks'
import React, { createContext, PropsWithChildren } from 'react'

export interface LiveProviderProps {
code?: string // 受控组件
defaultCode?: string // 非受控组件
language: string // 代码语言
// 代码运行需要的全局对象,仅在 language=jsx 时生效,比如边这块代码就需要保证 useState 能拿到才可以运行
// function Count() {
// const [count, setCount] = useState(0)
// return (
// <>
// <p>count: {count}</p>
// <button onClick={() => setCount(count + 1)}>+1</button>
// </>
// )
// }
scope?: Record<string, any>
disabled?: boolean // 有时候我想禁止编辑
onCodeChange?: (code: string) => void
}

export interface Context {
code: string
language: Language
onCodeChange: (code: string) => void
scope: Record<string, any>
disabled: boolean
}

export const LiveContext = createContext<Context>({} as Context)

const LiveProvider: React.FC<PropsWithChildren<LiveProviderProps>> = (props) => {
const { children, language, scope = {}, disabled = false } = props
// 这块使用了 ahooks 中的 useControllableValue,推荐去学习下如何使用
const [code, setCode] = useControllableValue(props, {
defaultValue: '',
defaultValuePropName: 'defaultCode',
valuePropName: 'code',
trigger: 'onCodeChange',
})

function onCodeChange(newCode: string) {
setCode(newCode)
}

return (
<LiveContext.Provider
value={{
code,
language,
onCodeChange,
scope,
disabled,
}}
>
{children}
</LiveContext.Provider>
)
}

export default LiveProvider

编辑器组件

编辑器组件仅负责代码录入不负责代码编译,只需要保证有代码可编辑可高亮,编辑功能我这里使用了 react-simple-code-editor,它是一个轻量级编辑器,或者也可以使用 use-editable,这里就不具体进行了,代码高亮这块使用了 prism-react-renderer,两者结合便可以实现一个基本的编辑器组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import React, { useContext } from 'react'
import Highlight, { defaultProps } from 'prism-react-renderer'
import theme from 'prism-react-renderer/themes/nightOwl'
import Editor from 'react-simple-code-editor'
import { LiveContext } from './LiveProvider'

const LiveEditor = () => {
const { code, disabled, language, onCodeChange } = useContext(LiveContext)

return (
<Editor
value={code}
onValueChange={onCodeChange}
disabled={disabled}
tabSize={2}
padding={10}
style={{
fontFamily: '"Fira code", "Fira Mono", monospace',
fontSize: 12,
...theme.plain,
}}
highlight={(code) => (
<Highlight {...defaultProps} theme={theme} code={code} language={language}>
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<>
{tokens.map((line, i) => (
<div {...getLineProps({ line, key: i })}>
{line.map((token, key) => (
<span {...getTokenProps({ token, key })} />
))}
</div>
))}
</>
)}
</Highlight>
)}
/>
)
}

export default LiveEditor

效果预览

007zq4ODly1h34ibj6xssj30lo09qtax

截止到这里,我们已经实现一个代码高亮组件了,如果目标只是如此,你就可以不继续往下看了,当然我们目标应该不只如此,接下来考虑预览组件怎么做。

预览组件

预览组件相对 Editor 而言比较复杂,我们要考虑多种语言的情况,考虑下边一串代码,如何才能编译成一个 react 组件?

1
2
3
4
5
6
7
8
9
10
function Count() {
const [count, setCount] = useState(0)

return (
<>
<p>count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</>
)
}

不只 jsx 语法,我还要支持原生 html,为了代码可扩展性,首先让我们把 Preview 组件细分一下再考虑具体不同语言的编译。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import React, { useContext } from 'react'
import HtmlPreview from './components/HtmlPreview'
import ReactPreview from './components/ReactPreview'
import { LiveContext } from './LiveProvider'

const LivePreview = () => {
const { language } = useContext(LiveContext)

const Preview = {
html: HtmlPreview,
jsx: ReactPreview,
}[language]

// 仅渲染受支持的语言
return Preview ? <Preview {...props} /> : null
}

export default LivePreview

在上边组件中我们根据 Context 提供的 language 选项渲染出对应不同组件,接下来先看最麻烦的 react 怎么渲染。

渲染 react

要实现运行时渲染 jsx 语法,我们需要借助 sucrase,它可以在浏览器环境对代码进行快速编译,更多使用方式可以参考官方文档。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import React, { useContext, useState, useEffect } from 'react'
import { LiveContext } from '../LiveProvider'
import { generateElement } from '../utils/transpile'
import { Transform, transform } from 'sucrase'

// 将编译后的字符串变成可执行代码,并且通过闭包方式将一些依赖传入,类似下边这种,有种 UMD 模块的感觉
// (function (name) {
// console.log(`hello ${name}`)
// })('张三')
function evalCode(code: string, scope: Record<string, any>) {
const scopeKeys = Object.keys(scope)
const scopeValues = Object.values(scope)
return new Function(...scopeKeys, code)(...scopeValues)
}
function generateNode({ code = '', scope = {} }) {
// 删除末尾分号,因为下边会在 code 外包装一个 return (code) 的操作,有分号会导致语法错误
const codeTrimmed = code.trim().replace(/;$/, '')
const opts = { transforms: ['jsx', 'imports'] as Transform[] }
// 前边补上一个 return,以便下边 evalCode 后能正确拿到生成的组件
const transformed = transform(`return (${codeTrimmed})`, opts).code.trim()
// 编译后只是一个字符串,我们通过 evalCode 函数将它变成可执行代码
return evalCode(transformed, { React, ...scope })
}
// 如果是一个函数的话,说明它是一个组件,否则就是一个组件实例或者元素
function resolveElement(node: React.ReactNode) {
const Element =
typeof node === 'function' ? node : () => <>{React.isValidElement(node) ? node : null}</>
return <Element />
}

const ReactPreview = () => {
const { code, scope } = useContext(LiveContext)
const [node, setNode] = useState<React.ReactNode>()

// 当 code 或者 scope 变了后都需要重新编译代码
useEffect(() => {
transpileAsync(code).catch(console.error)
}, [code, scope])

function transpileAsync(newCode: string) {
// - transformCode may be synchronous or asynchronous.
// - transformCode may throw an exception or return a rejected promise, e.g.
// if newCode is invalid and cannot be transformed.
// - Not using async-await to since it requires targeting ES 2017 or
// importing regenerator-runtime...
try {
// 这里对比 react-live 简化了部分代码,只为能更简单看懂主流程,react-live 编译前允许使用者转换处理代码
const transformResult = newCode
return Promise.resolve(transformResult).then((transformedCode) => {
// Transpilation arguments
const input = {
code: transformedCode,
scope,
}
// 一定要通过这种方式保存组件,因为 setState 支持传入一个function,但是组件本身又是一个方法,直接通过 setState(FunctionalElement)
// 会让 react 以为你传入的组件是一个更新 state 的函数
setNode(() => generateNode(input))
})
} catch (e) {
return Promise.resolve()
}
}

return resolveElement(node)
}
export default ReactPreview

渲染 html

上边我们介绍了如何预览 react 代码,那我如果我想预览一些 html 代码呢?这个相比渲染 jsx 就简单多了。
最简单方式是使用 dangerouslySetInnerHTML 将 html 文本设置进一个元素,例如 <div dangerouslySetInnerHTML={{ __html: "<span>hello</span>" }}></div>,但是这种方式局限性太大,不适用完整的 html 标签,也没有 window.onload 等一系列事件 ,所以我考虑使用 iframe 来达成这个需求,我们知道 iframe 需要一个 src url,大家是不是以为我们必须把代码转化为可访问 url 才行?我一开始也是这么想的,初版使用的 createObjectURL 方式生成了一个 url,不过后边发现其实有更简单的方式,那就是使用 srcCode 属性,直接将代码传入就行。。。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React, { useContext } from 'react'
import { LiveContext } from '../LiveProvider'

const HtmlPreview = () => {
const { code } = useContext(LiveContext)

return code ? (
<iframe
srcCode={code}
frameBorder={0}
allowFullScreen
allow='accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking'
/>
) : null
}

export default HtmlPreview

效果预览

007zq4ODly1h34gw134djg30ls0a412h

结尾

如上代码只是对 react-live 的拙略模仿,并且为了教程清晰有意删减了很多功能,例如 ErrorBoundrynoInline,推荐各位有条件可以直接阅读源码,很简短。🥹
下篇文章我将记录一下如何在 nextjs 中使用 markdown,并且在 markdown 中嵌入 react-live 组件。

参考