优化了盟友几行代码,硬要请我喝咖啡

616 阅读4分钟

前些日子对接过联盟一个帮派的 Java web 项目,对接人王道友提过好几次,说该项目的用户登录校验经常失灵,明明登录过了,刷新页面后又时常会跳去登录页,一直未找到原因,甚是困扰。

话已至此,一向行侠仗义的我决定趟一趟这浑水。

image.png

问题分析

由于不熟悉项目,首先让王道友介绍了项目登录的主流程,得到如下流程图。

image.png

随后花了点时间在问题复现上,最终定位到了核心代码,321上代码

 let userCheck = {
    isRunning: false,
    interval: 30,
    checkSessionUrl: "https://xxx/user/checkSession",
    returnUrl: null,
    userGuid: null,
    timer: null,

    close: function () {
      clearTimeout(timer);
      this.isRunning = false;
    },
    open: function () {
      if (this.isRunning === true) {
        return;
      }

      this.isRunning = true;
      let container = document.getElementById("userCheckContainer");
      if (!container) {
        container = document.createElement("div");
      }
      container.id = "userCheckContainer";
      container.style.display = "none";
      document.body.appendChild(container);
      this.returnUrl = this.removeQueries(window.location.href);
      // 问题所在
      window.addEventListener(
        "message",
        function (event) {
          this.userGuid = event.data.userGuid;
        },
        false
      );

      this.refreshFrame();
    },
    // 获取当前登录用户guid,当前未登录用户则返回空字符串
    getUserGuid: function () {
      this.close();
      this.open();
      return this.userGuid;
    },
    refreshFrame: function () {
      let frame = document.createElement("iframe");
      frame.id = "userCheckFrame";
      frame.src = `${this.checkSessionUrl}?returnUrl=${encodeURIComponent(
        this.returnUrl
      )}`;
      frame.sandbox = "allow-same-origin allow-scripts allow-forms";

      let container = document.getElementById("userCheckContainer");
      if (container) {
        container.innerHTML = "";
        container.appendChild(frame);
      }

      if (this.isRunning) {
        this.timer = setTimeout(
          () => this.refreshFrame(),
          this.interval * 1000
        );
      }
    },
    removeQueries: function (url = "") {
      let idx = url.indexOf("?");
      if (idx < 1) {
        return url;
      }
      return url.substring(0, idx - 1);
    },
  };

  getUserInfo().then((userInfo) => {
    const newGuid = userUtils.getUserGuid();
    if (userInfo.guid !== newGuid) {
      // 跳去登录页
    }
  });

眼尖的道友估计一眼就看出了端倪,核心问题在于用户一致性校验环节的异步流程错乱。
userCheck 行 28-34 的 message 事件回调是异步给 userGuid 赋值的,而行 39-42 getUserGuid 却以同步的方式返回结果。
行 75 执行getUserGuid时,只有当 message 回调在行 76 执行前返回结果,登录校验流程才能正常,否则校验异常跳去登录页。

妙啊!原来是玄学编程,有缘者登录之。

企业微信截图_20250520130713.png

话不多说,开始设计解决方案吧。

解决方案

方案设计方向是把上述用户一致性校验环节的异步流程理顺。
源代码流程是动态创建 iframe 并监听其 message 回调,并以轮询方式重试,存在的问题有:

  • 同步异步时序错乱(核心问题,上文分析过)
  • 未考虑 iframe 加载异常场景、未设置 refreshFrame 轮询上限,可能进入死循环
  • 未校验 message 消息合法性,可能拿到错误信息
  • 未正常处理 getUserId 多次调用场景

现在以异步编程的方式重新设计下流程:

image.png

编码实战

基于上述流程上代码

// 执行程序单例
let processPromise: Promise<string> | null = null;
// 副作用池
const sideEffectPools: Function[] = [];

function log(...args: any[]) {
  console.log(`[AuthCheck]`, ...args);
}
// 创建iframe容器
function createCheckFrame(url: string, returnUrl = window.location.href) {
  const frameId = "userCheckFrame";
  let frame = document.createElement("iframe");
  const frameAttrs = {
    id: frameId,
    src: `${url}?returnUrl=${encodeURIComponent(returnUrl)}`,
    sandbox: "allow-same-origin allow-scripts allow-forms",
    style: "display: none;position: absolute;",
  };
  Object.entries(frameAttrs).forEach(([key, val]) => {
    frame.setAttribute(key, val);
  });

  // 销毁iframe容器
  sideEffectPools.push(() => {
    frame.parentNode?.removeChild(frame);
    frame = null; // 解除引用
  });
  return frame;
}

// iframe 加载异常
function frameErrorPromise(frame: HTMLIFrameElement) {
  return new Promise((_, reject) => {
    frame.onerror = () => {
      reject(new Error("iframe加载错误"));
    };
  });
}

// iframe 消息回调
function frameMessagePromise<T>(
  validator?: (evt: MessageEvent, next: Function) => void
) {
  return new Promise<T>((resolve) => {
    const messageCallback = (event: MessageEvent) => {
      if (typeof validator === "function") {
        validator(event, resolve);
        return;
      }
      resolve(event.data);
    };
    window.addEventListener("message", messageCallback, false);

    // 清理事件监听
    sideEffectPools.push(() => {
      window.removeEventListener("message", messageCallback);
    });
  });
}
// 超时 Promise
function frameTimeoutPromise(ms = 1000, err?: string) {
  return new Promise((_, reject) => {
    let timer = setTimeout(() => reject(new Error(err)), ms);
    // 清理计时器
    sideEffectPools.push(() => {
      clearTimeout(timer);
      timer = null;
    });
  });
}
// 清理副作用
function cleanSideEffects() {
  while (sideEffectPools.length) {
    const sideEffect = sideEffectPools.pop();
    sideEffect?.();
  }
}
// 获取用户基本信息
function _getCurrentUser(url: string, timeout = 5 * 1000) {
  cleanSideEffects();

  const frame = createCheckFrame(url);
  const promise = Promise.race([
    frameMessagePromise((event: MessageEvent, next: Function) => {
      log("messageCallback:", event.origin, event.data);
      const host = new URL(url).origin;
      // 只接受同源消息
      if (evt.origin === host) {
        next(evt.data?.userGuid);
      }
    }),
    frameErrorPromise(frame),
    frameTimeoutPromise(timeout, "获取用户信息超时"),
  ]).finally(cleanSideEffects);
  document.body.appendChild(frame);
  return promise;
}
// 获取当前登录用户guid
async function getUserGuid(args?: {
  retryCount?: number;
  checkSessionUrl: string;
  timeout?: number;
}) {
  let {
    retryCount = 1,
    checkSessionUrl = "https://xxx/user/checkSession",
    timeout,
  } = args || {};

  if (processPromise) return processPromise;

  const execute = async () => {
    try {
      const res = (await _getCurrentUser(checkSessionUrl, timeout)) as string;
      processPromise = null;
      return res;
    } catch (err) {
      log(err?.message);

      if (retryCount > 0) {
        log(`开始重新获取,剩余重试次数${retryCount}次`);
        retryCount--;
        return execute();
      }
      processPromise = null;
      throw new Error(`获取用户信息失败`);
    }
  };

  processPromise = execute();
  return processPromise;
}

完成代码,接入测试下:

正常场景 image.png

超时场景
image.png

竞态场景 image.png

完美,项目交由王道友测试后,其嘴角微微上扬,内心亦起了一丝丝敬意。

小结

用户登录校验经常失灵,根本原因在于用户一致性校验环节的异步流程错乱。
本文通过梳理原有检验流程,结合promise、async/await 异步函数重构了代码,并考虑到了多重边界场景,最终道友们的登录缘分,终可不必再靠玄学。

不多说了,王道友已经买好咖啡在门口了,我去去就回……

793f9b86e950352a5209b3df1643fbf2b0118bcf.gif