0%

cookie,session,token

服务器验证是前提。
cookie 保存在客户端,服务器不加密不保存;大小 4K,一般保存 sessionId。有 expire 和 max-age。
session 保存在服务端,服务器要加密并保存;
token 保存在客户端,服务器要加密不保存。明文传递给客户端,但是是加密的。

localStorage,sessionStorage,cookie

localStorage:整个浏览器有效,大小 10M.永久有效。
sessionStorage:只在当前 tab 页有效,相邻 tab 不同共享。大小 5M.tab 关闭即失效。
cookie:整个浏览器有效,大小 4K.根据设置的过期时间。可以保存在浏览器和服务端。

cookie 是以键值对的形式保存在浏览器。以=相关联,以;分隔。

1
2
3
4
// 查看cookie
document.cookie;
// 新增cookie
document.cookie = "name = Tom";

localStorage

同源问题:非同源的不能操作修改 localStorage。

1
2
3
4
5
6
7
8
9
10
// 设置localStorage
localStorage.setItem("name", "Tom");
// 查看localStorage
localStorage.getItem("name"); // Tom
// 设置相同键名,会覆盖之前的值
localStorage.setItem("name", "Jack"); // name被覆盖为Jack
// 删除指定localStorage
localStorage.removeItem("name");
// 删除所有localStorage
localStorage.clear();

强制缓存,协商缓存

HTTP 缓存都是从第二次请求开始的:
第一次请求资源时,服务器返回资源,并在 response header 中回传资源的缓存策略;
第二次请求时,浏览器判断这些请求参数,击中强缓存就直接 200,否则就把请求参数加到 request header 头中传给服务器,看是否击中协商缓存,击中则返回 304,否则服务器会返回新的资源。
image.png

强制缓存

在缓存未失效时,直接使用浏览器缓存数据,不再请求服务端.生效时,状态码是 200.
cache-control 优先级 > expires 优先级。

协商缓存

第一次请求服务器,或者cache-controlExpire过期,第二次请求就会与服务器协商,与服务端对比资源是否修改更新.没有修改,则返回 304 状态码,继续使用缓存数据.如果更新,则返回 200 状态码,并将缓存信息一并返回.

跨域

原因: 浏览器的同源策略(为了安全),即同端口,域名,协议.

跨域并不是请求发不出去,请求能发出去,服务端能收到请求并正常返回结果,只是结果被浏览器拦截了。

同源策略

限制的内容有:

  • cookie, localstorage 等存储内容
  • DOM 节点
  • ajax 请求后浏览器返回的结果

允许的内容:

  • <img src="xxx">
  • <link href="xxx">
  • <script src="xxx">

解决方法

解决方案有 jsonp、cors、postMessage、websocket、Node 中间件代理(两次跨域)、nginx 反向代理、window.name + iframe、location.hash + iframe、document.domain + iframe
日常工作中,用得比较多的跨域方案是 cors 和 nginx 反向代理

iframe

通过 contentWindow 获取内部的 iframe 的 window。非同源的,父级无法获取子级的内容,跨域。
父级访问子级:iframe.contentWindow.xxx
子级访问父级:window.parent.xxx
访问祖先顶级:window.top.xxx

跨域解决

服务器中转请求

跨域是浏览器的限制,对服务器没有限制。
浏览器向同源的服务器发请求,该服务器向不同源的服务器发请求,返回请求内容,再返给浏览器。

相同基础域名+iframe

设置document.domain = 'xxx'

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 当前页面是test2.jsxx.com;需要请求test.jsxx.com/xx.php,存在跨域
// 利用test2.jsxx.com可以请求该资源,创建一个相同基础域名的iframe,
// 利用iframe去请求资源
document.domain = "jsxx.com";

var iframe = document.createElement("ifarme");
iframe.src = "http://test.jsxx.com/index.html"; // 这个域名的文件设置相同的基础域名
iframe.id = "myIframe";
iframe.style.display = "none";
iframe.onload = function () {
var contentWindow = document.getElementById("myIframe").contentWindow;
// 发起请求
};
document.body.appendChild(iframe);

window.name + iframe

同一个窗口不同的页面,共享同一个 window。
在当前窗口的当前页面,设置 window.name = ‘123’。跳转到另一个页面,这个页面也可以拿到 window.name=’123’。

同一个窗口下的 iframe 如果和父级主窗口不同源,是拿不到 window 的信息的。
解决方法:让该不同源的 iframe 跳转到一个和父级同源的 iframe。
注意:要在跳转之前,请求数据,使用 window.name 进行保存。跳转的同时,获取 iframe 保存的 name。(iframe 保存的是请求的跨域的那个 window 的 name)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
var flag = false

var iframe = document.createElement('iframe');
var getDatas = function(){
if(flag){
// 跳转后的加载走这里,contentWindow存的是同一个name,可以获取了
var data = iframe.contentWindow.name;
console.log(JSON.parse(data));
}else{
// 第一次加载,走这里,跳转到同源地址
flag = true;
setTImeout(()=>{
ifrmae.contentWindow.location = 'index2.html'
},500)
}
}

// 跨域获取的资源链接
iframe.src = 'http://test.jsxx.com/index.html';
// 每次加载时,获取数据
iframe.onload = getDatas();

document.body.appendChild(iframe)

// index.html
// 获取的数据保存到window上
window.name = 'xxx'

postMessage+iframe

不经常用原因:

  1. 伪造数据端漏洞
  2. XSS 攻击
  3. 兼容性问题

JSONP

客户端期望:{"name": "Jack", "age": "18"}
JSONP 实际返回的:callback({"name": "Jack", "age": "18"})
利用 script 标签允许跨域,在当前页面定义回调函数。在新建的 script 标签上指定要跨域的 src,src 所对应的文件里执行 ajax 请求,成功的回调就是页面中定义的回调。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//* 原理

// 当前页面
<script type="text/javascript">
// 定义回调函数
function getData(data){
console.log(data)
}
</script>
// 新建一个用于跨域的标签,页面加载的时候会执行
<script src="http://test.jsxx.com/jsonp/jsonp.js"></script>

// 跨域标签所指向的文件 jsonp.js
$.ajax('xxx'){
success(data){
// 执行回调
getData(data)
}
}

实际操作是使用新建 script 的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//* 实际操作
<script type="text/javascript">
var oBtn = document.getElementById('btn');

oBtn.onClick = function(){
oScript = document.createElement("script")
oScript.src = "http://test.jsxx.com/jsonp/jsonp.php?cb=test"
document.body.appendChild(oScript)
document.body.removeChild(oScript)


// 使用jQuery封装的方法
$.ajax({
url: "http://test.jsxx.com/jsonp/jsonp.php",
type: "GET",
dataType: "JSONP",
jsonp: "cb",
success: function(data){
console.log(data)
}
})
}
</script>

cors

服务端设置Access-Control-Allow-Origin,可以设置哪些域名可以访问资源.

nginx 反向代理

搭建一个中转 nginx 服务器,用于转发请求.
理解: 设置一个中转用的代理服务器,所有接口都访问这个服务器,在这个服务器内设置对应的目标服务器.选择目标服务器获取数据,发送给客户端.
实现思路:通过 nginx 配置一个代理服务器(域名与 domain1 相同,端口不同)做跳板机,反向代理访问 domain2 接口,并且可以顺便修改 cookie 中 domain 信息,方便当前域 cookie 写入,实现跨域登录。

1
2
3
4
5
6
7
8
9
10
11
server {
listen 80; # 监听的端口
server_name localhost; # 用户访问 localhost,反向代理到 http://webcanteen.com
location / {
proxy_pass http://webcanteen.com # 即需要代理访问的地址
}
# 也可以写其他的比如/api
location /api {
proxy_pass http://api.web.com # 这就可以将请求/api的接口代理到这个地址
}
}

保持前后端实时通信的方法

  1. websocket
  2. event-source
  3. ajax 轮询
  4. 永久帧

websocket

优点: 它是在单个 tcp 连接上进行的全双工通讯协议,客户端和服务器只进行一次握手就可以进行持续双向的数据传输.节约资源和带宽.
缺点: 1.兼容性.2.不支持断线重连,需要手写心跳连接的逻辑.3.通信机制复杂

event-source

优点:(1)只需一次请求,便可以 stream 的方式多次传送数据,节约资源和带宽 (2)相对 WebSocket 来说简单易用 (3)内置断线重连功能(retry)
缺点: (1)是单向的,只支持服务端->客户端的数据传送,客户端到服务端的通信仍然依靠 AJAX,没有”一家人整整齐齐“的感觉(2)兼容性令人担忧,IE 浏览器完全不支持

AJAX 轮询

优点:兼容性良好,对标低版本 IE
缺点:请求中有大半是无用的请求,浪费资源

持久登录

单点登录机制:不同源的站点,相同基础域名,一个登录,其他都登录。
image.png

后端加密的操作

username:md5(md5(username) + salt) => ident_code(身份识别码)
password:md5(md5(password) + salt)
salt:自定义的字符串
token:身份令牌,32/16 位随机字符串。每次登录就重新生成。
auth:ident_code:token
timeout:cookie 的过期时间
PC:30 天(如果不点连续登录就是 1 天) App:1 年

1
2
3
cookie auth= ident_code:token
// 后端设置cookie
setCookie('auth',ident_code:token, 过期时间, '/'【有效地址】, '.baidu.com'【有效域】)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function get(key, cb) {
var cookieArray = document.cookie.split(";");

for (var i = 0; i < cookieArray.length; i++) {
var cookieItem = cookieArray[i];

var cookieItemArray = cookieItem.split("=");

if (cookieItemArray[0] == key) {
cb(cookieItemArray[1]);
return this;
}
}
cb(undefined);
s;
return this;
}

文件上传

同步上传
image.png

异步上传

image.png
image.png
image.png

HTTP 版本

HTTP/1.1

  • 默认持久连接(Connection:keep-alive)
  • 增加管道机制(支持多个请求同时发送)
  • 增加 PUT/PATCH/DELETE/OPTIONS 等请求方式
  • 增加 HOST 字段
  • 增加 100 状态码,支持只发送头信息
  • 增加身份认证机制
  • 支持传送内容的一部分和断点续传
  • 增加 24 个状态码

HTTP/2.0

  • 增加双工模式(同时发送和处理多个请求)
  • 服务器推送(服务端会把客户端需要的资源一起推送到客户端)
  • 头部信息压缩机制(每次请求把所有信息发给服务端【HTTP 协议不带状态】)
  • 二进制协议(头信息与数据体使用二进制进行传输)
  • 多工(先发送已处理好的部分,再响应其他请求,最后再处理没解决的地方)

HTTP1 和 HTTP2 的区别

  1. http1 是明文传送,http2 是二进制传送
  2. http2 支持多路复用
  3. http2 支持头部压缩
  4. http2 支持服务器推送

HTTP 和 HTTPS 的区别

  1. http 是超文本传输协议,信息是明文传输,https 是具有安全性的 TLS 加密传输协议
  2. https 需要 ca 申请证书
  3. http 端口使用 80,https 使用 443
  4. https 的传输内容都被 SSL/TLS 加密,且运行在 SSL/TLS 上,SSL/TLS 运行在 TCP 连接上。

加密方式

受 TLS 协议保护的通信过程:先对传输的数据进行了加密(使用对称加密算法)。并且对称加密的密钥是为每一个连接唯一生成的(基于 TLS 握手阶段协商的加密算法和共享密钥),然后发送的每条消息都会通过消息验证码(Message authentication code, MAC),来进行消息完整性检查,最后还可以使用公钥对通信双方进行身份验证

非对称加密

简单理解就是

  1. 服务端发证书给客户端
  2. 客户端获取 CA 证书中的公钥.
  3. 客户端生成对称密钥, 用公钥加密私钥 =>发给服务端.
  4. 服务端用私钥解密
  5. 双方使用这个对称秘钥通信,这时候加密方式就是对称加密

缺点

证书可能是伪造的,可能被篡改

解决方法

数字签名和摘要.
对传输内容通过 hash 算法计算出一段固定的串.用私钥加密,就是数字签名.
这个只能用 CA 的公钥解密.防止 CA 被篡改或者伪造.

请求网页的过程

xxxxxxxx

URI

URI:统一资源标识符,用来标识唯一的一个资源。
URL:统一资源定位符。URL 可以标识一个资源,并且可以用地址定位资源。
URN:统一资源命名。通过名字来表示资源。用名称定位资源。
URL 肯定是 URI,URI 不一定是 URL,也可能是 URN。
URI 是比较宽范的,包含了 URL。

URL

  1. 资源标识
  2. 具有资源定位功能
  3. 指明了获取资源的协议

URL 组成

  1. 协议名称(scheme):http,https,ws
  2. 主机名(host):域名或 IP
  3. 端口号(port):80(http 默认),443(https 默认)
  4. 路径(path):index/index.html
  5. 查询字符串(query):?a=1&b=2

URL 对象

创建 URL 对象,

1
new URL("https://google.com/search?query=JavaScript");

searchParams 查询参数

查询参数是 URLSearchParams 类型.
有以下方法:

  • append(name, value) —— 按照 name 添加参数,
  • delete(name) —— 按照 name 移除参数,
  • get(name) —— 按照 name 获取参数,
  • getAll(name) —— 获取相同 name 的所有参数(这是可行的,例如 ?user=John&user=Pete),
  • has(name) —— 按照 name 检查参数是否存在,
  • set(name, value) —— set/replace 参数,
  • sort() —— 按 name 对参数进行排序,很少使用,

……并且它是可迭代的,类似于 Map。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let url = new URL("https://google.com/search");

url.searchParams.set("q", "test me!"); // 添加带有一个空格和一个 ! 的参数

alert(url); // https://google.com/search?q=test+me%21

url.searchParams.set("tbs", "qdr:y"); // 添加带有一个冒号 : 的参数

// 参数会被自动编码
alert(url); // https://google.com/search?q=test+me%21&tbs=qdr%3Ay

// 遍历搜索参数(被解码)
for (let [name, value] of url.searchParams) {
alert(`${name}=${value}`); // q=test me!,然后是 tbs=qdr:y
}

TCP

传输控制协议
特点:面向连接(收发数据前,必须建立可靠的连接)
建立连接基础:三次握手
应用场景:数据必须准确无误的收发
HTTP 请求、FTP 文件传输、邮件收发
优点:稳定、重传机制、拥塞机制、断开连接
缺点:速度慢、效率低、占用资源、容易被攻击

UDP

类似于喇叭广播
特点:面向无连接(不可靠协议,无状态传输机制)
无连接信息发送机制
应用场景:无需确保通信质量且要求速度快,无需确保信息完整
消息收发,语音通话,直播
优点:安全,快速,漏洞少
缺点:不可靠,不稳定,易丢包
总结:只要目的源地址,端口号,地址,端口号 确定,就可以直接发送信息,但不保证一定能收到完整的信息。

三次握手

iShot2023-05-21 22.07.22.png
iShot2023-05-21 22.06.57.png

四次挥手

FIN-WAIT-1:等待远程 TCP 的中断请求,或先前的连接中断请求的确认
FIN-WAIT-2:从远程 TCP 等待连接中断请求
CLOSE-WAIT:等待从本地用户发送的连接中断请求
LAST-ACK:等待原来发向远程 TCP 的连接中断请求的确认
TIME-WAIT:等待足够的时间以确保远程 TCP 接收到连接中断请求的确认
CLOSED:没有任何连接状态

过程

第一次:客户端发送连接关闭报文 FN1,客户端进入终止等待(FIN-WAIT-1)
第二次:服务器收到连接关闭报文,发送确认报文 ACK1,服务器进入 CLOSE-WAIT 状态。
客户端收到服务端的确认请求,客户端进入终止等待 2(FIN-WAIT-2)状态,有可能有未发送完的信息
第三次:服务端确认数据发送完毕,向客户端发送连接关闭报文 FN1 ACK1,进入最后确认状态(LAST-ACK)
第四次:客户端收到服务端的连接关闭报文后,发出确认接收报文 ACK1,客户端进入时间等待状态(TIME-WAIT)
服务端接收到确认报文后,立即进入连接状态(CLOSED)。客户端在等待 2MSL 后关闭连接。

HTTP 报文

请求方式

PUT:上传资源。form 表单不支持,提交即存储的原则,需要配置服务器。
DELETE:删除资源。form 表单不支持,提交即存储的原则,需要配置服务器。
POST:修改资源。
GET:获取资源。

GET/POST 异同

  1. post 更安全:不会作为 url 一部分,不会被缓存,不会保存在浏览器记录中
  2. post 发送的数据量更大:GET 有长度限制
  3. post 能发送更多的数据类型:GET 只能发 ASCII 字符
  4. post 比 get 慢:POST 包含更多请求头,POST 请求先将请求头发给服务器,然后才发数据,GET 会缓存。
  5. post 的请求体是 formData,get 的请求体是 query sring。

状态码

1xx:信息,服务器收到请求。需要请求者继续执行操作。
2xx:成功,操作被成功接收并处理。
3xx:重定向,需要进一步的操作以完成请求。
4xx:客户端错误。请求包含语法错误或无法完成的请求。
5xx:服务端错误。服务器在处理请求的过程中发生错误。

304

缓存重定向。自上一次请求后,网页未被修改。

第一次发送请求,服务器会返回两个信息在响应头里。
E-Tag:唯一标识。
Last-Modifed:上次修改时间。
第二次发送请求,拿上这两个信息,重新命名,
If-Modify-Since:=> Last-Modifed 的信息。
If-None-Match: => E-Tag 的信息。
发给 服务端,服务端对比后,发现没有变化,就返回 304。浏览器从缓存中获取数据。

301 和 302

301:永久重定向,表示请求的资源分配了新的 url,以后应使用新 url。
302:临时重定向,请求的资源临时分配了新的 url(response 中 location 所指的地址),本次请求暂时使用新 url。服务器返回 302 时,也会返回 location,浏览器再次请求 location 中指定的地址,也就是浏览器请求了 2 次

401

为认证设计,返回 401 说明未通过认证。可能是认证出错或没有认证。

403

服务器拒绝请求。
这是授权相关,完成了认证,但是权限不够,不预授权。

404

未找到相应网页或资源。

Accept & Content-Type

q:相对品质因子,权重。从 0 到 1,表示优先级。默认 1。0 表示该类型不被浏览器接受。
Accept(请求头):type;q=value, type;q=value 分号是表示符号,逗号是分隔符号。

Content-Type(响应头):text/html;charset=UTF-8(Accept-Charset)
返回的资源类型与编码

Accept-Language:支持的语言。
Content-Language:返回的语言。

Accept-Encoding:接受的压缩格式。(gzip,defalte,br)
Content-Encoding:返回的压缩格式。(优化传输大小)

Connection:Keep-alive

长连接。
http1.1:默认开启。需要关闭:close。

Content-length

消息实体的传输长度。

referer

来源域名。告诉服务器是从哪个域名来的。
比如从百度搜 qq 官网,qq 官网请求头会显示 referer:www.baidu.com
场景:资源防盗链。拉取资源前判断 referer 是否是自己的域名。

浏览器缓存

pragma:no-cache(响应头) => 忽略缓存。每次请求都从走服务器。

cache-control

http1.0:cache-control 替代 pragma。向下兼容。
选项:
none-cache:忽略缓存(并非不要缓存)。
no-store:不要缓存。
max-age=316000:缓存的有效时长。从请求开始到过期的时间秒数。
public:响应可以被任何对象缓存。
private:响应只能被单个用户缓存,不能被共享。

Expires/max-age

过期时间。

流程

  1. 发请求
  2. 有缓存,查看是否过期,不过期,直接读取缓存,返回 200
  3. 是否有 E-tag,Last-Modifed。有向服务端发送。E-tag(If-None-Match)优先级高,先验证它,一致后再验证 Last-Modifed(If-Modifed-Since).
  4. 资源未修改,返回 304,从缓存获取资源。失效,返回 200,重新返回资源。

history

histroy 栈
存储历史记录。
length 属性:存了多少条。

replaceState

用于替换 history 栈中的条目。有三个参数。

  1. 对象
  2. 网页名
  3. url 地址
1
history.replaceState("text name", null, "text.html");

pushState

和 replaceState 类似。也是三个参数。
区别:pushState 是添加,replaceState 是直接替换。
这两个方法修改 url 后,都不会引起渲染。

popState

在历史活动条目发送改变时触发。
在 pushState 和 replaceState 触发时不改变,手动点击后退时,触发。

1
window.addEventListener("popState", () => {});

hashChange

当 hash 值改变,可以监听事件。、

web worker

开启一个新的线程。线程内只有 this,没有 window 和 document。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if (window.Worker) {
// 实例化
var worker = new Worker("worker.js");
// 信息
var message = { addThis: { num: 1, num2: 1 } };
// 传递信息
myWorker.postMessage(message);
// 接收信息
myWorker.onmessage = function (e) {
console.log(e);
};
}

// wokker.js
this.onmessage = function (e) {
if (e.data.addThs) {
var message = {
result: e.data.addThis.num1 + e.data.addThis.num2,
};
this.postMessage(message);
}
};

手写数组转树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// 例如将 input 转成output的形式
let input = [
{
id: 1,
val: "学校",
parentId: null,
},
{
id: 2,
val: "班级1",
parentId: 1,
},
{
id: 3,
val: "班级2",
parentId: 1,
},
{
id: 4,
val: "学生1",
parentId: 2,
},
{
id: 5,
val: "学生2",
parentId: 2,
},
{
id: 6,
val: "学生3",
parentId: 3,
},
];

let output = {
id: 1,
val: "学校",
children: [
{
id: 2,
val: "班级1",
children: [
{
id: 4,
val: "学生1",
children: [],
},
{
id: 5,
val: "学生2",
children: [],
},
],
},
{
id: 3,
val: "班级2",
children: [
{
id: 6,
val: "学生3",
children: [],
},
],
},
],
};
1
2
3
4
5
6
7
8
function arrToTree(arr) {
return arr.filter((item) => {
item.children = arr.filter((i) => {
return item.id === i.parentId;
});
return !item.parentId;
});
}
1
2
3
4
5
6
7
8
9
function arrToTree(arr, parentId) {
let parentArr = arr.filter((item) =>
parentId === undefined ? item.parentId === null : item.parentId === parentId
);
return parentArr.map((i) => {
i.children = arrToTree(arr, i.id);
return i;
});
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function getChildren(arr, result, parentId) {
for (let item of arr) {
if (item.parentId === parentId) {
const newItem = { ...item, children: [] };
result.push(newItem);
getChildren(arr, newItem.children, item.id);
}
}
}
function arrToTree(arr, parentId) {
const result = [];
getChildren(arr, result, parentId);
return result;
}
1
2
3
4
5
6
7
8
9
function arrToTree(arr) {
let map = new Map();
for (let item of arr) {
item.children = [];
map.set(item.id, item);
map.has(item.parentId) && map.get(item.parentId).children.push(item);
}
return map.get(1);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 写法一
function arrayToTree(array) {
let root = array[0];
array.shift();
let tree = {
id: root.id,
val: root.val,
children: array.length > 0 ? toTree(root.id, array) : [],
};
return tree;
}

function toTree(parenId, array) {
let children = [];
let len = array.length;
for (let i = 0; i < len; i++) {
let node = array[i];
if (node.parentId === parenId) {
children.push({
id: node.id,
val: node.val,
children: toTree(node.id, array),
});
}
}
return children;
}

console.log(arrayToTree(input));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 写法二
function getTreeList(rootList, id, list) {
for (let item of rootList) {
if (item.parentid === id) {
list.push(item);
}
}

for (let i of list) {
i.children = [];
getTreeList(rootList, i.id, i.children);
// 去除children为[]的情况
if (i.children.length === 0) {
delete i.children;
}
}
return list;
}
const res = getTreeList(input, null, []);

手写 Promise/A+

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
// 定义状态
const PENDING = "PENDING";
const FULFILLED = "FULFILLED";
const REJECTED = "REJECTED";

class MyPromise {
constructor(executor) {
// 捕获执行器的错误
try {
// 立即执行执行器
executor(this.resolve, this.reject);
} catch (error) {
this.reject(error);
}
// promise状态
this.status = PENDING; // 默认值
// 成功后的值
this.value = undefined;
// 失败后的原因
this.reason = undefined;
// 成功回调
this.successCallback = [];
// 失败回调
this.failCallback = [];
}

// 添加resolve
resolve = (value) => {
// 由状态修改后无法再次修改
// 如果状态不是等待 就阻止程序向下执行
if (this.status !== PENDING) return;

// 将状态更改为成功
this.status = FULFILLED;
// 保存成功后的值
this.value = value;
// 成功回调是否存在,存在就调用
// this.successCallback && this.successCallback(this.value)
this.successCallback.forEach((fn) => fn());
};
// 添加reject
reject = (reason) => {
// 如果状态不是等待 就阻止程序向下执行
if (this.status !== PENDING) return;
// 将状态更改为失败
this.status = REJECTED;
// 保存失败后的原因
this.reason = reason;
// 失败回调是否存在,存在就调用
// this.failCallback && this.failCallback(this.reason)
this.failCallback.forEach((fn) => fn());
};

// then方法内部就是判断状态,成功状态调用成功函数,失败状态调用失败函数
// then成功回调的参数是成功后的值,失败回调的参数是返回的原因

then = (successCallback, failCallback) => {
// then链式传递
successCallback =
typeof successCallback === "function"
? successCallback
: (value) => value;
failCallback =
typeof failCallback === "function"
? failCallback
: (reason) => {
throw reason;
};
// then方法每次都要返回一个promise
let promise2 = new MyPromise((resolve, reject) => {
// 判断状态
if (this.status === FULFILLED) {
// 将代码用setTimeout包裹变成异步代码,为了能拿到promise2
setTimeout(() => {
// 捕获错误
try {
let x = successCallback(this.value);
// 判断x的值是普通值还是promise对象
// 如果是普通值,直接调用resolve
// 如果是promise, 查看promise对象返回的结果,根据结果,绝对调用resolve还是reject
// 把返回值传递给下一个回调
resolvePromise(promise2, x, resolve, reject);
} catch (error) {
reject(error);
}
}, 0);
} else if (this.status === REJECTED) {
// 将代码用setTimeout包裹变成异步代码,为了能拿到promise2
setTimeout(() => {
// 捕获错误
try {
let x = failCallback(this.value);
// 判断x的值是普通值还是promise对象
// 如果是普通值,直接调用resolve
// 如果是promise, 查看promise对象返回的结果,根据结果,绝对调用resolve还是reject
// 把返回值传递给下一个回调
resolvePromise(promise2, x, resolve, reject);
} catch (error) {
reject(error);
}
}, 0);
} else {
// 状态是pending,是存在异步情况
// 将成功回调和失败回调存储起来
this.successCallback.push(() => {
setTimeout(() => {
// 捕获错误
try {
let x = successCallback(this.value);
// 判断x的值是普通值还是promise对象
// 如果是普通值,直接调用resolve
// 如果是promise, 查看promise对象返回的结果,根据结果,绝对调用resolve还是reject
// 把返回值传递给下一个回调
resolvePromise(promise2, x, resolve, reject);
} catch (error) {
reject(error);
}
}, 0);
});
this.failCallback.push(() => {
setTimeout(() => {
// 捕获错误
try {
let x = failCallback(this.value);
// 判断x的值是普通值还是promise对象
// 如果是普通值,直接调用resolve
// 如果是promise, 查看promise对象返回的结果,根据结果,绝对调用resolve还是reject
// 把返回值传递给下一个回调
resolvePromise(promise2, x, resolve, reject);
} catch (error) {
reject(error);
}
}, 0);
});
}
});

return promise2;
};
// 捕获错误
catch(failCallback) {
return this.then(undefined, failCallback);
}

// 返回 then的链式调用
finally = (callback) => {
// 使用then保证链式调用
return this.then(
(value) => {
// 使用resolve保证返回promise对象
return MyPromise.resolve(callback()).then(() => value);
},
(reason) => {
return MyPromise.resolve(callback()).then(() => {
throw reason;
});
}
);
};

static all(array) {
// 设置返回的数组
let result = [];
// 设置计数器
let index = 0;
return new MyPromise((resolve, reject) => {
for (let i = 0; i < array.length; i++) {
let current = array[i];
if (current instanceof MyPromise) {
// 是promise对象
current.then(
(value) => addData(i, value),
(reason) => reject(reason)
);
} else {
// 普通值
addData(i, array[i]);
}
}

function addData(key, value) {
// 保证位置是按顺序的
result[key] = value;
// 跑一个算一个
index++;
// 计数器和数组长度相等说明都跑完了
if (index === array.length) {
resolve(result);
}
}
});
}
static resolve(value) {
if (value instanceof MyPromise) return value;
return new MyPromise((resolve) => resolve(value));
}
}

const resolvePromise = (promise2, x, resolve, reject) => {
if (promise2 === x) {
// 如果自我调用就报错,并阻止继续执行
return reject(
new TypeError("Chaining cycle detected for promise #<Promise>")
);
}
if (x instanceof MyPromise) {
// x 是否是MyPromise的实例对象,是就是promise对象
x.then(resolve, reject);
} else {
// 普通值
resolve(x);
}
};

手写去重

1
2
let arr = [];
Array.form(new Set(arr));

手写防抖

顾名思义,防止抖动,手抖多点了几次,怎么办,只算最后一次.
debounce

手写节流

顾名思义,一直在流,但是为了节约,隔一段时间流一次.
throttle

手写多维数组扁平化

手写 redux

手写 useState,useEffect,useReducer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
let state = [];
let setters = [];
let stateIndex = 0;

function createSetter(index) {
return function (newState) {
state[index] = newState;
render();
};
}

function useState(initState) {
state[stateIndex] = state[stateIndex] ? state[stateIndex] : initState;
setters.push(createSetter(stateIndex));
stateIndex++;
// 状态
let value = state[stateIndex];
// 方法
let setter = setters[stateIndex];

return [value, setter];
}

// 上一次的依赖值
let prevDepsAry = [];
// 这个声明用于多个effect的情况
let effectIndex = 0;

function useEffect(callback, depsAry) {
// 判断callback是否为函数
if (Object.prototype.toString.call(callback) !== "[object Function]")
throw new Error("useEffect接收一个函数");
// 判断depsAry是否传递
if (typeof depsAry === "undefined") {
// 没有传递
callback();
} else {
// 判断depsAry是不是数组
if (Object.prototype.toString.call(depsAry) !== "[object Array]")
throw new Error("useEffect依赖必须是数组");
// 获取上一次的值,如果有就取,没有不取
let prevDeps = prevDepsAry[effectIndex];
// 判断当前依赖值与上一次依赖,是否有变化,有才调用callback
let hasChanged = prevDeps
? depsAry.every((dep, index) => dep === prevDeps[index]) === false
: true;
// 判断值是否有变化
if (hasChanged) {
callback();
}
// 比完之后要同步依赖值,保证依赖值最新
prevDepsAry[effectIndex] = depsAry;
// 多个useEffect时标记增加,记得在render时归零
effectIndex++;
}
}

function useReducer(reducer, initState) {
const [state, setState] = useState(initState);
// useReducer内部返回dispatch方法
function dispatch(action) {
// dispatch内部调用reducer返回最新的state
const newState = reducer(state, action);
setState(newState);
}
return [state, dispatch];
}

function App() {
const [count, dispatch] = useReducer(render, 0);

function reducer(state, action) {
switch (action.type) {
case "increment":
return state + 1;
break;
case "decrement":
return state - 1;
default:
return state;
}
}

return (
<div>
{count}
<button onClick={() => dispatch({ type: "increment" })}>add</button>
<button onClick={() => dispatch({ type: "decrement" })}>delete</button>
</div>
);
}

function render() {
stateIndex = 0;
effectIndex = 0;

ReactDOM.render(<App />, document.getElementById("root"));
}

手写 Vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
const Observer = function (data) {
// 循环修改为每个属性添加get set
for (let key in data) {
defineReactive(data, key);
}
};

const defineReactive = function (obj, key) {
// 局部变量dep,用于get set内部调用
const dep = new Dep();
// 获取当前值
let val = obj[key];
Object.defineProperty(obj, key, {
// 设置当前描述属性为可被循环
enumerable: true,
// 设置当前描述属性可被修改
configurable: true,
get() {
console.log("in get");
// 调用依赖收集器中的addSub,用于收集当前属性与Watcher中的依赖关系
dep.depend();
return val;
},
set(newVal) {
if (newVal === val) {
return;
}
val = newVal;
// 当值发生变更时,通知依赖收集器,更新每个需要更新的Watcher,
// 这里每个需要更新通过什么断定?dep.subs
dep.notify();
},
});
};

const observe = function (data) {
return new Observer(data);
};

const Vue = function (options) {
const self = this;
// 将data赋值给this._data,源码这部分用的Proxy所以我们用最简单的方式临时实现
if (options && typeof options.data === "function") {
this._data = options.data.apply(this);
}
// 挂载函数
this.mount = function () {
new Watcher(self, self.render);
};
// 渲染函数
this.render = function () {
with (self) {
_data.text;
}
};
// 监听this._data
observe(this._data);
};

const Watcher = function (vm, fn) {
const self = this;
this.vm = vm;
// 将当前Dep.target指向自己
Dep.target = this;
// 向Dep方法添加当前Wathcer
this.addDep = function (dep) {
dep.addSub(self);
};
// 更新方法,用于触发vm._render
this.update = function () {
console.log("in watcher update");
fn();
};
// 这里会首次调用vm._render,从而触发text的get
// 从而将当前的Wathcer与Dep关联起来
this.value = fn();
// 这里清空了Dep.target,为了防止notify触发时,不停的绑定Watcher与Dep,
// 造成代码死循环
Dep.target = null;
};

const Dep = function () {
const self = this;
// 收集目标
this.target = null;
// 存储收集器中需要通知的Watcher
this.subs = [];
// 当有目标时,绑定Dep与Wathcer的关系
this.depend = function () {
if (Dep.target) {
self.addSub(Dep.target);
}
};
// 为当前收集器添加Watcher
this.addSub = function (watcher) {
self.subs.push(watcher);
};
// 通知收集器中所的所有Wathcer,调用其update方法
this.notify = function () {
for (let i = 0; i < self.subs.length; i += 1) {
self.subs[i].update();
}
};
};

const vue = new Vue({
data() {
return {
text: "hello world",
};
},
});

vue.mount(); // in get
vue._data.text = "123"; // in watcher update /n in get

错误边界

捕获错误显示备用 UI,而不是整个组件崩溃.
使用 getDerivedStateFromError()渲染备用 UI,使用 ComponentDidCatch 捕获错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}

static getDerivedStateFromError(error) {
// 更新 state 使下一次渲染能够显示降级后的 UI
return { hasError: true };
}

componentDidCatch(error, errorInfo) {
// 你同样可以将错误日志上报给服务器
logErrorToMyService(error, errorInfo);
}

render() {
if (this.state.hasError) {
// 你可以自定义降级后的 UI 并渲染
return <h1>Something went wrong.</h1>;
}

return this.props.children;
}
}

无法捕获:

  1. 事件处理
  2. 异步代码 (例如 setTimeout 或 requestAnimationFrame 回调函数)
  3. 服务端渲染
  4. 错误边界自身抛出来的错误 (而不是其子组件)

对于无法捕获的错误,可以使用 try/catch.
或者监听 oneror 事件,window.addEventListener('error',fn())

ErrorBoundary 是什么?

  • 在 React 中,ErrorBoundary 是一个组件
  • 用于处理渲染期间出现的 JavaScript 错误
  • 当在渲染组件期间抛出未捕获的错误时,React 会在组件树中向上寻找最近的 ErrorBoundary 组件,然后调用它的 componentDidCatch() 方法

componentDidCatch() 怎么用?

  • componentDidCatch() 接收两个参数:error(表示发生的错误)和 info(包含有关错误发生位置的组件树信息)

ErrorBoundary 还有哪些作用?

  • ErrorBoundary 组件可以将错误信息记录到日志中,或者渲染一些错误信息
  • ErrorBoundary 组件还可以帮助防止应用程序崩溃

作为抛出未捕获错误的 ErrorBoundary 和 try-catch 异常捕获之间什么关系?

React 的 ErrorBoundary 和 try-catch 的区别在于

  1. ErrorBoundary 可以捕获并处理子组件内部的错误,包括渲染错误和生命周期错误
  2. try-catch 只能捕获当前代码块内的同步错误
  3. ErrorBoundary 可以显示错误信息和 fallback UI,让用户知道发生了错误
  4. try-catch 只能通过 console.log 等方式输出错误信息
  5. ErrorBoundary 只能用于类组件,而 try-catch 可以用于函数和类组件

为什么 ErrorBoundary 一定要写在类里面?

  • ErrorBoundary 是一个类组件,因此它必须被定义在类的范围内
  • 在 React 中,只有类组件才能成为高阶组件
  • ErrorBoundary 需要通过继承 React.Component 类来创建一个组件,因此必须使用类声明来定义

如果想在函数组件中使用 ErrorBoundary,可以考虑使用 React Hooks 中的 useErrorBoundary
下面是一个简单的 ErrorBoundary 的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}

static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI.
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// You can also log the error to an error reporting service
logErrorToMyService(error, errorInfo);
}
render() {
if (this.state.hasError) {
// You can render any custom fallback UI return
<h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
  • 可以像常规组件一样访问这个组件

例如:

1
2
3
<ErrorBoundary>
<MyWidget />
</ErrorBoundary>

已有的工具 react-error-boundary

直接使用工具

React 更新流程

通过改变 class 组件或者函数组件的状态来触发页面的更新的过程

  1. 用户事件(点击等)产生,执行回调
  2. 回调调用 setState,产生 update
  3. react 根据 update 创建渲染任务,在任务中进行 fiber 的 diff,最终会将 patch 更新到真实 DOM
  4. 渲染任务并非立即执行,会放进调度器队列中,经过调度器调度执行.

优先级

整个过程中都有优先级:

  1. 用户事件产生: 根据不同的事件类型执行回调,指定不同的事件优先级
  2. 状态改变,产生 update: 根据产生该 update 的事件优先级,计算 update 优先级
  3. react 创建渲染任务: 根据本次 update 以及未完成的工作计算渲染优先级,根据渲染优先级计算调度优先级
  4. 调度器调度执行: 根据挂载到 fiber 上的 update 优先级及渲染优先级来控制需要跳过的 fiber 及 update.

总体分为两类:

lane 优先级: 事件优先级,update 优先级,渲染优先级
调度优先级: 调度优先级.

JSX

JSX 即 JavaScript XML。一种在 React 组件内部构建标签的类 XML 语法。JSX 为 react.js 开发的一套语法糖

type:用于标识节点的类型。它可以是类似“h1”“div”这样的标准 HTML 标签字符串,也可以是 React 组件类型或 React fragment 类型。
config:以对象形式传入,组件所有的属性都会以键值对的形式存储在 config 对象中。
children:以对象形式传入,它记录的是组件标签之间嵌套的内容,也就是所谓的“子节点”“子元素”。

jsx 转 DOM 过程

  • 使用 React.createElement 或 JSX 编写 React 组件,实际上所有的 JSX 代码最后都会转换成 React.createElement(…) ,Babel 帮助我们完成了这个转换的过程。
  • createElement 函数对 key 和 ref 等特殊的 props 进行处理,并获取 defaultProps 对默认 props 进行赋值,并且对传入的子节点进行处理,最终构造成一个虚拟 DOM 对象
  • ReactDOM.render 将生成好的虚拟 DOM 渲染到指定容器上,其中采用了批处理、事务等机制并且对特定浏览器进行了性能优化,最终转换为真实 DOM

虚拟 DOM

Virtual DOM,即虚拟 DOM 节点.使用 JS 的 Object 对象模拟 DOM 中的节点.通过特定的 render 方法将其渲染成真实 DOM.比操作真实 DOM 减小性能开销.
操作虚拟 DOM 并不会比操作真实 DOM 快,只不过虚拟 DOM 可以将多次操作合并为一次,也就是减少 DOM 操作.
比如添加 1000 个节点,操作真实 DOM 要一个个添加,虚拟 DOM 可以一次性添加.
通过对比前后虚拟 dom,得出变化的部分,只修改有变化的 dom,节省性能.
虚拟 DOM 因为是对象,所以可以支持跨平台.

Diff 算法

传统的 Diff 算法也是一直都有的,diff 算法,会对比新老虚拟 DOM,记录下他们之间的变化,然后将变化的部分更新到视图上。其实之前的 diff 算法,是通过循环递归每个节点,然后进行对比,复杂程度为 O(n^3),n 是树中节点的总数,这样性能是非常差的。
dom-diff 的原理?
dom-diff 算法会比较前后虚拟 DOM,从而得到 patches(补丁),然后与老 Virtual DOM 进行对比,将其应用在需要更新的地方,将 O(n^3) 复杂度的问题转换成 O(n^1=n) 复杂度的问题,得到新的 Virtual DOM。
缺点: 在同级对比中,

降低时间复杂度的方法:

  1. 两个不同类型的元素会产生不同的树
  2. 对于同一层级的一组子节点,它们可以通过唯一 key 进行区分

React Diff

React 需要同时维护两棵虚拟 DOM 树: 一棵表示当前的 DOM 结构,另一棵在 React 状态变更将要重新渲染时生成.
React 通过对比这两棵树的差异,决定是否要修改 DOM 结构.以及如何修改,这就是 Diff 算法.
diff 算法的目的就是对比新旧两次结果,找到可复用的部分,然后剩下的该删除删除,该新增新增.

流程:

  1. React diff 是先对新旧两棵树做深度优先遍历,避免对两棵树做完全比较,因此算法复杂度 O(n).然后给每个节点生成唯一标识.
  2. 在遍历过程中,每遍历一个节点,就将新旧两棵树做对比,并且只对同一级别的的元素进行比较,记录差异,

策略

(1) tree diff

tree diff 主要针对跨层级操作,如果 A,D 两个节点同级,A 整体被移动到 D 作为子节点,那么并不是真正的移动,而是删除 A 节点,
在 D 处创建子节点 A,和 A 的子节点.

(2) Component diff

针对同一层级的 React 组件的 diff.

  • 如果是同类型的组件,就按照原策略继续比较虚拟 DOM 树.
  • 如果不是同类型,就销毁原组件,创建新组件,

(3) element diff

针对同一层级的所有节点.当节点处于同一层级,diff 提供三种操作,插入,移动,删除.
对同一层级的同组节点添加唯一的 key 进行区分.通过 key 来判断那些节点可以复用,直接使用移动操作.

为什么不能用 index 作为 key?

因为 index 会变,数组中如果删除中间某个值,它的 index 会被后面的替换.
key 只是在同层级唯一的,并不是在整个 React 环境下是唯一的.不然的话,复杂度太高.

Fiber 和虚拟 DOM 的关系

  • 在 React16 之前,react 直接递归渲染 VDom,setState 会触发重新渲染,对比渲染出的新旧 VDom,对差异部分进行 dom 操作.
  • 在 React16 之后,为了优化性能,会先把 vdom 转化成 fiber,也就是从树转化成连链表,然后再渲染.整体渲染流程分为两个部分:
    • render 阶段: 从虚拟 dom 转化成 fiber,对需要操作的节点打上 effectTag 标记.
    • commit 阶段: 对有 effectTag 标记的 fiber 节点进行 dom 操作,并执行所有的 effect 副作用函数.

从虚拟 dom 转化到 fiber 的过程叫 reconcile(调和),这个过程是可以打断的.由调度执行.

Fiber 和 diff 的关系

diff 算法作用在 reconcile 阶段:

  • 第一次渲染不需要 diff,直接 vdom 转 fiber.
  • 再次渲染时候会产生新的 vdom,这个时候需要和之前的 fiber 做对比,决定怎么产生新的 fiber,对可复用的节点打上修改的标记,剩余的旧节点打上删除标记,新节点打上新增标记.

React Diff 总结

react 是基于 vdom 的前端框架,组件渲染产生 vdom,渲染器把 vdom 渲染成 dom。
浏览器下使用 react-dom 的渲染器,会先把 vdom 转成 fiber,找到需要更新 dom 的部分,打上增删改的 effectTag 标记,这个过程叫做 reconcile,可以打断,由 scheducler 调度执行。reconcile 结束之后一次性根据 effectTag 更新 dom,叫做 commit。
这就是 react 的基于 fiber 的渲染流程,分成 render(reconcile + schedule)、commit 两个阶段。
当渲染完一次,产生了 fiber 之后,再次渲染的 vdom 要和之前的 fiber 对比下,再决定如何产生新的 fiber,目标是尽可能复用已有的 fiber 节点,这叫做 diff 算法。
react 的 diff 算法分为两个阶段:

  • 第一个阶段一一对比,如果可以复用就下一个,不可以复用就结束。
  • 第二个阶段把剩下的老 fiber 放到 map 里,遍历剩余的 vdom,一一查找 map 中是否有可复用的节点。
  • 最后把剩下的老 fiber 删掉,剩下的新 vdom 新增。

这样就完成了更新时的 reconcile 过程。
其实 diff 算法的核心就是复用节点,通过一一对比也好,通过 map 查找也好,都是为了找到可复用的节点,移动过来。然后剩下的该删删该增增。
理解了如何找到可复用的节点,就理解了 diff 算法的核心。

Fiber

为什么需要 Fiber

在 React 15 版本中采用的是 Virtual DOM 对比方案,通过对比 Virtual DOM 找出差异部分,从而只将差异部分更新到页面中,避免更新整体 DOM 以提高性能。
在 Virtual DOM 比对的过程中 React 使用了递归,递归调用的过程不能被终止,如果 Virtual DOM 的层级比较深,递归比对的过程就会长期占用主线程,而 JavaScript 又是单线程,不能同时执行多个任务,其他任务只能等待执行,而且 JavaScript 的执行和 UI 的渲染又是互斥的,此时用户要么看到的就是空白界面,要么就是有界面但是不能响应用户操作,处于卡顿状态,用户体验差。
核心就是递归比对的过程长期占用主线程产生了性能问题。

Fiber 如何解决这种问题

在 Fiber 架构中 React 放弃了递归调用,采用循环来模拟递归,因为循环可以随时被中断。
React 利用浏览器空闲时间执行比对任务, 解决了 React 执行比对任务长期占用主线程的问题。
因为采用了链表的结构,即使被中断了, 也可以从未完成处理的 Fiber 处继续遍历.
React 在执行完一个任务单元后,查看是否有其他的高优先级任务,如果有,放弃占用主线程,先执行优先级高的任务.

什么是 Fiber

  1. Fiber 是一个执行单元

React16 之前,将 VirtualDOM 树整体看成一个任务,进行递归处理,任务整体庞大,执行耗时且不能中断.
React16 开始,将整个任务分成一个个小的任务进行处理,每个小的任务就是 Fiber 节点的构建.
任务是在浏览器的空余时间执行,每个单元执行后,都会检查是否还有空余时间,如果有,就继续执行,没有就交还主线程的控制权.

  1. Fiber 是一种数据结构

Fiber 其实是一个 js 对象,对象中有 children 属性表示节点的子节点,有 sibling 属性表示节点的下一个兄弟节点,有 return 属性表示节点的父节点.

1
2
3
4
5
6
7
8
9
10
11
12
type Fiber = {
// 组件类型 div、span、组件构造函数
type: any,
// DOM 对象
stateNode: any,
// 指向自己的父级 Fiber 对象
return: Fiber | null,
// 指向自己的第一个子级 Fiber 对象
child: Fiber | null,
// 指向自己的下一个兄弟 iber 对象
sibling: Fiber | null,
};

image.png

Fiber 的工作方式

render 阶段: 构建 Fiber 对象,构建链表,在链表中标记要执行的 DOM 操作,可中断.
commit 阶段: 根据构建好的链表进行 DOM 操作,不可中断.

先从上向下走,构建节点对应的 Fiber 对象,然后再从下向上走,构建 Fiber 对象及链表

渲染阶段

Reconcilation(协调阶段)

从虚拟 DOM => Fiber 树的阶段,可以认为是 diff 阶段,这个阶段可以被中断.
这个阶段会找出所有节点变更,例如节点新增,删除,属性变更.(就是副作用)
在 Reconcilation 阶段的生命周期有:

  • constructor
  • static getDerivedStateFromProps
  • shouldComponentUpdate
  • render

Commit(提交阶段)

将上个阶段计算出来需要处理的副作用一次性执行.
这个阶段必须同步执行,不能被中断.
在 Commit 阶段的生命周期有:

  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

在 Reconcilation 阶段如果时间片用完,就会让出控制权。
因为协调阶段执行的工作不会导致任何用户可见的变更,所以在这个阶段让出控制权不会有什么问题。
需要注意的是:因为协调阶段可能被中断、恢复,甚至重做,React 协调阶段的生命周期钩子可能会被调用多次!, 例如 componentWillMount 可能会被调用两次。

现在你应该知道为什么’提交阶段’必须同步执行,不能中断的吧? 因为我们要正确地处理各种副作用,包括 DOM 变更、还有你在 componentDidMount 中发起的异步请求、useEffect 中定义的副作用… 因为有副作用,所以必须保证按照次序只调用一次

Fiber 实现所利用的 api

利用了 window 的一个 API: requestIdleCallback

1
2
3
requestIdleCallback(function (deadline) {
//deadline.timeRemaining() 获取浏览器的空余时间
});

兼容性

目前只有 chrome 支持 requestIdleCallback,React 为此自己实现一个.
使用 MessageChannel 模拟,将回调延迟到绘制操作之后执行

实现 Fiber

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
import React from "react";

const jsx = (
<div id="a1">
<div id="b1">
<div id="c1"></div>
<div id="c2"></div>
</div>
<div id="b2"></div>
</div>
);

const container = document.getElementById("root");

/**
* 1. 为每一个节点构建 Fiber 对象
* 2. 构建 Fiber 链表
* 3. 提交 Fiber 链接
*/

// 创建根元素 Fiber 对象
const workInProgressRoot = {
stateNode: container,
props: {
children: [jsx],
},
};

let nextUnitOfWork = workInProgressRoot;

function workLoop(deadline) {
// 如果下一个要构建的执行单元存在并且浏览器有空余时间
while (nextUnitOfWork && deadline.timeRemaining() > 0) {
// 构建执行单元并返回新的执行单元
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
// 如果所有的执行单元都已经构建完成
if (!nextUnitOfWork) {
// 进入到第二个阶段 执行 DOM 操作
commitRoot();
}
}
// Fiber 工作的第一个阶段
function performUnitOfWork(workInProgress) {
// 构建阶段向下走的过程
// 1. 创建当前 Fiber 节点的 DOM 对象并存储在 stateNode 属性中
// 2. 构建子级 Fiber 对象
beginWork(workInProgress);
// 如果子级存在
if (workInProgress.child) {
// 返回子级 构建子级的子级
return workInProgress.child;
}
// 开始构建阶段向上走的过程
// 如果父级存在
while (workInProgress) {
// 构建 Fiber 链表
completeUnitOfWork(workInProgress);
// 如果同级存在
if (workInProgress.sibling) {
// 返回同级 构建同级的子级
return workInProgress.sibling;
}
// 同级不存在 退回父级 看父级是否有同级
workInProgress = workInProgress.return;
}
}

function beginWork(workInProgress) {
// 如果 Fiber 对象没有存储其对应的 DOM 对象
if (!workInProgress.stateNode) {
// 创建 DOM 对象并存储在 Fiber 对象中
workInProgress.stateNode = document.createElement(workInProgress.type);
// 为 DOM 对象添加属性
for (let attr in workInProgress.props) {
if (attr !== "children") {
workInProgress.stateNode[attr] = workInProgress.props[attr];
}
}
}
// 创建子级 Fiber 对象
if (Array.isArray(workInProgress.props.children)) {
// 记录上一次创建的子级 Fiber 对象
let previousFiber = null;
// 遍历子级
workInProgress.props.children.forEach((child, index) => {
// 创建子级 Fiber 对象
let childFiber = {
type: child.type,
props: child.props,
return: workInProgress,
effectTag: "PLACEMENT",
};
// 第一个子级挂载到父级的 child 属性中
if (index === 0) {
workInProgress.child = childFiber;
} else {
// 其他子级挂载到自己的上一个兄弟的 sibling 属性中
previousFiber.sibling = childFiber;
}
// 更新上一个子级
previousFiber = childFiber;
});
}
}

function completeUnitOfWork(workInProgress) {
let returnFiber = workInProgress.return;

if (returnFiber) {
// 链头上移
if (!returnFiber.firstEffect) {
returnFiber.firstEffect = workInProgress.firstEffect;
}
// lastEffect 上移
if (!returnFiber.lastEffect) {
returnFiber.lastEffect = workInProgress.lastEffect;
}
// 构建链表
if (workInProgress.effectTag) {
if (returnFiber.lastEffect) {
returnFiber.lastEffect.nextEffect = workInProgress;
} else {
returnFiber.firstEffect = workInProgress;
}
returnFiber.lastEffect = workInProgress;
}
}
}

// Fiber 工作的第二阶段
function commitRoot() {
// 获取链表中第一个要执行的 DOM 操作
let currentFiber = workInProgressRoot.firstEffect;
// 判断要执行 DOM 操作的 Fiber 对象是否存在
while (currentFiber) {
// 执行 DOM 操作
currentFiber.return.stateNode.appendChild(currentFiber.stateNode);
// 从链表中取出下一个要执行 DOM 操作的 Fiber 对象
currentFiber = currentFiber.nextEffect;
}
}

// 在浏览器空闲的时候开始构建
requestIdleCallback(workLoop);

构建 Fiber 链表

  1. 链表的构建顺序

链表的顺序是由 DOM 操作的顺序决定的,c1 是第一个要执行 DOM 操作的所以它是链的开始,A1 是最后一个被添加到 Root 中的元素,所以它是链的最后。
image.png

  1. 如何向链的尾部添加新元素?

在链表结构中通过 nextEffect 存储链中的下一项。
在构建链表的过程中,需要通过一个变量存储链表中的最新项,每次添加新项时都使用这个变量,每次操作完成后都需要更新它。这个变量在源码中叫做 lastEffect。
lastEffect 是存储在当前 Fiber 对象的父级上的,当父级发生变化时,为避免链接顺序发生错乱,lastEffect 要先上移然后再追加 nextEffect

  1. 将链表保存在什么位置?

链表需要被保存在 Root 中,因为在进入到第二阶段时,也就是 commitRoot 方法中,是将 Root 提交到第二阶段的。
在源码中,Root Fiber 下有一个叫 firstEffect 的属性,用于存储链表。
在构建链表的遍历过程中,C1 开始,Root 是结尾,如何才能将 C1 存储到 Root 中呢?
其实是链头的不断上移做到的。
image.png

fiberRoot

fiberRoot 是整个应用的根节点相当于整棵 fiber 树的大脑,一方面可以作为存储载体,它记录了整个 firber 树的调度状态,以及渲染状态,更重要的是可以控制老 fiber 树和新 fiber 树的切换,从而达到双缓冲的效果。

rootFiber

代表 root 容器对应的 fiber 节点,作为渲染树的入口节点
image.png

beginwork

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function beginWork(fiber: Fiber): Fiber | undefined {
if (fiber.tag === WorkTag.HostComponent) {
// 宿主节点diff
diffHostComponent(fiber);
} else if (fiber.tag === WorkTag.ClassComponent) {
// 类组件节点diff
diffClassComponent(fiber);
} else if (fiber.tag === WorkTag.FunctionComponent) {
// 函数组件节点diff
diffFunctionalComponent(fiber);
} else {
// ... 其他类型节点,省略
}
}

宿主节点对比

1
2
3
4
5
6
7
8
9
10
11
12
13
function diffHostComponent(fiber: Fiber) {
// 新增节点
if (fiber.stateNode == null) {
fiber.stateNode = createHostComponent(fiber);
} else {
updateHostComponent(fiber);
}

const newChildren = fiber.pendingProps.children;

// 比对子节点
diffChildren(fiber, newChildren);
}

类组件节点对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function diffClassComponent(fiber: Fiber) {
// 创建组件实例
if (fiber.stateNode == null) {
fiber.stateNode = createInstance(fiber);
}

if (fiber.hasMounted) {
// 调用更新前生命周期钩子
applybeforeUpdateHooks(fiber);
} else {
// 调用挂载前生命周期钩子
applybeforeMountHooks(fiber);
}

// 渲染新节点
const newChildren = fiber.stateNode.render();
// 比对子节点
diffChildren(fiber, newChildren);

fiber.memoizedState = fiber.stateNode.state;
}

子节点对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
function diffChildren(fiber: Fiber, newChildren: React.ReactNode) {
let oldFiber = fiber.alternate ? fiber.alternate.child : null;
// 全新节点,直接挂载
if (oldFiber == null) {
mountChildFibers(fiber, newChildren);
return;
}

let index = 0;
let newFiber = null;
// 新子节点
const elements = extraElements(newChildren);

// 比对子元素
while (index < elements.length || oldFiber != null) {
const prevFiber = newFiber;
const element = elements[index];
const sameType = isSameType(element, oldFiber);
if (sameType) {
newFiber = cloneFiber(oldFiber, element);
// 更新关系
newFiber.alternate = oldFiber;
// 打上Tag
newFiber.effectTag = UPDATE;
newFiber.return = fiber;
}

// 新节点
if (element && !sameType) {
newFiber = createFiber(element);
newFiber.effectTag = PLACEMENT;
newFiber.return = fiber;
}

// 删除旧节点
if (oldFiber && !sameType) {
oldFiber.effectTag = DELETION;
oldFiber.nextEffect = fiber.nextEffect;
fiber.nextEffect = oldFiber;
}

if (oldFiber) {
oldFiber = oldFiber.sibling;
}

if (index == 0) {
fiber.child = newFiber;
} else if (prevFiber && element) {
prevFiber.sibling = newFiber;
}

index++;
}
}

双缓冲

React 中,WorkInProgress 树就是一个缓冲,它在 Reconciliation 完毕后一次性提交给浏览器进行渲染。它可以减少内存分配和垃圾回收,WorkInProgress 的节点不完全是新的,比如某颗子树不需要变动,React 会克隆复用旧树中的子树。

completeWork

副作用的收集和提交
将所有标记为 EffectTag 的节点串联起来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function completeWork(fiber) {
const parent = fiber.return;

// 到达顶端
if (parent == null || fiber === topWork) {
pendingCommit = fiber;
return;
}

if (fiber.effectTag != null) {
if (parent.nextEffect) {
parent.nextEffect.nextEffect = fiber;
} else {
parent.nextEffect = fiber;
}
} else if (fiber.nextEffect) {
parent.nextEffect = fiber.nextEffect;
}
}

提交所有副作用

1
2
3
4
5
6
7
8
9
10
11
12
13
function commitAllWork(fiber) {
let next = fiber;
while (next) {
if (fiber.effectTag) {
// 提交,偷一下懒,这里就不展开了
commitWork(fiber);
}
next = fiber.nextEffect;
}

// 清理现场
pendingCommit = nextUnitOfWork = topWork = null;
}

Concurrent Mode 优点

  • 快速响应用户操作和输入,提升用户交互体验
  • 让动画更加流畅,通过调度,可以让应用保持高帧率
  • 利用好 I/O 操作空闲期或者 CPU 空闲期,进行一些预渲染。 比如离屏(offscreen)不可见的内容,优先级最低,可以让 React 等到 CPU 空闲时才去渲染这部分内容。这和浏览器的 preload 等预加载技术差不多。
  • 用 Suspense 降低加载状态(load state)的优先级,减少闪屏。 比如数据很快返回时,可以不必显示加载状态,而是直接显示出来,避免闪屏;如果超时没有返回才显式加载状态。

React 性能优化

React.memo useMemo useCallback

  • React.memo 是包裹整个组件,浅层对比 props 的变化,决定是否渲染组件,
  • useMemo 是更加细粒度的进行性能优化.
  • useMemo 是返回一个 memoized 值,只有当依赖项变化才会触发重新渲染.
  • useMemo 和 useCallback 用法差不多,都是在第一次渲染的时候执行,然后在依赖项改变时再次执行,不同点在于,useMemo 返回缓存的变量,useCallback 返回缓存的函数

异步组件

Suspense 让组件’等待’某个异步操作,直到异步操作完成再渲染.
当数据还没加载完成会展示 suspense 中 fallback 的内容.

那么回到我们的异步组件上来,如果让异步的代码放在同步执行,是肯定不会正常的渲染的,我们还是要先请求数据,等到数据返回,再用返回的数据进行渲染,那么重点在于这个等字,如何让同步的渲染停止下来,去等异步的数据请求呢? 抛出异常可以吗? 异常可以让代码停止执行,当然也可以让渲染中止。
Suspense 就是用抛出异常的方式中止的渲染,Suspense 需要一个 createFetcher 函数会封装异步操作,当尝试从 createFetcher 返回的结果读取数据时,有两种可能:一种是数据已经就绪,那就直接返回结果;还有一种可能是异步操作还没有结束,数据没有就绪,这时候 createFetcher 会抛出一个“异常”。

这个“异常”是正常的代码错误吗? 非也,这个异常是封装请求数据的 Promise 对象,里面是真正的数据请求方法,既然 Suspense 能够抛出异常,就能够通过类似 componentDidCatch 的 try{}catch{} 去获取这个异常。

获取这个异常之后干什么呢? 我们知道这个异常是 Promise,那么接下来当然是执行这个 Promise,在成功状态后,获取数据,然后再次渲染组件,此时的渲染就已经读取到正常的数据,那么可以正常的渲染了。
接下来我们模拟一下 createFetcher 和 Suspense

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
*
* @param {*} fn 我们请求数据交互的函数,返回一个数据请求的Promise
*/
function createFetcher(fn) {
const fetcher = {
status: "pedding",
result: null,
p: null,
};
return function () {
const getDataPromise = fn();
fetcher.p = getDataPromise;
getDataPromise.then((result) => {
/* 成功获取数据 */
fetcher.result = result;
fetcher.status = "resolve";
});

if (fetcher.status === "pedding") {
/* 第一次执行中断渲染,第二次 */
throw fetcher;
}
/* 第二次执行 */
if (fetcher.status) return fetcher.result;
};
}
  • 返回一个函数,在渲染阶段执行,第一次组件渲染,由于 status = pedding 所以抛出异常 fetcher 给 Susponse,渲染中止。
  • Susponse 会在内部 componentDidCatch 处理这个 fetcher,执行 getDataPromise.then, 这个时候 status 已经是 resolve 状态,数据也能正常返回了。
  • 接下来 Susponse 再次渲染组件,此时,此时就能正常的获取数据了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export class Suspense extends React.Component {
state = { isRender: true };
componentDidCatch(e) {
/* 异步请求中,渲染 fallback */
this.setState({ isRender: false });
const { p } = e;
Promise.resolve(p).then(() => {
/* 数据请求后,渲染真实组件 */
this.setState({ isRender: true });
});
}
render() {
const { isRender } = this.state;
const { children, fallback } = this.props;
return isRender ? children : fallback;
}
}

用 componentDidCatch 捕获异步请求,如果有异步请求渲染 fallback,等到异步请求执行完毕,渲染真实组件,借此整个异步流程完毕

lazy 和 Suspense

异步加载组件
React.lazy 接受一个函数,这个函数需要动态调用 import()。它必须返回一个 Promise ,该 Promise 需要 resolve 一个 default export 的 React 组件。

1
2
3
4
5
6
7
8
9
const LazyComponent = React.lazy(() => import("./test.js"));

export default function Index() {
return (
<Suspense fallback={<div>loading...</div>}>
<LazyComponent />
</Suspense>
);
}

React.lazy 是如何配合 Susponse 实现动态加载的效果的呢?

实际上,lazy 内部就是做了一个 createFetcher,而上面讲到 createFetcher 得到渲染的数据,而 lazy 里面自带的 createFetcher 异步请求的是组件。lazy 内部模拟一个 promiseA 规范场景。我们完全可以理解 React.lazy 用 Promise 模拟了一个请求数据的过程,但是请求的结果不是数据,而是一个动态的组件。

createPortal

将 jsx 作为 children 渲染到 DOM 的不同部分,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<div>
<SomeComponent />
{createPortal(children, domNode, key?)}
</div>
//-----------------------
import { createPortal } from 'react-dom';

// ...

<div>
<p>这个子节点被放置在父节点 div 中。</p>
{createPortal(
<p>这个子节点被放置在 document body 中。</p>,
document.body
)}
</div>

portal 只改变 DOM 节点的所处位置。在其他方面,渲染至 portal 的 JSX 的行为表现与作为 React 组件的子节点一致。该子节点可以访问由父节点树提供的 context 对象、事件将从子节点依循 React 树冒泡到父节点。
portal 中的事件传播遵循 React 树而不是 DOM 树。例如点击

内部的 portal,将触发 onClick 处理程序。如果这会导致意外的问题,请在 portal 内部停止事件传播,或将 portal 移动到 React 树中的上层。

portal 允许组件将它们的某些子元素渲染到 DOM 中的不同位置。这使得组件的一部分可以“逃脱”它所在的容器。例如组件可以在页面其余部分上方或外部显示模态对话框和提示框。

逻辑复用

高阶组件 HOC

接收一个组件,返回一个新的组件。
接收的组件接受高阶组件的 props,也就是复用的 props 和方法。

render props

使用 children 属性作为传入参数。

自定义 hook

将复用的 state 和方法抽离到一个 hook 中,返回对应的 state 和方法。

将组件作为参数传递

大致有三种情况:

  1. 传递 jsx 元素
  2. 传递组件本身
  3. 传递返回 jsx 的回调

方式一

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function DownloadOutlined() {
return /* icon 的实现*/;
}

function Button({ icon, children }) {
return (
<button>
{icon}
{children}
</button>
);
}

function App() {
return <Button icon={<DownloadOutlined />}>test</Button>;
}

可以看出来,icon 直接传递了一个 jsx 创建好的组件,从而满足了用户自定义 icon 的需求。
相比于通过字符串枚举内置 icon, 给了用户更大的定制空间。

方式二

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function DownloadOutlined() {
return /* icon 的实现*/;
}

function Button({ icon: Icon, children }) {
return <button>
// 渲染方式进行了改变
<Icon />
{children}
</Button>
}

function App() {
return <Button icon={DownloadOutlined}>test</Button>
}

通过直接传递组件本身的方式,也可将其传递给子组件进行渲染,当然,子组件渲染的地方也改成了 而非上文的 {icon}。ps: 上文中由于 jsx 语法要求,将 icon 变量名改成了首字母大写的 Icon。

方式三

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function DownloadOutlined() {
return /* icon 的实现*/;
}

function Button({ icon, children }) {
return <button>
// 渲染方式进行了改变
{icon()}
{children}
</Button>
}

function App() {
return <Button icon={() => <DownloadOutlined />}>test</Button>
}

方案对比

当父组件发生渲染时,Button 组件是否会发生不必要的渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import React, { useState } from "react";

function DownloadOutlined() {
return <span>icon</span>;
}

const Button1 = React.memo(({ icon, children }) => {
console.log("button1 render");

return (
<button>
{icon}
{children}
</button>
);
});

const Button2 = React.memo(({ icon: Icon, children }) => {
console.log("button2 render");

return (
<button>
<Icon />
{children}
</button>
);
});

const Button3 = React.memo(({ icon, children }) => {
console.log("button3 render");
return (
<button>
{icon()}
{children}
</button>
);
});

export default function App() {
const [count, setCount] = useState(0);
console.log("App render");

return (
<>
<Button1 icon={<DownloadOutlined />}>button1</Button1>
<Button2 icon={DownloadOutlined}>button2</Button2>
<Button3 icon={() => <DownloadOutlined />}>button3</Button3>
<button onClick={() => setCount((pre) => pre + 1)}>render</button>
</>
);
}

可以看出,Button1 和 Button3 均进行了渲染,这是由于这两种方案下,icon 的参数发生了变化,对于 Button1, , 本质是 React.createElement(DownloadOutlined), 此时将会返回一个新的引用,就导致了 Button1 参数的改变,从而使得其会重新渲染。而对于 Button3,就更加明显,每次渲染后返回的箭头函数都是新的,自然也会引发渲染。而只有方案二,由于返回的始终是组件的引用,故不会重新渲染。
要避免(虽然实际中,99%的场景都不需要避免,也不会有性能问题)这种情况,可以通过加 memo 解决。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export default function App() {
const [count, setCount] = useState(0);
console.log("App render");

const button1Icon = useMemo(() => {
return <DownloadOutlined />;
}, []);

const button3Icon = useCallback(() => {
return <DownloadOutlined />;
}, []);

return (
<>
<Button1 icon={butto1Icon}>button1</Button1>
<Button2 icon={DownloadOutlined}>button2</Button2>
<Button3 icon={button3Icon}>button3</Button3>
<button onClick={() => setCount((pre) => pre + 1)}>render</button>
</>
);
}

实际的场景中,Icon 组件往往不会如此简单,往往会有一些参数来控制其比如颜色、点击行为以及大小等等,此时,要将这些参数传递给 Icon 组件,这也是笔者想要讨论的:
情况二:需要传递来自父组件(App)的参数的情况。
在现有的基础上, 以传递 size 到 Icon 组件为例,改造如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import React, { useState, useMemo, useCallback } from "react";

// 增加 size 参数, 控制 icon 大小
function DownloadOutlined({ size }) {
return <span style={{ fontSize: `${size}px` }}>icon</span>;
}

// 无需修改
const Button1 = React.memo(({ icon, children }) => {
console.log("button1 render");

return (
<button>
{icon}
{children}
</button>
);
});

// 增加 iconProps,来传递给 Icon 组件
const Button2 = React.memo(({ icon: Icon, children, iconProps = {} }) => {
console.log("button2 render");

return (
<button>
<Icon {...iconProps} />
{children}
</button>
);
});

// 无需修改
const Button3 = React.memo(({ icon, children }) => {
console.log("button3 render");
return (
<button>
{icon()}
{children}
</button>
);
});

export default function App() {
const [count, setCount] = useState(0);
const [size, setSize] = useState(12);
console.log("App render");

// 增加size依赖
const button1Icon = useMemo(() => {
return <DownloadOutlined size={size} />;
}, [size]);

// 增加size依赖
const button3Icon = useCallback(() => {
return <DownloadOutlined size={size} />;
}, [size]);

return (
<>
<Button1 icon={button1Icon}>button1</Button1>
<Button2 icon={DownloadOutlined} iconProps={{ size }}>
button2
</Button2>
<Button3 icon={button3Icon}>button3</Button3>
<button onClick={() => setCount((pre) => pre + 1)}>render</button>
<button onClick={() => setSize((pre) => pre + 1)}>addSize</button>
</>
);
}

通过上述改动,可以发现,当需要从 App 组件中,向 Icon 传递参数时,Button1 和 Button3 组件本身不需要做任何改动,仅仅需要修改 Icon jsx 创建时的参数即可,而 Button2 的 Icon 由于渲染发生在内部,故需要额外传递 iconProps 作为参数传递给 Icon。与此同时,render 按钮点击时,由于 iconProps 是个引用类型,导致触发了 Button2 的额外渲染,当然可以通过 useMemo 来控制,此处不再赘述。
接下来看情况三,当子组件(Button1 - button3)需要传递它自身内部的状态到 Icon 组件中时,需要做什么改动。
设想一个虚构的需求, Button1 - Button3 组件内部维护了一个状态,count,也就是每个组件点击的次数,而 DownloadOutlined 也接收一个参数,count, 随着 count 的变化,他的颜色会从 rbg(0, 0, 0) 变化为 rgb(count, 0, 0)。
DownloadOutlined 改动如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 增加 count 参数,控制 icon 颜色
function DownloadOutlined({ size = 12, count = 0 }) {
console.log(count);
return (
<span style={{ fontSize: `${size}px`, color: `rgb(${count}, 0, 0)` }}>
icon
</span>
);
}

const Button2 = React.memo(({ icon: Icon, children, iconProps = {} }) => {
console.log("button2 render");
const [count, setCount] = useState(0);

return (
<button onClick={() => setCount((pre) => pre + 40)}>
{/* 将count参数注入即可 */}
<Icon {...iconProps} count={count} />
{children}
</button>
);
});

const Button3 = React.memo(({ icon, children }) => {
console.log("button3 render");
const [count, setCount] = useState(0);

return (
// 此处为了放大颜色的改变,点击一次加 40
<button onClick={() => setCount((pre) => pre + 40)}>
{/* 将 count 作为参数传递给 icon 函数 */}
{icon({ count })}
{children}
</button>
);
});

export default function App() {
/* 省略 */

const button3Icon = useCallback(
(props) => {
// 接收参数并将其传递给icon组件
return <DownloadOutlined size={size} {...props} />;
},
[size]
);

/* 省略 */
}

而对于 button1, 由于 icon 渲染的时机,是在 App 组件中,而在 App 组件中,获取 Button1 组件内部的状态并不方便(可以通过 ref, 但是略显麻烦)。此时可以借助 React.cloneElement api 来新建一个 Icon 组件并将子组件参数注入,改造如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const Button1 = React.memo(({ icon, children }) => {
console.log("button1 render");
const [count, setCount] = useState(0);
// 借助 cloneElement 向icon 注入参数
const newIcon = React.cloneElement(icon, {
count,
});

return (
<button onClick={() => setCount((pre) => pre + 40)}>
{newIcon}
{children}
</button>
);
});

从这个例子可以看出,如果传入的组件(icon),需要获取即将传入组件(Button1, Button2, Button3)内部的状态,那么直接传递 jsx 创建好的元素,并不方便,因为在父组件(App)中获取子组件(Button1)内部的状态并不方便,而直接传递组件本身,和传递返回 jsx 创建元素的函数,前者由于元素真正的创建,就是发生在子组件内部,故可以方便的获取子组件状态,而后者由于是函数式的创建,通过简单的参数传递,即可将内部参数传入 icon 中,从而方便的实现响应的需求。
其中,三种方案,在不做 useMemo, useCallback 这样的缓存情况下,直接传递组件本身,由于引用不变,可以直接避免非必要渲染,但是当需要接收来自父组件的参数时,需要开辟额外的字段 iconProps 来接收父组件的参数,在不做缓存的情况下,由于参数的对象引用每次都会更新从而也存在不必要渲染的情况。当然,这种不必要的渲染,在绝大部分场景下,并不会存在性能问题。
考虑了来自父组件的传参后,除了方案二直接传递组件本身的方案需要对子组件增加 iconProps 之外,其余两个方案由于 jsx 创建组件元素的写法本身就在父组件中,只需稍作改动即可将参数携带入 Icon 组件中。
而当需要接收来自子组件的参数场景下,方案一显得略有不足,jsx 的创建在父组件已经创建好,子组件中需要注入额外的参数相对麻烦(使用 cloneElement 实现参数注入)。而方案三由于函数的执行时机是在子组件内部,可以很方便的将参数通过函数传参带入 Icon 组件,可以很方便的满足需求。

从实际开发组件的场景来看,被作为参数传递的组件需要使用子组件内部参数的,一般通过方案三传递函数的方案来设计,而不需要子组件内部参数的,方案一二三均可,实际的开销几乎没有差异,只能说方案一写法较为简单,也是 antd 的 api 中最常见的用法。而方案三,多见于需要子组件内部状态的情况,比如 antd 的面包屑 itemRender,Form.list 的 children 的渲染,通过函数注入参数给被作为参数传递的组件方便灵活的进行渲染。

Jira

参数解构

用于简化浅拷贝操作

1
onChange={e => setParam(...param, e.target.value)}

fetch 方法使用

fetch 方法返回的是异步的可以使用 async/await

1
2
3
4
5
fetch("").then(async (res) => {
if (res.ok) {
setList(await res.json());
}
});

避免修改原值

对于修改某个值,可以先将该值拷贝,再修改拷贝值,将修改后的拷贝值抛出,避免修改原值.

1
2
3
4
5
removeIndex: (index) => {
const copy = [...value];
copy.splice(index, 1);
setValue(copy);
};

自定义 Hook

注意点:

  1. hooks 要放在最外层,层级要求最高.
  2. 不要在循环,条件,嵌套中使用 Hook,确保它在最顶层.

只在 React 函数中调用 Hook,不要在 js 函数中调用.

挂载时 Hook

1
2
3
4
5
const useMount = (callback) => {
useEffect(() => {
callback();
}, []);
};

防抖 Hook

1
2
3
4
5
6
7
8
9
10
11
12
// 参数一,需要防抖的值,可以是任意类型
const useDebounce = <V>(value: V, delay?: number): any => {
const [debounceValue, setDebounceValue] = useState();

useEffect(() => {
//挂载时设置定时器
const timeout = setTimeout(() => setDebounceValue(value), (delay = 1000));
//卸载时,清除定时器
return () => cleatTimeout(timeout);
//设置的依赖用于变化后重新执行
}, [value, delay]);
};

Hooks 中的闭包陷阱

以设置网站标题为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const useDocument = (title:string, keepOnUnmount?:boolean=true) => {
// 拿到文档当前的title
const oldTitle = React.useRef(document.title).current()
// 挂载时,将传入title赋值
useEffect(()=>{
document.title = title
},[])
// 卸载时,判断是否有保持传入title的参数
useEffect(()=>{
//这里闭包里保存的oldTitle没有变化
return () => {
if(!keepOnUnmount) {
document.title = oldTitle
}
},[keepOnUnmount,oldTitle])
}

闭包陷阱

“闭包陷阱” 最大的问题就是在函数数内无法获取的最新的 state 的值.
函数式组件每次 render 都会生产一个新的 log 函数,这个新的 log 函数会产生一个在当前这个阶段 value 值的闭包。
log 方法内的 value 和点击触发时的 value 相同,value 的后续变化对 log 内的 value 不造成影响.
如何解决:

  1. 使用 useRef

原理: useRef 每次 render 时都会返回同一个引用类型的对象.设置值和读取值都在这个对象上处理.

  1. useState 更新值时传入回调函数

**setValue**(value => value + 1)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const FunctionComponent = () => {
const [value, setValue] = useState(1);
const log = () => {
setTimeout(() => {
alert(value);
}, 1000);
};
return (
<div>
<p>FunctionComponent</p>
<div>value: {value}</div>
<button onClick={() => setValue(value + 1)}>add</button>
<br />
<button onClick={log}>alert</button>
</div>
);
};

副作用

副作用(Side Effect)是指函数或者表达式的行为依赖于外部世界。

useMemo 和 useCallback

而 useCallback 就是一个特殊版本的 useMemo,专门来处理函数的
案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import "./styles.css";
import React from "react";

export default function App() {
const [count, setCount] = React.useState(0); // 加了这一行
const value = { name: 1 };

React.useEffect(() => {
setCount(Math.random()); // 加了这一行
("render");
}, [value]);
return (
<div className="App">
<h1>Hello CodeSandbox</h1>
<h2>Edit to see some magic happen!</h2>
</div>
);
}

这里循环的原因是:组件渲染 → useEffect 执行 → setCount 触发循环 → 组件渲染 → useEffect 执行 → setCount 触发循环…
注意是组件渲染之后,刷新的是 value,和上一个不是同一个 value.那就需要给它套上 useMemo.

1
2
3
const value = React.useMemo(() => {
return { name: 1 };
}, []);

useMemo 的意思就是:不要每次渲染都重新定义,而是我让你重新定义的时候再重新定义(第二个参数,依赖列表)。大家看到这里的依赖列表是空的,是因为 useMemo 里的回调函数确实没用到啥变量,如果有变量的话大家的 IDE 就会提醒加上依赖了。
这就是使用 useMemo 的原理,useMemo 适用于所有类型的值,加入这个值恰好是函数,那么用 useCallback 也可以。也就是说,useCallback 是一种特殊的 useMemo。
使用场景:

  1. 它不是状态,也就是说,不是用 useState 定义的(redux 中的状态实际上也是用 useState 定义的)
  2. 它不是基本类型
  3. 它会被放在 useEffect 的依赖列表里 || 自定义 hook 的返回值

自定义 hook 的返回值也成立是因为,你不知道自定义 hook 的返回值将会被用在哪里,它可能会被用在依赖也可能不会,所以干脆都加上;而像上面那个在组件中定义的 value,你就可以见机行事了

自定义 Hook

自定义 Hook 是目前最好的重用 React 逻辑的方法,它和普通的函数很像很像,自定义 Hook 的特殊之处在于,它是有状态的,它返回的也是状态。所以在什么时候我们应该用到自定义 Hook?那就是,我们想要抽象出处理状态的逻辑的时候

状态管理

大概可以分为三种场景
简单场景:使用状态提升,组合组件
使用缓存管理:react-query
使用客户端状态管理:redux, context, url

关于全局使用的状态操作

方法一: 可以使用 redux 设置一个 state,设置对应的 reducer,
一个同步设置状态,一个异步 thunk.
方法二: 使用缓存,react-query.

useContext

给整个 app 设置登录注册用户信息相关的 context.将 app 包裹,就可以随时验证登录状态.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
//1.创建Context
interface IContext {
user: User | null;
login: (form:AuthForm) => Promise<void>;
register: (form:AuthForm) => Promise<void>;
logout: () => Promise<void>;
}
interface AuthForm {
username: string;
password: string;
}
const AuthContext = createContext<IContext| undefined>(undefined);
// 2.设置Context的名字
AuthContext.displayName = 'AuthContext'
// 3. 设置provider包裹整个app组件
const AuthProvider = ({children}:{children: ReactNode}) => {
// 各种在登录或者要验证用户信息的方法
// 设置报错反馈
return (<AuthContext.Provider children={children} value={/*各种方法的变量*/} />)
}
// 4. 使用自定义Hook设置调用方法
const useAuth = () => {
const context = useContext(AuthContext)
if(!context){
throw new Error('useAuth必须在AuthProvider中使用')
}
return context
}
// 5. 将AuthProvider和父级的Provider合并
export const AppProvider = ({ children }: { children: ReactNode })) => {
return (
<QueryClientProvider client={new QueryClient()}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
)
}
// 6. 在index.ts文件将整个app组件包裹
<AppProviders>
<DevTools />
<App />
</AppProviders>

redux

react-query

这是一个适用于 React Hooks 的请求库,这个库将帮助你获取、同步、更新和缓存你的远程数据, 提供两个简单的 hooks,就能完成增删改查等操作,React-Query 使用声明式管理服务端状态,可以使用零配置开箱即用地处理缓存,后台更新和陈旧数据。

React-Query 封装了完整的请求中间状态(isLoadingisError…)。
不仅如此,React-Query 还为我们做了如下工作:
多个组件请求同一个 query 时只发出一个请求
缓存数据失效/更新策略(判断缓存合适失效,失效后自动请求数据)
对失效数据垃圾清理
数据的 CRUD 由 2 个 hook 处理:
useQuery处理数据的查
useMutation处理数据的增/删/改

乐观更新

更新操作后,在数据返回前可以先乐观的更新数据,但是有的情况下会有失败的可能.这时候再选择回滚更新.
useMutationonMutate 回调允许返回一个特定值,该值稍后将作为最后一个参数传递给 onErroronSettled 处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const queryClient = useQueryClient();

useMutation(updateTodo, {
// 当 mutate 调用时
onMutate: async (newTodo) => {
// 撤销相关的查询(这样它们就不会覆盖我们的乐观更新)
await queryClient.cancelQueries(["todos"]);

// 保存前一次状态的快照
const previousTodos = queryClient.getQueryData(["todos"]);

// 执行"乐观"更新
queryClient.setQueryData(["todos"], (old) => [...old, newTodo]);

// 返回具有快照值的上下文对象
return { previousTodos };
},
// 如果修改失败,则使用 onMutate 返回的上下文进行回滚
onError: (err, newTodo, context) => {
queryClient.setQueryData(["todos"], context.previousTodos);
},
// 总是在错误或成功之后重新获取:
onSettled: () => {
queryClient.invalidateQueries("todos");
},
});

项目案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { QueryKey, useQueryClient } from "react-query";
import { Task } from "types/task";
import { reorder } from "./reorder";

export const useConfig = (
queryKey: QueryKey,
callback: (target: any, old?: any[]) => any[]
) => {
const queryClient = useQueryClient();
return {
// 第二个参数即在成功获取数据后刷新数据
onSuccess: () => queryClient.invalidateQueries(queryKey),
// 配置乐观更新
onMutate: async (target: any) => {
const previousItems = queryClient.getQueryData(queryKey);
queryClient.setQueryData(queryKey, (old?: any[]) => {
return callback(target, old);
});
return {
previousItems,
};
},
// 如果出错就将原数据回滚
onError: (error: any, newItem: any, context: any) => {
queryClient.setQueryData(queryKey, context.previousItems);
},
};
};

fetch 与 axios 区别

使用 fetch 封装,注意和 axios 的区别,无法捕获非网络异常导致的 error.必须手动 Promise.reject()抛出.

1
2
3
4
5
6
const data = await res.json();
if (res.ok) {
return data;
} else {
return Promise.reject(data);
}

无感登录

在挂载时,设置方法.获取 token,更新 user 信息.没有 token 后端返回 401 就跳转到登录页.

1
2
3
4
5
6
7
8
9
const bootstrapUser = async () => {
let user = null;
const token = auth.getToken();
if (!token) {
const data = await http("me", { token });
user = data.user;
}
return user;
};

乐观更新

自定义 Hook 异步操作

自带 loading,错误处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import { useState } from "react";

interface State<D> {
error: Error | null;
data: D | null;
stat: "idle" | "loading" | "error" | "success";
}

const defaultInitialState: State = {
stat: "idle",
data: null,
error: null,
};

export const useAsync = <D>(initialState?: State<D>) => {
const [state, setState] = useState<State<D>>({
...defaultInitialState,
initialState,
});

//设置data说明状态成功
const setData = (data: D) =>
setState({
data,
error: null,
stat: "success",
});
//设置data说明状态失败
const setError = (error: Error) =>
setState({
error,
data: null,
stat: "error",
});
// 接收异步
const run = (promise: Promise<D>) => {
// 如果不是promise类型报错
if (!promise || !promise.then) {
throw new Error("请传入Promise类型数据");
}
// 如果是,刚开始是loading状态
setState({ ...state, stat: "loading" });
// 最后返回promise
return promise
.then((data) => {
setData(data);
return data;
})
.catch((error) => {
setError(error);
// 注意catch会捕获error,不主动抛出就不能继续往下传递
return Promise.reject(error);
});
};
// 将所有信息暴露
return {
isIdle: state.stat === "idle",
isLoading: state.stat === "loading",
isError: state.stat === "error",
isSuccess: state.stat === "success",
run,
setData,
setError,
...state,
};
};

错误边界

捕获错误显示备用 UI,而不是整个组件崩溃.
无法捕获:

  1. 事件处理
  2. 异步代码 (例如 setTimeout 或 requestAnimationFrame 回调函数)
  3. 服务端渲染
  4. 错误边界自身抛出来的错误 (而不是其子组件)

如果一个类组件定义了生命周期方法中的任何一个(或两个)static getDerivedStateFromError()componentDidCatch(),那么它就成了一个错误边界。
使用static getDerivedStateFromError()在抛出错误后渲染回退 UI。 使用 componentDidCatch() 来记录错误信息。

实现方法

需要使用类组件声明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import React from "react";

type FallBackRender = (props: { error: Error | null }) => React.ReactElement;

export class ErrorBoundary extends React.Component<
React.PropsWithChildren<{ fallbackRender: FallBackRender }>,
{ error: Error | null }
> {
state = { error: null };
// 当子组件抛出异常,这里会接收到并且调用
static getDerivedStateFromError(error: Error) {
return { error };
}

render() {
const { error } = this.state;
const { fallbackRender, children } = this.props;
if (error) {
return fallbackRender({ error });
}
return children;
}
}

然后包裹整个组件树

类型守卫

类型守卫(当符合条件时,value 就是 Error 类型)

1
const isError = (value: any): value is Error => value?.message;

React Router

在要使用的页面构建 Router 的 Context.

路由参数 Hook

由此设置依据 url 返回参数进行数据传递.
作用:返回页面 URL 中指定键的参数值.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const useUrlQueryParam = <K extends string>(keys: K[]) => {
const [searchParams, setSearchParams] = useSearchParams()

return [
//使用useMemo优化这个方法
useMemo(() =>
keys.reduce((prev, key) => {
return {
...prev, [key]:searchParams.get('key') || ''
}
},{} as {[key in K]: string})
,[keys,searchParams]),
// 返回setSearchParams,约束入参
(params: Partial<[key in K]: unknown>) =>setSearchParams(params)
] as const
}

Shop 项目

使用 reduxjs/toolkit 中的 redux.
reducer 采用切片的方法.

1
2
3
4
5
6
7
8
// store/index.ts
// 设置根reducer对象
export const rootReducer = {
auth: authSlice.reducer,
};
export const store = configureStore({
reducer: rootReducer,
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// auth.slice.ts
export const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
setUser(state,action) {
state.user = action.payload
}
}
})

const {setUser} = authSlice.actions;

export const selectUser = (state:RootState) => state.auth.user
export const login = (form:AuthForm) => (dispatch:AppDispatch) =>auth.login(form).then(user => dispatch(setUser(user))

记得使用 Provider 将 Context 包裹

使用场景

  1. 拆分巨型应用,使得应用更好维护
  2. 兼容历史应用,实现增量开发

方案

可以选择的样式方案根据写法主要分为三种:第一种是常规 CSS(regular CSS),即原生 CSS 和各种预处理语言;第二种是在 JS 侧写样式的 CSS in JS 方案,例如 styled-components;第三种是在 HTML 中写工具类,由 CSS 框架生成对应样式的方案,例如 Tailwind CSS 。

CSS Modules

CSS Modules 是解决命名空间问题的一种方案,它可以基于指定的规则生成选择器名称,无需开发者遵守严格的规范,同时也避免对全局样式造成污染。

以下是一个简单的例子,原始代码是这样的:

1
2
3
.test {
color: red;
}
1
2
3
import styles from "index.less";
// ...
<div className={styles.test} />;

经过转换后,成为了这样:

1
2
3
4
5
6
7
._xxxxxx {
color: red;
}

var modules_xxx = {"test":"_xxxxxx"};
// ...
<div className={modules_xxx['test']} />

通过对选择器添加 hash 值等方法,使选择器不会与其他地方产生冲突。

emotion

可以模块化 css

1
yarn add @emotion/styled @emotion/react

写法,注意首字母大写

1
2
3
4
5
6
7
8
const Container = styled.div`
display: flex;
`;

// 注意styled后只能跟原生元素,如果要修改antd,加括号
const ShadowCard = styled(Card)`
width: 40rem;
`;

antd

老版本 antd 和新版本 react-script 有冲突会报 less 警告.可以将引入 antd.css 改为 antd.min.css 即可解决.

Grid 布局和 flex 布局

区别:

  1. 一维布局: flex;二维布局: grid;
  2. 从内容出发用 flex,布局出发用 grid.

内容出发:先有一组数据,数量不固定,希望均匀分布在容器中,由内容自己的大小占据空间.
布局出发: 先规划网格,再把元素往里面填充.

v6 版本相对比前面有较大修改

安装

1
yarn add react-router-dom

基本使用

常用方法

Routes

新版本 Routes 替代了 Switch,并且 Routes 和 Route 必须关联,
Route 内可以直接嵌套子路由.子路由的位置可以由Outlet组件占位.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { Routes , Route , Outlet  } from 'react-router
import { BrowserRouter } from 'react-router-dom'
const index = () => {
return <div className="page" >
<div className="content" >
<BrowserRouter >
<Menus />
<Routes>
<Route element={<Home />}
path="/home"
></Route>
<Route element={<List/>}
path="/list"
></Route>
<Route element={<Layout/>}
path="/children"
>
<Route element={<Child1/>}
path="/children/child1"
></Route>
<Route element={<Child2/>}
path="/children/child2"
></Route>
</Route>
</Routes>
</BrowserRouter>
</div>
</div>
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Layout渲染二级路由
function Container() {
// 这里Outlet的占位功能,声明了子路由的渲染位置
return (
<div>
{" "}
<Outlet />
</div>
);
}
/* 子路由菜单 */
function Menus1() {
return (
<div>
<Link to={"/children/child1"}> child1 </Link>
<Link to={"/children/child2"}> child2 </Link>
</div>
);
}

function Layout() {
return (
<div>
这里是 children 页面
<Menus1 />
<Container />
</div>
);
}

路由状态和页面跳转

1
<Link to="/invoices">Invoices</Link>

Link 避免了重新渲染。Link 只会触发相匹配路由页面的更新,不会刷新整个页面。
Link 所做的事:

  • 有 onClick 就执行 onClick
  • click 的时候阻止 a 标签默认事件
  • 根据跳转的 href,使用 history 跳转,只是链接变了,并不刷新整个页面

useLocation

可以使用 useLocation 获取到路由状态 location 的信息,这里保存了hash | key | pathname | search | state等状态.

useNavigate

1
2
3
4
5
6
7
8
function Home() {
const navigate = useNavigate();
return <>
<button onClick={() => navigate('/list', { state: 'alien'})}>
跳转列表页
</button>
<>
}

navigate第一个参数是路径,第二个是路由状态信息,可以传递state等信息.

useParams

动态路由

通过useParams获取 url 上的动态路由信息

1
<Route element={<List />} path="/list/:id" />

跳转动态路由:

1
<button onClick={() => navigate("/lsit/1")}>跳转列表页</button>

获取动态参数

1
2
3
4
5
function List() {
const params = useParams();
console.log(params, "params"); // {id: '1'}
return <div>React yes!</div>;
}

useSearchParams

用于 url 参数信息获取或设置.
const [searchParams, setSearchParams] = useSeatchParams()
方法中 searchParams 可以获取 params 中的 url 信息,第二个可以设置.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Index() {
const [searchParams, setSearchParams] = useSeatchParams();
const name = search.get("name");
return (
<>
<button
onClick={() => {
setSearchParams({ name: "alien", age: 22 });
}}
>
设置params
</button>
</>
);
}

区别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const Detail = (props) => {
// 方式1:通过search传递参数,比较复杂,不推荐,
//需要用解构赋值获得search对象,然后调用get函数获取对应的key的值
// const [search] = useSearchParams()
// const detail = { id: search.get('id'), title: search.get('title') }

// 方式2:通过params参数传递,非常简单直接使用useParams()
// const detail = useParams()

// 方式3:通过state传递参数
// const { state: detail } = useLocation();
// console.log("此处可以收到来自route的state参数:", detail);

//方式1+plus:自定义的钩子根据传入的key数组拿到search中的数据并包装成对象
const detail = useUrlQueryParam(["id", "title"]);
console.log("获取到对象:", detail);

return (
<ul>
<li>ID:{detail.id}</li>
<li>TITLE:{detail.title}</li>
<li>
CONTENT:{detail.title}+{detail.id}
</li>
</ul>
);
};

export default Detail;

//js下极简的封装
export const useUrlQueryParam = (keys) => {
const [search] = useSearchParams();
//遍历keys,从search中获取对应的值,返回一个新对象
const query = keys.reduce((acc, key) => {
acc[key] = search.get(key);
return acc;
}, {});
return query;
};

useRoutes

用于自由配置路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
const routeConfig = [
{
path: "/home",
element: <Home />,
},
{
path: "/list/:id",
element: <List />,
},
{
path: "/children",
element: <Layout />,
children: [
{ path: "/children/child1", element: <Child1 /> },
{ path: "/children/child2", element: <Child2 /> },
],
},
];

const Index = () => {
const element = useRoutes(routeConfig);
return (
<div className="page">
<div className="content">
<Menus />
{element}
</div>
</div>
);
};

const App = () => (
<BrowserRouter>
<Index />
</BrowserRouter>
);

疑难问题

axios 内部只是不能调用 hook,因为 hooks 的定义就是只能在函数组件顶层或其他 hook 内部调用。
解决办法:先在 axios 方法外调用 useNavigate 钩子得到 nav 方法,然后在 axios 回调里用 nav 方法进行路由跳转

Navigate 组件应该是放在业务组件里。比如路径/a 要跳转到路径/b,则是放在路径/a 对应的 A 组件里,不是放在 routes 里面

原理

BrowserRouter

  • 通过 createBrowserHistory 创建 history 对象,并通过 useRef 保存 history 对象。
  • 通过 useLayoutEffect 来监听 history 变化,当 history 发生变化(浏览器人为输入,获取 a 标签跳转,api 跳转等 )。派发更新,渲染整个 router 树。这是和老版本的区别,老版本里面,监听路由变化更新组件是在 Router 中进行的。
  • 还有一点注意的事,在老版本中,有一个 history 对象的概念,新版本中把它叫做 navigator 。

不使用

exact

取消了,所有路由默认都是严格匹配,如果想模糊匹配,可以在路由后加*

新增数据路由

主要是针对数据的新增一些 api。
比如 createBrowser 之类。
loader 方法,在路由加载之前可以用来请求数据。
errorElement:错误组件,路由通过 useRoutesError 返回错误信息,可以调用这个组件。只在使用数据路由时生效。

区域划分

git 初始化后。区域分为工作区,暂存区,版本库。
工作区:编写代码的区域。
暂存区:将代码暂时保存的区域,命令git add .
版本库:即本地仓库,代码在本地保存的地方。命令git commit -m 'xxx'
远程仓库:即线上仓库。一般关联后,通过git push推送到远程仓库。通过git pull从远端拉取。

新建仓库推送步骤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 本地git初始化
git init

// 创建.gitignore文件
touch .gitignore

// 提交文件到暂存区
git add .

// 提交提交信息到版本库
git commit -m 'feat: first commit'

// 关联远程仓库
git remote add origin https://github.com/zaxtseng/xxx.git

// 本地分支改名或者不改也行
git branch -M main
// 推送到远程分支(没改名)
git push -u origin master
// 改名了就推送到改名的分支
git push -u origin main

新建分支并推送

1
2
3
4
// 创建并切换
git checkout -b newBranch
// 推送到远程同名
git push origin newBranch:newBranch

切换分支

1
2
3
4
5
git checkout dev
# 2.23以后新版本命令
git switch dev
# 创建并切换
git switch -c test

删除远程分支

1
2
3
4
5
6
7
8
9
10
// 推送空分支到远程就删除了
git push origin :newBranch

//delete删除
git push origin --delete newBranch

// 对于不想改名,本地是master分支,远程是main分支的情况
// 直接推送到远程master分支就会新建一个master分支并且删除main分支了
// 推送到远程分支(没改名)
git push -u origin master

删除本地分支

1
2
3
4
// 只有推送后才会删除
git branch -d master
// 强制删除
git branch -D master

查看所有分支

1
git branch -a

切换远程分支

git checkout -b 本地分支名 origin/远程分支名

1
2
3
4
5
6
7
8
9
10
11
# 查看所有分支
git branch -a
# 显示所有分支
* dev
master
remotes/origin/HEAD -> origin/master
remotes/origin/master
remotes/origin/release/caigou_v1.0

# 切换到本地分支并且映射到远程指定分支
git checkout -b dev origin/release/caigou_v1.0

新建本地项目推送远程仓库

git 初始化

1
git init

git 暂存提交

1
2
git add .
git commit -m 'first'

关联远程仓库

1
2
3
4
git remote add origin git@github.com:xxx/xxx.git

# 当前分支关联远程分支
git branch --set-upstream-to=origin/dev dev

修改主分支名称

只有 github 是主分支 main,其他都是 master.

1
2
//强制修改当前分支为main
git branch -M main

推送到远程并设置对应关系

1
2
3
4
git push -u origin main

// 强制执行(注意会覆盖之前的)
git push -u origin master -f

如果之前创建了 Readme

大概率会推送失败,需要先拉取远程的 readme.md

1
2
3
git pull origin main
// 如果拉取报错
git pull origin main --allow-unrelated-histories

再执行上面的推送远程

撤销合并

1
git merge --abort //回滚到合并之前

版本回退

适用场景: 如果想恢复到之前某个提交的版本,且那个版本之后提交的版本我们都不要了,就可以用这种方法。

1
2
git reset --hard 版本名
// hash名通过git log查找

使用“git push -f”强制提交更改:
此时如果用“git push”会报错,因为我们本地库 HEAD 指向的版本比远程库的要旧.

此方法就可以用于被其他人覆盖代码导致代码消失.

版本删除

原理: git revert 是用于“反做”某一个版本,以达到撤销该版本的修改的目的。比如,我们 commit 了三个版本(版本一、版本二、 版本三),突然发现版本二不行(如:有 bug),想要撤销版本二,但又不想影响撤销版本三的提交,就可以用 git revert 命令来反做版本二,生成新的版本四,这个版本四里会保留版本三的东西,但撤销了版本二的东西

1
git revert -n 版本名

git 使用 dev 覆盖本地及远程 master 分支
1、切换到 dev 分支:

1
git checkout dev

2、删除本地的 master 分支:

1
git branch -D master

3、将 dev 分支复制到本地的 master 分支:

1
git checkout -b master

4、推送到远程并覆盖仓库的 master 分支:

1
git push -u origin master --force

git 远程分支强制覆盖本地的分支
1、下载代码到本地

1
git fetch --all

2、将 HEAD 指向最新下载的版本

1
git reset --hard origin/分支名

git fetch

  • git fetch 是将远程主机的最新内容拉到本地,用户在检查了以后决定是否合并到工作本机分支中。
  • 而 git pull 则是将远程主机的最新内容拉下来后直接合并,即:git pull = git fetch + git merge,这样可能会产生冲突,需要手动解决。

git rebase

目的是改变提交的基底.从而实现之前的提交记录都是一条。

git rebase 和 git merge 区别

二者的目的都是将代码合并.但是 git rebase 的提交记录更整洁.

1
2
3
4
# 从master的提交A创建了新分支feature,并有两条提交记录
D---E feature
/
A---B---C master

使用 merge

  • 切换到 feature 分支: git checkout feature。
  • 合并 master 分支的更新: git merge master。
  • 新增一个提交 F: git add . && git commit -m “commit F” 。
  • 切回 master 分支并执行快进合并: git chekcout master && git merge feature。

下面提交历史可以看到,提交历史有两条线,一条是 master 的提交记录,一条是 feature 的.

1
2
3
4
5
6
7
8
9
10
* 6fa5484 (HEAD -> master, feature) commit F
* 875906b Merge branch 'master' into feature
|\
| | 5b05585 commit E
| | f5b0fc0 commit D
* * d017dff commit C
* * 9df916f commit B
|/
* cb932a6 commit A

使用 rebase

就是第二条命令换为 git rebase master.
而 rebase 的提交记录,只有一条线,rebase 保存了 DE 的修改,把基底从 A 挪到了 C,
实际上是复制了 DE 的提交,把之前的丢弃,再从 C 处重新新增的。所以 id 和之前的不一样。

本地仓库可以使用,线上仓库不要使用,因为id变化了,历史记录变化了,有可能有问题。
1
2
3
4
5
6
* 74199ce (HEAD -> master, feature) commit F
* e7c7111 commit E
* d9623b0 commit D
* 73deeed commit C
* c50221f commit B
* ef13725 commit A

具体操作

1
2
3
4
5
6
7
git checkout feature
# 修改基底为最新的master 的最新的提交
git rebase master
# 提交
git add . && git commit -m "commit F"
# 切换到master合并feature的代码
git chekcout master && git merge feature

git 撤销

git 版本撤销(回滚)

已经提交到线上仓库了。
可以使用git reset --hard HEAD

1
2
3
4
5
6
# 回滚到上一个版本
git reset --hard HEAD^
# 回退到指定版本可以先通过git log查到某个版本的id
git log
# 指定回退的版本id
git reset --hard HEAD abc123

git 撤销某个文件的修改

1
2
git restore file.js
git checkout -- file.js

git 撤销暂存区文件添加

相当于 git add 的反操作。

1
2
3
4
# git@2.23之后新版本操作命令
git restore --staged file.js
# 或者使用git reset
git reset HEAD file.js

文件删除

对于工作区的删除与撤销删除。
下面命令只针对,删除文件的操作保存到暂存区了。

1
2
3
4
5
6
7
8
9
10
11
# 删除某个文件
rm file3.js
# 提交到暂存区
git add file3.js
# 提交到版本库(本地仓库)
git commit -m "删除file3"

# 撤销删除file3
git checkout -- file3.js
# 新版本命令
git restore file3.js

git reflog(回流记录)

对于回滚后的记录,没有回滚之前的记录。想查看之前的使用这个命令。
查到对应的 ID,使用 git reset 回滚到对应的 id 版本。

git reset、checkout、revert

命令 范围 常见用例
git reset 提交级别 丢弃私有分支中的提交或抛弃未提交的变更
git reset 文件级 取消暂存文件
git checkout 提交级别 在分支之间切换或检查旧快照
git checkout 文件级 丢弃工作目录中的变更
git revert 提交级别 撤销公共分支中的提交
git revert 文件级 (不适用)

常用

  • stash:存储临时代码。
  • reset –soft:软回溯,回退 commit 的同时保留修改内容。
  • cherry-pick:复制 commit。
  • revert:撤销 commit 的修改内容。
  • reflog:记录了 commit 的历史操作。

git log

查看提交日志

git merge

默认合并是 Fast-forward 模式。没有 git log 记录。
可以使用--no--ff -m 'xxx'添加记录。
场景:master 是主分支,dev 是开发分支。目前 dev 分支 commit 进度超过 master。master 需要将进度赶到 dev 的进度。

1
2
3
4
5
6
7
# 从dev切换到master
git switch master
# 合并dev分支
git merge dev

# 合并时添加备注信息
git merge --no-ff -m '合并了功能1' dev

git stash

贮藏功能,相当于独立一块区域用于保存当前的开发内容。
场景:当前分支功能未开发完,需要修改线上开发 master 分支的 bug。
git stash apply,pop,drop,clear,list, push -m’xxx’

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# 当前master分支有bug,又有未开发完的功能在开发分支work

# 把当前开发的功能贮藏
git stash
# 或者贮藏时加备注信息
git stash push -m 'xxx' // 贮藏信息为xxx
# 通过list查看
git stash list
# 切换到有bug的master分支
git checkout master
# 创建一个修改bug的分支issue
git checkout -b issue
# 或者使用新版本代码创建
git switch -c issue
# 修改后推送合并
git add .
git commit -m '修改了功能1'
# 切换到dev分支再合并
git switch master
git merge --no-ff -m '合并了修复bug1的分支代码' issue

# 删除修复分支issue
git checkout -D issue

# 切回原分支work
git switch work

# 把修改好的代码拿到这个分支
git cherry-pick 分支id
# 取回贮藏区的代码
git stash apply // 只取回,不删除,取回指定的就是在后面加上stash@{id}
git stash pop // 取回并删除这一项
git stash drop // 删除第一项
git stash clear // 清空贮藏区

tag 标签

tag 的指针不会移动,master 的指针随着 commit 而移动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 给当前的commit打tag
git tag v1.0
# 给以前的commit打tag
git tag v0.9 id名
# 添加备注信息
git tag -a v0.9 -m '发布了0.9' id名
# 查看备注信息
git show v0.9
# 删除标签
git tag -d v0.9
# 推送到远程仓库有标签
git push origin v1.0
# 删除远程标签
git push origin :refs/tags/v1.0

commitlint

提交时的分类
必填项,用于说明本次提交做出哪种类型的修改,必须是以下任意一值:

  • feat: A new feature(新功能)
  • fix: A bug fix(bug 的修复)
  • docs: Documentation only changes(修改项目中的文档)
  • style: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)(不影响代码逻辑下的样式修改,通常是风格修改,例如空格、格式、分号方面的修改等)
  • refactor: A code change that neither fixes a bug nor adds a feature(重构,不包括修复 bug 和添加新功能)
  • perf: A code change that improves performance(性能优化)
  • test: Adding missing or correcting existing tests(添加或者修改测试代码)
  • chore: Changes to the build process or auxiliary tools and libraries such as documentation generation(对构建过程或辅助工具和库(如文档生成)的更改)

关于框架选择

Creat React App

它适用于以下类型的网站:
管理后台
仪表盘
数据分析
form 表单
内网应用

CRA 的优势:
✅ 官方出品
✅ 零配置
✅ CSR(即页面完全在浏览器渲染),简单易于学习
✅ 服务器和客户的代码完全解耦
✅ 易于部署,因为打包后的文件是静态文件
CRA 的劣势:
⛔️ 打包后的代码可能会臃肿
⛔️ 需要手动配置路由、状态管理、代码分割、样式文件等
⛔️ 不能用于需要 SEO 检索的网站
⛔️ 首屏效果不好,因为 CSR 页面在初始加载时比较慢,

Gatsby

它适用于以下类型的网站:
完美支持个人网站、博客、文档网站(PS: React 的官方文档使用的就是 Gatsby),甚至是电子商务网站。

Gatsby 的优势:

✅ 页面渲染性能优秀
✅ 对 SEO 友好
✅ 对打包文件进行了优化
✅ 轻松部署到 CDN(基于出色的扩展功能)
✅ 可以创建一个具有离线功能的 PWA 应用
✅ 丰富的插件系统

Gatsby 的劣势:

⛔️ 使用起来相较于 CRA 更为复杂
⛔️ 需要了解 GraphQL 和 Node.Js 的相关知识
⛔️ 配置繁重
⛔️ 构建时间会随着内容的增加而变长
⛔️ 有些功能可能需要付费

值得强调的是,丰富的插件系统是选择 Gatsby 的重要原因,比如 Gatsby 提供许多博客主题插件,其他例如谷歌分析、图片压缩、预加载插件等等。

Next.js

适用于高动态或者面向用户的网页,这些页面需要优秀的 SEO,并且可能每分每秒都在变化。
举个例子:今日头条的首页会根据每个人不同的喜好来推送不同的信息流。如果使用 Gatsby 或 Create React App,会首先渲染一个空页面,然后通过 HTTP 调用来获取信息流的新闻数据。然后有了 Next ,可以在服务器端进行数据的获取,并返回完整的页面。
可以在 Next.js 的展示页面 查看有哪些应用是用 Next.js 构建的。
Next.js 的优势:

✅ 支持服务器端预渲染
✅ 对 SEO 友好
✅ 零配置
✅ 适用于面向用户的高动态内容
✅ 还可以像 Gatsby 一样做 SSG ( Server Side Generation)

Next.js 的劣势:

⛔️ 使用起来比 CRA 更复杂
⛔️ SSR 增加了额外的复杂程度
⛔️ 扩展取依赖于服务器
⛔️ 没有丰富的插件生态系统
⛔️ 有些功能可能需要付费

不可变数据

既然是不可变数据,为何还能变更。这里说的不可变是指不能改变数据本身,如果需要改变数据,是先创建一个该数据的副本,然后在这个副本上进行修改操作,这样就在没有改变原始数据的基础上做了数据变更。此技术称之为写时复制。

1
2
3
4
5
6
7
8
9
10
11
12
const [todos, setTodos] = useState([
{
id: "1",
name: "learn immer",
},
]);

function addTodo(todo) {
const newTodos = [...todos, todo]; // 这里不是直接修改todos,而是创建了一个newTodos。

setTodos(newTodos);
}

immer

当拷贝一个值时,如果这个值是引用类型,那么直接赋值给另一个值时,会把值的引用也拷贝过去,改变这个值,会影响原值。
通常我们会使用深拷贝解决。
而 immer 会将改变应用到值的代理上,返回新的值。这样也不会影响原值。
immer 是使用 Proxy 进行的包装。

安装

1
yarn add immer

对比

1
2
3
4
5
6
7
8
9
10
11
12
//常规
const state = { id: "1", name: "张三" };
const newState = { ...state, name: "李四", age: 25 };
//immer
import produce from "immer";

const state = { id: "1", name: "张三" };

const newState = produce(state, (draft) => {
draft.name = "李四";
draft.age = 25;
});

更多层级

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
//常规
const state = {
id: "1",
name: "张三",
address: {
city: "北京",
},
};

const newState = {
...state,
address: {
...state.address,
city: "上海",
},
};
//immer
import produce from "immer";
const state = {
id: "1",
name: "张三",
address: {
city: "北京",
},
};

const newState = produce(state, (draft) => {
draft.address.city = "上海";
});

console.log(state);
/*输出:
{
id: '1',
name: '张三',
address: {
city: '北京',
},
}
*/
console.log(newState);
/*输出:
{
id: '1',
name: '张三',
address: {
city: '上海',
},
}
*/

更新数组

1
2
3
4
5
const nums = [1, 2, 3, 5];

const newNums = [...nums, 6]; // 在数组的末尾添加6
const newNums2 = [...nums.slice(0, 1), ...nums.slice(2)]; //删除数字2
const newNums3 = [...nums.slice(0, 1), 7, ...nums.slice(2)]; // 将数字2替换为7
1
2
3
4
5
6
7
8
9
10
11
12
import produce from "immer";
const nums = [1, 2, 3, 5];

const newNums = produce(nums, (draft) => {
draft.push(6);
}); // 在数组的末尾添加6
const newNums2 = produce(nums, (draft) => {
draft.splice(1, 1);
}); //删除数字2
const newNums3 = produce(nums, (draft) => {
draft.splice(1, 1, 7);
}); // 将数字2替换为7

对象数组操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const todos = [
{
id: 1,
text: "learn react",
},
{
id: 2,
text: "learn redux",
},
{
id: 3,
text: "learn immer",
},
];

function addTodo(todo) {
return [...todos, todo];
}

function toggleTodo(index) {
return todos.map((todo, idx) => {
if (idx === index) {
return { ...todo, completed: !todo.completed };
}
return todo;
});
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const todos = [
{
id: 1,
text: "learn react",
},
{
id: 2,
text: "learn redux",
},
{
id: 3,
text: "learn immer",
},
];

function addTodo(todo) {
return produce(todos, (draft) => {
draft.push(todo);
});
}

function toggleTodo(index) {
return produce(todos, (draft) => {
draft[index].completed = !draft[index].completed;
});
}

在底层,immer 使用了 ES6 Proxy 对现有的状态(即 JavaScript 对象)做了代理,形成了 draft 对象。对 draft 对象的任何同步赋值操作都会被代理捕捉到,等变更操作完成后,immer 会根据捕捉到的变更操作,来生成新的 JavaScript 对象。

与 React 的状态更新结合使用

使用 immer 可以简化深层的状态更新操作。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { useState } from "React";
import produce from "immer";

const [user, setUser] = useState({
userName: "张三",
age: 16,
});

// 常规方式
const handleBirthDayClick = () => {
setUser((prevUser) => ({
...prevUser,
age: prevUser.age + 1,
}));
};

// immer方式
const handleBirthDayClick2 = () => {
setUser(
produce((draft) => {
draft.age += 1;
})
);
};

当然,如果真的遇到很深层次的 React 组件状态,就需要看看是否真的有必要设计出这么深层次的数据结构。

immer 更常见的是与 useReducer 结合使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { useReducer } from "React";
import produce from "immer";

const todoReducers = produce((draft, action) => {
switch (action.type) {
case "ADD_TODO":
draft.push(action.payload);
return;
case "TOGGLE_TODO":
draft[action.payload].completed = !draft[action.payload].completed;
return;
default:
}
});

const [todos, dispatch] = useReducer(todoReducers, []);

immutable.js

这是 Facebook 官方推出的数据不可变工具.

可变数据

引用类型就是可变数据,复制一份引用类型,改变内部属性的值,所有复制的值都会变化,因为引用的是同一份地址.

不可变数据方案

常规的比如浅拷贝,但是对于层级比较深的就无法处理.

Redux devtools

调试工具。

  1. 下载 redux devtools 插件。
1
2
3
4
5
6
7
8
9
10
// 开启 redux-devtools
const composeEnhancers =
(window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;

// 使用中间件
const middleWares = applyMiddleware(thunk, reduxPromise);
// 配合 devtools
const enhancer = composeEnhancers(middleWares);
// 创建store
const store: Store = legacy_createStore(enhancer);

动效库 framer-motion

需要自定义配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<motion.div
variants={staggerTextContainer}
initial="initial"
whileInView="animate"
viewport={{ once: false, amount: 0.6 }}
className="flex flex-col lg:flex-row"
>
{/* text */}
<motion.div variants={fadeInUp} className="lg:w-[40%]">
<h3 className="h3">{pretitle}</h3>
<h2 className="h2 mb-6">{title}</h2>
</motion.div>
{/* slider */}
<motion.div variants={fadeInUp} className="lg:w-[60%] lg:absolute lg:right-0">
<Slider clients={clients} />
</motion.div>
</motion.div>

基本使用

安装

yarn add react-router-domnpm i react-router-dom

使用

导入路由核心组件

1
import { BrowserRouter as Router, Route, Link } from "react-router-dom";

使用Router包裹整个应用.

1
2
3
<Router>
<div className="App">//...</div>
</Router>

指定路由入口和出口

1
2
3
4
5
6
7
8
9
const First = () => <p>页面</p>;
const App = () => {
<Router>
<div>
<Link to="/first">页面</Link>
<Route path="/first" component={First} />
</div>
</Router>;
};

注意: v6 版本中将 component 替换成了 element.

常见组件

Router

包裹整个组件,一个 React 应用只使用一次.
两种常用 Router: Browser RouterHashRouter

用于指定导航链接,最终会被编译为<a>

Route

展示组件相关信息

  • path 属性: 路由规则
  • component 属性: 展示的组件
  • Route 写在哪,渲染的组件就展示在哪

Switch(v6 已弃用)

使用Switch包裹路由就会只加载一个匹配的路由.

注意: v6 版本使用 Routes 替换 Switch.

作用: 选中的 Link 有class="active",就可以给对应的类加上样式.

其他参数

1
2
3
4
5
6
7
<NavLink activeClassName="selected" exact
to={{
pathname="/mine",
search="?sort=name",
hash="#the-hash",
state={flag: 'flag'} //可以隐式传参
}}>Mine页面</NavLink>

Redirect(v6 弃用)

v6 弃用,使用 Navigate 替代.

from 是输入某个路径,to 是要跳转的路径.

1
<Redirect from="/home" to="/" />

其他方法

pushreplace
区别: push叠加, replace直接替换.

1
2
3
4
5
6
7
const Mine = () => {
const handleClick = () => {
props.history.push("/home");
props.history.replace("/home");
};
return <button onClick={handleClick}>Click</button>;
};

场景: 首页重定向到指定路由,url 中包含该路由地址

1
2
// 直接将Navigate作为element组件
<Route path="/" element={<Navigate to={"/projects"} />} />

withRouter

高阶组件,用于当前组件没有被Router包裹,而无法拿到相关的参数.
使用withRouter(Mine)包裹即可.

Prompt(v6 弃用)

类似钩子函数,用于跳转之前的操作

1
<Prompt when={!!this.state.name} message={"确定离开?"} />

编程式导航

push(path)

push 表示跳转到某个页面,参数 path 表示要调整的路径.
this.props.history.push("/home")

go(n)

前进或后退到某个页面,参数 n 表示前进或后退的页面数量

默认路由

1
<Route path="/" component={Home} />

模糊匹配

React 默认使用模糊匹配,即 pathname 相同即会匹配.

精确匹配

关键字: exact
更加精准: strict

404 页面

当所有规则都没有匹配中时,设置 404 页面.即不设置规则.

简约写法

1
<Route path="/" render={() => <Home />} />

参数传递

在 path 上使用path="name/:name/:id?",使用冒号传递,问号表示可选.

读取

props.match.params.name

其他读取方式

1
2
3
4
5
6
7
8
9
10
import querystring from "querystring";

const Mine = (props) => {
const params = new URLSearchParams(props.location.search);
console.log(params.get("name"));
//第二种
const value = querystring.parse(props.location.search); //注意会获取前面的?,要截取
console.log(value);
return <p>Mine</p>;
};

路由嵌套

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import Book from "./Book";
import WebBook from "./WebBook";
import JavaBook from "./JavaBook";

function App() {
return (
<div className="App">
<Router>
<Switch>
<Route path="/" component={Home} />
<Book>
<Switch>
<Route path="/book/webBook" component={WebBook}></Route>
<Route path="/book/javaBook" component={JavaBook}></Route>
</Switch>
</Book>
</Switch>
</Router>
</div>
);
}