外观
第56章—ReactPlayground项目实战:babel编译、iframe预览
2409字约8分钟
2024-09-19
我们实现了多文件的切换、文件内容编辑:
左边部分可以告一段落。
这节我们开始写右边部分,也就是文件的编译,还有 iframe 预览。
编译前面讲过,用 @babel/standalone 这个包。
安装下:
npm install --save @babel/standalone
npm install --save-dev @types/babel__standalone在 Preview 目录下新建 compiler.ts
import { transform } from '@babel/standalone'
import { Files } from '../../PlaygroundContext'
import { ENTRY_FILE_NAME } from '../../files'
export const babelTransform = (filename: string, code: string, files: Files) => {
  let result = ''
  try {
    result = transform(code, {
      presets: ['react', 'typescript'],
      filename,
      plugins: [],
      retainLines: true
    }).code!
  } catch (e) {
    console.error('编译出错', e);
  }
  return result
}
export const compile = (files: Files) => {
  const main = files[ENTRY_FILE_NAME]
  return babelTransform(ENTRY_FILE_NAME, main.value, files)
}调用 babel 的 transform 方法进行编译。
presets 指定 react 和 typescript,也就是对 jsx 和 ts 语法做处理。
retainLines 是编译后保持原有行列号不变。
在 compile 方法里,对 main.tsx 的内容做编译,返回编译后的代码。
在 Preview 组件里调用下:
import { useContext, useEffect, useState } from "react"
import { PlaygroundContext } from "../../PlaygroundContext"
import Editor from "../CodeEditor/Editor";
import { compile } from "./compiler";
export default function Preview() {
    const { files} = useContext(PlaygroundContext)
    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>
}在 files 变化的时候,对 main.tsx 内容做编译,然后展示编译后的代码。
看下效果:
可以看到,右边展示了编译后的代码,并且左边编辑的时候,右边会实时展示编译的结果。
这样编译后的代码能直接放到 iframe 里跑么?
明显不能,我们只编译了 main.tsx,它引入的模块没有做处理。
前面讲过,可以通过 babel 插件来处理 import 语句,转换成 blob url 的方式。
我们来写下这个插件:
function customResolver(files: Files): PluginObj {
    return {
      visitor: {
        ImportDeclaration(path) {
           path.node.source.value = '23333';
        },
      },
    }
}babel 的编译流程分为 parse、transform、generate 三个阶段:
通过 astexplorer.net 看下对应的 AST:
我们要改的就是 ImportDeclaration 节点的 source.value 的内容。
可以看到,确实被替换了。
那替换成什么样还不是我们说了算。
我们分别对 css、json 还有 tsx、ts 等后缀名的 import 做下替换:
首先,我们要对路径做下处理,比如 ./App.css 这种路径提取出 App.css 部分
万一输入的是 ./App 这种路径,也要能查找到对应的 App.tsx 模块:
如果去掉 ./ 之后,剩下的不包含 . 比如 ./App 这种,那就要补全 App 为 App.tsx 等。
过滤下 files 里的 js、jsx、ts、tsx 文件,如果包含这个名字的模块,那就按照补全后的模块名来查找 file。
之后把 file.value 也就是文件内容转成对应的 blob url:
ts 文件的处理就是用 babel 编译下,然后用 URL.createObjectURL 把编译后的文件内容作为 url。
而 css 和 json 文件则是要再做一下处理:
json 文件的处理比较简单,就是把 export 一下这个 json,然后作为 blob url 即可:
而 css 文件,则是要通过 js 代码把它添加到 head 里的 style 标签里:
全部代码如下:
import { transform } from '@babel/standalone'
import { File, Files } from '../../PlaygroundContext'
import { ENTRY_FILE_NAME } from '../../files'
import { PluginObj } from '@babel/core';
export const babelTransform = (filename: string, code: string, files: Files) => {
  let result = ''
  try {
    result = transform(code, {
      presets: ['react', 'typescript'],
      filename,
      plugins: [customResolver(files)],
      retainLines: true
    }).code!
  } catch (e) {
    console.error('编译出错', e);
  }
  return result
}
const getModuleFile = (files: Files, modulePath: string) => {
    let moduleName = modulePath.split('./').pop() || ''
    if (!moduleName.includes('.')) {
        const realModuleName = Object.keys(files).filter(key => {
            return key.endsWith('.ts') 
                || key.endsWith('.tsx') 
                || key.endsWith('.js')
                || key.endsWith('.jsx')
        }).find((key) => {
            return key.split('.').includes(moduleName)
        })
        if (realModuleName) {
            moduleName = realModuleName
        }
      }
    return files[moduleName]
}
const json2Js = (file: File) => {
    const js = `export default ${file.value}`
    return URL.createObjectURL(new Blob([js], { type: 'application/javascript' }))
}
const css2Js = (file: File) => {
    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' }))
}
function 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',
                            })
                        )
                    }
                }
            }
        }
    }
}
export const compile = (files: Files) => {
  const main = files[ENTRY_FILE_NAME]
  return babelTransform(ENTRY_FILE_NAME, main.value, files)
}看下效果:
可以看到,./App 的模块内容编译之后变为了 blob url。
我们引入 ./App.css 试下:
可以看到,css 模块也变为了 blob url。
我们在 devtools 里 fetch 下 blob url 可以看到它的内容:
fetch("blob:http://localhost:5173/xxxx")
  .then(response => response.text())
  .then(text => {
    console.log(text);
  });可以看到,./App.tsx 的内容是 babel 编译过后的。
./App.css 的内容也是我们做的转换。
而上面的 react、react-dom/client 的包是通过 import maps 引入:
其实还有一个问题要处理:
比如 App.tsx 的 jsx 内容编译后变成了 React.createElement,但是我们并没有引入 React,这样运行会报错。
处理下:
babel 编译之前,判断下文件内容有没有 import React,没有就 import 一下:
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, {
        presets: ['react', 'typescript'],
        filename,
        plugins: [customResolver(files)],
        retainLines: true
        }).code!
    } catch (e) {
        console.error('编译出错', e);
    }
    return result
}现在,如果没引入 React 就会自动引入:
至此, main.tsx 的所有依赖都引入了:
- react、react-dom/client 的包通过 import maps 引入
 - ./App.tsx、./App.css 或者 xx.json 之类的依赖通过 blob url 引入
 
这样,编译过后的这段代码就可以直接在浏览器里跑了:
我们加个 iframe 来跑下:
加一个 iframe 标签,src url 同样是用 blob url 的方式。
用 ?raw 的 import 引入 iframe.html的文件内容:
<!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" id="appSrc"></script>
<div id="root"></div>
</body>
</html>替换其中的 import maps 和 src 的内容。
之后创建 blob url 设置到 iframe 的 src。
当 import maps 的内容或者 compiledCode 的内容变化的时候,就重新生成 blob url。
import { useContext, useEffect, useState } from "react"
import { PlaygroundContext } from "../../PlaygroundContext"
import Editor from "../CodeEditor/Editor";
import { compile } from "./compiler";
import iframeRaw from './iframe.html?raw'
import { IMPORT_MAP_FILE_NAME } from "../../files";
export default function Preview() {
    const { files} = useContext(PlaygroundContext)
    const [compiledCode, setCompiledCode] = useState('')
    const [iframeUrl, setIframeUrl] = useState(getIframeUrl());
    useEffect(() => {
        const res = compile(files);
        setCompiledCode(res);
    }, [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" id="appSrc"></script>',
            `<script type="module" id="appSrc">${compiledCode}</script>`,
        )
        return URL.createObjectURL(new Blob([res], { type: 'text/html' }))
    }
    useEffect(() => {
        setIframeUrl(getIframeUrl())
    }, [files[IMPORT_MAP_FILE_NAME].value, compiledCode]);
    return <div style={{height: '100%'}}>
        <iframe
            src={iframeUrl}
            style={{
                width: '100%',
                height: '100%',
                padding: 0,
                border: 'none',
            }}
        />
        {/* <Editor file={{
            name: 'dist.js',
            value: compiledCode,
            language: 'javascript'
        }}/> */}
    </div>
}看下效果:
看下 iframe 的内容:
没啥问题。
预览功能完成!
案例代码上传了小册仓库,可以切换到这个 commit 查看:
git reset --hard a02195cfa12948e969bb9dc9cf01cdbe79331ab4总结
前面章节实现了代码编辑,这节我们实现了编译以及在 iframe 里预览。
使用 @babel/standalone 做的 tsx 代码的编译,编译过程中需要对 .tsx、.css、.json 等模块的 import 做处理,变成 blob url 的方式。
tsx 模块直接用 babel 编译,css 模块包一层代码加到 head 的 style 标签里,json 包一层代码直接 export 即可。
对于 react、react-dom/client 这种,用浏览器的 import maps 来引入。
之后把 iframe.html 的内容替换 import maps 和 src 部分后,同样用 blob url 设置为 iframe 的 src 就可以了。
这样就能实现浏览器里的编译和预览。