tmagic-editor/docs/guide/runtime.md
roymondchen d16ab9a805 docs: 重写快速开始与 runtime 指南,与 playground/runtime 源码对齐
快速开始:
- 补充 admin-client / runtime 项目结构说明
- 完善 UI adapter(element-plus / tdesign-vue-next)说明
- 增加 Monaco worker 注入与常见报错处理
- 重写 m-editor 完整示例,对齐 playground 源码

runtime 指南:
- 完善 tmagic.config.ts 与 .tmagic 入口产物说明
- 拆分 playground / page 双入口实现细节
- 新增 vite 多入口构建、跨域方案
- 补充 @tmagic/vue-runtime-help 常用 Hook 表格

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 19:40:49 +08:00

12 KiB
Raw Blame History

RUNTIME

本章详细介绍 tmagic-editor 中 runtime 的概念、目录结构与实现方式。所有内容均与开源仓库 runtime/vue/ 一一对应,可以对照阅读。

runtime 是什么

runtime 是用来解析 DSL 的执行环境。编辑器只负责生成 DSL最终把它渲染成可见页面的工作交给 runtime。

在一份完整的 tmagic-editor 项目中runtime 同时承担两个角色:

角色 入口 用途
page runtime/vue/page/ 线上发布产物,加载 window.magicDSL 渲染真实页面
playground runtime/vue/playground/ 编辑器中央 iframe 加载的画布,响应增删改并渲染所见即所得

两者共用同一份组件、插件、数据源代码,只在入口(main.ts / App.vue)上有差异。

::: tip DSL、playground 与 editor 之间的通信原理可以前往教程继续了解。 :::

创建 runtime 项目

::: tip 推荐用 npm create tmagic@latest / pnpm create tmagic 快速生成 runtime 模板,按提示选择 runtime 即可。 :::

生成的项目结构如下(与 runtime/vue/ 完全一致):

runtime/vue
├── page/                 # 线上 page 入口
│   ├── App.vue
│   ├── index.html
│   ├── main.ts
│   └── utils/
├── playground/           # 编辑器内 iframe 入口
│   ├── App.vue
│   ├── index.html
│   └── main.ts
├── public/
├── scripts/              # build 脚本res / page / playground / all
├── tmagic.config.ts      # @tmagic/cli 配置:声明组件、插件、数据源
├── tmagic.config.local.ts# 本地覆盖配置(可选)
└── vite.config.ts        # 多入口构建page + playground

tmagic.config.ts声明组件 / 插件 / 数据源

tmagic.config.ts@tmagic/cli 的入口,它会扫描 packages 列表,生成 .tmagic/comp-entry.ts 等 5 个入口文件runtime 只需要从这些入口里 import 即可:

import { defineConfig } from '@tmagic/cli';

export default defineConfig({
  componentFileAffix: '.vue',
  // 是否使用 vite + 异步组件,详见 page/main.ts 中的 defineAsyncComponent
  dynamicImport: true,
  npmConfig: {
    client: 'pnpm',
    keepPackageJsonClean: true,
  },
  packages: [
    {
      // key 为组件 type需要与编辑器中 componentGroupList 的 type 对应
      button: '@tmagic/vue-button',
      container: '@tmagic/vue-container',
      img: '@tmagic/vue-img',
      'iterator-container': '@tmagic/vue-iterator-container',
      overlay: '@tmagic/vue-overlay',
      page: '@tmagic/vue-page',
      'page-fragment': '@tmagic/vue-page-fragment',
      'page-fragment-container': '@tmagic/vue-page-fragment-container',
      qrcode: '@tmagic/vue-qrcode',
      text: '@tmagic/vue-text',
    },
  ],
});

tmagic.config.local.ts 用于本地覆盖(不会被提交),常见用法是把线上 npm 包临时替换为本地组件目录调试。

执行 npm run tmagic(即 tmagic entryruntime 根目录下会生成:

.tmagic/
├── comp-entry.ts          # page 同步组件入口
├── async-comp-entry.ts    # page 异步组件入口dynamicImport 时使用)
├── config-entry.ts        # 编辑器右侧表单配置
├── value-entry.ts         # 组件初始值
├── event-entry.ts         # 组件事件 / 方法列表
├── plugin-entry.ts        # 插件入口
├── datasource-entry.ts          # 同步数据源入口
└── async-datasource-entry.ts    # 异步数据源入口

详细产物说明见页面发布 § @tmagic/cli

playground runtime 实现

playground 是编辑器中央 iframe 加载的画布,最关键的逻辑就是把编辑器派发的 DSL 变更同步到本地 Vue 状态并触发重新渲染。

@tmagic/vue-runtime-help 提供的 useEditorDsl Hook 已经帮我们实现了与编辑器的通信(onRuntimeReady / updateRootConfig / updatePageId / add / update / remove 等);只需要在入口里:

  1. 创建 TMagicApp 实例,注册组件、数据源、插件;
  2. 通过 provide('app', app) 把实例注入子组件;
  3. App.vue 里使用 useEditorDsl() + useComponent('page') 渲染页面。

完整的 runtime/vue/playground/main.ts

import { createApp } from 'vue';
import TMagicApp, { DataSourceManager, DeepObservedData } from '@tmagic/core';

import App from './App.vue';

import '@tmagic/core/resetcss.css';

DataSourceManager.registerObservedData(DeepObservedData);

Promise.all([
  import('../.tmagic/comp-entry'),
  import('../.tmagic/plugin-entry'),
  import('../.tmagic/datasource-entry'),
]).then(([components, plugins, dataSources]) => {
  const vueApp = createApp(App);

  const app = new TMagicApp({
    ua: window.navigator.userAgent,
    platform: 'editor',
  });

  if (app.env.isWeb) {
    app.setDesignWidth(window.document.documentElement.getBoundingClientRect().width);
  }

  Object.entries(components.default).forEach(([type, component]: [string, any]) => {
    app.registerComponent(type, component);
  });

  Object.entries(dataSources.default).forEach(([type, ds]: [string, any]) => {
    DataSourceManager.register(type, ds);
  });

  Object.values(plugins.default).forEach((plugin: any) => {
    vueApp.use(plugin, { app });
  });

  window.appInstance = app;
  vueApp.config.globalProperties.app = app;
  vueApp.provide('app', app);

  vueApp.mount('#app');
});

playground/App.vue 出乎意料地短:

<template>
  <component v-if="pageConfig" :is="pageComponent" :key="pageConfig.id" :config="pageConfig"></component>
</template>

<script lang="ts" setup>
import { useComponent, useEditorDsl } from '@tmagic/vue-runtime-help';

const { pageConfig } = useEditorDsl();
const pageComponent = useComponent('page');
</script>

::: tip 关键点

  • platform: 'editor' 告知 @tmagic/core 进入编辑模式;
  • useEditorDsl() 内部已经调用 window.magic?.onRuntimeReady({...}),把 add/update/remove 等回调挂载到全局,编辑器通过 iframe.contentWindow.magic 触发;
  • 当 DSL 变化时,pageConfig 自动更新;当页面 DOM 渲染完成,useEditorDsl 会调用 magic.onPageElUpdate(...) 把页面元素同步给编辑器,让选中框能够吸附。 :::

page runtime 实现(线上发布)

runtime/vue/page/main.ts 与 playground 的差别在于:

  1. 不需要响应编辑器消息,直接读取 window.magicDSL(或 localPreview 模式下从 localStorage 读取);
  2. 使用 defineAsyncComponent + 异步入口,按需加载组件,减小首屏体积
  3. 数据源走 registerDataSourceOnDemand,只注册当前 DSL 用到的;
  4. 注入 requestuserRender 等业务侧 API 给组件复用。
import { createApp, defineAsyncComponent, resolveDirective, withDirectives } from 'vue';

import TMagicApp, { DataSourceManager, DeepObservedData, getUrlParam, registerDataSourceOnDemand } from '@tmagic/core';

import components from '../.tmagic/async-comp-entry';
import asyncDataSources from '../.tmagic/async-datasource-entry';
import plugins from '../.tmagic/plugin-entry';

import request, { service } from './utils/request';
import AppComponent from './App.vue';
import { getLocalConfig } from './utils';

import '@tmagic/core/resetcss.css';

DataSourceManager.registerObservedData(DeepObservedData);

const vueApp = createApp(AppComponent);
vueApp.use(request);

const dsl = ((getUrlParam('localPreview') ? getLocalConfig() : window.magicDSL) || [])[0] || {};

const app = new TMagicApp({
  ua: window.navigator.userAgent,
  config: dsl,
  request: service,
  curPage: getUrlParam('page'),
  useMock: Boolean(getUrlParam('useMock')),
});

app.setDesignWidth(app.env.isWeb ? window.document.documentElement.getBoundingClientRect().width : 375);

Object.entries(components).forEach(([type, component]: [string, any]) => {
  app.registerComponent(type, defineAsyncComponent(component));
});

Object.values(plugins).forEach((plugin: any) => {
  vueApp.use(plugin, { app });
});

registerDataSourceOnDemand(dsl, asyncDataSources).then((dataSources) => {
  Object.entries(dataSources).forEach(([type, ds]: [string, any]) => {
    DataSourceManager.register(type, ds);
  });

  vueApp.config.globalProperties.app = app;
  vueApp.provide('app', app);
  vueApp.mount('#app');
});

page/App.vueuseDsl()(注意不是 useEditorDsl

<template>
  <component :is="pageComponent" :config="pageConfig as MPage"></component>
</template>

<script lang="ts" setup>
import type { MPage } from '@tmagic/core';
import { useComponent, useDsl } from '@tmagic/vue-runtime-help';

const { pageConfig, app } = useDsl();
const pageComponent = useComponent('page');
</script>

vite 多入口构建

runtime/vue 通过单个 vite 工程构建出两份产物(vite.config.ts

build: {
  rolldownOptions: {
    input: {
      page: path.resolve(__dirname, './page/index.html'),
      playground: path.resolve(__dirname, './playground/index.html'),
    },
  },
}

加上 package.json 中提供的 build 脚本:

{
  "scripts": {
    "tmagic": "tmagic entry",
    "dev": "vite --force",
    "build": "rimraf ./dist && node scripts/build.mjs --type=all",
    "build:libs": "node scripts/build.mjs --type=res",
    "build:page": "node scripts/build.mjs --type=page",
    "build:playground": "node scripts/build.mjs --type=playground"
  }
}

最常用的两个:

  • npm run build:libs:构建 编辑器侧用到的 config / value / event / ds-config / ds-value 五份 UMD 资源(输出到 dist/entry/),编辑器通过 asyncLoadJs 异步加载(参考快速开始 § propsConfigs / propsValues)。
  • npm run build:同时产出 playground/index.htmlpage/index.htmlentry/,可以一份产物覆盖编辑器、预览、线上三种场景。

@tmagic/vue-runtime-help 常用 Hook

Hook 作用
useEditorDsl() playground 入口使用,建立与编辑器通信、维护当前页面 pageConfig
useDsl() page 入口使用,从 window.magicDSL 中读取并维护 pageConfig
useComponent(type) 通过组件 type 解析出已注册的 Vue 组件(找不到时会回退到 magic-ui-${type}
useApp() 取出注入的 TMagicApp 实例
useComponentStatus() 获取组件在编辑器中的展示/禁用状态

::: tip React runtime 的实现思路完全一致,对应包是 @tmagic/react-runtime-help,可以参照本节自行迁移。 :::

跨域

playground 是被编辑器以 iframe 形式加载的,开发期需要保证 runtime 服务允许跨域。仓库里的做法是用 Vite 的 proxy 把 runtime 反代到 playground 同域:

// playground/vite.config.ts
server: {
  port: 8098,
  proxy: {
    '^/tmagic-editor/playground/runtime': {
      target: 'http://127.0.0.1:8078',
      changeOrigin: true,
      prependPath: false,
    },
  },
}

如果编辑器和 runtime 跨域部署,需要在 runtime 服务侧返回 Access-Control-Allow-Origin,并保证 iframe 的 postMessage 同源策略允许双方通信。

进一步阅读

  • 基础概念编辑器、模拟器、runtime 的关系
  • 组件开发组件四件套component / form-config / init-value / event
  • 页面发布page.html 注入 DSL 的发布流程
  • 教程:从零实现一份 runtime理解 magic API 与 DSL 解析