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

3,026 阅读6分钟

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

应该提供哪些主要功能 🤔

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

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

Provider 组件

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

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,两者结合便可以实现一个基本的编辑器组件。

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 组件?

function Count() {
  const [count, setCount] = useState(0)

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

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

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,它可以在浏览器环境对代码进行快速编译,更多使用方式可以参考官方文档。

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 属性,直接将代码传入就行。。。

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,推荐各位有条件可以直接阅读源码,源码代码量很少且简单易懂,偷偷的告诉你,react 官网也在使用它。 后边有机会我将记录一下如何在 nextjs 中使用 markdown,并且在 markdown 中嵌入 react-live 组件。

参考