js宏和wps加载项的jsapi性能探测器
王子陶
@金山办公
这是一个适用于js宏和wps加载项的性能探测器. 能统计给个api和调用路径的调用次数和时间长度. 帮助开发者排查jsapi性能问题
背景
有开发者开发的wps加载项中遇到了严重的性能问题, 导致wps和浏览器同时卡顿.
涉及到的单元格数量巨大
浏览器自带的性能功能统计无法统计jsapi
jsapi调用次数以百万计
开发者无法看到wps进程调用栈
排查性能问题时两眼一抹黑, 不知道那个api消耗了多少, 不知道api调用频率
开发者没意识到很多中间对象获取过程有ipc开销
开发者的加载项是全异步式设计的, 传统统计方式难以做链路跟踪
WPS JSAPI现状
js宏和wps加载项都是同步式调用.
js宏: 运行在wps主进程内, 可以直接调用wps核心
wps加载项: jsapi时同步式api
需要发送跨进程调用请求, 阻塞等待wps主进程完成请求.
这期间浏览器主进程只能等待跨进程调用, 无法完成后台异步任务或者处理计算任务.
即时在cpu和内存比较空闲的状态下, 每次ipc调用都需要50微秒左右
目标
性能探查器不侵入调用wpsapi的代码, 不用在调用api的地方插入事件记录代码
可以方便地开关
可以在加载项打包时剔除性能探测器
能够根据实际业务需要, 修改统计方式
原理
利用JavaScript的Proxy类型, 给wps对象做代理
代理器中记录api运行时长
代理器给返回值或者属性递归式生成代理对象
实现
核心代码
/**
* 递归创建 Proxy — 用"代理拦截器"包裹 WPS 对象,统计每次 API 调用耗时
*
* 【新手必读】
* Proxy 好比"传话筒":你把话告诉传话筒,传话筒先计个时,再把话转给真人(WPS),
* 等真人回话时再计一次时,就知道这次调用花了多久。
*
* 为什么需要递归?
* 你写 app.ActiveSheet.Range("A1").Value2 时发生了 4 次操作:
* ① get ActiveSheet → ② get Range → ③ call Range("A1") → ④ set Value2
* 如果只代理 app,就只能统计到第①步,后面的 3 步就漏掉了。
* 所以:每当你访问一个属性(get)或调用一个函数(call),如果返回值还是对象,
* 就继续给它也套一层 Proxy,这样整个调用链的每一步都能被统计到。
*
* @param {object|function} target - 原始 WPS 对象
* @param {string} path - 当前对象在 API 树中的路径,如 "Application.ActiveSheet"
* @returns {Proxy} 包裹后的代理对象,用法与原始对象完全一致
*/
function createProxy(target, path) {
// 不是对象也不是函数(数字/字符串/null/undefined),直接返回,不代理
if (!_isWrappable(target)) return target;
// 防止双重代理:如果这个对象已经有 Proxy 了,直接返回已有的
// 原理:createProxy 在 target 对象上放了 [PROXY] 标记,下次见到就知道不用再包了
if (target[PROXY]) return target[PROXY];
// ===== 为什么要用空壳 mockTarget?=====
// 直接 new Proxy(target, ...) 对某些 WPS 原生对象会报安全错误。
// 解决思路:用一个空对象 {} 当"替身",在 trap 里转发到真正的 target。
// 就像你不能直接碰高压线,但可以戴绝缘手套操作 — mockTarget 就是那只手套。
let mockTarget = {}
for (const key in mockTarget) {
mockTarget[key] = null
}
// ===== 核心:创建 Proxy 代理 =====
// Proxy 有两大"陷阱(trap)":get(读属性时触发)和 set(写属性时触发)
let proxy = new Proxy(mockTarget, {
// ─── get 陷阱:读取属性时触发 ───
// 当你写 app.ActiveSheet 时,JavaScript 引擎就会调用这个函数
get(_, prop, receiver) {
// Symbol 类型的属性是"暗号"(如 PROXY / PROXY_TARGET),
// 它们不是 WPS API,不需要计时,直接处理
if (prop === PROXY) return receiver; // 问"你是 Proxy 吗?"→ 回"是"
if (prop === PROXY_TARGET) return target; // 问"你的真身是谁?"→ 返回原始对象
// 用 "." 连接路径,形成如 "Application.ActiveSheet.Range" 的完整路径
const childPath = path ? `${path}.${String(prop)}` : String(prop);
// 如果是其它 Symbol 属性,直接转发给原始对象,不统计
if (typeof prop === 'symbol') return Reflect.get(target, prop, receiver);
// === get 访问计时:记录读取这个属性花了多久 ===
const m = profiler.recordAccessStart(childPath, 'get');
// 实际读取属性的值(从 WPS 对象拿,不是从空壳拿)
const value = target[prop];
if (m) profiler.recordAccessEnd(childPath, 'get', m);
// ===== 关键:返回值如果是函数,包装成"带计时的调用器" =====
// 比如 app.ActiveSheet.Range 是一个函数方法,
// 用户接下来会调用它:.Range("A1")
// 我们需要拦截这个调用,在它执行前后计时
if (typeof value === 'function') {
return function(...args) {
// === call 调用计时:记录这次函数调用花了多久 ===
const frame = profiler.recordCallStart(childPath);
try {
// 调用前:把参数中的代理对象还原成 WPS 原生对象
// 为什么?WPS 不认识我们的 Proxy,必须喂原生对象给它
const result = value.apply(target, _unwrapArgs(args));
profiler.recordCallEnd(childPath, frame);
// 调用后:如果返回值又是对象,继续套 Proxy(递归的核心!)
// 这样下次访问返回值上的属性时,又能被计时了
return _isWrappable(result) ? createProxy(result, childPath) : result;
} catch (e) {
// 即使抛异常也要结束计时,否则统计数据会不准确
profiler.recordCallEnd(childPath, frame);
throw e;
}
};
}
// 返回值是普通对象(非函数),继续套 Proxy 代理
// 这样后续对这个对象读属性/调方法时,都能被统计到
// 例如:app.ActiveSheet 返回的 Sheet 对象,包一层 Proxy 后
// 再访问 .Range() .Name 等都会被拦截计时
return _isWrappable(value) ? createProxy(value, childPath) : value;
},
// ─── set 陷阱:写入属性时触发 ───
// 当你写 app.ActiveSheet.Range("A1").Value2 = 123 时,
// 最后的 .Value2 = 123 就会触发这个 set 陷阱
set(_, prop, value, receiver) {
const childPath = path ? `${path}.${String(prop)}` : String(prop);
// === set 访问计时:记录写入这个属性花了多久 ===
const m = profiler.recordAccessStart(childPath, 'set');
// 如果你写入的值是一个代理对象,先把它还原成 WPS 原生对象
// 否则 WPS 拿到 Proxy 包装的数字/字符串/对象会不认识
target[prop] = _unwrapArg(value)
if (m) profiler.recordAccessEnd(childPath, 'set', m);
return true; // 必须返回 true,告诉引擎写入成功
},
});
// 在原始对象上做记号:这个对象已经有 Proxy 了,下次别重复创建
target[PROXY] = proxy
// 在 Proxy 上保存原始对象的引用(调试时有用)
proxy.target = target;
return proxy
}完整代码
【金山文档 | WPS云文档】 WPS加载项jsapi性能分析