☰
一块大屏的数据在浏览器里怎么变成画面:背景、子屏、前景三层结构
2026/10/31 2:14:18 网站建设 项目流程

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

很多人对"大屏"的想象是一堆图表拼在一张画布上。真做过交付的会知道,一块能在现场连续跑几个月不白屏的大屏,难点从来不在图表怎么选,而在"图纸怎么变成画面"这条链路:数据是一份 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会主动把这些字段过滤掉:

// 序列化侧:这些键不进持久化ignoreKeys=['emitter','controls','editor','group']

所以加载侧必须把被过滤掉的东西重建回来,这一步就是dataEnlivenObjects:

// 示意(按勘察记录还原的调用顺序,非源码逐字)constobjs=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取较小缩放比、居中交付默认会出现黑边
standard1: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。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询