1. 从一份带公式的良率周报说起:改造需求是怎么来的
1.1 芯片厂工程师的“复制粘贴”困境
先还原一个我实际遇到的场景。某家晶圆制造企业有一个内部在线作业系统,工程师每周要提交良率分析周报。报表里不只是静态数字,而是一整块从Excel里维护好的表格,里面有SUM、AVERAGE、IF、ROUND这类常规公式,甚至还有几个跨sheet引用的CPK计算。过去这些报表是离线Excel文件,评审会上用共享屏幕看。后来公司要求线上化,所有报告都走网页系统,于是问题来了。
工程师把Excel里的表格复制粘贴到网页端的富文本编辑器里,粘贴结果乍一看没问题,数字、边框、配色都在。但只要想改任何一个上游参数,比如某道工序的良率从98.2%改成98.5%,下游那些用公式算出来的汇总值完全不会动。因为富文本编辑器只接手了粘贴时的静态HTML,浏览器从剪贴板里取到的Excel内容本身就不包含公式对象,公式在复制的那一刻已经变成了纯数值。工程师只能改完参数之后,再回到Excel里重算一遍,重新复制一次。一天好几个报告来回折腾,体验很差。
这个需求最终落到了我头上:能不能在现有富文本编辑器里,让用户直接导入一个带公式的Excel文件,导入后能保留这些公式,并且让公式在网页里还能“活”起来,至少重新计算时不至于手动改每一处结果。
1.2 需求边界:不是做在线Excel,是保留公式语义
接到需求的第一个冲动是:这直接换一个在线表格插件不就完了吗?市面上的SpreadJS、Handsontable确实能完美承载Excel公式。但企业内部系统不是从零开发的,编辑器、附件系统、审批流已经全部接好,如果只为了一个公式导入就换掉整个编辑器,牵扯到的权限、模板、历史数据、人员培训都是成本。
所以需求必须收敛成更精准的一句话:在wangEditor这个富文本编辑器里,用户通过“导入Excel”按钮选择.xlsx文件,编辑器能够解析文件里的单元格数据,把表格还原成可编辑内容,同时保留每个单元格的公式文本和最新缓存值。公式不是摆设,至少要能支持常规的加减乘除、SUM、AVERAGE这类常用场景,用户修改了某个输入单元格后,能够触发一次重新计算,看到联动更新的结果。
这句话定义了整个改造的边界。我们不碰图表透视表,不碰条件格式,不碰完整Excel渲染。要做的只是把Excel公式的“语义”带进富文本编辑器,而不是把浏览器变成Excel客户端。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 更换方案还是动源码:我把候选路线全都比了一遍
2.1 商业表格控件和自研编辑器之间的取舍
正式动工前,我们开了两个评审会,把可选项摆到桌面上。
第一条路线是集成在线表格类控件。Handsontable、SpreadJS、x-data-spreadsheet都试过Demo。优点很直观:公式引擎成熟,对Excel兼容性好,甚至能直接加载.xlsx文件。但缺点也一样明显:一是体积大,商业授权费用不低;二是交互形态和既有页面风格不统一,用户从“填报表”跳到“电子表格”会有割裂感;三是这些控件本质上是一个独立编辑器,和富文本编辑器里的纯文本、图片、段落内容混排时,数据模型很难打通。
第二条路线是保留wangEditor,但在编辑器外部做一个“Excel中转工具”,比如用户先上传Excel,后端用Python openpyxl解析公式,把结果生成静态表格重新粘贴进去。这个方案开发量小,但公式成了死数据,并没有解决联动更新问题,只是把复制粘贴的动作变成自动化了。
第三条路线就是我们最终走的:基于wangEditor源码做扩展,在编辑器内部增加一个“Excel表格”自定义节点,节点里保存行列数据、公式文本和缓存值。编辑器和公式解析的职责分开:wangEditor负责展示和交互,SheetJS负责读文件,后端负责公式重算。
我建议你也先做这个对比,不要一上来就改源码。如果你们的系统里公式重算是个低频操作,那第二条路线就够了,没必要给自己找麻烦。只有当“公式联动更新”是刚需时,动源码才值得。
2.2 wangEditor源码里真正需要改的三个位置
wangEditor v5的架构和v4差别很大,很多老教程已经不适合了。v5是基于Slate.js框架构建的,编辑器内部是一棵类似DOM树的节点树,可以注册自定义节点类型。这意味着我们可以在不破坏原有功能的前提下,注入一种新的表格节点。
源码层面有三个位置必须动。
第一是自定义菜单。我们需要在编辑器工具栏加一个“导入Excel”按钮,点击后弹出文件选择窗口。wangEditor v5的菜单插件机制里有registerMenu之类的入口,自定义菜单并不复杂,麻烦的是菜单点击后需要拿到组件实例去触发文件输入框,这里要处理好上下文。
第二是自定义节点。默认的wangEditor表格是一个table节点,单元格是table-cell,里面再塞段落。我们要做的是让一个单元格同时携带公式文本和缓存值两个属性。Slate节点本质上就是普通JSON对象,所以我们可以扩展table-cell的type,或者干脆新建一个excel-table-cell类型。我用的是后者,理由是不想影响编辑器原有的table渲染和快捷键逻辑。
第三是粘贴和插入的逻辑。文件导入走的是editor.insertNode,而用户直接Ctrl+V粘贴Excel内容时,浏览器拿到的是HTML片段,不是xlsx文件。HTML片段里只有数据和样式,没有公式。很多人在这里卡很久,其实答案很直接:想保留公式,就不能依赖粘贴,必须走文件导入接口。后面我会专门讲这个。
3. 解析Excel文件并把公式织入编辑器节点:核心改造过程
3.1 用SheetJS读文件:raw、f、v分别是什么
解析Excel文件我选的是SheetJS(通常叫xlsx库),这个库在前端解析.xlsx文件非常成熟。它读取工作簿的方式是:
javascript复制import * as XLSX from 'xlsx';
const res = await file.arrayBuffer();
const workbook = XLSX.read(res, { cellFormula: true, cellText: true });
const sheet = workbook.Sheets[workbook.SheetNames[0]];
读取时有两个参数千万不能漏:cellFormula和cellText,漏了拿不到公式和文本格式。
拿到sheet对象后,通过sheet['A1']这样的方式能取到单个单元格。单元格对象有几个关键字段:
v:原始值,格式不限,可能是数字、字符串、日期对象;f:公式文本,比如=SUM(B2:B10),只有带cellFormula: true才有;w:格式化后的文本,比如数字显示成“98.20%”;raw:在我看来是把v和w区分开的关键,通常读出来的单元格对象本身就够用。
一个典型的带公式单元格是这样:
javascript复制{
v: 98.25,
f: '=SUM(B2:B10)',
w: '98.25%',
t: 'n'
}
我们要做的就是把f和w都保存下来。公式是给计算逻辑用的,格式化文本是给用户看的初始显示值。
3.2 自定义表格节点:把公式存到data-formula里
解析完Excel数据后,需要把它转换成一个wangEditor能识别的节点结构。我自定义了一个excel-table节点,整体结构大概是:
javascript复制const excelTableNode = {
type: 'excel-table',
rows: rowCount,
cols: colCount,
children: gridCells.map((rowCells) => ({
type: 'excel-table-row',
children: rowCells.map((cell) => ({
type: 'excel-table-cell',
value: cell.w || '',
formula: cell.f || '',
rawValue: cell.v ?? null,
children: [{ text: cell.w || '' }]
}))
}))
};
这里的children是Slate节点必须有的,用于文本编辑。formula、value、rawValue是自定义属性,Slate的节点数据模型允许带其他字段,但在自定义插件里要声明这些属性,否则编辑器序列化时可能会丢掉。
同时需要注册excel-table的插件,告诉编辑器怎么渲染它。这里用到的概念是withNode,它会在编辑器每次操作时校验节点结构。最关键的一点:自定义节点类型的元素默认是void模式,也就是说用户不能直接在单元格里输入文本,否则会破坏我们的数据结构。我这里的做法是:单元格默认不可直接编辑,双击单元格时弹出一个输入框,用户改的是rawValue,改完后手动触发重新计算。
如果你希望单元格可以直接编辑,那要处理的事情会多很多,包括光标定位、选区移动、自动换行、公式覆盖等。对于一个内部系统来说,弹窗编辑反而是更稳的方案,用户学习成本也不高。
插入节点的代码比较简单:
javascript复制editor.insertNode(excelTableNode);
// 如果希望先清空当前选区,可以加一句:
// editor.deleteFragment();
3.3 粘贴场景的真相:纯粘贴拿不到公式,必须走文件导入
这是我在后面和业务方解释最多的地方。很多用户觉得“我在Excel里复制了,网页上粘贴为什么不行?”因为浏览器的剪贴板在复制Excel区域时,根本不会带上公式对象。Excel复制出来的HTML片段里,单元格就是一个个<td>带样式,里面的文本是v值或w值,没有f字段。
所以无论你在wangEditor源码里怎么写onPaste,都不可能从粘贴的HTML中还原公式。想拿到公式,只能让用户导入原始.xlsx文件,或者后端提供一个接口,用户把Excel上传后由后端解析出公式再传回前端。
我们最终的方案是两者结合:工具栏“导入Excel”走前端解析;如果Excel文件太大或者里面带了跨sheet公式,则提示用户走“高级导入”,由后端Python解析后返回JSON再插入编辑器。后者对公式的支持更完整,但多一次网络请求。
如果你被安排做类似需求,我建议一开始就和产品经理对齐这一点:粘贴还原公式在技术上是做不到的,不要在演示时给用户错误预期。
3.4 回显与重新计算:前端展示值,后端算结果
编辑器的数据最终要存到数据库。我设计的存储格式很简单:整个excel-table节点序列化成JSON后,随富文本内容一并保存。公式文本存在每个单元格的formula字段里,展示时优先取value,公式只是辅助信息。
用户修改某个单元格后,点击“重新计算”按钮,前端把整个表格的单元格数据(包括坐标、值、公式)发送到后端。后端用Python的formulas库或者openpyxl重新计算公式,把计算结果返回前端,更新对应单元格的value和显示文本。
为什么不直接用前端公式库?因为芯片制造企业场景里的公式经常带自定义函数,比如企业内部封装的一个良率损失函数,Excel里通过VBA或插件注册,纯前端根本起不来。后端计算的好处是能统一维护业务函数,和内部系统打通。
这里有个细节需要注意:回传坐标时,要保存单元格所在的列字母和行号,也就是A1、B2这种。SheetJS读取时,列是字母,行是数字。后端重新计算时也是用这个坐标体系。如果前端把坐标转成数字下标,再转回去容易出错。我建议在整个链路里都统一用A1格式的坐标字符串。
4. 芯片制造场景专属的“坑”和绕坑方案
4.1 1000行大表格插入后编辑器直接卡死
第一个遇到的硬骨头是性能。用户上传了一张1000行乘20列的晶圆良率明细表,里面还有几列公式。我满怀信心地调用editor.insertNode,页面直接卡了十几秒,然后浏览器弹“无响应”。
一开始我以为是SheetJS解析慢,后来打点发现解析只要200毫秒,卡顿出在Slate渲染上。Slate对节点数量敏感,一次性插入上万个节点,整个编辑区会触发大量渲染。
解决方式有两个层面。第一,插入前压缩节点数量。我们用行列合并算法把纯空行、纯空列去掉,同时限制单次导入上限是500行乘30列,超出就提示用户拆分。第二,插入时不要走insertNode一次塞大对象,而是先editor.insertBreak或者构建一个不可变的Block,再通过Transforms.insertNodes分批插入。实践中我用了“先插入一个占位段落,再在异步任务里分批插入表格行”的方式,配合requestIdleCallback,卡顿从十几秒降到一秒内。
芯片行业的数据表经常是上万行级别的,不要指望浏览器端全能。产品层面得约定清楚:超过500行的Excel建议走后端文件解析,生成静态报告附件,而不是塞进富文本编辑器。真要硬塞,我劝你做好分页加载的准备,复杂度会高一个量级。
4.2 相对引用与跨表引用:公式存成字符串不是万能的
第二个坑是公式引用。Excel里的公式分相对引用和绝对引用,例如=SUM(B2:B10)是相对引用,复制到其他行时会自动变成=SUM(B3:B11)。我们虽然把公式文本存成了字符串,但并没有实现“用户插入一行后公式自动调整”的能力。
这个问题的本质是:你存的是公式的“快照”,不是公式的“实时关系”。如果用户只是改单元格值,后端重新计算没问题;但如果用户增删了表格行,原来的公式引用区间就错位了。比如B5这一行被删除,SUM(B2:B10)可能还在,但它已经把少算了一行,引用范围没有自动更新。
为了控制复杂度,我在产品层面做了一个限制:编辑器内的Excel表格节点固定为“静态行列结构”,不支持在编辑器里直接增删行。用户要调整结构,只能回到Excel里改完重新导入。这个限制一开始业务方不接受,但解释清楚后他们理解了,因为他们的工作流里表格结构是模板固定的,改结构本来就是低频操作。
如果你一定要支持行增删后公式自动调整,那就要在插入行时解析公式字符串里的范围引用,做行列偏移。这个工作量不亚于实现半个公式引擎,对于内部系统我不建议第一版就做。
4.3 数据保密与公式白名单:不能把整个Excel当成一根管道
芯片制造企业的数据保密要求很高。一开始我们把Excel里的所有sheet都解析出来,只要sheet名匹配就导入。后来安全部门提出,某些sheet里含有工艺参数和成本指标,不该出现在报告系统里。
于是我们加了过滤机制:只导入第一个sheet,或者只导入名称以“上报”开头的sheet。同时在解析时只允许接受白名单内的公式函数,比如SUM、AVERAGE、IF、ROUND、MAX、MIN、STDEV、COUNTIF等。如果遇到VLOOKUP、INDIRECT、宏函数,直接报错并提示用户简化公式后重新上传。
这个白名单限制看着是“功能阉割”,实际上是保护双方。因为富文本编辑器最终要渲染成HTML,如果公式里混入了CONCATENATE生成的危险字符串,再被当成HTML输出,很容易出现XSS隐患。另外,Excel公式注入攻击也是一个不可忽视的点:单元格值如果以=, +, -, @开头,在某些场景下会被执行。我们在导入时对这类开头的纯文本值做了强转:在前面加一个单引号,和后端导出Excel时防止CSV注入是一个思路。
如果你只是做Demo,可能会觉得这些限制很烦,但在制造企业里,安全审查不过关,项目连测试环境都上不了。
5. 给同样要改wangEditor源码的人一份落地清单
5.1 改造前必须确认的五个问题
这个项目做完后,我复盘出一份前期调研清单,建议你在写代码前先和业务方把这些问题对齐。
问题一:用户手中的Excel文件结构是不是固定的?如果每个部门发过来的表头都不一样,解析规则就很难统一。先跑一两个部门的真实Excel看结构,比闷头开发强得多。
问题二:公式需要支持到什么程度?只支持SUM和AVERAGE,还是支持嵌套IF、VLOOKUP?答案直接决定你要不要在后端引入formulas这种重量级引擎。
问题三:公式计算是用户主动触发还是自动触发?自动触发对前端架构压力很大,用户刚开始肯定会要求自动,等他们意识到慢之后会改回手动。
问题四:导入的Excel是否涉及多个sheet?如果只导入第一个sheet,要在按钮下方明确提示,避免用户产生“我文件有内容的,怎么网页上没了”的误解。
问题五:富文本编辑器里是否需要允许用户直接编辑单元格,还是弹窗编辑?我强烈建议第一版选弹窗编辑,因为直接编辑的选区管理会让你交很多学费。
5.2 从原型到上线:我建议的推进节奏
我按三周时间做完第一版。第一周只做最小闭环:SheetJS解析Excel,插入自定义表格节点,保存时保留公式字段,回显时能显示公式。不做重新计算,不搞性能优化。第二周接入后端计算接口,跑通“改值-请求-更新”的流程。第三周处理边界情况:大表格限制、公式白名单、空单元格补全、样式简单映射。
这里有一点特别重要:样式不要追求完美还原Excel。边框、底色、合并单元格这些基础样式可以保留,但列宽、行高、字体、对齐方式这些细节,在富文本编辑器里过度还原反而是负担。我们最终只保留了边框、背景色和合并单元格,其他样式全部简化成编辑器默认值。用户真正看的是数据内容和公式联动,不是花哨的表皮。
5.3 几个能让维护者少掉头发的细节
最后分享几个写代码时容易被忽略的点。
第一,单元格坐标不要用纯数字下标,统一用A1格式。SheetJS读取时给了sheet['A1']这种接口,我们的序列化结构里也应该存colName字段,方便后端计算时直接定位。
第二,公式文本中可能带有前缀等号,也可能是无等号的形式。后端计算时有些库要求带等号,有些要求不带。我在入库前统一去掉前缀等号,存储结构里只存纯公式文本,在调用计算接口时再按需拼回等号,避免两头不一致。
第三,设置编辑器内容时,别忘了给自定义节点配置normalize函数。Slate有个特点:如果节点结构不符合schema,它有可能会自动修正,修正得不好就把你的自定义属性丢掉了。所以一定要在with环节里对excel-table-cell定义好allowed attributes,并测试一遍“内容从服务端读回来再写入编辑器”的往返流程。我们就在这个环节踩过坑:第一次序列化时formula字段还在,重新setChildren进去之后,自定义属性全被清了,排查半天才发现是缺了节点的isVoid配置。
第四,如果你们系统还在用wangEditor v4,我的部分代码示例不能直接套用,但思路是通的。v4的编辑器是contenteditable + 自定义命令,没有Slate那套节点模型。这种情况下,我会建议你直接用HTML结构存储,然后把公式放在data-formula属性里,监听单元格的blur事件去触发重算。缺点是v4对复杂表格内容的兼容性不如v5,如果你可以选版本,优先上v5。
这个项目做完之后,我最深的体会是:所谓源码改造,未必是想象中那种所有逻辑都要自己写的“硬刚”。大部分工作其实是选好切入点,把开源工具的能力组合起来,再用自定义节点把边界圈住。wangEditor不是为Excel场景设计的,但通过自定义节点,你完全可以把它变成业务系统里的“半个表格组件”。当然,你得先接受一个现实:它永远不会变成真正的Excel,但只要业务方接受这个边界,这套方案足以让良率周报的线上化顺畅跑起来。
