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
实现原理1. React 产物构建(/bundler.html)构建方式实现原理2. 构建编辑器(/index.html)和 Mometa 运行时(mometa)构建方式实现原理3. MometaEditorPlugin 原理Reference
实现原理
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
})
构建时使用 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
})
实现原理
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
})
构建编辑器
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 运行时
产物有两个:
- Entry 产物
- 目的一:读取
babel/plugin-react
注入的__mometa
元数据,并转到 react element 的 source 元数据中 - 目的二:为 iframe 注入通信、路由、渲染能力
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
if (isInIframe()) {
// 注入 iframe 宿主通信的方法
require('./shared-register')
// 通过拿到 Main 的 rxjs 响应式对象进行路由的通信
require('./location-register')
// 通过拿到 Main 的 rxjs 响应式对象进行渲染通信,发送当前选中或悬浮的元素给宿主
require('./render-register')
}
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);
注入 Mometa 运行时
相关代码
new EntryPlugin(compiler.context, require.resolve('./runtime/runtime-entry'), name).apply(compiler)
注入环境变量
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 中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 在编译时读取
mometa-material 配置
并使用node vm
运行并获取配置的数据 - 通过
EventStream
发送给浏览器内,刷新资源配置 - 添加并编译 mometa-material 配置文件
物料文件的改动监听交由 webpack,首次或一旦检测到改动,则触发 webpack 编译(第一次子编译),Node.js VM 环境执行编译后代码,得到 materials 配置;通过 materials 配置计算得到前端可执行代码,进行 webpack 编译(第二次子编译);所以使用该方式可以达到物料动态预览的效果