Mars低代码如何基于ESBuild实现在线编译和运行

2,368 阅读10分钟

背景

当前市面上有很多在线编辑器,比如 stackblitzcodepen码上掘进 等,也有很多低代码平台嵌入了在线编辑器功能,比如:阿里 LowCodeEngine 等。在线编辑器正变得越来越受开发者欢迎,除了能够在线开发以外,更重要的在于能够完成在线编译、实时预览,在适当的业务场景下,通过在线编译能够完成业务闭环,不仅提高了运营效率,同时提升了用户体感。

本篇文章会介绍实现一个简单React在线开发、在线编译和实时预览功能。

前沿

目前前端开发的主流构建工具主要有:webpackjsvitejs(esbuild + rollup)parceljs 等。 在国内可能大部分项目都是 webpack 构建,在新势力vitejs迅猛发展下,越来越多的VueReact项目采用了Vite作为构建工具,主要得益于Vite在开发模式下基于ESBuild构建,构建速度和性能遥遥领先,看一张官网截图:

截图来源官网地址:esbuild.github.io/

方案概览

以低代码场景为例,为了满足更多个性化的开发需求,需要引入自定义组件功能,从而实现开发者在线开发、在线编译和实时预览,以下是总体实现思路。

  • 在线编辑器
  • 代码编译
  • 在线运行

实现编辑器

目前主流的编辑器有 monaco-editorcodemirror,从开发者受欢迎程度来看,monaco-editor 更受喜爱,主要因为 monaco-editor 是基于vs-code的网页版实现,体验几乎一致。

对应React版插件为:@monaco-editor/react ,下面我们先基于该插件实现一个基础的编辑器功能。

安装
pnpm add @monaco-editor/react monaco-editor -S
使用
import Editor,{ loader } from '@monaco-editor/react';

export default function CodingEditor() {

  // 初始化monaco,默认为jsdelivery分发,由于网络原因改为本地cdn
  loader.config({
    paths: {
      vs: 'xxx/min/vs',
    },
  });
  
  return (
    <Editor
      height="calc(100vh - 155px)"
      language="json"
      theme="vs-light"
      defaultValue="// code "
      options={{
        lineNumbers: 'on',
        minimap: {
          enabled: false,
        },
      }}
      onMount={handleEditorDidMount}
    />
  );
}

语法非常简单,但是需要注意的是,我用的是loader.config加载资源的,vs那里需要引入资源路径,最终一个基础的在线编辑器就有了。

使用坑

虽然使用简单,但是,很快大家会发现一个问题,那就是cdn加载及其慢,原因是monaco-editor加载了国外的cdn导致编辑器很慢才显示,甚至编辑器压根无法显示,比如:

加载成功(需要工具)

加载失败

源码查找

解决方法
  • 通过loader配置,修改cdn

  • 通过loader配置,加载monaco-editor

第一种方式最为简单,第二种需要结合webpack-plugin才能实现,然后对于vite项目这种方式无法使用。

最终版

import Editor from '@monaco-editor/react';

export default function CodingEditor() {

  // 初始化monaco,默认为jsdelivery分发,由于网络原因改为本地cdn
  loader.config({
    paths: {
      vs: 'xxx/min/vs',
    },
  });
  
  return (
    <Editor
      height="calc(100vh - 155px)"
      language="json"
      theme="vs-light"
      defaultValue="// code "
      options={{
        lineNumbers: 'on',
        minimap: {
          enabled: false,
        },
      }}
    />
  );
}

实际上编辑器依赖了很多monaco-editor的插件,这个地方通过配置vs路径,可以很好的解决这个问题,我是用的公司的cdn,建议大家也可以下载插件源码,上传到公司的cdn,最后把前缀放进去即可。

页面效果

我们最终是要实现上面截图效果,在线开发jsxlessconfig.jsreadme.md然后在线编译,远程加载的方式实现自定义业务组件开发。

在线编译

前面提到了,目前前端的工具基本都是基于Node环境构建的,如果要实现代码在线编译,是不是也只能搭建后端Node服务才能实现?答案是否定的。 当前很多工具都开发了浏览器版本,以帮助我们实现浏览器环境下的在线编译功能,比如:babel-standaloneesbuild 等。

babel-standalone编译

<div id="output"></div>
<!-- Load Babel -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- Your custom script here -->
<script type="text/babel">
  const getMessage = () => "Hello World";
  document.getElementById("output").innerHTML = getMessage();
</script>

这是官网的例子,设置type="text/babel"后,就可以编写jsx语法,插件会自动转换。当然,@babel/standalone插件也提供了api的方式来编译代码,比如:

安装

pnpm add @babel/standalone

使用

var input = 'const getMessage = () => "Hello World";';
var output = Babel.transform(input, { presets: ["env"] }).code;

由于本文主要讲解基于esbuild实现组件编译和预览,所以此处不过多介绍,大家可以根据自身经验选择不同的编译工具。

esbuild编译

大家可能比较奇怪,esbuild也能在浏览器环境下运行?答案是支持的,不过需要注意的是,使用esbuild需要加载esbuild-wasm文件。

近些年 webassembly 非常火,它是一种二进制指令格式,在现代浏览器模式下能够提供接近原生的运行速度,它还有跨平台、高效性能、安全性等诸多优点。 令人高兴的是,esbuild就提供了wasm这种版本的插件包:esbuild-wasm

官网示例

我在翻阅文档的时候,看到这几个单词,可以说,非常高兴:Bundling for the browser

它的API也非常的简单,一个transform,另一个是build,前者用于代码转换,后者用于代码打包,当然也可以用来做代码编译。

编译 React 组件

  1. 安装插件
// npm 插件的方式
pnpm add esbuild-wasm -S

// 推荐动态加载
const loadScript = (src: string) => {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.type = 'text/javascript';
    script.defer = true;
    script.onload = resolve;
    script.onerror = reject;
    script.src = src;
    document.head.append(script);
  });
};

loadScript('xxx/npm/esbuild-wasm@0.20.2/lib/browser.min.js');

因为动态加载的方式能够提升其它页面加载性能,路径这里大家需要替换成自己公司的cdn。

  1. 加载wasm文件
  // 加载esbuild-wasm
  const initWasm = async () => {
    try {
      await loadScript('https://static.xxx.cn/npm/esbuild-wasm@0.20.2/lib/browser.min.js');
      await window.esbuild.initialize({ wasmURL: 'https://static.xxx.cn/npm/esbuild-wasm@0.20.2/esbuild.wasm' });
    } catch (error) {
      message.error('加载wasm失败,请刷新重试');
    }
  };

如果wasm加载失败,后面的编译将无法进行。

  1. 代码编译
// 此处只提供关键代码....
import React from 'react'

const code `
export default ({ id, type, config }, ref) => {
  const format = () => {
    return dayjs(new Date()).format('YYYY-MM-DD HH:mm:ss');
  };
  return (
    <div className="bgColor" data-id={id} data-type={type}>
      <Button style={config.style} {...config.props}>
        {config.props.text}
      </Button>
      <p>{format()}</p>
    </div>
  );
};
`

// 编译代码
const handleCompile = async () => {
    try {
      window.React = window.React || React;
      const result = await window.esbuild.build({
        bundle: true,
        format: 'esm',
        platform: 'browser',
        minify: true,
        // jsx: 'automatic',// 浏览器不支持jsx-runtime
        stdin: {
          contents: code,
          loader: 'tsx',
        },
      });
      const output = result.outputFiles?.[0] || {};
      console.log('编译结果:', output.text)
    } catch (error: any) {
      console.error(error);
    }
};

这里有几个注意事项:

  • 顶部必须添加导入:import React from 'react' 大家应该还记得React 17之前的版本,每次开发都需要先导入React就是因为编译后的代码依赖了这个变量,如果不导入就会报错,而现在大家又发现不需要再导入了,是因为最新版本的React编译方式变了,编译后的代码删除了React变量的依赖。
  • React变量挂载到window中,比如:window.React = window.React || React;
  • 编译方式有两种:esmiife ,前者为现代浏览器而生的格式,后者为立即执行函数,为了兼容旧浏览的写法。但是,在vite里面,我们会发现多了一种umd格式,也就是混合模式,主要为了跨平台兼容。
  1. 编译结果如下
var o=({id:e,type:a,config:t},s)=>{
    let r=()=>dayjs(new Date).format("YYYY-MM-DD HH:mm:ss");
    return React.createElement("div",{className:"bgColor","data-id":e,"data-type":a},
        React.createElement(Button,{style:t.style,...t.props},t.props.text),
        React.createElement("p",null,r()))
};
export{o as default};

当然,代码实际上是压缩的,我特意给它做了展开。这段代码就是jsx编译后的代码,因为format=esm所以,它编译后是一个模块,需要export导出。

很明显里面依然是基于React.createElement创建元素,所以我们需要提前导入React

在线运行

做到这儿的时候,看似已经完成了,但接下来还有很多难点需要解决,字符串如何在浏览器下运行?大家可能首先想到的就是evalnew Fucntion,但是这里比较特殊,它是一个包含export的字符串,如果用new Function去验证一下很快就会发现一堆问题。

它是一个导出模块,new Function是执行一段代码返回一个结果,那怎么办?

【解铃还须系铃人】既然是模块,那就需要按照模块的方式去解析,答案就是:import

静态导入:

import dayjs from 'dayjs'

const format = ()=>{
    return dayjs(new Date());
}

这种包导入属于静态导入,本地基于vite开发的时候,实际上dayjs会被替换为真实的引用地址,只要看过vite原理分析的都知道。

动态导入:

// 创建Test.tsx

export default () => {
  return <h1>测试一下</h1>;
};

// 创建App.tsx
export const App = ()=>{
  useEffect(() => {
    import('./Test.tsx').then((res) => {
      console.log('res', res);
    });
  }, []);
}

打印结果

问题思考

那问题来了,我们前面编译后的结果是字符串,难道要在前端通过fs去往磁盘写入一个文件,然后用相对地址去导入吗?前端肯定是不能往磁盘写入文件的,也没有这个权限,所以这条路行不通。但是动态导入的参数除了相对地址以外,它还可以是一个远程地址,比如:

import dayjs from 'https://cdn.skypack.dev/dayjs@1.10.7'

console.log(dayjs(new Date()).format('YYYY-MM-DD'));

那我们有没有可能把上面的字符串转换成一个远程地址?答案是肯定的,其实有很多种方式转换,最为常见的就是字符串转Blob对象。

Blob转换

const compileCode = `var o=({id:e,type:a,config:t},s)=>{let r=()=>dayjs(new Date).format("YYYY-MM-DD HH:mm:ss");return React.createElement("div",{className:"bgColor","data-id":e,"data-type":a},React.createElement(Button,{style:t.style,...t.props},t.props.text),React.createElement("p",null,r()))};export{o as default};
`

const blob = new Blob([compileCode], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);

try {
  const module = await import(url);
  console.log('模块:', module.default)
} catch (error) {
  console.error('模块加载失败:', error);
}
URL.revokeObjectURL(url);

通过转换为二进制的大对象,我们就可以拿到一个url地址,最后通过import实现字符串的加载。module.default就是字符串解析为js后的导出模块对象。

组件渲染

export default function Preview(){
    const [Component, setComponent] = useState<any>(null);
    const loadModule = ()=>{
        const compileCode = `var o=({id:e,type:a,config:t},s)=>{let r=()=>dayjs(new Date).format("YYYY-MM-DD HH:mm:ss");return React.createElement("div",{className:"bgColor","data-id":e,"data-type":a},React.createElement(Button,{style:t.style,...t.props},t.props.text),React.createElement("p",null,r()))};export{o as default};
`
        const blob = new Blob([compileCode], { type: 'application/javascript' });
        const url = URL.createObjectURL(blob);
        
        try {
          const module = await import(url);
          console.log('模块:', module.default)
          setComponent(()=>module.default)
        } catch (error) {
          console.error('模块加载失败:', error);
        }
        URL.revokeObjectURL(url);
    }
    
    useEffect(()=>{
        loadModule();
    },[])
    
    return {Component && <Component />}
}

页面效果

以上就是基于低代码场景下实现自定义组件的整体开发编译核心实现,当然完整的功能远比上面解析的要复杂。

大家可能看完还会有不少疑问,比如:代码里面有import xxx from 'xxx'这种语法怎么解析?按ctrl + s如何保存格式化文件,less如何编译,md用的什么插件等,的确,在做的过程中会遇到很多问题,但我无法一次性把所有内容都讲出来,只能挑选一个重点作为思路描述给大家听。

另外还有一个问题,代码保存到数据库以后,在Mars系统中拖拽组件的时候,如何实现远程加载?其实这个过程跟上面截图的效果比较类似,我们会把编译后的代码转换为blob对象,当做文件上传到cdn,拖拽组件的时候,会拿到组件的cdn地址,然后通过import()进行动态解析,从而实现远程加载。

  1. 保存代码自动格式化,用的是prettier插件。
  2. less编译用的是less插件
  3. md文档用的是@bytemd/react,是偷偷从掘进的文章编写里面找到的插件。
  4. import语法解析暂时没做,需要通过全局cdn引入,实现的话跟我前一篇文章vite-plugin插件原理分析有些类似,在Mars里面,由于时间关系,没来得及加,后续可能开源以后,不忙了,会慢慢添加。
  5. config.js大家可能不知道做什么的,其实是组件的配置,等你看了源码大概就明白了。