
实现原理
1. React 产物构建(/bundler.html)
React 产物是 Mometa 编辑器当前编辑的 React 代码
构建方式
-
构建时使用
babel/plugin-react插件**,**构建时注入代码元数据引入 babel/plugin-react 插件:
JavaScriptgetSingleConfig(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)引入插件:
JavaScriptgetSingleConfig(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略)源代码:
JavaScriptfunction 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)注入元数据后的代码:
JavaScriptfunction 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> ) } -
元数据类型定义JavaScriptinterface 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 运行时JavaScriptgetSingleConfig('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 }) -
构建
编辑器JavaScriptgetSingleConfig(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 元数据中JavaScriptif (__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') }
-
-
Empty Placeholder产物- 单独打包的 React 占位组件,显示为
空视图元素, - 在
React 产物打包的情况下使用babel/plugin-react注入Empty Placeholder
- 单独打包的 React 占位组件,显示为
-
-
编辑器原理(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中注入editorConfigJavaScriptnew 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 运行时相关代码JavaScriptnew EntryPlugin(compiler.context, require.resolve('./runtime/runtime-entry'), name).apply(compiler) -
注入环境变量
JavaScriptnew 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 中JavaScriptif (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 配置文件
- webpack 在编译时读取
-
Discussion
留言与讨论
想法、补充和不同意见都欢迎。