很多人对"大屏"的想象是一堆图表拼在一张画布上。真做过交付的会知道,一块能在现场连续跑几个月不白屏的大屏,难点从来不在图表怎么选,而在"图纸怎么变成画面"这条链路:数据是一份 JSON,屏幕上是一帧画面,中间差着九个步骤的距离。
这篇拆的是运行器(Renderer)侧的启动流程。素材来自我们对自家产品代码仓库的勘察记录,只讲结构,不涉及具体业务数据,也不做性能数字承诺。
一、先看结论:一条十步启动链
终端用户打开一个已发布的大屏链接,运行器实际做的事情是这样的:
- 宿主页面放置
<datavis-render>组件,组件挂载时以type: 'render'创建引擎实例 ------ 只初始化渲染能力,不初始化选择、绘制、历史这些编辑 Handler - 把全局函数集
visApi挂到editor.visApi,把宿主传入的userData挂到editor.userData,供组件里的脚本取用 - 触发
ready事件,把 editor 实例交给宿主 - 宿主调用
exposeSetDesign(design)传入图纸 JSON handleSetScreenData解析:若compress为真,先decompressFromBase64解压- 把解压结果拆成三层:
backend(背景层)、screen(子屏列表)、frontend(前景层) - 用
dataEnlivenObjects把纯 JSON 还原成引擎对象实例 - 合并
config,调setRenderMode定缩放模式(full / fit / standard) - 扫描所有对象用到的字体,动态加载字体 CSS;若配置了密码,初始化密码门禁
- 切到
defaultScreenId指定的子屏(没有就取第一个),触发页面挂载
十步里,真正决定这套架构长什么样的是第 6 步。其余九步都在为它服务。
二、为什么非要三层,而不是一层
图纸的三层是这样分配的:
┌──────────────────────────────────────────┐
│ frontend 前景层(1 个) │ 标题、装饰边框、水印、返回按钮
├──────────────────────────────────────────┤
│ screen 子屏列表(N 个,可轮播) │ 图表、地图、设备卡片、工艺流程
├──────────────────────────────────────────┤
│ backend 背景层(1 个) │ 底图、粒子、网格、光效
└──────────────────────────────────────────┘
如果只做一层,会立刻撞上两个具体问题。
第一,切屏要重建整棵树。 背景里那张几 MB 的底图、顶部那排标题栏,会跟着子屏一起卸载再挂载。用户看到的是闪一下 ------ 在现场验收时,"闪一下"是最容易被挑刺的地方。
第二,公共元素被复制 N 份。 一个 logo 放在每一张子屏里,改一次要改 N 处;图纸体积也随子屏数量线性涨。
分成三层之后,切屏退化成一句话:只换中间那层的 objects,背景和前景原地不动。 背景层只有一个、前景层只有一个,是硬约束 ------ 这不是偷懒,是为了让"切屏"这个最高频的动作只剩下最小代价。
代价也有,后面第九节说。
三、第 5 步:图纸进来先解压
图纸里绝大部分是样式属性和重复的坐标数组,纯文本 JSON 的冗余度极高,同样的结构压缩后体积能掉一大截。所以图纸带一个 compress 标记,为真时用 decompressFromBase64(lz-string)解开。
这是个纯 CPU 换带宽的交换:服务端存小文件、传得快,客户端多花一点解压时间。具体掉多少体积、解压多少毫秒,跟图纸里对象的数量和嵌套深度强相关,这里不给数字 ------ 给了也是某个特定图纸的数,换一个场景就不成立。
有一个容易踩的点:compress 是可选的传输标记,不是版本号。 判断必须写成"标记为真才解压",不能写成"有数据就解压"。早期图纸不带这个字段,如果代码走了无条件解压分支,老图纸会被当成压缩数据解一遍,直接抛异常 ------ 而且这种异常通常只在那几个最老的存量大屏上出现,测试环境里根本复现不了。
四、第 6-7 步:JSON 变对象实例,这一步最不能省
拆完层之后,三层里的每个节点还只是 { type: 'rect', left: 100, ... } 这样的数据。直接用 v-for 把它们渲染出来不行吗?不行,因为对象不只是数据。
引擎里的对象带行为:toJSON 序列化、选中后的控制点、内部事件 emitter、所属的 group 引用。序列化的时候,BaseObject.toJSON 会主动把这些字段过滤掉:
js
// 序列化侧:这些键不进持久化
ignoreKeys = ['emitter', 'controls', 'editor', 'group']
所以加载侧必须把被过滤掉的东西重建回来,这一步就是 dataEnlivenObjects:
js
// 示意(按勘察记录还原的调用顺序,非源码逐字)
const objs = dataEnlivenObjects(layer.objects) // 内部逐个走 Factory.getInstance(editor, {type, ...})
editor.setObjects(objs) // 挂到引擎
editor.initTypeCount() // 重置类型计数,恢复"矩形_1 / 矩形_2"的自动命名
这里藏着一个很值得学的设计:对象的类身份不参与持久化。 存的只有 type 字符串,加载时由工厂按 type 重新实例化。好处是老图纸不会因为类被改名、被拆分而失效;代价是 type 变成了事实上的协议 ------ 一旦某个 type 要改名,就得在工厂里留别名映射,否则历史图纸全部变成空白。
initTypeCount 那一步容易被忽略但很关键:类型计数如果不在加载后重置,新建一个矩形会被命名成"矩形_37",而画布上其实只有 3 个矩形。这种小 bug 不致命,但非常影响体验。
五、第 8 步:三种缩放模式,选错就是现场事故
setRenderMode 只有三个取值,但选错要付出代价:
| 模式 | 行为 | 什么时候用 | 风险 |
|---|---|---|---|
full |
按 X / Y 分别缩放,撑满容器 | 容器宽高比与设计稿一致 | 比例不一致就变形,文字被拉扁 |
fit |
取较小缩放比、居中 | 交付默认 | 会出现黑边 |
standard |
1:1 不缩放 | 设计稿尺寸刚好等于屏尺寸 | 小了留白、大了出滚动条 |
经验总结:拼接屏和一体机的物理比例经常不是标准 16:9,现场又很难临时改设计稿。所以交付默认给 fit 而不是 full ------ 有黑边至少不变形,变形是甲方一眼就能看出来的问题,黑边可以用深色底图吃掉。
三种模式最终都落到同一件事上:算出一个 viewportTransform 矩阵,然后写到 canvasDom.style.transform 上。
为什么不直接改 canvas 的 width/height?因为改尺寸会触发重排,所有对象的坐标要重算一遍;而 transform 是合成层变换,浏览器只需要重新合成一次位图。这也是为什么大屏的缩放看起来很"廉价" ------ 它根本没重新算布局。
六、第 9 步:字体是首帧体验的隐形杀手
运行器会扫描全部对象用到的字体族,动态加载对应 CSS。
不做这一步会怎样:首帧用系统默认字体渲出来,字体文件到了再替换 ------ 字宽变了,居中的标题歪了,换行的段落重排。大屏上这个跳动格外刺眼,因为字大、留白少,差几个像素都看得出来。
顺带一提,密码保护也是在这一步初始化的,但它只是运行时的"门禁"(config.password 存在才启用),不是权限系统。真要控制谁能看,得在宿主应用那层做。
七、第 10 步与切屏时序:那个 100ms 是有原因的
切屏时发生的事,顺序不能乱:
合并 背景 + 目标子屏 + 前景 → editor.objects
→ fire('page') // 通知宿主:换页了
→ setSignalData() // 派发信号数据
→ 延迟 100ms → onPageMounted() // 通知组件:可以量 DOM 了
为什么不是立刻调 onPageMounted:那一刻 DOM 还没提交,组件在回调里量容器宽高会拿到 0,图表初始化出来就是一条线或者空白。100ms 是个经验值,不是标准答案 ------ 更稳的做法是组件自己用 nextTick 加 ResizeObserver 等真实尺寸,但在运行器侧用一行延迟兜住,成本最低。
另一个必须知道的事实:子屏切换会重新触发数据请求和挂载事件。 所以如果组件在 mounted 里开了 setInterval 却不在 unmounted 里清掉,轮播跑一天就是几十个定时器叠在一起。
运行器侧对这个问题的解法值得抄:把定时器挂到宿主对象上做单例 ------ HTTP 轮询挂 editor.__apiRequestIntervalTimer,WebSocket 实例挂 editor.__websocketInstance。这比"靠每个组件自觉遵守生命周期"可靠得多:单例是机制,自觉是纪律,机制赢。
八、三层结构带来的两个隐含约定
结构一旦定了,用法就跟着被约束:
其一,数据是全局的,组件靠路径取。 接口数据挂在全局,组件用 signal 字段(比如 device.temperature)从全局数据里取自己那一段。这意味着三层里的组件不应该各自去请求各自的后端接口 ------ 一旦这么写,切屏、共享字段、统一鉴权全都会乱。
其二,前景层是屏幕坐标,不随子屏变。 前景层压在子屏之上,它的坐标系是屏幕而不是子屏。把"应该跟着子屏变"的内容放进前景层,切屏时它不会变 ------ 这个错很常见,因为从视觉上看它确实"在最上面"。
九、这套结构的代价
只讲好处不叫分析。三层结构的成本很明确:
- 首帧是同步合并的。 子屏对象特别多时,合并加实例化会把首帧拉长,需要自己做分批或骨架屏。
- 背景不能跟着切。 背景层只有一个,做不了"每屏换一张底图"的效果;要做得把背景塞进子屏,那就等于放弃了背景层的复用。
- 前景层会挡事件。 它盖在子屏上面,鼠标事件要先判断是否穿透,否则子屏里的图表点不动。
十、小结
回到最开始那个问题:一份 JSON 怎么变成画面。
答案不是"渲染一下",而是先分层、再解压、再实例化、再定缩放、再等字体、最后按正确时序通知组件 。十步里没有一步是炫技,全都是被现场问题逼出来的:分层是为了切屏不闪,实例化是为了对象有行为,fit 优先是为了不变形,100ms 延迟是为了能量到 DOM。
写大屏引擎和写普通前端页面最大的差别就在这儿 ------ 普通页面可以容忍一次闪烁、一次重排、一次字体跳动;大屏挂在墙上,跑三个月,任何一次都会被看见。