大多数前端项目里,数据库和视觉渲染是两条平行线:后端从库里取出数据,前端拿到JSON后再交给图表库画图。但其实我们完全可以把这条链路做得更轻、更有趣——用SQLite存一份轻量数据,再通过CSS Paint Worklet在页面任意元素的背景上直接绘制由数据驱动的图案。这篇文章就以一个实际的实战项目为主线,把这两项技术串起来讲透。

先搞清楚CSS Paint Worklet是什么
CSS Paint API是Houdini计划中落地度最高的部分,而Paint Worklet就是它的执行载体。简单说,你可以用JavaScript写一个绘图函数,注册成自定义的CSS属性值,然后在样式表里像使用普通函数那样使用它,例如写成background: paint(my-pattern)。浏览器会在合成阶段调用你写的绘制逻辑,把结果当作背景图渲染出来。
它和直接操作Canvas最大的区别在于运行环境。Paint Worklet运行在独立的渲染进程线程中,不能访问DOM,不能访问window对象,连setTimeout都没有。这些限制听起来苛刻,但恰恰保证了绘制过程永远不会阻塞主线程,也不会因为脚本异常导致页面卡死。同时,因为它是CSS的一部分,所以天然支持伪元素、边框、遮罩等所有能吃图片的CSS场景,复用性比在页面里塞一堆canvas标签强得多。
注册一个Worklet只需要三步:写绘制类、调用registerPaint、在主线程加载Worklet模块。下面是最小可运行示例:
// paint-grid.js 文件,会被Worklet线程加载
class GridPainter {
// 声明输入的CSS自定义属性,浏览器变更时会自动触发重绘
static get inputProperties() {
return ['--grid-density', '--grid-color'];
}
// 声明输入参数,对应CSS里 paint(my-pattern, 10, 20) 的后两个参数
static get inputArguments() {
return ['<number>', '<color>'];
}
paint(ctx, geometry, properties, args) {
const density = properties.get('--grid-density').value || 8;
const color = properties.get('--grid-color').toString() || '#3a7bd5';
const cell = geometry.width / density;
ctx.strokeStyle = color;
ctx.lineWidth = 1;
for (let x = 0; x <= density; x++) {
ctx.beginPath();
ctx.moveTo(x * cell, 0);
ctx.lineTo(x * cell, geometry.height);
ctx.stroke();
}
}
}
registerPaint('grid-pattern', GridPainter);
<!-- 主线程HTML中加载并使用 -->
<style>
.data-panel {
background-image: paint(grid-pattern);
--grid-density: 12;
--grid-color: #4f8ef7;
}
</style>
<script>
if (CSS.paintWorklet) {
CSS.paintWorklet.addModule('./paint-grid.js');
} else {
console.warn('当前浏览器不支持CSS Paint API');
}
</script>用SQLite准备一份可量化的数据源
绘制逻辑要有意义,输入就不能是拍脑袋写死的数字。我们用一个SQLite数据库来管理数据,比如记录每个板块的活跃度指标。SQLite单文件、零配置的特性特别适合这种轻量场景,项目里直接带一个.db文件就能跑。假设表结构如下:
CREATE TABLE activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
module_name TEXT NOT NULL,
score REAL NOT NULL, -- 活跃度得分,0到100
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO activity (module_name, score) VALUES
('analytics', 87.5),
('billing', 42.1),
('monitor', 66.8),
('search', 93.2),
('report', 31.4);接下来要把这些数据喂给前端。一种做法是Node.js加better-sqlite3,同步API写起来最省事,起一个小型HTTP服务直接吐JSON:
const Database = require('better-sqlite3');
const http = require('http');
const db = new Database('./activity.db', { readonly: true });
http.createServer((req, res) => {
if (req.url === '/api/activity') {
// 按得分降序取数据,方便前端做视觉映射
const rows = db.prepare(
'SELECT module_name, score FROM activity ORDER BY score DESC'
).all();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(rows));
} else {
res.writeHead(404);
res.end();
}
}).listen(8080);如果你更熟悉Python技术栈,SQLAlchemy配合FastAPI也能达到同样效果,核心都是把SQL查询结果序列化成JSON。要提醒的一点是,SQLite在高并发写入时会锁整个库,但本文场景以读为主,加readonly标志打开即可,既安全又能避免误操作。
把SQLite数据映射成Paint Worklet的绘制参数
关键环节在于数据到视觉的映射。回顾前面讲的限制:Worklet线程拿不到fetch,也没有XMLHttpRequest,所以数据必须由主线程取回,再通过CSS自定义属性传进去。这也解释了为什么inputProperties的设计如此重要——它是主线程与Worklet之间唯一的桥梁。
映射思路是把每条记录的score转换成一个色块的大小或颜色深浅。我们可以把整个数据数组编码成一个紧凑的字符串,比如用逗号拼接的分数序列,主线程设置到元素的自定义属性上:
// 主线程:拉取SQLite数据并注入CSS变量
async function applyDataDrivenBackground(el) {
const res = await fetch('/api/activity');
const rows = await res.json();
const scores = rows.map(r => r.score.toFixed(1)).join(',');
el.style.setProperty('--score-list', scores);
el.style.setProperty('--block-count', rows.length);
}
applyDataDrivenBackground(document.querySelector('.data-panel'));Worklet一侧则解析这个字符串,绘制渐变色块矩阵,分数越高颜色越饱满:
class ScoreBlocksPainter {
static get inputProperties() {
return ['--score-list', '--block-count'];
}
paint(ctx, geometry, properties) {
const raw = properties.get('--score-list').toString();
if (!raw) return;
const scores = raw.split(',').map(Number);
const gap = 6;
const blockW = (geometry.width - gap * (scores.length + 1)) / scores.length;
scores.forEach((score, i) => {
// 分数映射为透明度,视觉上呈现数据强弱
const alpha = 0.15 + (score / 100) * 0.75;
ctx.fillStyle = `rgba(58, 123, 213, ${alpha.toFixed(3)})`;
const h = geometry.height * 0.3 + (score / 100) * geometry.height * 0.5;
const x = gap + i * (blockW + gap);
const y = geometry.height - h - gap;
// 圆角矩形,兼容性最好的手写方式
const r = Math.min(8, blockW / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + blockW, y, x + blockW, y + h, r);
ctx.arcTo(x + blockW, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + blockW, y, r);
ctx.fill();
});
}
}
registerPaint('score-blocks', ScoreBlocksPainter);这套映射的好处是数据更新成本极低。每当数据库里的分数变化,主线程只需重新请求接口并更新--score-list这一个属性,浏览器就会自动触发Worklet重绘,不需要手动管理任何绘制生命周期。
踩坑记录与方案对比
实际落地时最容易碰到的坑有两个。第一是addModule的路径问题:Worklet模块必须通过HTTP加载,直接用file协议打开页面会静默失败,开发时务必起本地服务。第二是自定义属性类型声明:如果在CSS里没有显式书写--score-list的初始值,某些版本的Chromium在首次取值时会抛出类型错误,稳妥的做法是初始化时统一设为空字符串或零值。
再和传统方案对比一下。用Canvas绘制,优点是API全面、生态成熟,能做复杂的交互式图表,但需要自己处理DPR适配、尺寸变化和重绘节流;用SVG生成背景,声明式写法清晰,但节点数一多性能会明显下滑。Paint Worklet恰好卡在两者中间:绘制由浏览器调度,响应式和Retina适配基本免费拿到,代价是环境受限、调试不如主线程方便。对于背景装饰类、数据氛围类这种不涉及点击交互的视觉呈现,Paint Worklet是当前性价比最高的选择;而需要精确交互的仪表盘,仍然建议交给ECharts这类成熟方案。
最后提一句兼容性,Chrome和Edge对Paint API支持完善,Firefox和Safari需要渐进增强处理。实践中可以把Paint Worklet当作增强层:支持则启用动态背景,不支持则回退到静态渐变,这样既保证了体验又不影响核心功能。把这个思路扩展下去,你还可以把SQLite换成定时更新的传感器数据,让页面背景真正活起来。
SQLiteCSS Paint Worklet数据可视化修改时间:2026-09-15 23:06:52