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性能分析

运行效果

广东省
浏览 60
1
5
分享
5 +1
1 +1
全部评论