阅读 413

如何实现一个简单的基于React的在线编辑预览组件

前言

随着时间的推移,在线IDE应用越来越多,功能也越来越完善。比如

于是乎,就对这些在线IDE的实现原理产生了好奇,自己也想尝试实现一个类似的功能。这里有一篇文章详细讲述了codesandbox的实现原理:传送门

我们尝试实现一个这样的功能,左边输入代码、右边预览效果。

image.png

文件结构如下

image.png

分别贴出代码内容

(1)package.json

{
  "name": "react",
  "version": "1.0.0",
  "description": "React example starter project",
  "keywords": [
    "react",
    "starter"
  ],
  "main": "src/index.js",
  "dependencies": {
    "@babel/runtime": "7.13.8",
    "@babel/standalone": "7.13.11",
    "acorn": "8.1.0",
    "escodegen": "2.0.0",
    "lodash": "4.17.21",
    "object-path": "0.11.5",
    "react": "17.0.1",
    "react-dom": "17.0.1",
    "react-monaco-editor": "0.43.0",
    "react-scripts": "4.0.0"
  },
  "devDependencies": {
    "typescript": "4.1.3"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  },
  "browserslist": [
    ">0.2%",
    "not dead",
    "not ie <= 11",
    "not op_mini all"
  ]
}
复制代码

(2)index.js

import { StrictMode } from "react";
import ReactDOM from "react-dom";

import App from "./App";

const rootElement = document.getElementById("root");
ReactDOM.render(
  <StrictMode>
    <App />
  </StrictMode>,
  rootElement
);
复制代码

(3)App.jsx

import "./styles.css";
import SandBox from './SandBox';

export default function App() {
  return (
    <div className="App">
      <SandBox />
    </div>
  );
}
复制代码

(4)SandBox.jsx

import React, { useState, useEffect, useRef } from "react";
import _debounce from "lodash/debounce";
import { createEditor } from "./util";
// import MonacoEditor from "react-monaco-editor";

function SandBox() {
  const viewRef = useRef(null);
  const runtimeRef = useRef(null);
  const [code, setCode] = useState(`
    function HolyCow() {
      return <span>HolyCow, My God!</span>
    }

    <HolyCow />
  `);

  useEffect(() => {
    runtimeRef.current = createEditor(viewRef.current);
    runtimeRef.current.run(code);
  }, []);

  const run = _debounce((newCode) => {
    runtimeRef.current.run(newCode || code);
  }, 500);

  const onCodeChange = ({ target: { value } }) => {
    setCode(value);
    run(value);
  };

  return (
    <div className="container" style={{ display: "flex" }}>
      <div className="code-editor" style={{ flex: 1 }}>
        <textarea value={code} onChange={onCodeChange} />
      </div>
      <div style={{ flex: 1 }}>
        <div className="preview" ref={viewRef} />
      </div>
    </div>
  );
}

export default SandBox;
复制代码

(5)util.js

import React from "react";
import ReactDOM from "react-dom";
import ObjPath from "object-path";
import { parse } from "acorn";
import { generate as generateJs } from "escodegen";
import { transform as babelTransform } from "@babel/standalone";

// 搜索目标节点
export function findReactNode(ast) {
  // ast标准结构 body
  const { body } = ast;

  // 自定义一个迭代器
  return body.find((node) => {
    // 根据React.createElement匹配吧~
    const { type } = node;
    // 这个ObjPath类似lodash的get
    const obj = ObjPath.get(node, "expression.callee.object.name");
    const func = ObjPath.get(node, "expression.callee.property.name");

    return (
      type === "ExpressionStatement" &&
      obj === "React" &&
      func === "createElement"
    );
  });
}

// 动态创建方法
export function createEditor(domElement, moduleResolver = () => null) {
  // 运行时的入参,带入方法用的
  function render(node) {
    ReactDOM.render(node, domElement);
  }

  // 同上
  function require(moduleName) {
    return moduleResolver(moduleName);
  }

  // 核心
  function getWrapperFunction(code) {
    try {
      // 1. 一大窜React&ES6代码谁认识,先得降级吧
      const esCode = babelTransform(code, { presets: ["es2015", "react"] })
        .code;

      // 2. 原生代码toAst(这里暂用acorn、babel、eslint 都符合 ESTree Spec标准, 传送门:https://github.com/estree/estree)
      const ast = parse(esCode, {
        sourceType: "module"
      });

      // 3. 我们的目的是把jsx => js并且运行React.createElement
      //    所以得先到jsx装在的部分
      const rnode = findReactNode(ast);

      // 4. 如果找到了运行语句,接下来必须要包装render方法在React.createElemnet外面才能运行吧
      if (rnode) {
        // 先找到位置,便于后面直接替换
        const nodeIndex = ast.body.indexOf(rnode);
        // 生成字符串,截掉没用的信息
        const createElSrc = generateJs(rnode).slice(0, -1);
        // 重新生成改造后的ast - 可以执行的语句
        const renderCallAst = parse(`render(${createElSrc})`).body[0];
        ast.body[nodeIndex] = renderCallAst;
      }

      // 5. 完事具备运行起来吧,eval效率贼低不说还不安全,new Function吧
      // 运行时方法很多,尤其在node端 vm库 - runInThisContext等
      // 前面三个入参,后面是函数体
      return new Function("React", "render", "require", generateJs(ast));
    } catch ({ message }) {
      // 兜底
      render(<pre style={{ color: "red" }}>{message}</pre>);
    }
  }

  // 妈的前面的核心不能暴露,还是返回方法吧
  return {
    // 查看编译结果
    compile(code) {
      return getWrapperFunction(code);
    },
    // 直接运行
    run(code) {
      this.compile(code)(React, render, require);
    },
    // 查看生成的字符串
    getCompiledCode(code) {
      return getWrapperFunction(code).toString();
    }
  };
}
复制代码

(6)styles.css

.App {
  font-family: sans-serif;
  text-align: center;
}

.container {
  width: 100vw;
  height: 100vh;
}

.code-editor {
  width: 50%;
}

.code-editor textarea {
  padding: 1em;
  width: 100%;
  height: 100%;
  border: solid 1px #000;
  min-height: 30em;
  outline: none;
  font-family: "Monaco";
  font-size: 14px;
  background: #333;
  color: #74b9ff;
}

复制代码

源码传送门

我们看到AST作为一个承上启下的作用,目前我们只是实现了简单的单一文件的编辑预览,如果是多模块我们又改怎么处理呢?无外乎还是需要AST查找,遍历出import的模块,并且把所有依赖的模块拼装起来,再进行执行。

文章分类
前端
文章标签