「注释 + 脏单元格」机制详解 ——JSA 异步自定义函数的实现思路

WPS JSA自定义函数开发中,异步操作无法同步返回单元格结果,可采用「注释+脏单元格」机制实现。


第一章 原理基础篇

1.1 WPS表格计算引擎的同步执行模型

WPS表格的公式计算引擎遵循同步单线程阻塞式计算模型,其核心特性如下:

  1. 自定义函数UDF的执行被包裹在计算引擎的单次计算栈帧内,函数需要在当前栈帧销毁前同步返回最终值,计算引擎才会将返回值渲染至单元格。

  1. 计算引擎本身不具备等待异步回调的能力:若函数返回Promise对象、或在异步回调中修改返回值,引擎会直接取同步执行阶段的返回值(如[object Promise]、空值)完成渲染,且栈帧销毁后不会再监听异步回调的执行结果。

  1. 计算引擎运行期间,直接通过宏代码修改单元格的Value属性,会被引擎拦截,容易出现偶发失效、公式覆盖、撤销逻辑异常等问题。

  1. 引擎的重算触发规则:仅当单元格被标记为「脏数据(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:首次同步计算(引擎初始化执行)

  1. 用户在目标单元格输入自定义函数公式,按下回车触发WPS表格计算引擎对该单元格的首次计算。

  1. 引擎创建UDF专属执行上下文,同步调用自定义函数,函数进入同步执行阶段。

  1. 函数同步执行第一步:通过Application.Caller()获取触发本次计算的单元格Range实例,将该引用保存在闭包中,供后续异步回调访问。

  1. 函数同步执行第二步:检查单元格是否存在符合规则的批注(即异步回调写入的结果暂存数据)。首次执行无对应批注,跳过结果读取分支。

  1. 函数同步执行第三步:初始化异步操作(XHR/fetch/定时器等),注册异步回调函数,将闭包中锁定的Range引用传入回调。

  1. 函数同步执行结束:向计算引擎返回初始占位值(通常为空字符串""),引擎接收该值完成首次渲染,本次计算栈帧完全销毁,引擎进入空闲状态。

阶段2:异步操作执行与回调触发

  1. 后台异步操作独立执行(网络请求、文件IO、延时计算等),不受WPS表格计算引擎的约束。

  1. 异步操作完成后,回调函数进入WPS JSA的V8事件循环队列,在UI线程空闲时同步执行。

  1. 回调执行核心操作1:获取异步执行的最终结果(成功数据/失败错误信息),通过AddComment()将结果完整写入锁定单元格的批注文本中。

  1. 回调执行核心操作2:调用Range.Dirty()方法,将目标单元格标记为脏数据,向计算引擎发送重算通知。

阶段3:二次同步计算(结果最终渲染)

  1. 计算引擎接收到脏数据标记,触发对目标单元格的第二次同步计算,创建新的UDF执行上下文,再次调用自定义函数。

  1. 函数同步执行第一步:再次通过Application.Caller()锁定目标单元格Range引用。

  1. 函数同步执行第二步:检测到单元格存在符合规则的结果批注,立即读取批注的完整文本内容,同步调用Comment.Delete()删除批注(核心操作,避免循环重算)。

  1. 函数同步执行结束:将读取到的结果文本作为返回值,同步返回给计算引擎。

  1. 计算引擎接收最终结果,完成单元格的渲染,本次计算栈帧销毁,整个执行流程闭环结束。

1.4 方案的核心优势

对比目前常见的异步UDF实现方案,这套机制有几个比较突出的优势:

  1. 适配原生计算规则:单元格始终保留公式,结果由计算引擎计算生成,支持单元格依赖联动、撤销重做、筛选排序等原生表格操作,无明显行为冲突。

  1. 低侵入式实现:仅使用单元格批注作为临时容器,使用后立即清理,不污染表格结构、不修改单元格原有属性、不占用辅助列/辅助单元格,对用户业务影响极小。

  1. 跨平台兼容性较好:基于WPS JSA原生API实现,可支持不同平台。

  1. 行为可控性强:遵循WPS计算引擎的执行规则,相比直接修改Range.Value的方案,出现偶发失效、引擎拦截的概率更低。

  1. 无额外依赖:纯原生实现,无需安装任何插件、无需对接第三方组件,仅需启用宏即可使用。


第二章 社区案例

2.1 同步 XHR

社区案例:自定义函数 JSAWEBSERVICE 从Internet或Intranet上的Web服务返回数据

代码特点分析:核心是将xhr.open的第三个参数设为false,使用同步 XHR 请求,等待请求完成后直接返回结果,无需处理异步回调,逻辑简单,符合计算引擎的同步模型。但同步请求会阻塞 WPS 表格 UI 线程,若网络请求慢,WPS 会出现 “假死” 状态。

2.2 异步 fetch

社区案例:自定义函数 JSAFETCH 在单元格中返回请求响应的异步数据

  • 采用fetch发起异步请求,避免阻塞 UI 线程,解决了同步版的 “假死” 问题。

  • 完整实现了「注释 + 脏单元格」机制:

  1. 通过Application.Caller()锁定单元格引用。

  1. 首次计算检查无批注后,发起异步请求,返回空值。

  1. 封装addCommentDirtyCalculate函数,统一处理 “写入批注 + 标记脏数据 + 兼容手动重算” 的逻辑。

  1. 异步请求完成后,调用该函数触发二次计算,二次计算时读取批注、删除批注并返回结果。

「注释 + 脏单元格」机制的简化版代码参考如下:

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.MacroOptionsWorkbook_Open 中注册函数,定义参数说明,支持在 WPS “插入函数” 对话框中可视化使用。

2.4 图片嵌入(异步 IO + 单元格可视化)

社区案例自定义函数 JSAIMAGE 将本地或网络图片嵌入单元格

  • 跨源图片处理:针对网络图片,创造性地采用 “迂回策略”:先通过 Shapes.AddPicture 将图片加载到工作表 Shape 对象,再利用 SaveAsPicture 保存到系统临时目录,最后通过 RangeEx.InsertCellPicture 嵌入单元格,解决了网络图片直接嵌入的兼容性问题。

  • 临时文件管理:使用 Env.GetTempPath() 获取系统临时目录,结合随机文件名生成函数 RANDNAME() 创建临时图片文件,嵌入完成后立即通过 FileSystem.unlinkSync 删除,避免磁盘垃圾残留。

  • 轻量级异步容错:虽然未显式使用网络异步请求,但通过 try-catch 捕获图片加载 / 保存 / 嵌入全链路异常,失败时返回 “按 {F9} 查看” 提示,结合「注释 + 脏单元格」的思想(隐式依赖重算机制),给予用户手动重试的能力。

  • 极简用户体验:公式保留在单元格中,图片直接嵌入单元格内部(而非悬浮 Shape),支持单元格排序、筛选时图片跟随移动,符合原生表格操作习惯。

北京
浏览 917
2
7
分享
7 +1
1
2 +1
全部评论 1
 
zbahjj
请问下,加载项公式,怎么获取Application.Caller()的地址啊,升级后无法获取了
· 四川省
回复