
通过 KaTeX 将公式渲染为图片,结合 VTable 的图片单元格类型,可以优雅地在表格中展示数学公式。配合 Web Worker 和 IndexedDB 缓存,渲染性能和用户体验都能保障。
背景
在工业数据表格中,经常需要展示计算公式(比如碳排放量的计算公式)。普通文本展示公式体验很差,而 VTable 作为高性能表格组件,支持图片单元格。思路就是:公式 → KaTeX 渲染 → HTML → Canvas → 图片 URL → 插入 VTable。
本文所有代码基于 Vue 3 + VTable + KaTeX。
一、整体架构
先看整体流程,心里有数:
scss
用户数据 (公式文本)
│
▼
┌─────────────────┐
│ prepareFormula │
│ Images() │
└────────┬────────┘
│
▼
┌─────────────────┐ 命中 ┌────────────┐
│ 检查缓存 │ ─────────▶ │ 返回缓存 │
└────────┬────────┘ └────────────┘
│ 未命中
▼
┌─────────────────┐
│ Web Worker │ ── 渲染 ──▶ Canvas
│ (katex.render) │
└─────────────────┘
│
▼
┌─────────────────┐
│ html2canvas │
│ 转 PNG │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 存入缓存 │
│ (内存 + IndexedDB)
└────────┬────────┘
│
▼
┌─────────────────┐
│ 返回 data URL │
└─────────────────┘
二、核心工具函数封装
把公式转图片的逻辑封装成独立工具, Vue 组件只管调用。
javascript
// formulaUtils.js
import katex from 'katex';
import 'katex/dist/katex.min.css';
import html2canvas from 'html2canvas';
// Web Worker 异步渲染,避免阻塞主线程
var worker = new Worker(new URL('./worker.js', import.meta.url));
var promiseWorker = new PromiseWorker(worker);
// 两级缓存:内存 Map + IndexedDB
let formulaImageMemoryCache = new Map();
let dbInstance = null;
2.1 缓存 Key 生成
用公式文本和尺寸生成唯一哈希,避免重复渲染。
javascript
export function getFormulaCacheKey(formulaText, width, height) {
const raw = `${formulaText}_${width}_${height}`;
let hash = 5381;
for (let i = 0; i < raw.length; i++) {
hash = ((hash << 5) + hash) ^ raw.charCodeAt(i);
}
return `vtable_formula_image_cache_v1:${(hash >>> 0).toString(16)}`;
}
2.2 核心转换函数
javascript
export async function convertFormulaToImage(formulaText, width = null, height = null) {
return new Promise(async (resolve, reject) => {
try {
// 1. 检查缓存
const cacheKey = getFormulaCacheKey(formulaText, width, height);
const cached = await getFormulaImageFromCache(cacheKey);
if (cached) {
resolve(cached);
return;
}
// 2. 优先用 Worker 渲染(主线程不卡顿)
try {
const response = await promiseWorker.postMessage({
type: 'convertImage',
formulaText,
width: width || 260,
height: height || 60,
scale: 2
});
if (response) {
await setFormulaImageToCache(cacheKey, response);
resolve(response);
return;
}
} catch (workerErr) {
console.warn('Worker 渲染失败,尝试回退到主线程渲染');
}
// 3. 主线程回退渲染
const tempDiv = document.createElement('div');
tempDiv.style.position = 'absolute';
tempDiv.style.left = '-9999px';
tempDiv.style.top = '-9999px';
tempDiv.style.background = 'white';
tempDiv.style.padding = '10px';
// 处理纯百分号情况
if (formulaText.trim() === '\\%' || formulaText.trim() === '\%') {
tempDiv.innerHTML = '<span>%</span>';
} else {
// KaTeX 渲染为 HTML
const htmlContent = katex.renderToString(formulaText, {
throwOnError: false,
displayMode: false,
output: 'html',
strict: false,
trust: true
});
tempDiv.innerHTML = htmlContent;
}
document.body.appendChild(tempDiv);
// 4. html2canvas 转图片
const canvas = await html2canvas(tempDiv, {
backgroundColor: '#ffffff',
scale: 2,
logging: false,
});
const dataUrl = canvas.toDataURL('image/png');
document.body.removeChild(tempDiv);
// 5. 存入缓存
await setFormulaImageToCache(cacheKey, dataUrl);
resolve(dataUrl);
} catch (error) {
reject(error);
}
});
}
2.3 批量处理表格数据
单个公式转换没问题,表格有多行公式需要批量处理。
javascript
export async function prepareFormulaImages(records, options = {}) {
const {
prepareRowIndexs = [0], // 要处理的行索引
shouldSkip = (key, value) =>
key === 'recordTime' ||
key.includes('noTransfer') || // 标记不过处理的字段
value === null ||
value === undefined,
imageWidth = 260,
imageHeight = 60
} = options;
const list = records || [];
if (list.length === 0) return;
// 根据行索引数组获取需要处理的行
const rowsToProcess = prepareRowIndexs
.map(index => list[index])
.filter(row => row !== undefined);
for (const row of rowsToProcess) {
if (!row) continue;
for (const key in row) {
if (!Object.hasOwn(row, key)) continue;
if (shouldSkip(key, row[key])) continue;
if (row[key + '_noTransfer']) continue;
try {
// 生成并缓存公式图片(data URL)
row[key] = await convertFormulaToImage(row[key], imageWidth, imageHeight);
} catch (e) {
console.error(`转换字段 ${key} 的公式失败:`, e);
row[key] = '';
}
}
}
}
三、VTable 组件集成
3.1 定义公式行数据
公式文本直接写在数据里,字段名加 _noTransfer: true 表示不做转换(单位行直接显示文本)。
javascript
// 公式行:信息项
const createProcessRowData = (suffix = '') => {
const makeField = (field) => suffix ? `${field}${suffix}` : field;
return {
[makeField('anodeEffectEmissionValue')]: 'E_{阳极效应}=EF_{CF4} * P * GWP_{C2F6} * 10^{-3}',
[makeField('liquidAluminumProduction')]: 'P',
[makeField('liquidAluminumProduction') + '_noTransfer']: true,
[makeField('anodeEffectCf4EmissionFactor')]: 'EF_{CF4}',
[makeField('anodeEffectC2f6EmissionFactor')]: 'EF_{C2F6}',
[makeField('cf4GlobalWarmingPotential')]: 'GWP_{CF4}',
[makeField('c2f6GlobalWarmingPotential')]: 'GWP_{C2F6}',
};
};
// 单位行
const createProcessUnitRow = (suffix = '') => {
const makeField = (field) => suffix ? `${field}${suffix}` : field;
return {
[makeField('anodeEffectEmissionValue')]: 'tCO_{2}e',
[makeField('liquidAluminumProduction')]: 't',
[makeField('liquidAluminumProduction') + '_noTransfer']: true,
[makeField('anodeEffectCf4EmissionFactor')]: 'kgCF_{4}/tAl',
[makeField('anodeEffectC2f6EmissionFactor')]: 'kgC_{2}F_{6}/tAl',
[makeField('cf4GlobalWarmingPotential')]: '-',
[makeField('c2f6GlobalWarmingPotential')]: '-',
};
};
3.2 判断单元格类型
公式行(行索引 0 和 1)显示图片,其他行显示文本。
javascript
const DATA_ROW_OFFSET = 2; // 前面有信息项、单位两行
const cellType = (args) => {
const isFormulaRow = args.row !== undefined &&
args.row >= DATA_ROW_OFFSET &&
args.row <= DATA_ROW_OFFSET + 1;
return isFormulaRow ? 'image' : 'text';
};
3.3 在列配置中使用
javascript
createProcessColumn('1#铝电解工序(400kA)') {
field: 'process1',
caption: '1#铝电解工序(400kA)',
columns: [
{
field: 'anodeEffectEmissionValue',
caption: '阳极效应排放量',
width: 300,
keepAspectRatio: true,
cellType // 指定为图片类型
},
{
field: 'liquidAluminumProduction',
caption: '铝液产量',
width: 80,
keepAspectRatio: true,
editor: 'input-editor', // 可编辑
style({ row }) {
const isReadonlyRow = row <= DATA_ROW_OFFSET + 1;
return {
bgColor: isReadonlyRow ? '#fff' : '#ffc' // 公式行白色,数据行浅黄
};
}
},
// ...
]
}
3.4 组件挂载时触发转换
javascript
import { prepareFormulaImages } from '../formulaUtils.js';
const FORMULA_IMAGE_ROWS = [0, 1]; // 公式图片占用的前两行
onMounted(async () => {
await getList(true); // 1. 获取表格数据
await initializeFormulaImages(); // 2. 转换公式为图片
});
const initializeFormulaImages = async () => {
try {
await prepareFormulaImages(records.value, {
prepareRowIndexs: FORMULA_IMAGE_ROWS,
shouldSkip: (key, value) => {
return key === 'recordTime' ||
key.includes('noTransfer') ||
value === null ||
value === undefined ||
value === '';
},
imageWidth: 260,
imageHeight: 60
});
} catch (error) {
console.error('公式图片初始化失败:', error);
} finally {
loading.value = false;
}
};
四、缓存设计
4.1 两级缓存策略
| 层级 | 介质 | 优点 | 缺点 |
|---|---|---|---|
| L1 | 内存 Map | 读写极快 | 页面刷新丢失 |
| L2 | IndexedDB | 持久化,容量大 | 读写较慢 |
4.2 读取缓存
javascript
export async function getFormulaImageFromCache(cacheKey) {
// 先检查内存缓存
if (formulaImageMemoryCache.has(cacheKey)) {
return formulaImageMemoryCache.get(cacheKey);
}
// 从 IndexedDB 获取
return await getFormulaImageFromIndexedDB(cacheKey);
}
4.3 写入缓存
javascript
export async function setFormulaImageToCache(cacheKey, imageUrl) {
// 存入内存缓存
formulaImageMemoryCache.set(cacheKey, imageUrl);
// 存入 IndexedDB
await setFormulaImageToIndexedDB(cacheKey, imageUrl);
}
4.4 清除缓存
javascript
export async function clearFormulaImageCache(clearMemory = true, clearIndexedDB = true) {
if (clearMemory) {
formulaImageMemoryCache.clear();
}
if (clearIndexedDB) {
const db = await initDB();
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
store.clear();
}
}
五、Web Worker 渲染
主线程渲染复杂公式会卡 UI ,把 KaTeX 渲染丢到 Worker 里做。
javascript
// worker.js
import katex from 'katex';
import 'katex/dist/katex.min.css';
self.onmessage = async function(e) {
const { type, formulaText, width, height, scale } = e.data;
if (type === 'convertImage') {
try {
// 在 Worker 中渲染公式
const htmlContent = katex.renderToString(formulaText, {
throwOnError: false,
displayMode: false,
output: 'html',
strict: false,
trust: true
});
// 返回 HTML 字符串,由主线程做 html2canvas 转换
self.postMessage({ success: true, htmlContent });
} catch (error) {
self.postMessage({ success: false, error: error.message });
}
}
};
六、效果展示
最终渲染效果:

公式以图片形式清晰展示,用户可以看到每个指标的计算方式。
七、局限性与改进
局限性:
- 首次渲染慢 --- 公式图片需要在页面加载时批量转换,实测 100+ 公式约需 3-5 秒
- 字体依赖 --- 必须确保页面加载了 KaTeX 字体,否则渲染出方块字
- 不支持公式编辑 --- 转成图片后无法再编辑公式文本
改进方向:
- 预渲染 --- 在构建阶段把常用公式预渲染好,直接从 IndexedDB 取
- 增量渲染 --- 只渲染当前 viewport 内的公式,滚动时再加载
- 公式编辑 --- 如果需要编辑功能,可以用 ContentEditable + KaTeX live preview
结语
用 KaTeX + html2canvas 把公式转图片,配合 VTable 的图片单元格,渲染效果清爽。缓存设计保证性能,Web Worker 避免卡主线程。如果你的表格也需要展示公式,这个方案可以直接抄。
有问题欢迎留言。