🍎把数学公式搬进 Web 表格:一个 VTable 实战案例

通过 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 });
        }
    }
};

六、效果展示

最终渲染效果:

公式以图片形式清晰展示,用户可以看到每个指标的计算方式。


七、局限性与改进

局限性:

  1. 首次渲染慢 --- 公式图片需要在页面加载时批量转换,实测 100+ 公式约需 3-5 秒
  2. 字体依赖 --- 必须确保页面加载了 KaTeX 字体,否则渲染出方块字
  3. 不支持公式编辑 --- 转成图片后无法再编辑公式文本

改进方向:

  1. 预渲染 --- 在构建阶段把常用公式预渲染好,直接从 IndexedDB 取
  2. 增量渲染 --- 只渲染当前 viewport 内的公式,滚动时再加载
  3. 公式编辑 --- 如果需要编辑功能,可以用 ContentEditable + KaTeX live preview

结语

用 KaTeX + html2canvas 把公式转图片,配合 VTable 的图片单元格,渲染效果清爽。缓存设计保证性能,Web Worker 避免卡主线程。如果你的表格也需要展示公式,这个方案可以直接抄。

有问题欢迎留言。

相关推荐
江无行者1 小时前
aly oss技能应用
前端
朝阳391 小时前
单向数据流
前端
小小小小宇1 小时前
H5 嵌入微信 / 支付宝 / 抖音小程序 WebView:调用原生能力完整方案
前端
卷帘依旧1 小时前
React中父子组件生命周期的执行顺序
前端
绝世唐门三哥1 小时前
ES6 --- import/export 全解析
开发语言·前端·javascript
小杍随笔1 小时前
【iNovel 前端架构深度解析:基于 Vue 3 + TypeScript + Tauri 的跨端小说写作工具】
前端·架构·typescript
yqcoder1 小时前
JavaScript 异步基石:Promise 完全指南
开发语言·前端·javascript
深度先生1 小时前
Windows 踩坑实录:better-sqlite3 安装、编译、打包报错彻底解决
前端
胡志辉1 小时前
Nginx CVE‑2026‑42945:隐藏18年高危漏洞被曝光(附解决方案)
前端·后端·nginx