学习React Playground:编译与预览

516 阅读2分钟

该文章是学习react小册的React Playground项目的总结

编译

在原理篇中有介绍是通过@babel/standalone来编译
安装

npm install --save @babel/standalone

npm install --save-dev @types/babel__standalone

首先看一下编译后的代码是怎么样的
将编译逻辑单独抽离出来 image.png 渲染只需要编译入口文件

import { ENTRY_FILE_NAME } from "@/files";
import { Files } from "@/store";
import { transform } from "@babel/standalone";

export const babelTransform = (filename: string, code: string, files: Files) => {
  let result = ""

  try {
    result = transform(code, {
      // 指定 react 和 typescript,也就是对 jsx 和 ts 语法做处理
      presets: ['react', 'typescript'],
      filename,
      plugins: [],
      // 编译后保持原有行列号不变
      retainLines: true
    }).code!
  } catch (error) {
    console.error('编译出错', error);
  }

  return result
}

export const compile = (files: Files) => {
  const main = files[ENTRY_FILE_NAME]
  return babelTransform(ENTRY_FILE_NAME, main.value, files)
}

在Preview组件引入Editor组件,将通过compile编译后的代码传入Editor组件,并监听files文件,在修改files文件时同步编译

import useStore from '@/store';
import { FC, useEffect, useState } from 'react';
import { compile } from './compiler';
import Editor from '../CodeEditor/Editor';

const Preview: FC = () => {
  const files = useStore(state => state.files)

  const [compiledCode, setCompiledCode] = useState('')

  useEffect(() => {
    const res = compile(files)
    setCompiledCode(res)
  }, [files])
  
  return <div style={{ height: '100%' }}>
    <Editor file={{
      name: 'dist.js',
      value: compiledCode,
      language: 'javascript'
    }} />
  </div>
};

export default Preview;

可以看到jsx编译成React.createElement,也同步了修改 chrome-capture-2024-7-30.gif 但是内容中引入的文件路径,babel并识别不了,可以用blob url的方式
但是怎么替换编译后代码中的路径呢?使用@babel/core
安装

npm i @babel/core

通过 astexplorer.net看到ImportDeclaration节点的source.value保存路径 image.png 可以看到是可以获取到路径,那就可以在transform的plugin

result = transform(code, {
      presets: ['react', 'typescript'],
      filename,
-      plugins: [],
+      plugins: [customResolver(files)],
      retainLines: true
    }).code!

企业微信截图_17252662617579.png 可以借鉴一下文档中的示例,添加ImportDeclaration方法,拿到path再判断文件的类型创建对应

const customResolver = (files: Files): PluginObj => {
  return {
    visitor: {
      ImportDeclaration(path) {
        const modulePath = path.node.source.value
        if (modulePath.startsWith('.')) {
          const file = getModuleFile(files, modulePath)
          if (!file)
            return

          if (file.name.endsWith('.css')) {
            path.node.source.value = css2Js(file)
          } else if (file.name.endsWith('.json')) {
            path.node.source.value = json2Js(file)
          } else {
            path.node.source.value = URL.createObjectURL(
              new Blob([babelTransform(file.name, file.value, files)], {
                type: 'application/javascript',
              })
            )
          }
        }
      }
    }
  }
}

json是没有导出,需要加上export default,file.value就是一个对象
css是添加到head里的style标签里
js就默认

const json2Js = (file: EditorFile) => {
  const js = `export default ${file.value}`
  return URL.createObjectURL(new Blob([js], { type: 'application/javascript' }))
}

const css2Js = (file: EditorFile) => {
  const randomId = new Date().getTime()
  const js = `
(() => {
  const stylesheet = document.createElement('style')
  stylesheet.setAttribute('id', 'style_${randomId}_${file.name}')
  document.head.appendChild(stylesheet)

  const styles = document.createTextNode(\`${file.value}\`)
  stylesheet.innerHTML = ''
  stylesheet.appendChild(styles)
})()
  `
  return URL.createObjectURL(new Blob([js], { type: 'application/javascript' }))
}

可以看到./App编译成blob url image.png 既然是个url,那能不能请求这个url呢?
直接访问./App的blob url
可以看到是返回了App编译后的内容 image.png image.png 但是有个问题,createElement是通过React使用的,但是React我们并没有导入
一种可以通过自己写导入,另外一种是在编译的时候自动加
在compiler.ts添加beforeTransformCode函数,判断是否导入React,没有就加导入React

export const beforeTransformCode = (filename: string, code: string) => {
  let _code = code
  const regexReact = /import\s+React/g
  if ((filename.endsWith('.jsx') || filename.endsWith('.tsx')) && !regexReact.test(code)) {
    _code = `import React from 'react';\n${code}`
  }
  return _code
}
export const babelTransform = (filename: string, code: string, files: Files) => {
+  let _code = beforeTransformCode(filename, code);
  let result = ''
  try {
-    result = transform(code, {
+    result = transform(_code, {
      presets: ['react', 'typescript'],
      filename,
      plugins: [customResolver(files)],
      retainLines: true
    }).code!
  } catch (error) {
    console.error('编译出错', error);
  }

  return result
}

再次访问App的blob url,可以看到加入了 image.png

预览

现在编译工作完成了,可以开始渲染了
新增iframe的html文件

image.png

<!doctype html>
<html lang="en">

<head>
 <meta charset="UTF-8" />
 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 <title>Preview</title>
</head>

<body>
 <script type="importmap"></script>
 <script type="module"></script>
 <div id="root"></div>
</body>

</html>

分别准备type为importmap、module的script标签,这两个内容都是未知,所以都需要替换

在Preview组件中,将importmap和编译后的main文件填充到空的script标签中,再根据最新的html内容创建blob url给iframe标签展示

import iframeRaw from './iframe.html?raw'
import { IMPORT_MAP_FILE_NAME } from '@/files';

const getIframeUrl = () => {
    const res = iframeRaw.replace(
      '<script type="importmap"></script>',
      `<script type="importmap">${files[IMPORT_MAP_FILE_NAME].value
      }</script>`
    ).replace(
      '<script type="module"></script>',
      `<script type="module">${compiledCode}</script>`,
    )
    return URL.createObjectURL(new Blob([res], { type: 'text/html' }))
  }

  const [iframeUrl, setIframeUrl] = useState(getIframeUrl());
  
useEffect(() => {
    setIframeUrl(getIframeUrl())
  }, [files[IMPORT_MAP_FILE_NAME].value, compiledCode]);
<iframe
      src={iframeUrl}
      style={{
        width: '100%',
        height: '100%',
        padding: 0,
        border: 'none',
      }}
    />

可以看到点击按钮跟修改文件代码都是显示正常 chrome-capture-2024-8-3.gif