为什么今天突然又想起它?因为公众号一篇冷门文章收到了评论,又激发了我对于它的热情:

https://zhuanlan.zhihu.com/p/353365352

image.png

服务器端的 memoize 故事

好几年前,学习了 memoize 这个函数,并且在实现工作中应用了它,起到了很好的效果。具体来说,是在一个 nodejs 服务器端,有一些操作特别耗时(有一些复杂的上下文,总之,不应该有这种耗时操作,但是已经难以优化),于是通过 memoize 包装了这个耗时的操作,在多次调用中,仅第一次比较耗时,后续的调用就是秒返回。

当然,除了使用了它,你也可以使用静态变量来存储第一次执行的结果,然后在调用中对这个变量判空即可。但是这样涉及到的代码改动比较多,远不如添加一个 memoize 来得优雅。通过使用 memoize,不用修改原有代码逻辑流,只是通过新增代码改变了系统的行为,符合开闭原则。

当时是在一个国际化团队中(某知名跨国品牌),这个提交其实改动很小,不过增加了好几个测试用例以证明它能工作。在提交后,很快被纳入主干,并顺利上线。后来我离职了,不清楚后面的情况,如果没有太大的变化,它已经在分布于全世界的服务器中运行了快三年了。在离职前夕,我总结自己的工作,发现对 memoize 的运用效果非常好,还可以说明一下闭包的妙用,所以写了那篇专栏。

前端的 memoize 故事

有意思的是,我加入到一个新团队后,立即又把这个 memoize 模式,在前端又应用了一次,同样起到了非常好的效果。目前运行在前端上,也有快一年的时间了,由于这次没有离职,所以很确定地知道,这段 memoize 代码日均运行了 8 万多次。

image.png

image.png

这个故事起源于前端页面体验较差,在分析过程中,发现会重复性地调用某些接口(短时间发出多个同样的接口请求,第一个请求的响应回来之前,又发出了同样的请求),比如其中一个是使用 code 换取微信 session,并再次使用微信的 sessionKey 换取自己服务器端的令牌。在拿到这个令牌前,页面一直牌加载中状态。总之,这是一个在此前端项目中被称为登录的动作,它的重复执行,是页面体验不佳的原因之一,至于为什么重复调用,原因又特别复杂。好在,使用 memoize 可以不去管这些繁杂的原因。

等等?为什么不用 mutex?

有同事提出 mutex 方案,大致是将 login 封装为: javascript const loginx = (() => { let mutex = false

return () => { if (mutex) return mutex = true login().finally(() => mutex = false) } })()

这个改动量虽然也不算大,但实际上还是破坏了某些场景。比如,第一个登录请求发出后,第二个登录请求会被强行中止,拿不到正常的结果(undefined),导致后续逻辑不可预测,风险较大。

memoizeAsync

这里的登录操作,实际上是一个异步函数,由于项目没有引入 lodash,所以手写了一个简化的 memoizeAsync 函数,虽然很简单,但是运行了快一年时间,没有出现故障。

测试先行

这个 memoizeAsync,其使用效果如以下测试代码所示。即在第一个调用 resolve 之前,如果又有同样的调用,不会发出 http 请求。 typescript

describe(memoize, () => { it(executes only once before underlying promise gets done, async () => { const originalExecute = jest.fn().mockImplementation(async () => { console.log(executing...) }) // Given an async function memoized const memoizedExecute = memoizeAsync(originalExecute)

  const sut = {
    execute: memoizedExecute,
  }

  // When call it multiple times
  await Promise.all([sut.execute(), sut.execute(), sut.execute()])

  // Then the underlying method will be run only once
  expect(originalExecute).toHaveBeenCalledTimes(1)

  // Because now the sut.execute gets resolved, so new calls will trigger underlying method fire again
  await sut.execute()
  expect(originalExecute).toHaveBeenCalledTimes(2)
})

})

简单的实现

实现到底多简单呢?其实就是用函数的参数作为缓存键,并且在 promise resolve 或者 reject 后都清空一下缓存即可:

typescript

export const memoizeAsync = (func: (...args) => Promise) => { const cache = {}

return async (...args) => { const cacheKey = JSON.stringify(args)

if (cache[cacheKey] === undefined) {
  cache[cacheKey] = func(...args).finally(() => (cache[cacheKey] = undefined))
}

return cache[cacheKey]

} }

对 login 的改动

测试先行

期待对登录的代码做完改动后,多个上层登录调用,只触发一次底层操作,并且本地会存储更新后的令牌信息:

typescript

describe(login, () => { beforeEach(() => { jest.clearAllMocks() })

it(should login wechat once without duplicate requests, async () => { const mockLoginResult = { jwt: 1234, sessionId: 5678 }

client.loginMutate.mockImplementation(async () => mockLoginResult)

await Promise.all([auth.handleLogin(), auth.handleLogin()])

expect(storageSync.getStorageSync(StorageSyncKeys.TOKEN)).toStrictEqual(mockLoginResult.jwt)

expect(client.loginMutate).toHaveBeenCalledTimes(1)

}) })

实现的改动仅两行

即将原有 login 方法重命名为 loginWithNewCode,并新增一个 login 方法,是对 loginWithNewCode 的 memoize 调用:

diff

  • export const login = (): Promise => {
  • export const loginWithNewCode = (): Promise => {

...

  • export const login = memoizeAsync(loginWithNewCode)

后续

其实,页面体验不佳,原因是多方面的,但归根结底,就是做了大量不必要做的事情。用 memoize ,可以做一个兜底,确保重复的请求,只触发底层一次操作。但这种不必要做的事情,从代码层面就应该删除。

下次分享一个实战案例,仅仅通过删除代码,就修复了问题,还提升了性能。