返回专题

专题 2023

Mometa 低码前端编辑器原理学习

面向研发的低代码元编程,代码可视编辑,辅助编码工具原理学习

Lowcode出码
本文目录9
  1. 实现原理
  2. 1. React 产物构建(/bundler.html)
  3. 构建方式
  4. 实现原理
  5. 2. 构建编辑器(/index.html)和 Mometa 运行时(mometa)
  6. 构建方式
  7. 实现原理
  8. 3. MometaEditorPlugin 原理
  9. Reference

实现原理

1. React 产物构建(/bundler.html)

React 产物是 Mometa 编辑器当前编辑的 React 代码

构建方式

  • 构建时使用 babel/plugin-react 插件**,**构建时注入代码元数据

    引入 babel/plugin-react 插件:

    JavaScript
    getSingleConfig(webpackEnv, {
      name: 'app',
      htmlName: 'bundler.html',
      entry: [paths.resolveApp('src/app/index.tsx')],
      babelPlugins: [require.resolve('../../babel/plugin-react')],
      plugins: [
        new MometaEditorPlugin({
          react: true,
          experimentalMaterialsClientRender: true,
          editorConfig: {
            bundlerURL: '/bundler.html'
          }
        }),
        new webpack.NormalModuleReplacementPlugin(/^\$mometa-external:(.+)$/, function (resource) {
          resource.request = resource.request.replace(/^\$mometa-external:(.+)$/, '$1')
        })
      ],
      refresh: false
    })
    
  • 构建时使用 Mometa 编辑器插件(MometaEditorPlugin

    引入插件:

    JavaScript
    getSingleConfig(webpackEnv, {
      name: 'app',
      htmlName: 'bundler.html',
      entry: [paths.resolveApp('src/app/index.tsx')],
      babelPlugins: [require.resolve('../../babel/plugin-react')],
      plugins: [
        new MometaEditorPlugin({
          react: true,
          experimentalMaterialsClientRender: true,
          editorConfig: {
            bundlerURL: '/bundler.html'
          }
        }),
        new webpack.NormalModuleReplacementPlugin(/^\$mometa-external:(.+)$/, function (resource) {
          resource.request = resource.request.replace(/^\$mometa-external:(.+)$/, '$1')
        })
      ],
      refresh: false
    })
    

实现原理

  • babel/plugin-react 原理(注入 Empty Placeholder 略)

    源代码:

    JavaScript
    function App() {
      const [list] = React.useState(['p1', 'p2'])
    
      return (
        <div>
          <h1>Title</h1>
          {list.map((text, i) => (
            <p key={i}>{text}</p>
          ))}
        </div>
      )
    }
    
    ReactDOM.render(<App />, window.root)
    

    注入元数据后的代码:

    JavaScript
    function App() {
      const [list] = React.useState(['p1', 'p2'])
    
      return (
        <div
          __mometa={{
            start: { line: 5, column: 4 },
            end: { line: 10, column: 4 },
            filename: '/App.tsx',
            name: 'div',
            text: 'raw text',
            // 以及其他数据
          }}
        >
          <h1 __mometa={{...}}>Title</h1>
          {list.map((text, i) => (
            <p __mometa={{...}} key={i}>{text}</p>
          ))}
        </div>
      )
    }
    
  • 元数据类型定义

    JavaScript
    interface RangeLocation {
      // 视图头部的位置
      // $POS<div></div>
      start: {
        line: number
        column: number
      }
      // 视图尾部的位置
      // <div></div>$POS
      end: {
        line: number
        column: number
      }
    }
    
    interface MometaData extends RangeLocation {
      // 视图 JSX name
      name: string
      // 视图 JSX 完整 text
      text: string
      // 视图 JSX 文件名
      filename: string
      // 视图 JSX 是否无 children, 如 <div></div>
      // 用来控制可视编辑的时候注入 空元素占位符,以便进行操作
      emptyChildren: boolean
      // 相对于 PWD 的相对文件名
      // 可视化编辑后,用于判断是否命中匹配的改动,resolve loading
      relativeFilename: string
      // 根据 mometa 数据 shasum 生成的唯一 id
      hash: string
      // 是否为某作用域内的第一个元素
      // 如 App 组件中,`div` 和 `p` 为其中的第一个元素,`h1` 不是
      // 用于可视化编辑后,控制是否需要前置包裹 <>$CONTENT</>
      // <p></p> => <><p>inserted</p><p></p></>
      isFirst: boolean
      // 视图 JSX 是否 selfClosed, 如 <div />
      // 用来控制可视编辑的时候是否能够插入到 children 中
      selfClosed: boolean
    
      // 视图内部头部的位置
      // <div>$POS  </div>
      innerStart: {
        line: number
        column: number
      }
      // 视图内部尾部的位置
      // <div>  $POS</div>
      innerEnd: {
        line: number
        column: number
      }
    
      /**
       * 视图容器信息,在 React 中为被 `{}` 包裹的 JSXElement
       * {list.map((text, i) => (
       *    <p key={i}>{text}</p>
       * ))}
       * 用于可视化编辑时(如删除、移动、插入),控制的是 container 还是 视图本身
       */
      container?: {
        isFirstElement: boolean
        text: string
        hash: string
      } & RangeLocation
    
      /**
       * 上个兄弟节点的位置信息,用于控制上移
       */
      previousSibling?: {
        text: string
      } & RangeLocation
      /**
       * 下个兄弟节点的位置信息,用于控制下移
       */
      nextSibling?: {
        text: string
      } & RangeLocation
    }
    

2. 构建编辑器(/index.html)和 Mometa 运行时(mometa)

  • 编辑器是在可视化编辑时的外壳
  • Mometa 运行时用于解析 React 打包后的产物和用于通信的,所以会在 React 产物打包的时候使用 MometaEditorPlugin 注入 Mometa 运行时

构建方式

  • 构建 Mometa 运行时

    JavaScript
    getSingleConfig('development', {
      cssExtract: false,
      refresh: false,
      entry: {
        entry: [nps.resolve(__dirname, '../../webpack/runtime/runtime-entry.js')],
        'empty-placeholder': [nps.resolve(__dirname, '../../src/mometa/runtime/empty-placeholder.tsx')]
      },
      optimization: {
        splitChunks: {
          chunks: 'all',
          name: false
        }
      },
      name: 'runtime',
      outputPath: nps.join(paths.appBuild, 'runtime'),
      target: 'node',
      filename: '[name].js',
      plugins: [...localPlugins],
      library: { type: 'commonjs2' },
      htmlName: false
    })
    
  • 构建编辑器

    JavaScript
    getSingleConfig(webpackEnv, {
      refresh: false,
      name: 'editor',
      entry: [
        // experimentalMaterialsClientRender 开启时,临时修复;build 后不会出现该问题
        'antd/dist/antd.css',
        // packages/editor/example/src/index.tsx
        paths.appIndexJs
      ],
      htmlPath: paths.resolveApp('public/editor-runtime.html'),
      htmlName: 'index.html'
    })
    

实现原理

  • Mometa 运行时产物有两个:

    1. Entry 产物

      • 目的一:读取 babel/plugin-react 注入的__mometa元数据,并转到 react element 的 source 元数据中

        JavaScript
        if (__mometa_env_react_jsx_runtime__ && __mometa_env_is_dev__) {
          let JSXDEVRuntime
          try {
            JSXDEVRuntime = require('$mometa-external:react/jsx-dev-runtime')
          } catch (err) {}
          if (JSXDEVRuntime) {
            const { jsxDEV } = JSXDEVRuntime
            // 转移 __mometa
            JSXDEVRuntime.jsxDEV = function _jsxDev() {
              let [type, props, key, isStaticChildren, source, ...rest] = arguments
              if (props?.__mometa) {
                const __mometa = props?.__mometa
                delete props?.__mometa
                if (isInIframe()) {
                  source = {
                    ...source,
                    __mometa
                  }
                }
              }
              console.log('### JSXDEVRuntime', { type, props, key, isStaticChildren, source, rest })
              return jsxDEV.apply(this, [type, props, key, isStaticChildren, source, ...rest])
            }
          }
        }
        
      • 目的二:为 iframe 注入通信、路由、渲染能力

        JavaScript
        // iframe
        if (isInIframe()) {
          // 注入 iframe 宿主通信的方法
          require('./shared-register')
          // 通过拿到 Main 的 rxjs 响应式对象进行路由的通信
          require('./location-register')
          // 通过拿到 Main 的 rxjs 响应式对象进行渲染通信,发送当前选中或悬浮的元素给宿主
          require('./render-register')
        }
        
    2. Empty Placeholder 产物

      • 单独打包的 React 占位组件,显示为空视图元素
      • React 产物 打包的情况下使用 babel/plugin-react 注入 Empty Placeholder
  • 编辑器原理(packages/editor/src/editor.ts)

    • UI 区渲染
    • 通过 createClientConnection 建立本地 Server 的 SSE 连接,接收如下素材相关信息:
      • set-materials
      • materials-loading
      • set-materials-client-render
      • error
    • 通过 ApiServerPack 建立调用本地 API 能力的封装,并允许传给字 Iframe 使用,相关能力如下:
      • up
      • down
      • del
      • copy
      • insert-asset
      • move-dom

3. MometaEditorPlugin 原理

React 产物打包的时候会使用 MometaEditorPlugin ,主要功能如下:

  • 拷贝编辑器代码到指定目录,并且在编辑器的 index.html 中注入 editorConfig

    JavaScript
    new CopyPlugin({
      patterns: [
        {
          from: nps.join(BUILD_PATH, mode),
          to:
            major < 5
              ? `${this.options.contentBasePath}[path][name].[ext]`
              : `${this.options.contentBasePath}[path][name][ext]`,
          transform: (content, absoluteFrom) => {
            if (absoluteFrom === nps.join(BUILD_PATH, mode, "index.html")) {
              content = replaceTpl(
                String(content),
                "editor-config",
                JSON.stringify(this.options.editorConfig)
              );
            }
            return content;
          },
        },
      ],
    }).apply(compiler);
    
  • 注入 Mometa 运行时 相关代码

    JavaScript
    new EntryPlugin(compiler.context, require.resolve('./runtime/runtime-entry'), name).apply(compiler)
    
  • 注入环境变量

    JavaScript
    new DefinePlugin({
      __mometa_env_is_local__: !!process.env.__MOMETA_LOCAL__,
      __mometa_env_is_dev__: compiler.options.mode === "development",
      __mometa_env_which__: JSON.stringify(this.options.react ? "react" : ""),
    }).apply(compiler);
    
  • 注入 React 运行时ReactRefreshWebpackPlugin

    注入的 React 运行时会在 Mometa 运行时中使用,主要是用户读取 React 组件 props 上的 __mometa 数据,并挂在到 fiber 中

    JavaScript
    if (this.options.react) {
      let hasJsxDevRuntime = false;
      try {
        hasJsxDevRuntime = !!require.resolve("react/jsx-dev-runtime");
      } catch (e) {}
    
      new DefinePlugin({
        __mometa_env_react_jsx_runtime__: hasJsxDevRuntime,
      }).apply(compiler);
    }
    
    if (this.options.react && this.options.react.refresh !== false) {
      const opts = Object.assign(
        {
          __webpack: this.getWebpack(compiler),
          library: compiler.options.name,
          overlay: false,
        },
        this.options.react.refreshWebpackPlugin
      );
      new ReactRefreshWebpackPlugin(opts).apply(compiler);
    }
    
  • 创建本地服务

    • 创建 Http 服务,并且监听 /submit-op(文件文本操作)、/open-editor(打开编辑器) 路由
    • 创建EventStream,并在素材构建的时候发送 materials-loading 消息
    • 实现客户端渲染热更新
  • 基于本地服务实现物料热更新逻辑

    物料热更新有两种热更新模式:服务端序列化数据热更新 或 客户端渲染热更新(开启 experimentalMaterialsClientRender)

    • 服务端序列化数据热更新

      物料文件的改动监听都在服务端 Node.js 内自己处理,首次或一旦检测到改动,则推送最新数据至浏览器进行更新,热更新 Node.js 实现见 https://github.com/imcuttle/hot-module-require ,返回为可序列化的物料数据。

    • 客户端渲染热更新

      物料文件的改动监听交由 webpack,首次或一旦检测到改动,则触发 webpack 编译(第一次子编译),Node.js VM 环境执行编译后代码,得到 materials 配置;通过 materials 配置计算得到前端可执行代码,进行 webpack 编译(第二次子编译);所以使用该方式可以达到物料动态预览的效果

      • webpack 在编译时读取 mometa-material 配置并使用 node vm 运行并获取配置的数据
      • 通过 EventStream 发送给浏览器内,刷新资源配置
      • 添加并编译 mometa-material 配置文件

Reference

返回首页
上一篇Tango 低码前端编辑器原理学习下一篇啊鸡入坑 AI 之《LangChain 上手》

Discussion

留言与讨论

想法、补充和不同意见都欢迎。