Learning-notes
前端学习笔记 & 踩坑日记 & 冷知识,记录一些工作中遇到的问题
1. isNaN()
和 Number.isNaN()
的区别
Number.isNaN()
方法确定传递的值是否为 NaN
,并且检查其类型是否为 Number
。它是 isNaN()
的更稳妥的版本。
和 isNaN()
相比,Number.isNaN()
不会自行将参数转换成数字,只有在参数是值为 NaN
的数字时,才会返回 true
,否则返回 false
。
Number.isNaN(NaN); // true
Number.isNaN(Number.NaN); // true
Number.isNaN(0 / 0); // true
Number.isNaN({}); // false
Number.isNaN('NaN'); // false
Number.isNaN('blabla'); // false
Number.isNaN(undefined); // false
isNaN({}); // true
isNaN('NaN'); // true
isNaN('blabla'); // true
isNaN(undefined); // true
2. CSS 实现文本溢出省略
单行文本:
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
多行文本:
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
3. 复制到粘贴板
const clipboardWriteText = copyText => {
// 判断是否存在clipboard并且是安全的协议
if (navigator.clipboard && window.isSecureContext) {
return new Promise((resolve, reject) => {
navigator.clipboard
.writeText(copyText)
.then(() => {
resolve(true);
})
.catch(() => {
reject(new Error('复制失败'));
});
});
}
// 否则用被废弃的execCommand
const textArea = document.createElement('textarea');
textArea.value = copyText;
// 使text area不在viewport,同时设置不可见
textArea.style.position = 'absolute';
textArea.style.opacity = '0';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.append(textArea);
textArea.focus();
textArea.select();
return new Promise((resolve, reject) => {
// 执行复制命令并移除文本框
if (document.execCommand('copy')) {
document.execCommand('copy');
resolve(true);
} else {
reject(new Error('复制失败'));
}
textArea.remove();
});
};
使用:
clipboardWriteText('balabalabala')
.then(() => {
console.log('复制成功');
})
.catch(() => {
console.log('复制失败');
});
4. 什么是抽象渗漏
?
抽象渗漏指的是在代码中暴露了底层的实现细节,这些底层实现细节应该被屏蔽掉。
举例:在数组内查找某个值是否存在的时候,我们通常会使用到 indexOf
方法,该方法成功时返回下标,失败时返回 -1
,这里用 -1
作为失败时的返回值,而这种细节应该被屏蔽掉。
所以更加推荐使用 includes
这种不会暴露代码底层实现细节的方法:
// 不推荐
[1, 2, 3].indexOf(1) !== -1; // true
// 推荐
[1, 2, 3].includes(1); // true
5. 高性能向下取整
核心是利用了位运算:
// 不推荐
const num = parseFloat(1.2);
const num = parseFloat('1.2');
// 推荐
const num = 1.2 >>> 0;
const num = '1.2' >>> 0;
6. 高性能判断奇偶
跟上条一样,也是利用位运算:
// 不推荐
if (num % 2) {
console.log(`${num}是奇数`);
} else {
console.log(`${num}是偶数`);
}
// 推荐
if (num & 1) {
console.log(`${num}是奇数`);
} else {
console.log(`${num}是偶数`);
}
7. SEO 优化
最好用 ssr 框架,比如 react 的 next,或者 vue 的 nuxt(废话)
HTML 标签语义化,在适当的位置使用适当的标签
a 标签都记得设置链接,并且要加上 title 属性加以说明
img 标签都记得加 alt 属性
谨慎使用 display: none,因为搜索引擎会过滤掉 display: none 中的内容
meta 信息包含 title、keywords、description,有的页面需要单独定制,有的需要通用
页面在 html 标签上加 lang=“zh-CN”属性,表明文档的语言
每个页面最好都要有且仅有一个 h1 标题,尤其是不需要登录的页面(若不喜欢 h1 的默认样式可通过 CSS 设置)
8. 冷知识:浏览器地址栏也能运行代码
运行 js:
做法是以 javascript:
开头,然后跟要执行的语句。比如:
// 需要注意的是并不是所有浏览器都支持
javascript: alert('你好');
运行 html:
做法是以 data:text/html,
开头,然后跟要执行的语句。比如:
<!-- 需要注意的是并不是所有浏览器都支持 -->
data:text/html,
<h1>hello</h1>
;
9. 冷知识:你不知道的 setTimeout
冷知识:最大延迟时间 24.8 天
大多数浏览器都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647,换算一下相当于 24.8 天。那么这就意味着 setTimeout 设置的延迟值大于做个数字就会溢出。
setTimeout(() => {
console.log('123');
}, 2147483647);
冷知识:setTimeout 的第一个参数回调函数也可以是字符串类型
setTimeout(`console.log('balabala');`, 0);
10. 冷知识:Math.min 和 Math.max
执行 Math.min 而不传参数的时候,得到的结果是 Infinity,执行 Math.max 而不传参数的时候,得到的结果是-Infinity:
Math.min(); // Infinity
Math.max(); // -Infinity
11. 我们整天挂在嘴边的闭包到底是什么?
这里收集了不同文献中的原话,具体怎么理解看你自己:
《JavaScript 高级程序设计》
闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。
《Node 深入浅出》
在 JavaScript 中,实现外部作用域访问内部作用域中变量的方法叫做闭包(closure)。
《JavaScript 设计模式与开发实践》
局部变量所在的环境被外界访问,这个局部变量就有了不被销毁的理由。这时就产生了一个闭包结构,在闭包中,局部变量的生命被延续了。
《你不知道的 JavaScript(上卷)》
内部的函数持有对一个值的引用,引擎会调用这个函数,而词法作用域在这个过程中保持完整,这就是闭包。换句话说:当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域外执行,这时就产生了闭包。
12. 节流与防抖
函数节流
// 方法一:定时器
const throttle = function (fn, delay) {
let timer = null;
return function () {
const context = this;
const args = arguments;
if (!timer) {
timer = setTimeout(() => {
fn.apply(context, args);
clearTimeout(timer);
}, delay);
}
};
};
// 方法二:时间戳
const throttle2 = function (fn, delay) {
let preTime = Date.now();
return function () {
const context = this;
let args = arguments;
let doTime = Date.now();
if (doTime - preTime >= delay) {
fn.apply(context, args);
preTime = Date.now();
}
};
};
函数防抖
function debounce(func, wait) {
let timeout;
return function () {
let context = this;
let args = arguments;
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
func.apply(context, args);
}, wait);
};
}
11. 冷知识:pr(pull Request)和 mr(merge Request)有什么区别?
答:没有区别。
一般我们执行分支合并,需要执行下面两个命令:
git pull // 拉取需要合并的分支
git merge // 合并进目标分支
Github 选择了第一个命令来命名,叫 Pull Request
。
Gitlab 选择了最后一个命令来命名,叫 Merge Request
。
反正都不咋地……这起的什么狗屁名字
正确的起名应该是:
Merge Request // 请求把代码合并进去
Push Request // 请求把代码推进去
12. 判断一个对象是普通对象还是通过类创建的
const isPlainObject = (obj: any): boolean => {
if (typeof obj !== 'object' || obj === null) {
return false;
}
let proto = Object.getPrototypeOf(obj);
if (proto === null) {
return true;
}
let baseProto = proto;
while (Object.getPrototypeOf(baseProto) !== null) {
baseProto = Object.getPrototypeOf(baseProto);
}
return proto === baseProto;
};
13. 判断是否在浏览器环境
const isBrowser = () => {
return (
typeof window !== 'undefined' &&
typeof window.document !== 'undefined' &&
typeof window.document.createElement !== 'undefined'
);
};
14. 判断是否为移动端
const userAgent = () => {
const u = navigator.userAgent;
return {
trident: u.includes('Trident'),
presto: u.includes('Presto'),
webKit: u.includes('AppleWebKit'),
gecko: u.includes('Gecko') && !u.includes('KHTML'),
mobile: !!u.match(/AppleWebKit.*Mobile.*/),
ios: !!u.match(/\\(i[^;]+;( U;)? CPU.+Mac OS X/),
android: u.includes('Android') || u.includes('Adr'),
iPhone: u.includes('iPhone'),
iPad: u.includes('iPad'),
webApp: !u.includes('Safari'),
weixin: u.includes('MicroMessenger'),
qq: !!u.match(/\\sQQ/i),
};
};
const isMobile = () => {
if (!isBrowser()) {
return false;
}
const { mobile, android, ios } = userAgent();
return mobile || android || ios || document.body.clientWidth < 750;
};
15. 判断页面是否在 iframe 框架里
const isInIframe = (): boolean => {
try {
return (
self !== top ||
self.frameElement?.tagName === 'IFRAME' ||
window.frames.length !== parent.frames.length
);
} catch {
return true;
}
};
16. 实现一个 compose 函数
const compose = (...funcs) => {
if (funcs.length === 0) {
return arg => arg;
}
if (funcs.length === 1) {
return funcs[0];
}
return funcs.reduce((a, b) => {
return (...args) => a(b(...args));
});
};
17. 处理数字精度问题
// 加
function add(arg1, arg2) {
let digits1, digits2, maxDigits;
try {
digits1 = arg1.toString().split('.')[1].length || 0;
} catch {
digits1 = 0;
}
try {
digits2 = arg2.toString().split('.')[1].length || 0;
} catch {
digits2 = 0;
}
maxDigits = 10 ** Math.max(digits1, digits2);
return (mul(arg1, maxDigits) + mul(arg2, maxDigits)) / maxDigits;
}
// 减
function sub(arg1, arg2) {
let digits1, digits2, maxDigits;
try {
digits1 = arg1.toString().split('.')[1].length || 0;
} catch {
digits1 = 0;
}
try {
digits2 = arg2.toString().split('.')[1].length || 0;
} catch {
digits2 = 0;
}
maxDigits = 10 ** Math.max(digits1, digits2);
return (mul(arg1, maxDigits) - mul(arg2, maxDigits)) / maxDigits;
}
// 乘
function mul(arg1, arg2) {
let digits = 0;
const s1 = arg1.toString();
const s2 = arg2.toString();
try {
digits += s1.split('.')[1].length;
} catch {}
try {
digits += s2.split('.')[1].length;
} catch {}
return (Number(s1.replace(/\\./, '')) * Number(s2.replace(/\\./, ''))) / 10 ** digits;
}
function div(arg1, arg2) {
let int1 = 0;
let int2 = 0;
let digits1;
let digits2;
try {
digits1 = arg1.toString().split('.')[1].length || 0;
} catch (e) {
digits1 = 0;
}
try {
digits2 = arg2.toString().split('.')[1].length || 0;
} catch (e) {
digits2 = 0;
}
int1 = Number(arg1.toString().replace(/\\./, ''));
int2 = Number(arg2.toString().replace(/\\./, ''));
return ((int1 / int2) * 10) ** (digits2 - digits1 || 1);
}
顺便说一下,关于处理精度问题的解决方案,目前市面上已经有了很多较为成熟的库,比如 bignumber.js
、decimal.js
、以及 big.js
等,这些库不仅解决了浮点数的运算精度问题,还支持了大数运算,并且修复了原生 toFixed 结果不准确的问题。我们可以根据自己的需求来选择对应的工具。
最后提醒一下:这玩意儿也就面试的时候写一下,强烈建议业务中还是用现成的库,出了问题我可不负责的嗷,唉,我好菜啊
18. 垂直居中 textarea
难点
根本就不能通过 css 来实现输入的垂直居中
网上的那些就会复制答案,什么 flex 都来了
只能用 js 来实现
思路
通过动态调整 paddingTop 来偏移文本内容。
需要注意的是,多行的时候,需要计算行数
可以通过 set Height 0,然后滚动高度就是输入文字的总高度,算完之后把高度复原
行数 = 文字总高度 / 行高
所以,设置行高很重要,默认是 normal,normal 是字符串,没办法计算的,所以自己手动设一个 lineheight 吧
<textarea id="text"></textarea>
textarea {
width: 200px;
height: 200px;
padding: 0;
margin: 0;
line-height: 1.2;
text-align: center;
border: 1px solid black;
box-sizing: border-box;
word-break: break-all;
resize: none;
}
// 获取行数,注意需要先把paddingtop置0,不然scrollHeight会把padding算进去
function getLinesCount(textEle, lineHeight) {
textEle.style.paddingTop = 0;
const h0 = textEle.style.height;
textEle.style.height = 0;
const h1 = textEle.scrollHeight;
textEle.style.height = h0;
return Math.floor(h1 / lineHeight);
}
function update() {
const textArea = document.querySelector('#text');
const lineHeight = Number(window.getComputedStyle(textArea).lineHeight.slice(0, -2));
const h = textArea.getBoundingClientRect().height;
const lines = getLinesCount(textArea, lineHeight);
const top = h / 2 - (lineHeight * lines) / 2;
textArea.style.paddingTop = `${top}px`;
}
window.onload = update;
19. interface 和 type 的区别
相同点:
都可以描述对象
都允许扩展(extends)
不同点:
type 可以为任何类型引入名称,interface 只能描述对象
type 不支持继承,只能通过交叉类型合并,interface 可以通过继承扩展,也可以通过重载扩展
type 无法被实现 implements,而接口可以被派生类实现
type 重名会抛出错误,interface 重名会产生合并
interface 性能比 type 好一点(社区有讨论过这点,争议比较大,不管对不对,我贴出来兄弟们自己判断吧)
20. gulp 和 webpack 的区别
21. 手写 getQueryString
const src = '<https://www.baidu.com/?id=123&name=aaa&phone=12345>';
const getQueryString = url => {
if (!url.includes('?')) {
return null;
}
const [, search] = url.split('?');
const obj = {};
search.split('&').forEach(item => {
if (item.includes('=')) {
const [key, val] = item.split('=');
Reflect.set(obj, key, val);
}
});
return obj;
};
getQueryString(src);
// { id: "123", name: "aaa", phone: "12345" }
22. 手写 Array.flat(Infinity)
const isArray = Array.isArray;
const flatDeep = arr => {
return arr.reduce((acc, val) => acc.concat(isArray(val) ? flatDeep(val) : val), []);
};
flatDeep([1, 2, [3, [4, [5, 6]]]]);
// [1, 2, 3, 4, 5, 6]
23. 算法 — 有效的括号
// map解法
const isValid = (s: string): boolean => {
if (s.length & 1) {
return false;
}
const stack: string[] = [];
const map = new Map<string, string>();
map.set('(', ')');
map.set('{', '}');
map.set('[', ']');
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (map.has(c)) {
stack.push(c);
} else {
const t = stack.at(-1);
if (map.get(t) === c) {
stack.pop();
} else {
return false;
}
}
}
return stack.length === 0;
};
// 栈解法
const isValid2 = (s: string): boolean => {
if (s.length & 1) {
return false;
}
const stack: string[] = [];
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (['(', '[', '{'].includes(c)) {
stack.push(c);
} else {
const t = stack.at(-1);
if ((t === '(' && c === ')') || (t === '[' && c === ']') || (t === '{' && c === '}')) {
stack.pop();
} else {
return false;
}
}
}
return stack.length === 0;
};
24. 图片加载失败处理方式
图片为空很容易判断:
<img src={imgSrc || defaultSrc} />
图片加载失败,使用图片自带的 error 事件处理即可:
<img
src={imgSrc}
onError={event => {
event.currentTarget.src = defaultSrc;
}}
/>
注意有些
加载 404 的图片不会走error
事件,而是走了load
事件,那么我们可以通过直接添加一个占位底图来实现,这样如果能加载就会覆盖占位图,如果不能加载那就会显示底下的底图
<div>
<img src={imgSrc} />
<img src={defaultSrc} />
</div>
25. 判断对象中是否存在某个属性的三种方法
1. hasOwnProperty()
hasOwnProperty
方法会返回一个布尔值,指示对象自身属性中是否具有指定的属性(不包含原型上的属性):
({ a: 1 }.hasOwnProperty('a')); // true
({ a: 1 }.hasOwnProperty('toString')); // false
2. in 操作符
in 操作符
会返回一个布尔值,指示对象自身属性中是否具有指定的属性(包含原型上的属性):
'a' in { a: 1 }; // true
'toString' in { a: 1 }; // true
3. Reflect.has()
Reflect.has
作用与in 操作符
相同:
Reflect.has({ a: 1 }, 'a'); // true
Reflect.has({ a: 1 }, 'toString'); // true
26. 实现深拷贝
1. 简易版
这个方法有些缺点,懂的都懂,不再废话了
const newData = JSON.parse(JSON.stringify(data));
2. 加强版
const deepClone = obj => {
const ans = Array.isArray(obj) ? [] : {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
ans[key] = obj[key] && typeof obj[key] === 'object' ? deepClone(obj[key]) : obj[key];
}
}
return ans;
};
const newData = deepClone(data);
3. 非主流版
structuredClone
:原生 js 的深拷贝,因为是新出的,所以兼容差的要死,不建议使用
const newData = structuredClone(data);
目前只有浏览器可以用,node 环境还不支持,并且只有最新几个版本的浏览器才能用
对了,而且这个方法不能拷贝函数,遇到函数会直接报错,嘻嘻嘻
4. 终极版
import { cloneDeep } from 'lodash';
const newData = cloneDeep(data);
27. 让指定方法最多只能被调用 1 次
/**
* @param n 最多调用次数
* @param func 回调函数
*/
function before(n, func) {
if (typeof n !== 'number') {
throw new TypeError('Expected a number');
}
if (typeof func !== 'function') {
throw new TypeError('Expected a function');
}
let result;
return function (...args) {
if (--n >= 0) {
result = func.apply(this, args);
}
if (n < 0) {
func = null;
}
return result;
};
}
function once(func) {
return before(1, func);
}
// 使用:
const initialize = once(doSomething);
initialize(); // 只有第一次有效
initialize(); // 无效
initialize(); // 无效
28. 判断是否为原生函数
lodash 源码中是这样实现的:
const reIsNative = RegExp(
`^${Function.prototype.toString
.call(Object.prototype.hasOwnProperty)
.replace(/[\\\\^$.*+?()[\\]{}|]/g, '\\\\$&')
.replace(/hasOwnProperty|(function).*?(?=\\\\\\()| for .+?(?=\\\\\\])/g, '$1.*?')}$`
);
const isObject = value => {
return value && ['object', 'function'].includes(typeof value);
};
const isNative = value => {
return isObject(value) && reIsNative.test(value);
};
// 使用:
isNative([].push); // true
isNative(myFunction); // false
vue 源码中是这样实现的:
const reIsNative = /native code/;
const isObject = value => {
return value && ['object', 'function'].includes(typeof value);
};
const isNative = value => {
return isObject(value) && reIsNative.test(value.toString());
};
// 使用:
isNative([].push); // true
isNative(myFunction); // false
不知道 lodash 为啥实现的如此复杂,可能是因为 lodash 太老了吧,都多少年了……
29. 不创建新变量的前提下,交换两个变量
方法一:四则运算
注意:由于 IEEE 754
标准的存在,第一种方法并不是一定安全的,可能会出现精度问题。
let [a, b] = [1, 2];
a = a + b;
b = a - b;
a = a - b;
console.log(a, b); // 2 1
方法二:位运算
let [a, b] = [1, 2];
a = a ^ b;
b = a ^ b;
a = a ^ b;
console.log(a, b); // 2 1
方法三:解构
let [a, b] = [1, 2];
[a, b] = [b, a];
console.log(a, b); // 2 1
30. 猜打印顺序
猜一猜下面代码的打印顺序:
const object = { a2: '', 2: '', 1: '', a1: '' };
for (const key in object) {
console.log(key);
}
先说答案:顺序是 1、2、a2、a1
解释:js 在对对象的 key 进行遍历的时候,会先判断 key 的类型,如果是 number 类型,则会放在前面,并且进行排序,如果是 string 类型,则放在后面,不进行排序(对 number 排序是为了方便内存寻址,string 不能进行四则运算,所以排序没有意义)。
31. 猜打印结果
console.log(11);
结果:11
解释:普通的十进制数字,没啥好解释的
console.log(0.11); // .11 前面本来没有0 保存的时候编辑器自动格式化了,淦
结果:0.11
解释:如果数值前面的整数部分为 0,那么 js 允许我们省略
console.log(11); // 11. 后面有个. 保存的时候编辑器自动格式化了,淦
结果:11
解释:如果小数点后面的小数部分为 0,那么 js 允许省略
console.log(011);
结果:9
解释:如果数值前面以 0 开头,那么 js 会把它当成八进制,逢八进一
console.log(080);
结果:80
解释:因为八进制的数值里面不可能出现数字 8,所以这种情况下是无效的八进制,js 会当成十进制进行处理
console.log(0o11);
结果:9
解释:0o 开头的数值也会被当成八进制处理
console.log(0o80);
结果:报错
解释:0o 开头的数值会被当成八进制处理,但是八进制的数值里面不可能出现数字 8,所以直接报错了
console.log(0b11);
结果:3
解释:0b 开头的数值会被当成二进制处理
console.log(0x11);
结果:17
解释:0x 开头的数值会被当成十六进制处理
console.log(11e2);
结果:1100
解释:科学计数法,表示 11 (10 * 2)
console.log(11.toString());
结果:报错
解释:在数字转字符串的过程中,toString 方法被当成小数点后面的小数部分了,所以报错了,正确写法如下:
// 方法一,小数点后面加空格
11. toString();
// 方法二,小数点后面再次调用toString
11..toString();
// 方法三,使用括号运算符提升优先级
(11).toString();
// 方法四,提前申明变量
const num = 11;
const string = num.toString();
32. 隐藏元素之 display、visibility、opacity
相同点:都能控制元素在视图中的可见性
不同点:直接看图
33. TCP 与 UDP 的区别
相同点
TCP
与UDP
都是运行在运输层的协议TCP
与UDP
的通信都需要开放端口
不同点
34. 关于代码质量引发的一些哲学问题
前言:之所以讨论这个话题,是因为在掘金上看到了一篇关于设计模式的文章,但是文章的作者为了封装而封装,为了职责链模式而硬套了,把原本很正常的逻辑变的更加复杂,于是引起了评论区一些大佬的讨论。
其中一个大佬的评论总结的很到位,也引发了一些思考,所以摘录在下面:
1、为了封装而封装,硬套设计模式,这就是代码越写越乱的典型(负优化)
2、大筐里有 4 种萝卜,作者觉得这样很乱,于是往大筐里又套 4 个小筐,把萝卜放到小筐里(犯了形而上学的错误,只是对代码量进行了转移,并没有减少,甚至为了转移后的联系,增加了很多额外代码)。
3、奥卡姆剃刀原理,如无必要、勿增实体
,没有必要把一段简单的 switch case
或者几行 if else
判断,直接在拦截器里可以搞定的事情,拆成 n 个子模块,而且为了联系上下文还要写一堆无用代码来桥接。
4、泰斯勒定律,复杂性守恒原理,复杂度不会凭空增加或消除,只能对复杂性进行转移
,这里是转移了复杂性,但是因为上下文的联系,不得不增加额外代码,这就增加了复杂性,所以转移的目的没有任何意义。
5、责任链设计模式,作者只掌握了形式,并没有掌握精髓。
6、其他评论说的对,这个场景的模式选择的不对,策略模式
更加合适。
35. 老掉牙的面试题:React diff 是什么?可以省略吗?
回答:可以省略,但是强烈不推荐(废话文学,面试的时候直接说不可以就好了)
下面看满分答案:
key 的作用就是服务于 diff 算法,是节点是否可以复用的首要判定条件
如果省略了 key,内部会默认使用 null,在列表节点有排序需求的情况下,会造成性能损耗
在 react 组件开发的过程中,key
是一个常用的属性值,多用于列表开发. 这里从源码的角度,分析key
在react
内部是如何使用的,key
是否可以省略.
ReactElement 对象
我们在编程时直接书写的jsx
代码,实际上是会被编译成 ReactElement 对象,所以key
是ReactElement对象
的一个属性.
构造函数
在把jsx
转换成ReactElement对象
的语法时,有一个兼容问题. 会根据编译器的不同策略,编译成 2 种方案.
最新的转译策略: 会将
jsx
语法的代码,转译成jsx()
函数包裹jsx
函数: 只保留与key
相关的代码(其余源码这里不讨论)/** * <https://github.com/reactjs/rfcs/pull/107> * @param {*} type * @param {object} props * @param {string} key */ export function jsx(type, config, maybeKey) { let propName; // 1. key的默认值是null let key = null; // Currently, key can be spread in as a prop. This causes a potential // issue if key is also explicitly declared (ie. <div {...props} key="Hi" /> // or <div key="Hi" {...props} /> ). We want to deprecate key spread, // but as an intermediary step, we will use jsxDEV for everything except // <div {...props} key="Hi" />, because we aren't currently able to tell if // key is explicitly declared to be undefined or not. if (maybeKey !== undefined) { // 2. 将key转换成字符串 key = '' + maybeKey; } if (hasValidKey(config)) { // 2. 将key转换成字符串 key = '' + config.key; } // 3. 将key传入构造函数 return ReactElement(type, key, ref, undefined, undefined, ReactCurrentOwner.current, props); }
传统的转译策略: 会将
jsx
语法的代码,转译成React.createElement()函数包裹React.createElement()函数
: 只保留与key
相关的代码(其余源码这里不讨论)/** * Create and return a new ReactElement of the given type. * See <https://reactjs.org/docs/react-api.html#createelement> */ export function createElement(type, config, children) { let propName; // Reserved names are extracted const props = {}; let key = null; let ref = null; let self = null; let source = null; if (config != null) { if (hasValidKey(config)) { key = '' + config.key; // key转换成字符串 } } return ReactElement(type, key, ref, self, source, ReactCurrentOwner.current, props); }
可以看到无论采取哪种编译方式,核心逻辑都是一致的:
key
的默认值是null
如果外界有显式指定的
key
,则将key
转换成字符串类型.调用
ReactElement
这个构造函数,并且将key
传入.
// ReactElement的构造函数: 本节就先只关注其中的key属性
const ReactElement = function (type, key, ref, self, source, owner, props) {
const element = {
$$typeof: REACT_ELEMENT_TYPE,
type: type,
key: key,
ref: ref,
props: props,
_owner: owner,
};
return element;
};
源码看到这里,虽然还只是个皮毛,但是起码知道了key
的默认值是null
. 所以任何一个reactElement
对象,内部都是有key
值的,只是一般情况下(对于单节点)很少显式去传入一个 key.
Fiber 对象
react
的核心运行逻辑,是一个从输入到输出的过程(回顾reconciler 运作流程
). 编程直接操作的jsx
是reactElement对象
,我们的数据模型是jsx
,而react内核
的数据模型是fiber树形结构
. 所以要深入认识key
还需要从fiber
的视角继续来看.
fiber
对象是在fiber树构造循环
过程中构造的,其构造函数如下:
function FiberNode(tag: WorkTag, pendingProps: mixed, key: null | string, mode: TypeOfMode) {
this.tag = tag;
this.key = key; // 重点: key也是`fiber`对象的一个属性
// ...
this.elementType = null;
this.type = null;
this.stateNode = null;
// ... 省略无关代码
}
可以看到,key
也是fiber
对象的一个属性. 这里和reactElement
的情况有所不同:
reactElement
中的key
是由jsx
编译而来,key
是由开发者直接控制的(即使是动态生成,那也是直接控制)fiber
对象是由react
内核在运行时创建的,所以fiber.key
也是react
内核进行设置的,程序员没有直接控制.
注意: fiber.key
是reactElement.key
的拷贝,他们是完全相等的(包括null
默认值)。
接下来分析fiber
创建,剖析key
在这个过程中的具体使用情况.
fiber
对象的创建发生在fiber树构造循环
阶段中,具体来讲,是在reconcileChildren
调和函数中进行创建.
reconcileChildren 调和函数
reconcileChildren
是react
中的一个明星
函数,最热点的问题就是diff算法原理
,事实上,key
的作用完全就是为了diff算法
服务的.
注意: 这里只分析 key 相关的逻辑,对于 diff 函数的算法原理不做详细分析
调和函数源码(只摘取了部分代码):
function ChildReconciler(shouldTrackSideEffects) {
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
lanes: Lanes
): Fiber | null {
// Handle object types
const isObject = typeof newChild === 'object' && newChild !== null;
if (isObject) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
// newChild是单节点
return placeSingleChild(
reconcileSingleElement(returnFiber, currentFirstChild, newChild, lanes)
);
}
}
// newChild是多节点
if (isArray(newChild)) {
return reconcileChildrenArray(returnFiber, currentFirstChild, newChild, lanes);
}
// ...
}
return reconcileChildFibers;
}
单节点
这里先看单节点的情况reconcileSingleElement
(只保留与key
有关的逻辑):
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement,
lanes: Lanes
): Fiber {
const key = element.key;
let child = currentFirstChild;
while (child !== null) {
// 重点1: key是单节点是否复用的第一判断条件
if (child.key === key) {
switch (child.tag) {
default: {
if (child.elementType === element.type) {
// 第二判断条件
deleteRemainingChildren(returnFiber, child.sibling);
// 节点复用: 调用useFiber
const existing = useFiber(child, element.props);
existing.ref = coerceRef(returnFiber, child, element);
existing.return = returnFiber;
return existing;
}
break;
}
}
// 不匹配,直接删除
deleteRemainingChildren(returnFiber, child);
break;
}
child = child.sibling;
}
// 重点2: fiber节点创建,`key`是随着`element`对象被传入`fiber`的构造函数
const created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, currentFirstChild, element);
created.return = returnFiber;
return created;
}
可以看到,对于单节点来讲,有 2 个重点:
key
是单节点是否复用的第一判断条件(第二判断条件是type
是否改变,比如div
改变为span
).如果
key
不同,其他条件是完全不看的
在新建节点时,
key
随着element
对象被传入fiber
的构造函数.
所以到这里才是key
的最核心作用, 是调和函数中, 针对单节点是否可以复用的第一判断条件
.
对于单节点来讲, key
是可以省略的, react
内部会设置成默认值null
. 在进行diff
时, 由于null === null
为true
, 前后render
的key
是一致的, 可以进行复用比较.
如果单节点显式设置了key
,且两次render
时的key
如果不一致,则无法复用.
多节点
继续查看多节点相关的逻辑:
function reconcileChildrenArray(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: Array<*>,
lanes: Lanes
): Fiber | null {
if (__DEV__) {
// First, validate keys.
let knownKeys = null;
for (let i = 0; i < newChildren.length; i++) {
const child = newChildren[i];
// 1. 在dev环境下, 执行warnOnInvalidKey.
// - 如果没有设置key, 会警告提示, 希望能显式设置key
// - 如果key重复, 会错误提示.
knownKeys = warnOnInvalidKey(child, knownKeys, returnFiber);
}
}
let resultingFirstChild: Fiber | null = null;
let previousNewFiber: Fiber | null = null;
let oldFiber = currentFirstChild;
let lastPlacedIndex = 0;
let newIdx = 0;
let nextOldFiber = null;
// 第一次循环: 只会在更新阶段发生
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
nextOldFiber = oldFiber.sibling;
}
// 1. 调用updateSlot, 处理公共序列中的fiber
const newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
// 如果无法复用, 则退出公共序列的遍历
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
}
// 第二次循环
if (oldFiber === null) {
for (; newIdx < newChildren.length; newIdx++) {
// 2. 调用createChild直接创建新fiber
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
}
return resultingFirstChild;
}
for (; newIdx < newChildren.length; newIdx++) {
// 3. 调用updateFromMap处理非公共序列中的fiber
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
lanes
);
}
return resultingFirstChild;
}
在reconcileChildrenArray
中, 有 3 处调用与fiber
有关(当然也和key
有关了), 它们分别是:
updateSlot
function updateSlot( returnFiber: Fiber, oldFiber: Fiber | null, newChild: any, lanes: Lanes ): Fiber | null { const key = oldFiber !== null ? oldFiber.key : null; if (typeof newChild === 'object' && newChild !== null) { switch (newChild.$$typeof) { case REACT_ELEMENT_TYPE: { // 重点: key用于是否复用的第一判断条件 if (newChild.key === key) { return updateElement(returnFiber, oldFiber, newChild, lanes); } else { return null; } } } } return null; }
createChild
function createChild(returnFiber: Fiber, newChild: any, lanes: Lanes): Fiber | null { if (typeof newChild === 'object' && newChild !== null) { switch (newChild.$$typeof) { case REACT_ELEMENT_TYPE: { // 重点: 调用构造函数进行创建 const created = createFiberFromElement(newChild, returnFiber.mode, lanes); return created; } } } return null; }
updateFromMap
function updateFromMap( existingChildren: Map<string | number, Fiber>, returnFiber: Fiber, newIdx: number, newChild: any, lanes: Lanes ): Fiber | null { if (typeof newChild === 'object' && newChild !== null) { switch (newChild.$$typeof) { case REACT_ELEMENT_TYPE: { //重点: key用于是否复用的第一判断条件 const matchedFiber = existingChildren.get(newChild.key === null ? newIdx : newChild.key) || null; return updateElement(returnFiber, matchedFiber, newChild, lanes); } } return null; } }
针对多节点的diff算法
可以分为三个步骤(请回顾算法章节React 算法之调和算法
):
第一次循环:比较公共序列
从左到右逐一遍历, 遇到一个无法复用的节点则退出循环.
第二次循环:比较非公共序列
在第一次循环的基础上, 如果
oldFiber
队列遍历完了, 证明newChildren
队列中剩余的对象全部都是新增.此时继续遍历剩余的
newChildren
队列即可, 没有额外的diff
比较.在第一次循环的基础上, 如果
oldFiber
队列没有遍历完, 需要将oldFiber
队列中剩余的对象都添加到一个map
集合中, 以oldFiber.key
作为键.此时则在遍历剩余的
newChildren
队列时, 需要用newChild.key
到map
集合中进行查找, 如果匹配上了, 就将oldFiber
从map
中取出来, 同newChild
进行diff
比较.
清理工作
在第二次循环结束后, 如果
map
集合中还有剩余的oldFiber
,则可以证明这些oldFiber
都是被删除的节点, 需要打上删除标记.
通过回顾diff算法
的原理, 可以得到key
在多节点情况下的特性:
新队列
newChildren
中的每一个对象(即reactElement
对象)都需要同旧队列oldFiber
中有相同key
值的对象(即oldFiber
对象)进行是否可复用的比较.key
就是新旧对象能够对应起来的唯一标识.如果省略
key
或者直接使用列表index
作为key
, 表现是一样的(key=null
时, 会采用index
代替key
进行比较). 在新旧对象比较时, 只能按照index
顺序进行比较, 复用的成功率大大降低, 大列表会出现性能问题.例如一个排序的场景:
oldFiber
队列有 100 个,newChildren
队列有 100 个(但是打乱了顺序). 由于没有设置key
, 就会导致newChildren
中的第 n 个必然要和oldFiber
队列中的第 n 个进行比较, 这时它们的key
完全一致(都是null
), 由于顺序变了导致props
不同, 所以新的fiber
完全要走更新逻辑(理论上比新创建一个的性能还要耗).同样是排序场景可以出现的 bug: 上面的场景只是性能差(又不是不能用),
key
使用不当还会造成bug
还是上述排序场景, 只是列表中的每一个
item
内部又是一个组件, 且其中某一个item
使用了局部状态(比如class组件
里面的state
). 当第二次render
时,fiber
对象不会delete
只会update
导致新组件的state
还沿用了上一次相同位置的旧组件的state
,造成了状态混乱。
总结
在react
中key
是服务于diff算法
, 它的默认值是null
, 在diff算法
过程中, 新旧节点是否可以复用, 首先就会判定key
是否相同, 其后才会进行其他条件的判定. 在源码中, 针对多节点(即列表组件)如果直接将key
设置成index
和不设置任何值的处理方案是一样的, 如果使用不当, 轻则造成性能损耗, 重则引起状态混乱造成 bug.
36. 扁平数组转 tree 结构
要求:输入 list,输出对应的 result
interface ArrayItem {
id: number;
name: string;
pid: number;
}
interface TreeItem extends ArrayItem {
children?: TreeItem[];
}
// 输入
const list: ArrayItem[] = [
{ id: 1, name: '部门1', pid: 0 },
{ id: 2, name: '部门2', pid: 1 },
{ id: 3, name: '部门3', pid: 1 },
{ id: 4, name: '部门4', pid: 3 },
{ id: 5, name: '部门5', pid: 4 },
];
// 输出
const result: TreeItem[] = [
{
id: 1,
name: '部门1',
pid: 0,
children: [
{
id: 2,
name: '部门2',
pid: 1,
},
{
id: 3,
name: '部门3',
pid: 1,
children: [
{
id: 4,
name: '部门4',
pid: 3,
children: [
{
id: 5,
name: '部门5',
pid: 4,
},
],
},
],
},
],
},
];
实现:
const arrToTree = (arr: ArrayItem[]): TreeItem[] => {
const res: TreeItem[] = [];
const map = new Map<PropertyKey, TreeItem>();
arr.forEach(item => {
map.set(item.id, item);
});
arr.forEach(item => {
const parent = map.get(item.pid);
if (parent) {
if (parent?.children) {
parent.children.push(item);
} else {
parent.children = [item];
}
} else {
res.push(item);
}
});
return res;
};
测试结果:
const ans = arrToTree(list);
console.log(JSON.stringify(ans) === JSON.stringify(result)); // true