分享1: bug分析 --> 源码对比 --> 注意事项
前言:之前一个需求在测试过程中发现了个很好玩的bug,这个bug在qa环境中没有,但是到了stg环境就有了,在stg环境中还是偶发的,而且多个页面用了重复的逻辑,就只有其中一个页面有bug,并且这个bug在页面做了某些操作后又会消失。本次我就是基于这个bug展开来做一个分享。从bug出现的原因,如何解决的bug,源码对比,分析我们在日常开发中需要注意的东西,这些内容展开来讲。
案例需求背景:在招投标过程中,允许供方和采方对过程中出现有疑问的地方进行澄清和答疑。本需求就是开发一个这样的页面给采方与供方使用。
1. bug分析
- 澄清和答疑页面的入口按钮都用到了CommonLinkBtn这个通用组件,这个组件的大致逻辑是这样:根据属性中type的类型决定跳转页面的链接。
-
在原本系统中已经有了一套澄清答疑的详版模式,本次需求是要新加一套简版模式。跳转区分详版模式还是简版模式由用户基本信息接口中的一个字段控制——baseInfo.clarifyDoubtsMode字段。
-
问题就出在了baseInfo接口这里,正常情况下,组件中拿到baseInfo后直接在CommonLinkBtn中用即可。但是在开标页面中,CommonLinkBtn组件是包裹在useMemo函数里面的,并且这个useMemo函数超级长,将近三百行,不容易发现当前的改动点是在useMemo中包裹的,容易忽略在它的依赖数组中添加baseInfo这个依赖项。这也就是本次bug出现的根源所在。
理论上来说,如果在useMemo中少添加了依赖项目,对应的功能点一定是有问题的,那么这个bug为啥在qa环境没有呢?而且在stg上也是偶发?更离谱的是做了某些操作后bug就消失了?
这里就需要着重分析没有在useMemo函数中添加baseInfo依赖项时的情况了。如下图:
大家可以看到,没添加baseInfo之前就已经有两个依赖项了。pageActioninfo是另一个接口返回的数据,isTipShow是个状态。这么一说大家可能就明白是怎么回事儿了。
在没添加baseInfo依赖项的情况下,baseInfo接口和pageActionInfo接口开始比起了速度,如果baseInfo接口比pageActionInfo接口快,那么在后者返回时useMemo就会重新执行,拿到正确的baseInfo,此时,页面是正常的。相反如果pageActionInfo接口比baseInfo接口快,useMemo重新执行的时候baseInfo还没有返回,后续baseInfo返回的时候useMemo也不会重新执行,就拿不到正确的baseInfo,此时就会有问题。
为什么就stg有偶发bug呢?页面做了某些操作之后,这个bug就会消失?
我认为是stg和qa环境接口的快慢不同所致,qa的baseInfo接口大概率比pageActionInfo接口快,所以qa环境很难复现bug。操作后bug消失是因为某些操作修改了isTipShow这个状态,引起了useMemo的重新执行,拿到了正确的baseInfo,bug就消失了。
2. 解决方案
这个bug的解决方案非常简单,在useMemo的依赖项数组中加上baseInfo即可。
这种偶发性bug相较于常规bug更难找到问题的根源,这里我提供一种解决思路。一般情况下,偶发性bug大多是由副作用引起的,也就是函数中给定了相同的输入,输出的结果不相同。我们在排查的时候可以先去排查那些不“纯”的函数,例如:网络请求,useEffect的内部逻辑,useMemo,useCallback的依赖项等。随着React的不断更新,函数组件变得越来越普及,开发模式逐渐从起初的面向对象编程转变为了函数式编程。在函数式编程模式中,我们需要尽可能的让自己所写的函数纯粹。
3. 避免useMemo的滥用
上述问题的根源出在了useMemo上,设想如果这里没有使用useMemo是不是就不会出现这样的bug。所以我认为日常开发中要尽量避免useMemo的滥用。我们来看看React官网是怎么对useMemo解释的。
也就是说,未来版本的React中useMemo可能会丢掉缓存。我们再看看ahooks中的一个描述
ahooks.js.org/zh-CN/hooks…
这段描述也来自于react官网legacy.reactjs.org/docs/hooks-…
种种迹象可以看出未来版本的React可能会在useMemo依赖项不变的情况下重新执行useMemo,此举也是为了释放一些内存空间优化性能。
我读了React 18 中 useMemo的源码,主要逻辑如下:
- workInProgressHook一定不存在空对象的情况,第一个if判断理论上都成立
workInProgressHook是createWorkInProgress这个函数返回的。我们可以看到createWorkInProgress函数本质都是调用createHook这个函数得到的。
而createHook中的返回值一定不会是空对象,因此useMemo中第一个if判断理论上永远成立
- 里面两个if分别在判断前一次渲染的依赖项和本次渲染的依赖项存不存在,其中有依赖项为空对象的情况那么就直接重新执行函数。最里面的if通过areHookInputsEqual函数用用Object.is去判断两次渲染之间的依赖项是否相等。
暂时没在源码中发现依赖项不变但是需要重新执行useMemo的逻辑(如果有懂的同学我们可以一起讨论一下这个点)
虽然未发现依赖项不变useMemo还会重新执行的情况,但在开发中过程中还是不要过于依赖useMemo,正如官方所说,它仅可作为性能调优的工具,在使用useMemo之前要确保即使不用useMemo,功能也能正常运行。因为React不能保证memo的值一定不会被重新渲染。可能会有同学将useMemo与Vue中的computed划等号,在当前阶段看好像这没什么大问题,但是后续随着React版本的更新,没人保证就这里还没问题。
4. useMemo使用不当反而会消耗性能
在对比依赖项的变化情况时是遍历每一项去对比的,如果计算很简单,但是依赖项很多,反而会加重性能的消耗。如下:
const calculateVal = useMemo(()=>{
return a+b+c+d-e+f-d
},[a,b,c,d,e,f])
这种情况下计算是非常简单的,但是这么多依赖项去遍历然后再对比,反而会消耗性能。
那在什么情况下使用useMemo呢?
我认为在有循环,递归等非常复杂的情况下再去使用。写一个UI,或者封装一个普通的功能完全是没必要用到useMemo的。
如果不知道这个函数的计算是否昂贵,是否需要用到useMemo。这里可以推荐一个api --- console.time(), console.endtime() 去看函数计算到底花了多久。
如果大于 500ms(这个标准有待商议),就算是昂贵计算,可以用useMmeo去包裹,否则就不用加useMemo。
5. 与Vue的computed进行对比
我们来看看Vue中的competed计算属性,看看它与useMemo的区别。
我们可以发现Vue的计算属性在依赖的响应式数据不变的情况下,是永远不会重新计算的,所以这里Vue和React还是有区别的。
首先说说Vue的主要思想:
数据劫持+发布订阅模式
初始化的时候会用Object.definePropety/Proxy将data中的数据做一个劫持,修改数据的时候调用setter方法,使用数据的时候调用getter方法。Vue初始化组件时会将响应式数据与其使用的地方做一个依赖关联(依赖追踪)。在后续修改响应式数据的时候(调用了setter方法)此时基于发布订阅者模式,Vue会通知所有用到这个响应式数据的地方,让其做定向更新。
computed的缓存机制:
一些同学可能会对computed的计算时机有一定的误区,他们认为只要依赖项数据发生变化后,computed会立即重新计算。但事实却不是这样,当computed计算属性仅仅只有在初次使用的时候才会被立即计算,后续更新过程中computed是不会立即重新计算的。当依赖的响应式数据发生变化时,Vue的发布订阅模式虽然会通知到computed,但它并不会立即重新计算。而是将这个computed标记为dirty(意味着数据已经不干净了,需要重新计算),等待这个computed被使用的时候,也就是调用了它的getter方法的时候,才会被重计算。过后会立即将这个dirty设置为false(意味着数据是干净的,后续可以直接使用,不用再重计算了)
主要源码:
notify方法:当依赖的响应式数据发生变化时,发布订阅模式会通知computed重新计算,当然computed并不会立即重新计算,而是调用notify方法将这个计算属性标记为dirty。
get方法: 每一次在使用computed的时候就会调用此方法返回value。方法中先进行了依赖追踪,依赖追踪是为了让后续过程中依赖的响应式数据发生变化后computed就能马上发现并做出响应。然后就是调用了refreshComputed方法。
可以看到虽然每次访问计算get方法中都会调用refreshComputed方法,但是真正要重新计算要求却很高。Vue做了三次拦截
-
被标记为脏且依赖未被追踪
-
computed的版本和全局版本相同
3.依赖检查。
命中这三者中的任何一个都不会重新计算。
set方法:如果computed内的值是函数,调用set就会报错,如果是对象并且定义了set方法,那就调用对应方法。
从原理源码的层面可以看出Computed对依赖数据的敏感度要比useMemo要强的原因,React通过Object.is去判断两次依赖项状态的变化情况,对于引用类型而言,其内部的属性发生变化并不会被Object.is觉察,只有引用发生了变化才会引起useMemo的重新执行。而Vue却不同,Vue对数据变化非常敏感,就算修改了响应式对象的其中一个属性,也会被监控到,从而去做一系列的改变。
6. 小彩蛋
在React中如果想使用类似Vue计算属性的功能,推荐用ahooks中的useCreation。我们来一起看看ahooks中useCreation的源码,看看他是怎么实现在依赖项不变的情况下就一定不会重新执行,useCreation逻辑如下:
import { useRef } from 'react';
export default function useCreation<T>(factory: () => T, deps: any[]) {
// factory 就是需要计算的函数
// deps 依赖数组
const { current } = useRef({
deps, /// 存储上一次的依赖数组
obj: undefined as undefined | T, // factory函数执行的结果
initialized: false, /// 是否已经初始化
});
if (current.initialized === false || !depsAreSame(current.deps, deps)) {
// 如果useCreaction没有初始化过或者依赖项数组发生了变化就会重新执行
current.deps = deps;
current.obj = factory();
current.initialized = true;
}
return current.obj as T;
}
/// 通过 === 按顺序对依赖数组逐项对比
function depsAreSame(oldDeps: any[], deps: any[]): boolean {
if (oldDeps === deps) return true; // 依赖项变化就重新执行
// 如果依赖项是引用数据类型,那么想要让useCreaction重新执行就必须要改变其引用类型才可以
for (let i = 0; i < oldDeps.length; i++) {
// 这里也是只会浅比较 需要注意
if (oldDeps[i] !== deps[i]) return false;
}
return true;
}
和useMemo不同的是 useCreaction判断相等用的是 === 而useMemo用的是Object.is()
=== : -0 等于 +0 NAN 不等于 NAN
Object.is -0 不等于 +0 NAN等于NAN 这里的区别稍微注意一下。
当然,也十分推荐将useCreation完全代替useRef去使用,它的性能也一定比useRef好。
const a = useRef(new Subject());
const b = useCreation(() => new Subject(), []);
- 每次重渲染,都会执行实例化 Subject 的过程,即便这个实例立刻就被扔掉了
- 通过 factory 函数,可以避免性能隐患,函数只有在依赖项变化才会执行,避免掉了每次都要重新实例化的过程。
分享2: 单点登录
1. 单点登录 Single Sign On(SSO)指在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的系统(多用于同一家公司的多个不同产品)。
2. 单点登录的必要条件——认证中心,用于将用户登录,注册,修改用户信息等对用户的基本操作全部抽离出来单独维护。
3. 重点难题就在于如何将认证中心对用户的对用户的认证操作适配到所有的子系统中?
标准做法:CAS,OAuth2.0
非标做法:根据业务需求定制化单点登录模型。
但是无论怎么做,核心就两种解决方案
4. 解决方案1:Session + Cookie 模式
- 用户在认证中心登录成功后会在服务器上存一个session,是一个键值对的数据形式(键就是全局唯一ID,注意不是用户ID,值就是用户的身份信息,id,名字等等) 换句话说,只要这个数据形式里面存有这个用户的键值对那么这个用户就算登录成功了,反之就没登陆成功或者是登录已过期。 这个数据形式一般存在数据库或者Redis缓存中。
- 登录成功后会把之前生成的全局唯一id——sessionid下放到用户这里,存在浏览器的cookie中。此后,当用户去访问子系统的时候请求会将这个sessionid带上,以判断登录情况。(前提条件,子系统之间得是相同的站点。如果是不同的站点去做的话需要特殊处理,不同站点的话正常情况是拿不到这个cookie的)
- 被访问的子系统在拿到这个sessionid的时候,需要再次拿这个id去认证中心确认用户的登录状态,若登录成功,此子系统才可以正常访问。
优势:认证中心拥有对用户的绝对控制力,可以随时让用户下线。若我直接去删掉用户的sessionid,那么用户在下一次请求资源的时候,这个系统去认证中心验证时就无法确认用户的登录状态,继而用户就会被引导到认证中心去重新登录。
劣势:
- 成本很高,如果用户数量非常大,子系统非常多,子系统对认证中心的请求会变得非常频繁,认证中心服务器的压力会非常大(高并发,高负载),同时存储用户登录信息的表也会非常大(查询慢导致慢接口,用户体验性下降)。
- 风险也很高,如果认证中心挂掉了,那么所有的子系统都挂掉了(需要做容灾)。
- 还有一种情况,就是某个子系统运行的很好,用户量很大,那么这个子系统需要扩容。但是子系统扩容那么认证中心也需要扩容,因为子系统的增量会带动认证中心的增量,这就显得很鸡肋。
5. 解决方案2: 单Token模式
- 用户在认证中心登录成功后,不需要做任何操作,直接生成一个不可被篡改的token(可以用JWT)发送给用户,用户自己存储。
- 访问子系统的时候会将这个token带过去,这里的区别来了,子系统不需要再去认证中心确认用户的登录状态,子系统可以自行认证这个token。(子系统和认证中心交换一个密钥,子系统可以用这个密钥来解密token,来确认用户的登录状态)
- 验证通过后就可以给用户正常访问了。
优势:
- 不烧钱,某个子系统如果需要扩容,认证中心是不需要跟着去扩的
- 认证中心的压力非常小。
缺点:对用户的控制能力会非常若。返回给用户的token要设置过期时间(那这个过期时间设置多久合适呢?太短了用户需要频繁的登录,太长了用户的违规操作不能让他及时下线),想要下线得让用户中心去通知每一个子系统哪个用户需要下线。
6. 解决方案3: 双Token模式
- 登录后给用户两个token,一个是过期时间较短的短token(用户主要用这个token子系统确认登录状态) ,另一个是过期时间较长的token(用于维持最久的登录状态)。
- 用户用短token去访问子系统(这个短token一般过期时间就是十几二十分钟),若短token过期,长token没过期(长token一般过期时间很长,一两个月)。那么当前子系统的此次请求返回失败,用户就会用长token去认证中心换一个新的短token。重新拿这个新的短token去访问子系统。
缺点:几乎没有太大的缺点。
优势:弥补了 session+cookie烧钱的缺陷,解决了单token对用户控制力度差的问题。
核心思想说白了就是让用户每隔一小段时间来用户中心一次,加强用户中心对用户的控制力度,将用户的控制权转回到认证中心。
7. 136到我们云筑网的单点登录方案:
用到了Oauth2.0,核心是解决方案3,双Token模式。