「注释 + 脏单元格」机制详解 ——JSA 异步自定义函数的实现思路
WPS JSA自定义函数开发中,异步操作无法同步返回单元格结果,可采用「注释+脏单元格」机制实现。
第一章 原理基础篇
1.1 WPS表格计算引擎的同步执行模型
WPS表格的公式计算引擎遵循同步单线程阻塞式计算模型,其核心特性如下:
自定义函数UDF的执行被包裹在计算引擎的单次计算栈帧内,函数需要在当前栈帧销毁前同步返回最终值,计算引擎才会将返回值渲染至单元格。
计算引擎本身不具备等待异步回调的能力:若函数返回Promise对象、或在异步回调中修改返回值,引擎会直接取同步执行阶段的返回值(如[object Promise]、空值)完成渲染,且栈帧销毁后不会再监听异步回调的执行结果。
计算引擎运行期间,直接通过宏代码修改单元格的Value属性,会被引擎拦截,容易出现偶发失效、公式覆盖、撤销逻辑异常等问题。
引擎的重算触发规则:仅当单元格被标记为「脏数据(Dirty)」时,才会被纳入下一次计算循环,重新执行公式逻辑。
1.2 机制依赖的核心宿主API
通过WPS JSA原生宿主API的组合,可突破同步计算模型的约束,实现异步结果的闭环回填。
API 接口 | 宿主环境基础行为 | 机制中的核心作用 |
Application.Caller() | 仅在UDF执行上下文(单元格公式触发函数执行)中有效,返回触发本次计算的单元格Range实例引用,脱离UDF上下文调用返回undefined | 同步执行阶段锁定目标单元格,闭包保留引用供异步回调操作,是异步回调与目标单元格建立关联的关键桥梁 |
Range.Comment 批注对象 | 单元格的持久化属性对象,不受计算引擎计算栈生命周期影响,可在计算引擎空闲状态下的异步回调中自由增删改读,数据可持久化存储于工作簿内 | 作为异步结果的临时暂存容器,解决异步回调执行时计算栈已销毁、无法直接向引擎返回值的核心问题 |
Range.Dirty() | 向WPS表格计算引擎发送指令,将指定单元格标记为「脏数据」,通知引擎该单元格的公式需要重新执行计算;自动重算模式下会触发立即重算,手动重算模式下标记为待重算状态 | 异步结果写入暂存容器后,触发引擎的二次计算,是打通异步回调与计算引擎的核心触发开关 |
Range.AddComment()/Comment.Text()/Comment.Delete() | 批注对象的原生读写、删除接口,操作即时生效,不受计算引擎状态约束 | 实现异步结果的写入、二次计算时的读取、以及结果读取后的清理,避免循环重算 |
1.3 机制的完整执行生命周期
阶段1:首次同步计算(引擎初始化执行)
用户在目标单元格输入自定义函数公式,按下回车触发WPS表格计算引擎对该单元格的首次计算。
引擎创建UDF专属执行上下文,同步调用自定义函数,函数进入同步执行阶段。
函数同步执行第一步:通过Application.Caller()获取触发本次计算的单元格Range实例,将该引用保存在闭包中,供后续异步回调访问。
函数同步执行第二步:检查单元格是否存在符合规则的批注(即异步回调写入的结果暂存数据)。首次执行无对应批注,跳过结果读取分支。
函数同步执行第三步:初始化异步操作(XHR/fetch/定时器等),注册异步回调函数,将闭包中锁定的Range引用传入回调。
函数同步执行结束:向计算引擎返回初始占位值(通常为空字符串""),引擎接收该值完成首次渲染,本次计算栈帧完全销毁,引擎进入空闲状态。
阶段2:异步操作执行与回调触发
后台异步操作独立执行(网络请求、文件IO、延时计算等),不受WPS表格计算引擎的约束。
异步操作完成后,回调函数进入WPS JSA的V8事件循环队列,在UI线程空闲时同步执行。
回调执行核心操作1:获取异步执行的最终结果(成功数据/失败错误信息),通过AddComment()将结果完整写入锁定单元格的批注文本中。
回调执行核心操作2:调用Range.Dirty()方法,将目标单元格标记为脏数据,向计算引擎发送重算通知。
阶段3:二次同步计算(结果最终渲染)
计算引擎接收到脏数据标记,触发对目标单元格的第二次同步计算,创建新的UDF执行上下文,再次调用自定义函数。
函数同步执行第一步:再次通过Application.Caller()锁定目标单元格Range引用。
函数同步执行第二步:检测到单元格存在符合规则的结果批注,立即读取批注的完整文本内容,同步调用Comment.Delete()删除批注(核心操作,避免循环重算)。
函数同步执行结束:将读取到的结果文本作为返回值,同步返回给计算引擎。
计算引擎接收最终结果,完成单元格的渲染,本次计算栈帧销毁,整个执行流程闭环结束。
1.4 方案的核心优势
对比目前常见的异步UDF实现方案,这套机制有几个比较突出的优势:
适配原生计算规则:单元格始终保留公式,结果由计算引擎计算生成,支持单元格依赖联动、撤销重做、筛选排序等原生表格操作,无明显行为冲突。
低侵入式实现:仅使用单元格批注作为临时容器,使用后立即清理,不污染表格结构、不修改单元格原有属性、不占用辅助列/辅助单元格,对用户业务影响极小。
跨平台兼容性较好:基于WPS JSA原生API实现,可支持不同平台。
行为可控性强:遵循WPS计算引擎的执行规则,相比直接修改Range.Value的方案,出现偶发失效、引擎拦截的概率更低。
无额外依赖:纯原生实现,无需安装任何插件、无需对接第三方组件,仅需启用宏即可使用。
第二章 社区案例
2.1 同步 XHR
社区案例:自定义函数 JSAWEBSERVICE 从Internet或Intranet上的Web服务返回数据
代码特点分析:核心是将xhr.open的第三个参数设为false,使用同步 XHR 请求,等待请求完成后直接返回结果,无需处理异步回调,逻辑简单,符合计算引擎的同步模型。但同步请求会阻塞 WPS 表格 UI 线程,若网络请求慢,WPS 会出现 “假死” 状态。
2.2 异步 fetch
社区案例:自定义函数 JSAFETCH 在单元格中返回请求响应的异步数据
采用fetch发起异步请求,避免阻塞 UI 线程,解决了同步版的 “假死” 问题。
完整实现了「注释 + 脏单元格」机制:
通过Application.Caller()锁定单元格引用。
首次计算检查无批注后,发起异步请求,返回空值。
封装addCommentDirtyCalculate函数,统一处理 “写入批注 + 标记脏数据 + 兼容手动重算” 的逻辑。
异步请求完成后,调用该函数触发二次计算,二次计算时读取批注、删除批注并返回结果。
「注释 + 脏单元格」机制的简化版代码参考如下:
function JSAWebService(url) {
let rng = Application.Caller();
url = url.valueOf();
// 如果有注释,说明数据回来了,读取并清除注释
if (rng.Comment) {
let txt = rng.Comment.Text();
rng.Comment.Delete();
return txt;
}
let xhr = new XMLHttpRequest();
xhr.open("GET", url, true); // true 表示异步
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) { // 4 = 请求完成
let result = (xhr.status == 200) ? xhr.responseText : "Error: " + xhr.status;
rng.AddComment(result);
rng.Dirty(); // 触发重算
}
};
xhr.send();
return ""; // 第一次返回空,等待重算
}function JSAWebService(url) {
let rng = Application.Caller();
url = url.valueOf();
if (rng.Comment) {
let text = rng.Comment.Text();
rng.Comment.Delete();
return text;
}
// 发起异步请求
(async () => {
try {
let response = await fetch(url);
let result = response.ok ? await response.text() : `HTTP ${response.status}`;
rng.AddComment(result); // 将结果写入注释
rng.Dirty(); // 标记单元格为“脏”,触发重新计算
} catch (e) {
rng.AddComment("Error: " + e.message);
rng.Dirty();
}
})();
// 第一次调用先返回空,等待异步触发重算
return "";
}2.3 AirScript 交互
社区案例:自定义函数 JSA_A_FETCHAIRSCRIPT 返回AirScript脚本的返回值
深度异步交互:针对 AirScript Webhook 场景,不仅发起异步 POST 请求,还通过 while 循环配合 DoEvents() 轮询响应状态,等待脚本执行完成,适配后端异步处理的长耗时场景。
完善的参数体系:支持 url(Webhook 地址)、token(身份令牌)、argv(脚本参数,支持 String/Object 类型自动转换)、waitTime(超时控制,默认 60 秒)。
健壮的工程化封装:
封装 addCommentDirtyCalculate 函数,统一处理 “写入批注 + 标记脏数据 + 手动重算模式兼容”,避免代码重复。
实现超时判断机制,超过 waitTime 后自动终止等待并返回超时提示。
通过 JSON.stringify 提取 data.result 字段,直接返回脚本的核心执行结果。
原生函数对话框集成:通过 Application.MacroOptions 在 Workbook_Open 中注册函数,定义参数说明,支持在 WPS “插入函数” 对话框中可视化使用。
2.4 图片嵌入(异步 IO + 单元格可视化)
社区案例:自定义函数 JSAIMAGE 将本地或网络图片嵌入单元格
跨源图片处理:针对网络图片,创造性地采用 “迂回策略”:先通过 Shapes.AddPicture 将图片加载到工作表 Shape 对象,再利用 SaveAsPicture 保存到系统临时目录,最后通过 RangeEx.InsertCellPicture 嵌入单元格,解决了网络图片直接嵌入的兼容性问题。
临时文件管理:使用 Env.GetTempPath() 获取系统临时目录,结合随机文件名生成函数 RANDNAME() 创建临时图片文件,嵌入完成后立即通过 FileSystem.unlinkSync 删除,避免磁盘垃圾残留。
轻量级异步容错:虽然未显式使用网络异步请求,但通过 try-catch 捕获图片加载 / 保存 / 嵌入全链路异常,失败时返回 “按 {F9} 查看” 提示,结合「注释 + 脏单元格」的思想(隐式依赖重算机制),给予用户手动重试的能力。
极简用户体验:公式保留在单元格中,图片直接嵌入单元格内部(而非悬浮 Shape),支持单元格排序、筛选时图片跟随移动,符合原生表格操作习惯。