參考 react-live,如何實現代碼在線預覽?

目前很多平臺都支持嵌入 CodeSandbox、StackBlitz 等第三方在線代碼運行平臺,僅僅通過一個 iframe 標籤就可以完成,這個當然是很方便的,不過有時候我們只是想展示一些輕量級代碼,再加上經常網絡抽風,還需要我們在他們平臺維護一堆倉庫,不知道哪天自己手抖就刪了,導致文章中 demo 效果無法展示。那麼有沒有可能我們自己實現這種在線運行的功能呢,答案當然是可以。

應該提供哪些主要功能 🤔

編輯器組件應該與預覽組件分離,給予用戶只使用編輯器的機會,單獨使用編輯器時就是一個普通的代碼高亮展示組件,所以提供兩個單獨組件。爲了方便我們代碼中編輯器組件與預覽組件跨組件交換數據,我們再額外提供一個 Provider 組件,該組件負責爲編輯器組件與預覽組件包裹一個 ContextProvider,我們的配置都通過 Provider 組件傳入,大致寫法結構如下:

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

效果預覽

截止到這裏,我們已經實現一個可編輯的代碼高亮組件了,如果目標只是如此,你就可以不繼續往下看了,當然我們目標應該不只如此,接下來考慮預覽組件怎麼做。

預覽組件

預覽組件相對 Editor 而言比較複雜,我們要考慮多種語言的情況,考慮下邊一串代碼,如何才能編譯成一個 react 組件?

不只 jsx 語法,我還要支持原生 html,爲了代碼可擴展性,首先讓我們把 Preview 組件細分一下再考慮具體不同語言的編譯。

在上邊組件中我們根據 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

效果預覽

結尾

如上代碼只是對 react-live 的拙略模仿,並且爲了教程清晰有意刪減了很多功能,例如 ErrorBoundrynoInline,推薦各位有條件可以直接閱讀源碼,源碼代碼量很少且簡單易懂,偷偷的告訴你,react 官網也在使用它。後邊有機會我將記錄一下如何在 nextjs 中使用 markdown,並且在 markdown 中嵌入 react-live 組件。

參考

來源:

https://juejin.cn/post/7110586128113074206

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/A1n2qhAbdqguWSoWl1RDxw