之前接了一个数据大屏需求,周期压得很紧,我当时就跟自己说:这次绝不能再按老一套来了。所谓老一套,大家应该都有画面感——一个巨大的前端项目,里面塞满了写死的像素值、各种适配代码、一堆图表组件互相传参,改一个布局能把人折磨到凌晨三点。这次我用了一种很新的大屏开发方式:CSS Grid做骨架,Web Components封装图表卡片,整个页面由一份JSON配置驱动。不是自研低代码平台,也不是付费大屏工具,就是一套轻量的、能落到代码里的工程化思路。如果你也经常被大屏项目搞得头疼,或者想绕过“复制上一版项目再改”的恶性循环,这篇文章应该对你有用。我会把核心原理、完整实现、踩过的坑和适用边界都记录下来。
1. 老一套大屏方案,到底卡在哪了
1.1 像素级适配的恶性循环
先说说我之前用传统方式开发大屏时遇到的真实情况。设计师给一张1920x1080的设计稿,然后我需要在各种分辨率的屏幕上让它都“看起来正常”。常见的做法是rem、vw/vh、再加一堆@media查询。
- rem方案:计算根字体大小,所有尺寸都要除以基准值,图表内部字体、间距都得跟着换算,每写一个组件都在做小学除法。
- vw/vh方案:1920宽度下一切正常,换到2560宽的屏幕上,所有模块被横向拉伸,圆角变椭圆,线图变粗,视觉上非常难受。
- 多重媒体查询:一屏适配写完,项目里多了几百行media query,没人敢动,一动就崩。
这些方案的共同问题在于:大屏不是页面,它本质上是“在一个固定场景下投放的信息面板”,受众不会拿手机缩放看,也没有滚动条。用适配普通网页的思路去做大屏,从一开始就在跟像素较劲。
1.2 组件复用成了空话
传统框架写大屏,组件复用也只是看上去很美。一个“销售概览组件”从上一个项目复制出来,要改props、改接口字段、改CSS变量、改父组件传参,改完发现样式还是跟当前主题不一致。时间久了没人愿意复用,新项目直接复制整页再改——相当于每一块大屏都是“一次性开发”,成本高,质量还不稳定。
图表组件普遍犯的一个毛病是:把数据请求、轮询逻辑、布局样式、主题配色全部耦合在一起。一旦换了一个数据源,或者换了一款图表,整个组件都要推倒重来。而大屏项目恰恰是多变的,今天用折线图,明天要换柱状图,后天领导说加一个环形图,这些变化都需要在组件层面被低成本承接。
1.3 改版成本高到劝退
还有更让人崩溃的改版。一屏改三个图、加两个模块,牵扯到布局、图表实例生命周期、请求逻辑、自适应逻辑,四五个地方同步动。有时候只是改一个数据源地址,也要重新跑一遍构建、重新联调。实际上大屏的需求变更频率比业务系统高得多,领导今天要看A指标,明天要换成B维度,后天说面板位置要对调。如果没有一套把“布局和组件”剥离开的机制,每一次变更都是在叠技术债,直到项目变成谁都不敢碰的屎山。
我当时就在想,大屏开发能不能反过来:页面结构用配置声明,图表能力用原生组件封装,布局交给Grid,适配交给Transform。这样改版只需要动配置,加图表只需要加配置项,整体复杂度会大幅降下来。
2. 新方案的整体设计:配置驱动 + 原生组件 + Grid 骨架
2.1 核心架构一句话
这套方案的核心可以用三句话概括:页面结构由一份 dashboard.json 描述,页面骨架由 CSS Grid 实现,图表能力由 Web Components 封装。数据请求和刷新逻辑全部内聚在组件内部,页面本身只负责“读配置、逐个实例化组件、把配置丢给组件”。组件之间如果需要联动,通过 CustomEvent 解耦,不需要全局状态管理。
用这种架构写大屏,最直观的感受是:页面代码几乎没有业务逻辑,它只是一个“渲染器”。真正的内容全部沉淀在 JSON 配置里。布局改了?改 JSON 里的 gridArea。数据源换了?改 JSON 里的 dataSource。主题调了?改一套 CSS 变量。这相当于把大屏开发的复杂度从“程序逻辑”转移到了“数据声明”上,而数据声明是可以被业务方理解和修改的。
2.2 为什么选 Web Components 而不是 React/Vue
并不是 React/Vue 不好,而是这套场景里我更看重“跨项目复用”和“框架无关”。用 Web Components 封装图表卡片,有四个实实在在的好处:
- 原生自定义元素,不依赖任何框架,可以直接在一个纯静态页面里用,也可以嵌到 React/Vue 项目里。
- Shadow DOM 天然隔离样式,图表主题、卡片背景、加载状态不会跟宿主页面打架,避免“样式越写越乱”的经典难题。
- 组件有明确的生命周期,connectedCallback 里初始化,disconnectedCallback 里销毁,非常适合图表这种需要管理实例的场景。
- 没有构建层面的强约束,不搞一套复杂的打包链路,Vite 随手就能跑起来。
如果你的团队全是 React 选手,沿用 React 组件加 JSON 配置驱动也完全可以,核心思路是一致的:配置驱动、布局分离。Web Components 只是让这套方案更“通用”,换项目不用迁移组件库。我第一次迁移这套组件时,直接把整个 components 目录复制过去,一行没改就能用,这个体验在传统框架里很难实现。
2.3 为什么布局选 CSS Grid + transform scale
这里我详细解释一下布局层的动机。大屏设计稿绝大多数是 16:9,常见的是 1920x1080。用 vw/vh 方案,在 1920 上正好,但换成 2560 宽或者带鱼屏,比例变了,所有东西被横向拉伸;用 rem 方案,字体、边距、图表内边距都很难精确控制。
我的选择是:最外层容器固定设置 width: 1920px; height: 1080px,再用 transform: scale() 整体缩放。内部所有尺寸都按 px 写死,设计稿量出来是多少就写多少,不需要任何换算。CSS Grid 负责把页面划分为若干区域,每个卡片模块通过 grid-row / grid-column 来定位,而不是绝对定位。
这样设计带来的直接好处:
- 设计稿上的每一个像素都能精确落地,还原度极高。
- 模块位置调整只需要改 CSS 类或者 JSON 里的行列区间,不需要动 DOM 顺序。
- 整体缩放只会产生一次浏览器重绘,不会像 rem 方案那样每个元素都在计算。
用 transform scale 需要处理一个关键点:缩放后容器在页面中的居中。容器要设置 transform-origin: top left,然后根据窗口尺寸计算缩放比例和偏移量。这块下面会给出完整代码。
我做过一张对比表,可以直观看出几种方案的差别:
| 方案 | 还原精度 | 不同比例屏幕表现 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| rem | 中 | 比例稳定,但换算繁琐 | 中 | 移动端适配习惯的团队 |
| vw/vh | 低 | 拉伸变形严重 | 低 | 固定分辨率项目 |
| 媒体查询 | 高 | 需要逐个断点调 | 高 | 页面级自适应 |
| transform scale | 高 | 等比缩放,留边居中 | 低 | 16:9 大屏首选 |
3. 落地实现:从零搭一个配置化大屏骨架
3.1 项目初始化与目录结构
我用 Vite 加原生 JS 初始化项目,目录结构非常清晰:
dashboard/ ├── index.html ├── src/ │ ├── main.js │ ├── styles/ │ │ ├── theme.css │ │ └── grid.css │ ├── components/ │ │ ├── chart-card.js │ │ └── title-bar.js │ ├── config/ │ │ └── dashboard.json │ └── core/ │ ├── scale.js │ └── loader.jsconfig 只管数据声明,core 管流程控制,components 管表现逻辑。模块之间没有循环依赖,每个人接手都能快速定位。我建议不管项目多小,都要保持这个分层,否则配置驱动的优势会很快被混乱的文件结构抵消。
3.2 页面骨架:Grid 布局与缩放适配
HTML 骨架是一个三层的结构:最外层 stage 是视口容器,中间 screen 是固定 1920x1080 的设计稿画布,里面的卡片由 loader 根据 dashboard.json 动态创建。
<div id="stage"> <div id="screen"></div> </div>关键 CSS 是这些:
#stage { width: 100vw; height: 100vh; overflow: hidden; background: #0b1a2f; display: flex; align-items: center; justify-content: center; } #screen { width: 1920px; height: 1080px; transform-origin: top left; display: grid; grid-template-columns: repeat(12, 1fr); grid-template-rows: repeat(8, 1fr); gap: 16px; padding: 16px; box-sizing: border-box; }缩放适配的核心逻辑在这里:
const stage = document.getElementById('stage'); const screen = document.getElementById('screen'); function resize() { const sw = stage.clientWidth / 1920; const sh = stage.clientHeight / 1080; const scale = Math.min(sw, sh); screen.style.transform = `scale(${scale})`; const left = (stage.clientWidth - 1920 * scale) / 2; const top = (stage.clientHeight - 1080 * scale) / 2; screen.style.left = `${left}px`; screen.style.top = `${top}px`; } window.addEventListener('resize', resize); resize();这里我用了 Math.min 实现等比缩放,而非 scaleX 和 scaleY 分别填满。等比缩放会让屏幕上下或左右留黑边,但保证了图表不变形。大屏上的数据展示,变形比留边更让人难以接受,所以我会坚持等比缩放。等宽屏普及、设计稿改成 21:9 时,只需要改 screen 的宽高和 resize 里的基数。
3.3 字体与间距:用CSS变量统一主题
大屏基本都是暗色主题,不需要搞复杂的多主题切换,但是颜色管理不能乱。我在 theme.css 里定义了一套设计变量:
:root { --color-bg: #0b1a2f; --color-panel: rgba(16, 42, 84, 0.65); --color-border: #1e6b9c; --color-text: #d7f3ff; --color-accent: #00e4ff; --font-main: 'Roboto', 'PingFang SC', sans-serif; --radius: 8px; --gap: 16px; }所有组件内部都引用这些变量,而不是写死颜色值。这样做的好处是,当甲方说“这个蓝色太亮了,整体压暗一点”的时候,我只需要改两个变量,所有组件同步生效。这个在传统开发里要全局搜索替换半天,在 Web Components 里依然有效,因为 CSS 变量可以穿透 Shadow DOM 边界。
3.4 数据层:统一请求与自动刷新
每个 chart-card 组件自己接收 dataSource 配置,比如:
{ "type": "line", "dataSource": { "url": "/api/trend", "interval": 5000 } }组件内部在 connectedCallback 里执行首次加载,然后 setInterval 定时刷新;disconnectedCallback 里 clearInterval。这样任何一个图表的刷新逻辑跟其他图表完全隔离,不会因为一处接口挂了影响其他图。
请求层用一个简单的 fetch 封装,包含错误重试和请求幂等:
async function fetchData(url, { retry = 3, delay = 1000 } = {}) { for (let i = 0; i < retry; i++) { try { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); } catch (e) { if (i === retry - 1) throw e; await new Promise(r => setTimeout(r, delay * (i + 1))); } } }重试逻辑很关键,大屏启动的时候往往服务端还没完全就绪,如果没有容错,一启动就是一片加载失败的红叉。我一般会在首次加载失败时显示一个友好的重试按钮,而不是干等。
4. 核心:把图表封装成自由配置的卡片组件
4.1 自定义元素的生命周期设计
一个 chart-card 标签在页面里的使用方式非常干净:
<chart-card config="{...}"></chart-card>更推荐的做法是让 loader 统一读取 dashboard.json,然后通过 property 注入配置。组件内部结构我选择用 Shadow DOM 隔离,构造函数里 attachShadow,然后所有 DOM 操作都在 shadowRoot 内完成。
组件生命周期设计是这样的:
class ChartCard extends HTMLElement { static get observedAttributes() { return ['config']; } constructor() { super(); this.attachShadow({ mode: 'open' }); this._timer = null; this._chart = null; } connectedCallback() { this.render(); this.initChart(); this.bindResize(); this.loadData(); } disconnectedCallback() { if (this._timer) clearInterval(this._timer); if (this._chart) this._chart.dispose(); } }这里有两个容易被忽略的细节。第一,connectedCallback 里不要直接对外部 DOM 做 querySelector,因为组件可能被移动到新的容器里,会导致重复绑定;第二,disconnectedCallback 里必须把 ECharts 实例 dispose 掉,否则 canvas 会一直驻留内存,大屏多图表场景下很快就卡。
4.2 JSON配置如何驱动ECharts渲染
完整的配置示例:
{ "id": "trend", "component": "chart-card", "title": "近七日访问趋势", "gridArea": "1 / 1 / 5 / 7", "chart": { "type": "line", "option": { "color": ["#00e4ff", "#ff9f43"], "xAxis": { "type": "category" }, "yAxis": { "type": "value" }, "series": [{ "type": "line", "smooth": true }] } }, "dataSource": { "url": "/api/trend", "interval": 10000 } }组件内部把 option 和数据源合并:先拿到基础 option 结构,再根据接口返回的数据填充 series 数据,然后 chart.setOption(fullOption, true)。这里要注意第二个参数代表是否 notMerge,决定了是整体替换还是增量合并。做数据刷新时我倾向于用 true 整体替换,避免上一次的旧 series 残留。
数据加载的核心方法是这样的:
async loadData() { const { url, interval } = this._config.dataSource; const data = await fetchData(url); this.updateChart(data); if (interval && !this._timer) { this._timer = setInterval(async () => { const fresh = await fetchData(url); this.updateChart(fresh); }, interval); } } updateChart(data) { const option = this._buildOption(data); this._chart.setOption(option, true); }_buildOption 会根据接口返回的数据结构动态生成 series 数据,这样图表类型、颜色、坐标轴这些结构配置与实时数据就完全分开了。
4.3 图表自适应与联动刷新
自适应这块,千万不要再用 window.resize 了,因为组件可能是后来动态创建的,而且缩放容器本身会变化。更稳的是用 ResizeObserver:
bindResize() { const observer = new ResizeObserver(() => { if (this._chart) this._chart.resize(); }); observer.observe(this); }ResizeObserver 会监听宿主元素的尺寸变化,图表自动重绘,不会因为整体 scale 导致图表内部糊成一团。
联动刷新是这套架构里最有价值的部分。组件点击事件里可以 dispatch 一个 CustomEvent:
this._chart.on('click', (params) => { this.dispatchEvent(new CustomEvent('chart-click', { detail: { seriesName: params.seriesName, value: params.value }, bubbles: true, composed: true })); });宿主页面监听这个事件,再更新其他组件的 config 属性或调用组件的方法。这里 composed: true 非常关键,它决定了事件能不能穿过 Shadow DOM 的边界。没有这行配置,事件会被 Shadow DOM 挡住,外部永远收不到。我第一次写的时候漏了这个属性,花了半天排查。
5. 实测记录:这套方案坑在哪里,怎么绕过
5.1 Shadow DOM里写ECharts样式踩到的坑
ECharts 的 tooltip 默认是渲染到 body 的,如果图表被包在 Shadow DOM 里,tooltip 的样式就会掉到外部,无法继承 Shadow 内部设置的主题,甚至会因为 z-index 层级被其他元素盖住。
对策是通过 ECharts 的 tooltip 配置里的 confine: true,同时配合 renderMode 设置。更彻底的办法是在组件里把 tooltip 的 DOM 节点手动移入 shadowRoot。我实测下来,配置 appendToBody 为 false(不同版本字段名略有差异)最省事,但要注意容器的 overflow 不能是 hidden,否则 tooltip 会被截断。
如果你一定要用 overflow: hidden 裁掉圆角溢出,那就只能在外部用全局样式定制 tooltip。我的建议是:图表卡片的容器不要设 overflow: hidden,圆角裁切用 border-radius 加 background 配合实现,反而更灵活。
5.2 scale缩放后字体和地图模糊的问题
整体 scale 之后,如果浏览器窗口缩放比例不是整数,字体和 SVG 元素会产生轻微的模糊。这个坑在普通网页上几乎发现不了,但大屏投到大尺寸显示器上会非常明显。
对策有三个:
- 给 ECharts 实例设置 devicePixelRatio,让 canvas 按实际像素渲染。当缩放比例是 1.5 时,默认的 devicePixelRatio 会导致 canvas 被拉伸,字体发虚。
- 字体尽量使用 font-weight: 500 而不是 300,粗一点的字体在缩放后模糊感会小很多。
- 地图类的 SVG 最明显,如果是 ECharts 的地图,让它在 canvas 渲染模式下工作,而不是 SVG 渲染模式;如果只是背景网格,直接用 CSS 重复渐变,不要用图片。
这个模糊问题不是 absolution 能解决的,实测下来只是“可接受”和“很难看”的区别。对于追求极致像素的人来说,还是得回到 rem 方案,但那就得接受更高的开发成本。
5.3 数据更新时setOption的合并策略
这块值得展开,因为很多人在这里翻车。第一次渲染用 chart.setOption(option, true),之后每次接口返回数据,如果直接 setOption({ series: [{ data: newData }] }, true),你会发现图例变了,旧数据还在,甚至 series 数量越来越多。
原因是:在 notMerge 模式下,ECharts 会整体替换配置,但如果你传入的 series 里没有完整的 name、type 字段,它不会智能识别为“更新原 series”,而是会生成新的配置覆盖。我的经验是:
- 结构变化时:用 true 全量替换,保证干净。
- 只有数据变化且结构稳定时:用默认 merge 模式,但需要把无用的属性置空。
- 数据刷新场景中,最简单可靠的是:用 Object.assign 把基础 option 和动态 option 合并后再 setOption(merged, true),同时保证 series 的 name 和 type 与初始配置一致。
另外,刷新数据时如果用户正 hover 在图表上,tooltip 会闪断,非常影响体验。处理方式是在合并后的 option 里保留 tooltip 的 show 属性,并且刷新频率不要低于 5 秒,低于 5 秒人眼会持续感知闪烁。
5.4 性能:多图表页面卡顿的优化手段
一个大屏动辄十几个图表,每个都有定时器、都有 canvas,如果不做性能控制,CPU 占用会迅速拉满。我实测的优化手段有四个:
- 定时器只在页面可见时运行:监听 visibilitychange,页面隐藏在后台时停止所有组件刷新,回到前台再立即恢复。后台大屏不需要实时数据,这个优化能省大量资源。
- 图表不可见区域不渲染:配合 IntersectionObserver,等卡片真正进入可视区再 initChart,而不是页面加载时一次性初始化所有图表。
- 减少重复请求:多个图表如果共用同一个接口数据,在请求层做一个简单的 Map 订阅,相同 URL 的请求只发一次,多个组件共享结果,避免同一个接口被请求七八次。
- 及时 dispose 无用实例:切换配置卡片时,旧实例必须 dispose,否则 canvas 会一直挂在 DOM 里。
这几个手段叠加之后效果很明显。我之前某个项目里 12 个图、每个 5 秒刷新,用这些手段之后 CPU 占用明显降了下来,风扇不转了,演示的时候不再尴尬。具体的数字我就不放了,每个项目差异很大,但方向是通用的。
6. 什么项目适合用这套方案,什么情况别硬上
6.1 最合适的落地场景
这套方案最适合三类场景:展示型大屏、需求变更频繁的大屏、多个团队各自维护一块屏的场景。
- 运营监控大屏:指标多、数据源多、刷新频率不同,配置驱动天然适合。
- 数据汇报大屏:领导今天要看这个指标,明天换那个维度,改 JSON 即可,不需要开发介入。
- 多团队共用底座:不同业务线各自维护自己的 dashboard.json,通过组件库共享,互不干扰。
它不需要高度交互、不需要复杂路由,页面结构完全可以通过配置变更来调整。如果符合这些特征,这套方案的投入产出比会非常高。
6.2 不要为了新而新:方案的边界
也得说点大实话,不然就是误导。这套方案不是银弹,以下情况别硬上:
- 页面有大量表格、复杂表单交互、多页面跳转时,Web Components 加配置驱动反而会变复杂,不如老老实实用框架。
- 团队没人熟悉自定义元素,看到 attachShadow 就头晕,学习成本也要算进去,不要低估。
- 屏幕比例千奇百怪,除了 16:9 还有 5:4、1:1 的,scale 方案的居中留边会浪费大量显示面积。比如一个 5:4 的竖屏,等比缩放后两侧留黑边,信息密度就上不去了。
- 图表极其复杂,比如世界地图叠加飞线、3D 地球这种,还是建议用成熟的大屏产品或专门的 3D 可视化方案。ECharts 的普通图表封装成配置没问题,但 3D 场景下 Web Components 的自绘逻辑会变成一座大山。
我知道有些人看到“配置驱动”就联想到低代码,然后说这不适合复杂场景。我的观点是:工具要放在合适的场景里才会发挥价值,大屏本身就是一种相对轻量的展示页,把复杂度前置到配置层,收益远大于成本。
6.3 后续可以继续扩展的方向
这套架构的扩展空间很大,我自己已经列了几个方向在计划里:
- 把 dashboard.json 放到服务端,做成在线配置下发,业务方改配置不用发版,运维大屏上线效率会提高一个量级。
- 把通用图表组件沉淀成 npm 包,跨项目复用,慢慢积累出一个内部的“可视化组件市场”。
- 增加模板能力,同一个配置在不同尺寸屏幕上用不同的 gridArea 自动重排,解决多比例屏的适配问题。
- 接入低代码编辑器的拖拽改 JSON,可视化调整模块位置,让业务方通过图形界面配置大屏。
这些方向并不需要推翻现有架构,都是基于当前这套骨架做增量扩展。这也是我选择 Web Components 的一个重要原因:它在扩张过程中不锁死任何技术栈。
最后说点我个人的体会。把大屏开发从“写页面”变成“写配置”之后,最大的变化不是代码量少了,而是心不累了。以前改一块大屏,我得前后端一起伺候;现在我把 dashboard.json 扔给后端同学,他都能自己加一个图表。如果你手头正好要接大屏项目,不妨从这篇文章里的最小骨架开始改一版试试。第一遍会慢一点,第二遍开始你就会觉得,回不去了。