react 的命门
2023 年的今天,众所周知,前端开发者是活在一个「数据驱动式」的 UI 开发时代。同时,这也是一个群雄争霸,逐鹿中原的纷乱时代。尽管有 solidjs 和 sveltejs 等后起之秀加入,但是当前还是一个三雄争霸的格局。这三雄分别是:
- react
- vue
- angular
这三个框架是是占据了 web 前端开发框架的三壁江山。虽然它们都是数据驱动的 UI 开发者框架,但是,对于「如何实现数据驱动」这一点上,三个框架所使用的策略和实现的原理是不一样的。相比上一代的 web UI 开发模式 - 通过手动去直接操作 DOM 来更新界面,现代前端开发模式是通过操作数据,然后让框架来操作 DOM 的间接方式来更新界面。以上就是「数据驱动 UI 开发」的确切含义。
从上面的阐述中我们可以看到,如何实现「数据驱动 DOM 更新 」就是所有的现代框架的最大命题。以自己的技术价值取向去实现数据驱动是每一个框架在列强林立争时代的安身立命之根本。鉴于它是如此的重要,在这里,我将「实现数据驱动背后的原理」称之为这个框架的「命门」。
本文的主题是 react。那 react 的命门是什么呢? react 的命门有三个东西
- re-render
- diff
- 值的相等性比较
本文聚焦到「值的相等性比较」这个主题。react 只有在它认为值已经发生了改变的情况下才会去更新界面。下面我们反推一下:当前后值不相等的时候,react 就会认为值发生了改变。那 react 依据什么来判断两个值是否相等的呢?
弄清楚上面这个原理,我们也就触摸到了 react 的命门了。下面,跟我一道,一起来触摸 react 的命门吧。
js 值的相等性比较的三板斧
react 百分之一百拥抱 js,在它的实现源码里面,没有什么黑魔法,全都是 js 的知识点。在实现值的相等性比较这一点上也不例外。因此,在触摸 react 命门之前,我们先要研究一下在 js 中,我们是怎么判断值是否是相等的。可以这么说,js 在实现值的相等性比较上面有三板斧:
==
(等号)===
(全等号)Object.is()
这三者既有区别又有联系。下面我们来一个个地看看。
等号
等号蕴含的是「松散相等性(loosely equal)」语义。所谓的「松散」主要体现在它在比较左右两个值的相等性的时候,它会进行隐式的类型转换。
总体来说,js 的等号在实现相等性比较的时候采用是下面的算法:
- 如果需要比较的两个值(放在等号是操作符的语境,其实它们应该被称之为「操作数(operand)」才对)具有相同的 js 数据类型的话,那么,它们的比较遵循下面的算法:
-
Object 类型 - 如果是引用类型的话(
typeof xxx
的结果是object
),并且是指向同一个对象实例(代表着内存块)的话,那么就返回true
。否则,就返回false
; -
String 类型 - 如果两个字符串都是以相同的顺序去排列相同的字符的话,那么就返回
true
;否则,就返回false
; -
Number 类型 - 如果两个数字在自然语义上所代表的值是相等的,那么就返回
true
;否则,就返回false
;因为+0
与-0
在自然语义上所代表的值是相同的,即是0
,所以,这两者是相等的。但是这里有一个特例 -
NaN
。在 js 引擎对==
的实现中,两个比较的值中,只要有一个是NaN
,那么返回值永远都是false
。所以,NaN
是不等于自己的,即:NaN == NaN
的结果永远都是false
。 -
Boolean 类型 - 只有两个相比较的布尔值都是相同的字面量值,比较结果才会是
true
,否则,就是false
。 -
BigInt 类型 - 如果两个数字在自然语义上所代表的值是相等的,那么就返回
true
;否则,就返回false
; -
Symbol 类型 - 如果两个比较的值是指向同一个 symbol 引用,那么就返回
true
;否则,就返回false
;
-
null
和undefined
只有跟null
和undefined
其中一个比较才会相等,其他情况一律返回false
; -
如果所需比较的两个值,一个值是对象类型,一个是基础数据类型(primitive),那么, js 引擎就会先把调用者对象实例的
toString()
方法,先将它转换字符串类型。 -
到了这一步,等号两边的值都是基础数据类型了(排除 null 和 undefined, 只是指 String, Number, Boolean, Symbol, BigInt)。如果它们是同一种数据类型,那么就用步骤一的算法去做比较;如果不是,又可以分为两种情况来进行或者不进行二次转换:
- 如果其中一个值是 Symbol 类型,则不会再进行二次类型转换,直接返回
false
; - 否则,就会将进行二次类型转换。对于不同的组合情况,js 引擎对应不同的二次转换规则:
- 布尔值 + 非布尔值:先将布尔值转换为数字类型:
true
转换为1
,false
转换为0
。然后再回到「步骤 4」 ; - 数字值 + 字符串:先尝试把字符串转换为数字类型(使用 Unary plus 和
Number()
的算法),转换失败的话就是默认为NaN
,然后再比较这两个数字值; - 数字值 + BigInt值:比较它们的自然语义上的数值是否相等。
- 字符串值 + BigInt值:先使用
BigInt()
构造函数去尝试将字符串转换为 BigInt 值,然后再进行比较。如果转换失败,直接返回false
。
- 布尔值 + 非布尔值:先将布尔值转换为数字类型:
- 如果其中一个值是 Symbol 类型,则不会再进行二次类型转换,直接返回
最后值得提的两点是:
- 等号在语义上是对称的。也就是说,
A == B
跟B == A
的结果是永远是一样的; - 以上的算法的顺序十分重要。在该算法里面,js 的最终目的是比较两个数值类型。但凡有一个值是数字类型,而另外一个不是的话。那么总是遵循:
- 先把 Object 类型转成字符串类型;
- 再把字符串类型或者布尔类型二次转换为数字类型;
- 最后才对比两个数字类型。
- 通过 object wrapper 来创建的结果有两种可能。一种是基础数据类型,一种还是 Object 类型。在比较的时候要注意这两者的区别。
实战讲解
下面我们针对等号两边的值不是同种类型的情况进行实战讲解。
// 等同于 `Number('1') == 1`,结果为 true
"1" == 1;
// 从等号的语义对称性得知,结果跟上面是一样的: true
1 == "1";
// 当比较的两个值包含布尔值的时候,先把它转换成数字类型: false -> 0, 0 == 0,结果为 true
0 == false;
// 遇到 null 或者 undefined 的时候,不会进行类型隐式转换。null 或者 undefined只会跟它们自己相等,其他情况的比较结果一律是 false。因为这里 null 是跟 0 比较,结果为 false
0 == null;
0 == undefined; // 同上
// `!!` 表示强制类型转换。因为 `!!null` 的强制转换结果为 false, 所以,这个比较等同于 `0 == false`。到这里,更上面提到的 case 是一样的。所以,结果为 true。
0 == !!null;
// 同上
0 == !!undefined;
// 上面反复在说,null 或者 undefined只会跟它们自己相等,其他情况的比较结果一律是 false。所以,这里的结果是 true。
null == undefined; // true
// 注意,number1 和 number2 都是通过 object wrapper 来创建的。因为使用了 `new` 关键字,所以,它们是 Object 类型。
const number1 = new Number(3);
const number2 = new Number(3);
// 因为等号两边的值不是同等类型,且 number1 是 Object 类型,所以,先调用 `number1.toString()` 转换得到 "3" == 3。到了这一步,是数字类型跟字符串比较的情况。所以,先通过 Number('3') 尝试把 "3" 转换为数字。转换成功。比较表达式等同于 `3 == 3`。所以,结果为 true。
number1 == 3;
// 因为 number1 和 number2 都是 Object 类型。所以,不涉及任何的类型转换。因为它们是独立的对象实例,并不指向同一个内存存储块。所以,结果为 false
number1 == number2;
const object1 = {
key: "value",
};
const object2 = {
key: "value",
};
// 原理跟上面讲述的一样。object1 和 object2 是独立的对象实例,并不指向同一个内存存储块
console.log(object1 == object2); // false
console.log(object1 == object1); // true
在上面,我们提到过一个注意点「通过 object wrapper 来创建的结果有两种可能。一种是基础数据类型,一种还是 Object 类型。在比较的时候要注意这两者的区别」。下面,我们针对这个注意点,继续进行实战演练一下:
const string1 = "hello";
const string2 = String("hello");
const string3 = new String("hello");
const string4 = new String("hello");
// case 1
console.log(string1 == string2); // true
// case2
console.log(string1 == string3); // true
// case3
console.log(string2 == string3); // true
// case4
console.log(string3 == string4); // false
case 1 的结果之所以是 true
,是因为 string2
本质上是 String 类型,跟字面量 string1
是一模一样的基础类型。
case 2 的结果之所以是 true
,是因为这里存在个类型转换。为什么呢?因为 string3
是通过 new String()
得到的,它是 Object 类型。Object 类型跟基础类型比较的时候,会先对自己执行toString()
转换。string3.toString()
的结果是字符串类型的 "hello"
。所以,最后的结果是true
case 3 的结果之所以是 true
,是因为 String("hello")
的结果就是字面量字符串 "hello"
,所以,它发生的类型转换给 case 2 是一样的。结果也是一样的。
case 4 的结果之所以是 false
,是因为string3
跟string4
都是 Object 类型。所以,js 引擎不会进行任何转换而直接比较。因为两者并不指向同一个内存存储块。所以,它们的比较结果就是 false
。
全等号
相比于等号,全等号所蕴含的是「严格相等性」的语义。
下面是全等号的算法描述:
- 如果需要比较的两个值属于不同的 js 数据类型,直接返回 false;
- 如果都是 Object 类型,则只有引用的是同一个对象实例的时候才返回 true,其他情况一律返回 false;
null
和undefined
只有跟自己比较才会相等;- 如果比较的两个值中,有一个值是
NaN
,结果直接返回 false。也就是说,NaN
甚至连自己都不全等。 - 剩下的情况就是相同基础数据类型的比较。这里的算法完全跟等号此等情况下所采用的比较算法完全一致。所以,我在这里就不赘述了。
通过对比等号和全等号的算法描述,我们可以看出,两者唯一区别在于:面对所比较的值具有不同的数据类型的情况,全等号是不会进行隐式的类型转换,而是直接返回 false。 这就是「严格相等性」的确切含义。
上面这个不同点其实是囊括了 null
跟 undefined
比较的情况的。因为 null
跟 undefined
在 js 的类型系统范畴中,它们是属于不同的类型。所以,比较结果还是遵循全等号的「严格相等性」语义,直接返回 false。下面做个对比说明
null == undefined // true
null === undefined // false
Object.is()
Object.is()
的比较算法基本上跟全等号是一样的,除了两点之外:
Object.is()
认为+0
与-0
是不一样的,即:Object.is(+0,-0)
的结果为false
。而全等号认为两者是一样的,即:+0 === -0
的结果为true
;Object.is()
认为NaN
跟自己是一样的,即:Object.is(NaN,NaN)
的结果为true
。而全等号认为两者是不一样的,即:NaN === NaN
的结果为false
;
是的,Object.is()
跟全等号的区别仅仅在于这两点而已。
在源码中,为了兼容不支持的 Object.is()
的宿主环境, react 实现了 Object.is()
的 polyfill。这个中的原理就是基于全等号之上再实现上面所提到的区别点。下面,我们自己来实现这个 polyfill。
首先,我们的初版是这样的:
function objectIs(x,y){
return x === y;
}
接下来,我们要把 x
,y
都是 NaN
的情况拎出来单独对待。这里就有个问题,怎样识别 NaN
呢?通过研究全等号的比较算法,我们会发现: 所有的值都会全等于自己,唯独一个例外。那就是 NaN
。所以,我们可以借助这一点就可以判断某个值是不是 NaN
(当然,浏览器也内置了一个 isNaN()
方法,现在我们假装它不存在)。到这里,我们的代码如下:
function isNaN(val){
return val !== val;
}
function objectIs(x,y){
return x === y || (isNaN(x) && isNaN(y));
}
下面我们简单测试一下:
objectIs(NaN, NaN) // true
最后,我们处理一下+0
和 -0
比较的情况。这里的实现原理是,任何数除以 +0
的结果都是 Infinity
,而任何数除以 -0
的结果都是 -Infinity
。最重要的一点是 Infinity
是不全等于 -Infinity
。我们可以利用这个现存的实现结果来反推当前的 x
,y
是不是 +0
和 -0
:
function isNaN(val){
return val !== val;
}
function isDiffSignedZero(x,y){
return 1/x !== 1/y
}
function objectIs(x,y){
return (x === y && !isDiffSignedZero(x,y)) || (isNaN(x) && isNaN(y));
}
下面我们简单测试一下:
objectIs(+0, -0) // false
最后的最后,我们重构一下,把上面的代码浓缩到一个函数里面:
function objectIs(x,y){
return (x === y && 1/x === 1/y) || (x !== x && y !== y);
}
实际上,这就是 react 实现 Object.is()
polyfill 的源码。源码路径为: react@18.2.0/packages/shared/objectIs.js
小结
从等号再到全等号,从全等号再到 Object.is()
,它们所实现的相等性的语义也越来越符合人类的直觉。毕竟 " " == 0
的结果为 true
确实在乍看一眼的时候十分地违反人类常识。全等号相比于等号,去除了隐式类型转换又好一点。但是对于 NaN 的处理(NaN === NaN
的结果为 false
)也是挺违反人类常识的 - 一个值竟然不等于自己?好在,我们有 Object.is()
。
我们可以用一张图来表达它们三者在相等性语义方面所覆盖 case 的范围大小关系:
最后,以等号的比较算法为参照基础,我们用文字来总结一下三者之间的联系点:
-
等号总是在尝试使用「隐式类型转换」来将需要比较的两个值最终转换为数值类型来比较;
-
而全等号却拒绝进行任何的「隐式类型转换」,即如果要比较的两个值是不同 js 数据类型,那么比较结果一律返回
false
。两个值是相同的 js 数据类型情况下所采用的比较算法,跟等号一模一样; -
Object.is()
相比于全等号又有两点不同:- 对待 NaN 跟自己比较的情况处理不同:
NaN === NaN
的结果为false
,而Object.is(NaN,NaN)
的结果为true
;
- 对待
+0
与-0
的比较情况处理不同:+0 === -0
的结果为true
,而Object.is(+0,-0)
的结果为false
;
- 对待 NaN 跟自己比较的情况处理不同:
其实,到了 Object.is()
这里,它所实现的相等性语义还是没有完全符合我们的需求。因为,一般来说,我们希望 Object.is({},{})
的结果是为 true
。不过 js 引擎也提供了接口 - toString()
给我们去自定义一个对象类型的数据语义。通过自己实现 toString()
方法,我们把 Object.is()
所缺失的一环给补上了,从而拼凑出一个实现真正语义上相等的方法全景图。
到这里上篇已经完了。下面,我们会在下篇介绍介绍 react 是如何利用相等性判断的,已经利用相等性判断来做什么事情,请敬请期待~