一块大屏的数据在浏览器里怎么变成画面:背景、子屏、前景三层结构

很多人对"大屏"的想象是一堆图表拼在一张画布上。真做过交付的会知道,一块能在现场连续跑几个月不白屏的大屏,难点从来不在图表怎么选,而在"图纸怎么变成画面"这条链路:数据是一份 JSON,屏幕上是一帧画面,中间差着九个步骤的距离。

这篇拆的是运行器(Renderer)侧的启动流程。素材来自我们对自家产品代码仓库的勘察记录,只讲结构,不涉及具体业务数据,也不做性能数字承诺。

一、先看结论:一条十步启动链

终端用户打开一个已发布的大屏链接,运行器实际做的事情是这样的:

  1. 宿主页面放置 <datavis-render> 组件,组件挂载时以 type: 'render' 创建引擎实例 ------ 只初始化渲染能力,不初始化选择、绘制、历史这些编辑 Handler
  2. 把全局函数集 visApi 挂到 editor.visApi,把宿主传入的 userData 挂到 editor.userData,供组件里的脚本取用
  3. 触发 ready 事件,把 editor 实例交给宿主
  4. 宿主调用 exposeSetDesign(design) 传入图纸 JSON
  5. handleSetScreenData 解析:若 compress 为真,先 decompressFromBase64 解压
  6. 把解压结果拆成三层:backend(背景层)、screen(子屏列表)、frontend(前景层)
  7. 用 dataEnlivenObjects 把纯 JSON 还原成引擎对象实例
  8. 合并 config,调 setRenderMode 定缩放模式(full / fit / standard)
  9. 扫描所有对象用到的字体,动态加载字体 CSS;若配置了密码,初始化密码门禁
  10. 切到 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。

写大屏引擎和写普通前端页面最大的差别就在这儿 ------ 普通页面可以容忍一次闪烁、一次重排、一次字体跳动;大屏挂在墙上,跑三个月,任何一次都会被看见。

相关推荐
梦想很大很大1 小时前
让 Codex 自由发挥,给 nvm-desktop 换个 UI
前端·人工智能·设计
蓝莓味的口香糖1 小时前
【VUE】【CSS】Vue移动端适配主流方案总结
前端·css·vue.js
shennv8881 小时前
接入大模型流式输出后,文字偶尔乱码?问题可能出在前端
前端
csj501 小时前
前端基础之《React(16)—Modal组件封装》
前端·react.js
雨季mo浅忆1 小时前
Vue关于前端XSS漏洞修复方案
前端·xss
切糕师学AI1 小时前
el-select-v2 深度解析:用虚拟滚动解决海量数据下拉选择性能瓶颈
前端·vue.js·elementui·el-select-v2
徐小夕2 小时前
JitOffice:把办公、AI 与审批装进同一个平台
前端·vue.js·github
徐小夕2 小时前
万字分享:为什么我们要自研Word解析引擎?JitWord 解析引擎 4.0 深度复盘
前端·vue.js·github
陈随易2 小时前
副业搞钱点子之独立开发
前端·后端·程序员