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

Last Edited Time
Oct 27, 2023 06:26 AM
date
Oct 18, 2023
slug
mometa-leaning-note
status
Published
tags
Lowcode
出码
summary
面向研发的低代码元编程,代码可视编辑,辅助编码工具原理学习
type
Post
notion image

实现原理

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

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

构建方式

构建时使用 babel/plugin-react 插件构建时注入代码元数据
引入 babel/plugin-react 插件:
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
})
https://github.com/imcuttle/mometa/blob/ee5250203ef629a70b86bc2a2de8caf2b4df0920/packages/editor/example/config/webpack.config.js#L893
构建时使用 Mometa 编辑器插件(MometaEditorPlugin
引入插件:
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
})
https://github.com/imcuttle/mometa/blob/ee5250203ef629a70b86bc2a2de8caf2b4df0920/packages/editor/example/config/webpack.config.js#L895
 

实现原理

babel/plugin-react 原理(注入 Empty Placeholder 略)
源代码:
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)
注入元数据后的代码:
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>
  )
}
元数据类型定义
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 运行时
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
})
https://github.com/imcuttle/mometa/blob/ee5250203ef629a70b86bc2a2de8caf2b4df0920/packages/editor/example/config/webpack.config.js#L825
构建编辑器
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'
})
https://github.com/imcuttle/mometa/blob/ee5250203ef629a70b86bc2a2de8caf2b4df0920/packages/editor/example/config/webpack.config.js#L908

实现原理

Mometa 运行时产物有两个:
  1. Entry 产物
      • 目的一:读取 babel/plugin-react 注入的__mometa元数据,并转到 react element 的 source 元数据中
        • 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])
              }
            }
          }
          https://github.com/imcuttle/mometa/blob/19b768444370e9e34f1c9d8333ea9e59228938fd/packages/editor/src/mometa/entry.ts#L15
      • 目的二:为 iframe 注入通信、路由、渲染能力
        • // iframe
          if (isInIframe()) {
            // 注入 iframe 宿主通信的方法
            require('./shared-register')
            // 通过拿到 Main 的 rxjs 响应式对象进行路由的通信
            require('./location-register')
            // 通过拿到 Main 的 rxjs 响应式对象进行渲染通信,发送当前选中或悬浮的元素给宿主
            require('./render-register')
          }
  1. 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
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);
https://github.com/imcuttle/mometa/blob/aea24e1b5983c2e50e1adab775e0221d31fef176/packages/editor/webpack/index.js#L82
注入 Mometa 运行时 相关代码
new EntryPlugin(compiler.context, require.resolve('./runtime/runtime-entry'), name).apply(compiler)
https://github.com/imcuttle/mometa/blob/aea24e1b5983c2e50e1adab775e0221d31fef176/packages/editor/webpack/index.js#L115
注入环境变量
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);
https://github.com/imcuttle/mometa/blob/aea24e1b5983c2e50e1adab775e0221d31fef176/packages/editor/webpack/index.js#L123
注入 React 运行时ReactRefreshWebpackPlugin
注入的 React 运行时会在 Mometa 运行时中使用,主要是用户读取 React 组件 props 上的 __mometa 数据,并挂在到 fiber 中
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);
}
https://github.com/imcuttle/mometa/blob/aea24e1b5983c2e50e1adab775e0221d31fef176/packages/editor/webpack/index.js#L136
创建本地服务
  • 创建 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