# React 简介
# 什么是 React
React 是一个用于构建用户界面的 JavaScript 库。
React 16 版本以上的称为 React Fiber。
注意
React 不是一个框架,它只是一个库,只提供 UI(view)层面的解决方案。在实际的项目当中,它并不能解决我们所有的问题,需要结合其它的库,例如 Redux、React-router 等来协助提供完整的解决方法。
在 MVC 模式中,React 就相当于 MVC 里面的 View。
# 与其他框架并存
React 可以与其他框架,比如 jQuery 一起使用。
React 只负责 id 为 root 的元素里的内容,因此,只要 jQuery 不操作 id 为 root 的元素里的内容,操作其他 DOM 元素就行了。两者不相互影响就没问题。
# 单向数据流
React 被设计成单向数据流,只允许父组件向子组件传值,不允许子组件直接修改父组件的值,这也是为了方便开发和测试。
如果子组件想修改父组件的值,应该是调用父组件中的方法去修改。
# 视图层框架
React 把自己称为视图层框架,这是想表达 React 并不是所有的事情都能做得到,比如数据传输,在大型项目中,我们就得借助 Flux (opens new window)、Redux (opens new window)、Mobx (opens new window) 等框架来辅助完成了。
# 函数式编程
React 是一个典型的函数式编程库。
# Vue 和 React 如何选择
Vue 上手快,学习成本相对较低,React 的学习曲线可能相对陡峭,JSX 语法、单向数据流等概念可能需要一些时间来适应。
Vue 使用的是基于 HTML 的模板语法,React 使用的是 JSX 语法。
Vue 提倡双向数据绑定,而 React 提倡单向数据流。
Vue 使用的是 Composition API,将组件逻辑封装到函数中,React 使用函数组件和 Hooks 搭配使用。
React 拥有一个庞大且活跃的生态系统,包括许多工具、库和第三方扩展,Vue 的生态系统相对来讲较小。因此,大型复杂项目还是比较推荐 React,Vue 适用于中小型项目。
在实际项目选型的时候,还需要考虑自身或团队对 Vue 和 React 的掌握程度,优先选择自己擅长的。
# 创建 React 项目
- 可以直接使用官方推荐的脚手架工具 create-react-app 来创建。
npx create-react-app react-api
注意
npx 是 npm 5.2+自带的 package 运行工具。关于 npx 的使用方法可以参考:npx 使用教程 (opens new window)。
React 项目默认是关闭 webpack 配置的,如果想开启,可以执行
npm run eject
命令。一旦开启,就没办法再关闭了。如果不想在安装包的时候版本号超过 package.json 里的版本,那么可以把包版本号前面的
^
去掉,这样,在安装依赖的时候,就会安装指定版本的包了。
# 编写 React
在编写 React 代码时,一定要引入 react
和 react-dom
这两个库,其中 react-dom 就是提供 DOM 操作的功能库。
# 编写 React 组件
组件的首字母一定要大写。
// 函数组件
function App() {
return (
<div className="App">
<h2>函数组件</h2>
</div>
);
}
2
3
4
5
6
7
8
// 类组件
class App extends React.Component {
render() {
return (
<div className="App">
<h2>类组件</h2>
</div>
);
}
}
2
3
4
5
6
7
8
9
10
注意
类组件的写法有两种。
import React from;
class App extends React.Component { ... }
2
import React, { Component } from;
class App extends Component { ... }
2
# 父子组件传值
📌 1. 父组件向子组件传值
- 在 React 中,父组件向子组件传值也是通过属性的方式进行传递的,在子组件中通过
this.props.xxx
进行使用。
// 父组件中传值
<TodoItem
item={item}
index={index}
handleItemDelete={this.handleItemDelete.bind(this)}
/>
2
3
4
5
6
// 子组件使用父组件传过来的值
import React, { Component } from "react";
class TodoItem extends Component {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
render() {
return <div onClick={this.handleClick}>{this.props.item}</div>;
}
handleClick() {
this.props.handleItemDelete(this.props.index);
}
}
export default TodoItem;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- 使用 ES6 的扩展运算符
// 父组件
function Component1() {
const prop = {
name: "xiaoming",
age: "18"
};
return (
<>
<User {...prop} />
</>
);
}
// 当然也可以直接用下面的方式传值,但是明显组件多的时候就不方便
function Component1() {
const prop = {
name: "xiaoming",
age: "18"
};
return (
<>
<User name="xiaoming" age="18" />
</>
);
}
// 子组件
function User(props) {
return (
<div>
<h2>我是user组件</h2>
<p>
{props.name}--{props.age}
</p>
</div>
);
}
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
📌 2. 子组件向父组件传值
同样是使用 props 巧妙地让子组件向父组件传值。
// 父组件
function Component1() {
const prop = {
name: "xiaoming",
age: "18"
};
// 父组件接收子组件传过来的值
function getChildData(data) {
console.log("子组件的值:" + data);
}
return <Footer getChildData={getChildData} {...prop} />;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
// 子组件
class Footer extends Component {
constructor(props) {
super(props);
this.state = {
count: 0
};
}
handleAdd = () => {
this.setState((state) => ({
count: state.count++
}));
this.props.getChildData(this.state.count);
};
render() {
const { name, age } = this.props;
return (
<div>
<button onClick={this.handleAdd}>点击加一{this.state.count}</button>
<p>{`Footer:${name}--${age}`}</p>
</div>
);
}
}
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
# JSX 详解
JSX (opens new window) 全称是 JavaScript and XML,是对 JavaScript 的一种语法扩展。它是一种技术,而不是一种语言。
JSX 的底层原理就是通过 React.createElement (opens new window) 方法将 HTML 代码转换成 JS 对象的。
function App() {
const message = "函数组件";
return (
<div className="App" tabIndex="1" data-tab="1" dataid="1">
<h2>{message}</h2>
</div>
);
}
2
3
4
5
6
7
8
上面这段代码用 JS 对象直接描述的方式来写的话就是下边这样的:
function App() {
const message = "函数组件";
const element = React.createElement(
"div",
{
className: "App",
tabIndex: "1",
dataTab: "1",
dataid: "1"
},
React.createElement("h2", null, `${message}`)
);
return element;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
可以看到,使用 JS 对象直接描述的方式相对于 JSX 来说繁琐了许多。
- 使用占位符 Fragment (opens new window) 可以实现同时有多个顶层 div 元素。
import React, { Fragment } from;
import './assets/css/App.css';
function App() {
const message = '函数组件';
return (
<Fragment>
<div className="App" tabIndex="1" data-tab="1" dataid="1">
<h2>{message}</h2>
</div>
<div>使用占位符</div>
</Fragment>
);
}
// 或者直接使用 React.Fragment
function App() {
const message = '函数组件';
return (
<React.Fragment>
<div className="App" tabIndex="1" data-tab="1" dataid="1">
<h2>{message}</h2>
</div>
<div>使用占位符</div>
</React.Fragment>
);
}
// 或者直接这么写
function App() {
const message = '函数组件';
return (
<>
<div className="App" tabIndex="1" data-tab="1" dataid="1">
<h2>{message}</h2>
</div>
<div>使用占位符</div>
</>
);
}
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
注意
JSX 中最外层只能有一个 div 元素。
在 JSX 中,属性的命名要求用小驼峰的形式,比如:className、tabIndex 等等;但是也有一个特例:dataid;此外,自定义的属性最好以 data- 开头。
JSX 中的注释方法如下。
多行注释
{ /* */ }
1
2
3单行注释
{ // 注释 }
1
2
3
如果 return 后面不加括号,那么 HTML 代码只能写在一行里,不能换行。
JSX 是声明式的,所以它的内部不应该出现命令式的语句,如 if ... else ...。
# React 支持的 JSX 元素类型
React 元素有三种基本类型:
React 封装的 DOM 元素,这部分元素会最终被渲染为真实的 DOM;
React 组件渲染的元素,这部分元素会调用对应组件的渲染方法;
React Fragment 元素,可以简写成
<>
,这一元素没有业务意义,也不会产生额外的 DOM,主要用来将多个子元素分组。
# JSX 子元素类型
JSX 元素可以指定子元素。子元素不一定是子组件,子组件一定是子元素。
子元素的类型包括:
字符串,最终会被渲染成 HTML 标签里的字符串;
另一段 JSX,会嵌套渲染;
JS 表达式,会在渲染过程中执行,并让返回值参与到渲染过程中;
布尔值、null 值、undefined 值,不会被渲染出来;
以上各种类型组成的数组。
# setState
- 在 React 中,改变数据需要使用 setState (opens new window) 方法,不能直接修改 state (opens new window) 里的数据。
handleInputChange(e) {
this.setState({
inputValue: e.target.value
});
}
2
3
4
5
- 不过现在新版的 React 的 setState 方法一般不直接传一个对象了,而是传一个函数,函数中返回内容。
handleInputChange(e) {
const value = e.target.value;
this.setState(() => ({
inputValue: value
}))
}
2
3
4
5
6
注意
- 这里要先把 e.target.value 的值保存下来,再赋给 setState 函数中的属性,不能直接像下面这么写,会报错。
handleInputChange(e) {
this.setState(() => ({
inputValue: e.target.value
}))
}
2
3
4
5
- 在事件处理函数内部的 setState 是异步的。
例如,如果 Parent 和 Child 在同一个 click 事件中都调用了 setState ,这样就可以确保 Child 不会被重新渲染两次。取而代之的是,React 会将该 state “冲洗” 到浏览器事件结束的时候,再统一地进行更新。这种机制可以在大型应用中得到很好的性能提升。
如果我们需要基于当前的 state 来计算出新的值,那就应该传递一个函数,而不是一个对象。因为传递一个函数可以让我们在函数内访问到当前的 state 的值。这就是给 setState 传递一个对象和传递一个函数的区别。
- 注意下面这种写法是不推荐的。尽管运行的时候并不会报错,但是它直接修改了 state 里的数据,违背了 React 中的 immutable (opens new window) 理念。
handleItemDelete(idx) {
this.state.list.splice(idx, 1)
this.setState({
list: this.state.list
})
}
2
3
4
5
6
推荐写法应该是这样的。
handleItemDelete(idx) {
const list = [...this.state.list];
list.splice(idx, 1)
this.setState({
list
})
}
2
3
4
5
6
7
更标准的写法。
handleItemDelete(idx) {
this.setState((prevState) => {
const list = [...prevState.list];
list.splice(idx, 1);
return { list };
})
}
2
3
4
5
6
7
- setState 里的函数可以接收一个参数,代表
this.state
。用法如下:
handleBtnClick() {
if (!this.state.inputValue) return;
this.setState(() => ({
list: [...this.state.list, this.state.inputValue],
inputValue: "",
}))
}
// 等价于下面的写法,而且也更推荐下面的写法
handleBtnClick() {
if (!this.state.inputValue) return;
this.setState((prevState) => ({
list: [...prevState.list, prevState.inputValue],
inputValue: "",
}))
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
state、props 和 render 的关系
当组件的 state 或者 props 发生改变时,render 函数就会重新执行。
当父组件的 render 函数重新执行时,子组件的 render 函数也会重新执行。
# 修正 this 指向
在 React 中,绑定 this 有以下两种方式:
📌 1. 在 constructor 中绑定
在绑定事件时,会导致 this 指向丢失,所以我们需要在绑定的时候用 bind 方法手动修正下。
<ul>
{this.state.list.map((item, index) => {
return (
<li key={index} onClick={this.handleItemDelete.bind(this, index)}>
{item}
</li>
);
})}
</ul>
2
3
4
5
6
7
8
9
不过,一般不推荐直接在 JSX 代码中 bind,会多次执行,影响性能问题。而是像下面这么写,统一在 constructor 中绑定。
constructor(props) {
super(props);
this.handleItemDelete = this.handleItemDelete.bind(this);
}
2
3
4
📌 2. 使用 ES6 的箭头函数
可以直接在 JSX 代码中绑定,如下:
handleAdd() {
this.setState(state => ({
count: state.count++
}))
}
render() {
return (
<div>
<button onClick={() => {this.handleAdd()}}>点击加一{this.state.count}</button>
</div>
)
}
2
3
4
5
6
7
8
9
10
11
12
13
不过,这也是不推荐的写法,因为也会多次执行,影响性能问题。推荐的写法应该是下面这样的:
handleAdd = () => {
this.setState(state => ({
count: state.count++
}))
}
render() {
return (
<div>
<button onClick={this.handleAdd}>点击加一{this.state.count}</button>
</div>
)
}
2
3
4
5
6
7
8
9
10
11
12
13
# label 标签的使用
使用 label 实现点击标签名的时候聚焦输入框。
给 input 绑定 id
属性,然后给 label 绑定一个值相同的 htmlFor
属性,就可以了。
<label htmlFor="inputArea">请输入内容:</label>
<input
id="inputArea"
type="text"
value={this.state.inputValue}
onChange={this.handleInputChange.bind(this)}
className="input"
/>
2
3
4
5
6
7
8
# React 非 DOM 属性
# dangerousSetInnerHTML
这个属性相当于 HTML 中的 innerHTML 属性。容易造成 XSS 攻击,尽量少使用。
使用方法
<div dangerouslySetInnerHTML={{ __html: "<p>我是插入的 html 代码</p>" }} ></div>
1
2
3
# ref
ref (opens new window) 这个属性能让我们访问到页面中的某个元素或者组件实例。因为并不推荐直接操作 DOM,所以也是尽量少使用。
不能在函数组件上使用,因为函数组件没有实例。
不过可以在函数内部使用。
使用方法
function Component1() { // 创建一个 ref const userRef = React.createRef(); return ( <> {/* App 是一个类组件 */} <App ref={userRef} /> </> ); }
1
2
3
4
5
6
7
8
9
10
11ref 的值其实是一个对象。
通过 current 属性我们可以操作到 DOM 元素,比如可以实现点击按钮聚焦 input 输入框的功能。
function Component1() { // 创建一个 ref const inputRef = React.createRef(); function handleClick() { console.log(inputRef); inputRef.current.focus(); } return ( <> <button onClick={handleClick}>点我</button> <input type="text" ref={inputRef}></input> </> ); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16ref 和 setState 一起使用时要注意,如果我们想获取在 setState 执行完成后的 DOM 元素,我们应该把代码写在 setState 的第二个参数里,这个参数是一个函数,它会在 setState 执行完成后再执行。不能直接把代码写在 setState 下面,因为 setState 是异步的,不会立即执行,即使写在下面,也会比 setState 先一步执行,从而获取不到 DOM 元素。
render() {
return (
<ul className="todoContent" ref={(ul) => this.ul = ul}>
{this.getTodoItems()}
</ul>
)
}
2
3
4
5
6
7
handleBtnClick() {
this.setState((prevState) => ({
list: [...prevState.list, prevState.inputValue],
inputValue: "",
}), () => {
// 这才是正确的位置
console.log(this.ul.querySelectorAll('li').length);
})
// 每次获取到的长度总会比预期的少1,这是因为 setState 是异步的,放在这里会比 setState 先执行
// 应该把这句话写在 setState 的第二个参数里,这个参数是一个函数,当 setState 执行完成后就会执行这个回调
console.log(this.ul.querySelectorAll('li').length);
}
2
3
4
5
6
7
8
9
10
11
12
13
# key
唯一标识,作用是为了提高渲染性能。跟 Vue 里的类似。
一般不建议使用 index 作为 key 值,这是因为当我们删除或者新增元素的时候,元素的 index 会发生改变,相应的它的 key 值就会跟上一次匹配不上,会影响到页面的渲染更新。因此要使用不会改变的并且唯一的值来作为 key 值。
# PropTypes 与 defaultProps
- 我们可以使用 PropTypes (opens new window) 属性来对子组件接收到的 props 值进行类型检查,使用这个属性的时候需要导入 prop-types 这个库。用法如下:
import PropTypes from "prop-types";
class TodoItem extends Component {
...
}
TodoItem.propTypes = {
content: PropTypes.string,
handleItemDelete: PropTypes.func,
index: PropTypes.number,
test: PropTypes.string.isRequired
}
2
3
4
5
6
7
8
9
10
11
12
- 我们还可以使用 defaultProps (opens new window) 属性来定义 props 的默认值,不需要导入 prop-types 这个库就可以使用。用法如下:
class TodoItem extends Component {
...
}
TodoItem.defaultProps = {
test: 'Hello World'
}
2
3
4
5
6
7
# 虚拟 DOM
# 什么是虚拟 DOM
虚拟 DOM 其实就是 JavaScript 对象,用来描述真实的 DOM 元素。
React 通过使用虚拟 DOM 减少了生成 DOM 和比对 DOM 带来的性能损耗,因为创建和对比 JS 对象要远比操作 DOM 元素方便的多,也快的多。
一段 HTML 代码可以用 JS 对象来表示如下:
<div class="box" id="content">
<div class="title">Hello</div>
<button>Click</button>
</div>
2
3
4
{
tag: 'div',
attrs: { className: 'box', id: 'content'},
children: [
{
tag: 'div',
arrts: { className: 'title' },
children: ['Hello']
},
{
tag: 'button',
attrs: null,
children: ['Click']
}
]
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# JSX 代码的转化
- JSX 代码在 React 底层的转化过程如下:
JSX 代码 -> React.createElement -> 虚拟 DOM(JS 对象) -> 真实 DOM
- 对于一段 JSX 代码,我们也可以直接使用 React.createElement (opens new window) 方法来实现。
class TodoItem extends Component {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
render() {
const { content, test } = this.props;
return (
<li className="todoItem" onClick={this.handleClick}>
{test} - {content}
</li>
);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class TodoItem extends Component {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
render() {
const { content, test } = this.props;
return React.createElement(
"li",
{
className: "todoItem",
onClick: this.handleClick
},
`${test} - ${content}`
);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 虚拟 DOM 的优点
提升性能。
使得跨端应用得以实现,比如 React Native,因为在安卓、ios 这些平台上是没有 DOM 元素的。
# React 生命周期函数
生命周期函数是指在某一时刻组件会自动调用执行的函数。
# React Hooks
Hooks 就是一套为函数组件设计的,用于访问 React 内部状态或执行副作用操作,以函数形式存在的 React API。
# React 合成事件
合成事件是原生 DOM 事件的一种包装,它与原生事件的接口相同,根据 w3c 规范,React 内部规范化(Normalize)了这些接口在不同浏览器之间的行为,开发者不用再担心事件处理的浏览器兼容性问题。
# 合成事件的优点
跨浏览器兼容性:不同浏览器的原⽣事件⾏为可能会存在差异。React 的合成事件为所有浏览器提供了⼀致的 API 和⾏为,从⽽消除了这种差异。
性能优化:React 使⽤了事件委托(event delegation)机制。这意味着对于同⼀类型的事件,React 并不会直接将事件处理器绑定到 DOM 节点上,⽽是将⼀个统⼀的事件监听器绑定到⽂档的根节点上。当事件发⽣时,React 会根据其内部映射确定真正的事件处理器。这样做可以有效减少事件监听器的数量,节省内存,提⾼性能。
集成到 React 的状态系统:React 合成事件系统与其组件⽣命周期和状态系统紧密集成,可以在事件处理函数中调⽤ setState,React 会正确地批处理更新和重新渲染。
提供更多的信息: React 的合成事件提供了⽐原⽣事件更多的信息,例如 event.target。
# 合成事件的原理
由于虚拟 DOM 的存在,在 React 中即使绑定一个事件到原生的 DOM 节点,事件也并不是绑定在对应的节点上,而是所有的事件都是绑定在根节点上。然后由 React 统一监听和管理,获取事件后再分发到具体的虚拟 DOM 节点上。
在 React 17 之前,所有的事件都是绑定在 document 上的,而从 React 17 开始,所有的事件都绑定在整个 App 上的根节点上,这主要是为了以后页面上可能存在多版本 React 的考虑。
因为如果⻚⾯上有多个 React 版本,它们都将在顶层注册事件处理器。这会破坏 e.stoppropagation():如果嵌套树结构中阻⽌了事件冒泡,但外部树依然能接收到它。这会使不同版本 React 嵌套变得困难重重。
React 这么做的原因主要有两个。
第一,虚拟 DOM render 的时候,DOM 很可能还没有真实地 render 到页面上,所以无法绑定事件。
第二,React 可以屏蔽底层事件的细节,避免浏览器的兼容性问题。同时呢,对于 React Native 这种不是通过浏览器 render 的运行时,也能提供一致的 API。
那为什么事件绑定在某个根节点上,也能触发实际 DOM 节点的事件呢?
在浏览器的原生机制中,事件会从被触发的节点往父节点冒泡,然后沿着整个路径一直到根节点,所以根节点其实是可以收到所有的事件的。这也称之为浏览器事件的冒泡模型。
因此,无论事件在哪个节点被触发, React 都可以通过事件的 srcElement 这个属性,知道它是从哪个节点开始发出的,这样 React 就可以收集管理所有的事件,然后再以一致的 API 暴露出来。这样的话,我们在写原生事件的时候,就再也不用再担心浏览器兼容性的问题了。
# 受控组件与表单
以 React state 为单一事实来源,并用 React 合成事件处理用户交互的组件,被称为 “受控组件”。
除了文本框之外,大部分表单元素,包括单选框、多选框、下拉框等都可以做成受控组件。当这些元素组合成一个表单时,开发者可以很容易获取到任一时刻的表单数据,然后进一步做验证、提交到服务器端等操作。
# 合成事件与原生 DOM 事件的区别
合成事件与原生 DOM 事件的区别如下:
1. 注册事件监听函数的方式不同
监听原生 DOM 事件基本有三种方式。
// 与 React 合成事件类似的,以内联方式写在 HTML 标签中
<button id="btn" onclick="handleClick()">
按钮
</button>;
// 在 JS 中赋值给 DOM 元素的事件处理属性
document.getElementById("btn").onclick = handleClick;
// 在 JS 中调用 DOM 元素的 addEventListener 方法(需要在合适时机调用 removeEventListener 以防内存泄漏)
document.getElementById("btn").addEventListener("click", handleClick);
2
3
4
5
6
7
8
9
10
合成事件不能通过 addEventListener 方法监听,它的 JSX 写法等同于 JS 写法。
const Button = () => <button onClick={handleClick}>按钮</button>;
// 编译为
const Button = () =>
React.createElement(
"button",
{
onClick: handleClick
},
"按钮"
);
2
3
4
5
6
7
8
9
10
有时我们需要以捕获方式监听事件,在原生事件中以 addEventListener 方法加入第三个参数。
div.addEventListener("click", handleClick, true);
而在 React 合成事件中,则需要用在事件属性后面加一个 Capture 后缀。
const Button = () => <button onClickCapture={handleClick}>按钮</button>;
2. 特定事件的行为不同
React 合成事件规范化了一些在各个浏览器间行为不一致,甚至是在不同元素上行为不一致的事件,比如:onChange、onBeforeInput、onMouseEnter、onMouseLeave、onSelect。
在 React 中,<input>
、<textarea>
和 <select>
三种表单元素的 onChange 合成事件被规范成了一致的行为:在不会导致显示抖动的前提下,表单元素值的改变会尽可能及时地触发这一事件。
以文本框为例,同样是输入一句话,合成 change 事件发生的次数要多于原生的次数,在 onChange 事件处理函数被调用时,传入的事件对象参数提供的表单元素值也尽可能是最新的。
3. 实际注册的目标 DOM 元素不同
可以通过 evt.nativeEvent 属性得到一个合成事件所包装的原生事件。
对比以下几个值:
evt.currentTarget;
evt.target;
evt.nativeEvent.currentTarget;
evt.nativeEvent.target;
2
3
4
其中,两种事件的 target 都是按钮元素本身,合成事件的 currentTarget 也是按钮元素,这是符合 w3c 规范的;但原生事件的 currentTarget 不再是按钮,而是 React 应用的根容器 DOM 元素
<div id="root"></div>
。
这是因为 React 使用了事件代理模式。React 在创建根(createRoot)的时候,会在容器上监听所有自己支持的原生 DOM 事件。当原生事件被触发时,React 会根据事件的类型和目标元素,找到对应的 FiberNode 和事件处理函数,创建相应的合成事件并调用事件处理函数。
# 什么时候使用原生 DOM 事件
需要监听 React 组件树之外的 DOM 节点的事件,包括 window 和 document 对象的事件。需要注意的是,在组件里监听原生 DOM 事件,属于典型的副作用,所以务必在 useEffect 中监听,并在清除函数中及时取消监听。
很多第三方框架,尤其是与 React 异构的框架,在运行时会生成额外的 DOM 节点。在 React 应用中整合这类框架时,常会有非 React 的 DOM 侵入 React 渲染的 DOM 树中。当需要监听这类框架的事件时,要监听原生 DOM 事件,而不是 React 合成事件。这同样也是 useEffect 或 useLayoutEffect 的领域。
# 创建自定义事件
对于一个自定义组件,除了可以从 props 接收参数并用于渲染之外,还很可能需要和父组件进行交互,从而反馈信息。这个时候,我们就需要为组件创建自定义事件。
需要注意的是,虽然自定义事件和原生事件看上去类似,但是两者的机制是完全不一样的:
原生事件是浏览器的机制;
而自定义事件则是纯粹的组件自己的行为,本质是一种回调函数机制。
在 React 中,自定义事件不用通过任何特殊的 API,只需要通过 props 给组件传递一个回调函数,然后在组件中的某个时机,比如用户输入,或者某个请求完成时,去调用这个传过来的回调函数就可以了。
习惯上我们都会将这样的回调函数命名为 onSomething 这种以 “on” 开头的名字,方便在使用的时候理解。
import { useState } from "react";
function ToggleButton({ value, onChange }) {
const handleClick = () => {
onChange(!value);
};
return (
<button style={{ width: "60px" }} onClick={handleClick}>
<span>{value ? "On" : "Off"}</span>
</button>
);
}
export default () => {
const [on, setOn] = useState(true);
return (
<>
<h1>Toggle Button</h1>
// onChange 就是一个自定义事件
<ToggleButton value={on} onChange={(value) => setOn(value)} />
</>
);
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 使用 Hook 简化事件的处理
比如将键盘按键这个事件封装成了一个 Hook,就可以简化键盘事件的使用。只要实现一次,就可以在多个组件中使用了。
import { useEffect, useState } from "react";
// domNode default to document.body
const useKeyPress = (domNode = document.body) => {
const [key, setKey] = useState(null);
useEffect(() => {
const handleKeyPress = (evt) => {
setKey(evt.key);
};
domNode.addEventListener("keypress", handleKeyPress);
return () => {
domNode.removeEventListener("keypress", handleKeyPress);
};
}, [domNode]);
return key;
};
export default () => {
const key = useKeyPress();
console.log(key);
return (
<div>
<h1>UseKeyPress</h1>
<label>Key pressed: {key || "N/A"}</label>
</div>
);
};
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
这也很好地展示了 Hook 的思路带给我们的惊喜,可以让本来很技术的一些实现,比如事件的监听和解绑,变得更加具有语义,从而也让代码更容易理解和维护。
# React 表单处理
表单作为用户交互最为常见的形式,在 React 中实现起来却并没有那么容易。甚至可以说,使用表单,是 React 开发中最为困难的一部分。主要有两方面的原因。
一方面,React 都是状态驱动,而表单却是事件驱动,比如点击按钮、输入字符等,都是一个个离散的事件。一般来说我们都需要将这些独立的事件转换成一定的应用程序状态,最终来完成表单的交互。
另一方面,表单元素一般都有自己的内在状态,比如原生的 input 节点就允许用户输入,这就需要我们在元素状态和表单状态之间做同步。
# 受控组件和非受控组件
受控组件:一个表单组件的状态完全由 React 管控。
非受控组件:表单元素的值不是由父组件决定的,而是完全内部的状态。
React 统一了表单组件的 onChange 事件,不管用户输入什么字符,都会触发 onChange 事件。而标准的 input 的 onchange 事件,则只有当输入框失去焦点时才会触发。React 的这种 onChange 的机制,让我们对表单组件有了更灵活的控制。
不过,受控组件的这种方式虽然统一了表单元素的处理,有时候却会产生性能问题。因为用户每输入一个字符,React 的状态都会发生变化,那么整个组件就会重新渲染。所以如果表单比较复杂,那么每次都重新渲染,就可能会引起输入的卡顿。在这个时候,我们就可以将一些表单元素使用非受控组件去实现,从而避免性能问题。
// 非受控组件的用法
import { useRef } from "react";
export default function MyForm() {
const inputRef = useRef();
const handleSubmit = (evt) => {
evt.preventDefault();
console.log("Name: " + inputRef.current.value);
};
return (
<form onSubmit={handleSubmit}>
<label>
Name:
<input type="text" ref={inputRef} />
</label>
<input type="submit" value="Submit" />
</form>
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 受控组件结合 hook 简化表单处理
import { useState, useMemo, useCallback } from "react";
// 实现这个 hook 去维护整个表单的状态,并提供根据名字去取值和设值的方法,以及增加表单验证的逻辑,从而方便表单在组件中的使用
const useForm = (initialValues = {}, validators) => {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const setFieldValue = useCallback(
(name, value) => {
setValues((values) => ({
...values,
[name]: value
}));
if (validators[name]) {
const errMsg = validators[name](value);
setErrors((errors) => ({
...errors,
[name]: errMsg || null
}));
}
},
[validators]
);
return { values, errors, setFieldValue };
};
export default () => {
const validators = useMemo(() => {
return {
name: (value) => {
if (value.length < 2) return "Name length should be no less than 2.";
return null;
},
email: (value) => {
if (!value.includes("@")) return "Invalid email address";
return null;
}
};
}, []);
const { values, errors, setFieldValue } = useForm({}, validators);
const handleSubmit = useCallback(
(evt) => {
evt.preventDefault();
console.log(values);
},
[values]
);
return (
<form onSubmit={handleSubmit}>
<div>
<label>Name: </label>
<input
value={values.name || null}
onChange={(evt) => setFieldValue("name", evt.target.value)}
/>
{errors.name && <span style={{ color: "red" }}>{errors.name}</span>}
</div>
<div>
<label>Email:</label>
<input
value={values.email || null}
onChange={(evt) => setFieldValue("email", evt.target.value)}
/>
{errors.email && <span style={{ color: "red" }}>{errors.email}</span>}
</div>
<button type="submit">Submit</button>
</form>
);
};
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
# 常用的 React Form 框架
-
需要和 antd design 一起使用,最早是基于高阶组件实现状态逻辑的重用,在 Hooks 出现之后,也基于 Hooks 重构了 Form 的实现。
-
最早将 React 中的 form 数据逻辑单独提取出来的表单框架,Formik 出现的时候还没有 Hooks,所以它其实利用的是 render props 设计模式实现了状态逻辑的重用。在 Hooks 出现之后, Formik 也提供了 Hook 的 API 去实现表单逻辑。
Formik 只提供了表单状态逻辑的重用,并没有限制使用何种 UI 库,这就意味着 Formik 在提供灵活性的同时,也意味着我们要自己管理如何进行 UI 布局以及错误信息的展示。
React Hook Form (opens new window)
在 Hooks 出现之后,完全基于 Hooks 实现的表单状态管理框架。区别于 antd 和 formik 的一个最大特点是,React Hook Form 是通过非受控组件的方式进行表单元素的管理。这可以避免很多的表单重新渲染,从而对于复杂的表单组件可以避免性能问题。
和 formik 一样,React Hook Form 也没有绑定到任何 UI 库,所以我们同样需要自己处理布局和错误信息的展示。
# React 单向数据流
React 的数据流主要包含了三种数据:属性 props、状态 state 和上下文 context。
只有这三种数据的变更会自动通知到 React 框架,触发组件必要的重新渲染。当数据流中混入了不属于它们其中任意一种的数据,就要小心,这种跳出 “三界之外” 的数据很有可能带来 Bug,比如数据改变了但组件并不重新渲染。
# props
自定义 React 组件接受一组输入参数,用于改变组件运行时的行为,这组参数就是 props。
在声明函数组件时,函数的第一个参数就是 props。它有两种写法:
在组件内部读取 props 对象的属性;
通过 ES6 的解构赋值语法展开函数参数,直接在组件内部读取单个 prop 变量。
这两种写法本质上是一样的,不过后者有一些很方便的功能。
// 为 prop 设置默认值
function MyComponent({ prop1, prop2, optionalProp = "default" }) {}
// 利用 ES2018 的 Rest Properties 语法,将解构剩余属性赋值给一个变量,便于透传给子元素
function MyComponent({ prop1, prop2, ...restProps }) {
return (
<ul {...restProps}>
<li>{prop1}</li>
<li>{prop2}</li>
</ul>
);
}
2
3
4
5
6
7
8
9
10
11
12
注意
无论是哪种写法,props 都是不可变的,不能在组件内改写从外面传进来的 props。
当 prop 值为布尔值的 true 时,JSX 可以简写成 <MyComponent booleanProp />
。此外还有一个特殊的 props,代表子元素的 children。
props 的数据流向是单向的,只能从父组件流向子组件,而不能从子组件流回父组件,也不能从当前组件流向平级组件。
# state
组件也可以拥有自己的数据。对一个函数组件来说,因为每次渲染函数体都会重新执行,函数体内变量也会被重新声明,如果需要组件在它的生命周期期间拥有一个 “稳定存在” 的数据,那就需要为组件引入一个专有的概念,即 state。
在函数组件中使用 state,需要调用 useState/useReducer。
state 与 props 一样,也是不可变的。需要修改 state 时,不能直接给 state 变量赋值,而是必须调用 state 更新函数,即 setXxx/dispatch。
当组件的 state 发生改变时,组件将重新渲染。那什么才算是改变呢?
从底层实现来看,React 是用 Object.is() (opens new window) 来判断两个值是否不同的。尤其注意,当新旧值都是对象、数组、函数时,判断依据是它们的值引用是否不同。
state 的更新具有异步性和自动批处理。
当读取和更改 state 都发生在同一组件中时,state 的流动仅限于当前组件之内。如果希望由子组件或后代组件来更改 state,需要将对应的 state 更新函数包在另一个函数,比如事件处理函数中,然后将函数以 props 或 context 的方式传给子组件或后代组件,由它们来决定调用的时机和参数。当这个函数被调用,state 被更新,当前组件则会重新传染。
# context
context 用于组件跨越多个组件层次结构,向后代组件传递和共享 “全局” 数据。使用方法如下:
// 1. 调用 React.createContext 方法创建 Context 对象
const MyContext = React.createContext("初始值");
// 2. 在组件 JSX 中使用 <MyContext.Provider> 组件,定义 value 值,并将子组件声明在前者的闭合标签里
function MyComponent() {
const [state, setState] = useState("文本");
const handleClick = () => {
setState("更新文本");
};
return (
<MyContext.Provider value={state}>
<ul>
<MyChildComponent />
<li>
<button onClick={handleClick}>更新 state</button>
</li>
</ul>
</MyContext.Provider>
);
}
// 3. 在子组件或后代组件中使用 useContext Hook 获取 MyContext 的值,这个组件就成为 MyContext 的消费者(Consumer)
function MyChildComponent() {
return <MyGrandChildComponent />;
}
function MyGrandChildComponent() {
const value = useContext(MyContext);
return <li>{value}</li>;
}
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
其中 MyContext.Provider 是可以嵌套使用的。MyGrandChildComponent 组件会去到组件树,从它的祖先节点中找到离它最近的 MyContext.Provider 即 MyComponent,读取后者的 value 值;当 MyComponent 的 state,也就是 MyContext.Provider 的 value 值发生更改时,会通知到它后代组件中所有消费者组件重新渲染。
Context.Provider 的 value 值也可以传一个对象进去,但要注意写法,避免在组件重新渲染时反复创建新的对象,比如利用 state 或 useMemo。
// 不要这样写
function MyComponent() {
const [state, setState] = useState("文本");
// ...
return (
<MyContext.Provider value={{ key: state }}>
<MyChildComponent />
</MyContext.Provider>
);
}
// 可以利用 state
function MyComponent() {
const [obj, setObj] = useState({ key: "文本" });
// ...
return (
<MyContext.Provider value={obj}>
<MyChildComponent />
</MyContext.Provider>
);
}
// 也可以利用 useMemo
function MyComponent() {
const [state, setState] = useState("文本");
const obj = useMemo(() => ({ key: state }), [state]);
// ...
return (
<MyContext.Provider value={obj}>
<MyChildComponent />
</MyContext.Provider>
);
}
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
context 的数据流向也是单向的,只能从声明了 Context.Provider 的当前组件传递给它的子组件树,即子组件和后代组件。而不能向父组件或祖先组件传递,也不能向当前子组件树之外的其他分支组件树传递。
# React 不可变数据
不可变数据是指一旦创建了就不能够被改变的数据。
不能够被改变是指当值发生改变时,重新开辟一个新的空间储存新的值,原有的值仍储存在之前的内存空间中不被改变,本质就是数据的引用地址发生了变化。
# 不可变数据的好处
编写纯函数(Pure Function)更容易;
可以避免函数对传入参数的一些副作用;
检测数据变化更轻量更快;
缓存不可变数据更安全;
保存一份数据的多个版本变得可行。
对于 React 来说,不可变数据可以简化比对数据的实现,降低成本;对于开发者来说,不可变数据在开发和调试过程中更容易被预测。
# 纯组件
在 React 里,纯组件是一个主要用于性能优化的独立 API:当组件的 props 和 state 没有变化时,将跳过这次渲染,直接沿用上次渲染的结果。
有两个 API 可以创建纯组件,函数组件使用 React.memo (opens new window),类组件使用 React.PureComponent (opens new window)。
注意
纯组件只应该作为性能优化的手段,开发者不应该将任何业务逻辑建立在到纯组件的行为上。
# 可持久化数据结构和 Immutable.js
在计算机编程中,可持久化数据结构是一种能够在修改之后保留其历史版本(即可以在保留原来数据的基础上进行修改——比如增添、删除、赋值)的数据结构。这种数据结构实际上是不可变对象,因为相关操作不会直接修改被保存的数据,而是会在原版本上产生一个新分支。
在 JS 中,可持久化数据结构的代表性实现,就是 FB 开源的 immutable.js (opens new window)。这个库提供了 List、Stack、Map、OrderedMap、Set、OrderedSet 和 Record 这些不可变数据类型。用这些类型 API 创建的数据,就是基于可持久化数据结构的不可变数据,可以直接用在 React 中。
# Immer
虽然 Immutable.js 很强大,但是在 React 项目中使用这个框架时,总是要时刻提醒自己,什么时候可以使用 JS 原生的数据类型,什么时候就必须切换到不可变数据类型,这增加了开发者在开发过程中的认知负荷。这虽然会提高程序运行效率,但同时也会降低开发者的开发效率。
那么有没有一种方式,既可以沿用熟悉的 JS 数据类型和方法,又可以优雅地加入不可变性呢?
Immer (opens new window) 就是这样一款框架,它可以让 JS 开发者使用原生的 JS 数据结构,和本来不具有不可变性的 JS API,创建和操作不可变数据。
在函数组件中,可以直接使用 Immer 提供的 Hooks 来替代 useState。
npm install immer use-immer
补充
Object.freeze 可以用于不可变数据吗?
无论是 Object.freeze 还是基于它实现的 deepFreeze,都对被 freeze 的对象有一定要求,比如引用关系不能形成环,不能 freeze window 对象等。最让人担心的还是它在 JS 严格模式和非严格模式下的行为不一致:修改一个 freeze 后的属性,严格模式下会抛 error ,非严格模式下修改失败但代码会继续往下执行。
# React 高阶组件
# 什么是高阶组件
高阶组件(HOC,Higher-Order Component)本质上是⼀个函数,它接收⼀个组件作为参数,然后返回⼀个新的组件。返回的新组件将拥有被包裹的组件的所有 props,并且可以添加额外的 props 或状态。
HOC 可以⽤于抽象出组件之间的共享代码,以增强组件的复⽤性和可维护性。也可以⽤于控制 props,封装组件状态,或者通过引⽤(ref)来访问组件实例。
具有以下特征的函数就是高阶组件:
const EnhancedComponent = withSomeFeature(WrappedComponent);
// ----------------- --------------- ----------------
// | ---- | |
// | | | |
// 增强组件 约定前缀 高阶组件 原组件
2
3
4
5
或者这样:
const EnhancedComponent = withSomeFeature(args)(WrappedComponent);
// ----------------- --------------- ---- ----------------
// | | | |
// | 高阶函数 参数 |
// | -------------------- |
// | | |
// 增强组件 高阶组件 原组件
2
3
4
5
6
7
为了开发高阶组件,一般可以先把多个组件公共的逻辑或者交互,抽取成为一个父组件,再封装成高阶组件。
比如下面这个高阶组件 withLoggedInUserContext,在用户尚未登录时显示登录对话框,登录成功后从服务器端读取当前用户数据,并把用户数据放到 LoggedInUserContext 中,供后代组件使用。
export const LoggedInUserContext = React.createContext();
const withLoggedInUserContext = (WrappedComponent) => {
const LoggedInUserContainer = (props) => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [currentUserData, setCurrentUserData] = useState(null);
useEffect(() => {
const fetchCurrentUserData = async () => {
const res = await fetch("/api/user");
const data = await res.json();
setCurrentUserData(data);
setIsLoading(false);
};
if (isLoggedIn) {
setIsLoading(true);
fetchCurrentUserData();
}
}, [isLoggedIn]);
return !isLoggedIn ? (
<LoginDialog onLogin={setIsLoggedIn} />
) : isLoading ? (
<div>读取中</div>
) : (
<LoggedInUserContext.Provider value={currentUserData}>
<WrappedComponent {...props} />
</LoggedInUserContext.Provider>
);
};
};
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
# 高阶组件的使用场景
事实上,在 React Hooks 成为主流以后,高阶组件还是在 React 组件库或 React 相关框架里的居多,而在 React 应用项目中比较少见。对抽象高阶组件,建议至少满足以下前提之一:
开发 React 组件库或 React 相关框架;
需要在类组件中复用 Hooks 逻辑;
需要复用包含视图的逻辑。
在业务中可能会使⽤到 HOC 的例⼦:
授权和权限管理:例如,你可能有⼀些组件只允许认证过的⽤户访问。你可以创建⼀个 HOC,它接收⼀个组件并返回⼀个新的组件,新的组件在渲染之前会检查⽤户是否已经认证。
数据获取:另⼀个常⻅的 HOC ⽤例是⽤于数据获取。例如,你可以创建⼀个 HOC,它接收⼀个组件并返回⼀个新的组件,新的组件在挂载时获取数据,并将数据通过 props 传递给被包裹的组件。
错误处理:你可以创建⼀个 HOC,它接收⼀个组件并返回⼀个新的组件,新的组件包裹原组件的渲染,并在发⽣错误时显示错误信息或其他备⽤内容。
# React 组件按需加载
要实现一个 React 组件的动态加载,总体思路主要就是三个部分:
1. 定义一个加载器组件,在使用的地方依赖于这个加载器组件而不是原组件;
2. 在加载器组件的执行过程中,使用 import 去动态加载真实的实现代码;
3. 处理加载过程和加载出错的场景,确保用户体验。
React 自身提供了懒加载 api React.lazy (opens new window) 。
在实际的项目开发中,我们一般也会直接使用 react-loadable (opens new window) 来完成按需加载,而不用自己去实现。
这两者基本没有区别,核心机制都是 import。只是 react-lodable 提供的 API 和选项比 React.lazy 丰富。
react-lodable 本身是通过高阶组件来实现的,这个高阶组件实现了模块加载、loading 状态以及错误处理的功能。
loader:用于传入一个加载器回调,在组件渲染到页面时被执行。在这个回调函数中,我们只需要直接使用 import 语句去加载需要的模块就可以了。
loading:表示用于显示加载状态的组件。在模块加载完成之前,加载器就会渲染这个组件。如果模块加载失败,那么 react-loadable 会将 errors 属性传递给 Loading 组件,我们可以根据错误状态来显示不同的信息给用户。
import Loadable from "react-loadable";
import Loading from "./my-loading-component";
const LoadableComponent = Loadable({
loader: () => import("./my-component"), // loader 用于传入一个加载器回调,在组件渲染到页面时被执行
loading: Loading // loading 表示用于显示加载状态的组件
});
export default class App extends React.Component {
render() {
return <LoadableComponent />;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
# React createPortal
Portal 提供了一种将子节点渲染到存在于父组件以外 DOM 节点的方案。
在 CSS 中,我们可以使用 position: fixed 等定位方式,让元素从视觉上脱离父元素。在 React 中,Portal 直接改变了组件的挂载方式,不再是挂载到上层父节点上,而是可以让用户指定一个挂载节点。
React 提供了 React.createPortal (opens new window) 这个 api 来实现这种效果。它有以下几种用法:
渲染子节点到 DOM 的任何部分;
实现模态对话框;
将 React 组件渲染到非 React 服务器标记中;
将 React 组件渲染到非 React DOM 节点。
// 使用 createPortal 定义模态框组件
import { useEffect, useRef, ReactNode } from "react";
import { createPortal } from "react-dom";
const modalRoot = document.querySelector("#modal-root") as HTMLElement;
type ModalProps = {
children: ReactNode;
};
function Modal({ children }: ModalProps) {
// create div element only once using ref
const elRef = useRef<HTMLDivElement | null>(null);
if (!elRef.current) elRef.current = document.createElement("div");
useEffect(() => {
const el = elRef.current!; // non-null assertion because it will never be null
modalRoot.appendChild(el);
return () => {
modalRoot.removeChild(el);
};
}, []);
return createPortal(children, elRef.current);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 使用定义好的 Modal 组件
import { useState } from "react";
function App() {
const [showModal, setShowModal] = useState(false);
return (
<div>
// you can also put this in your static html file
<div id="modal-root"></div>
{showModal && (
<Modal>
<div
style={{
display: "grid",
placeItems: "center",
height: "100vh",
width: "100vh",
background: "rgba(0,0,0,0.1)",
zIndex: 99
}}
>
I'm a modal!{" "}
<button
style={{ background: "papyawhip" }}
onClick={() => setShowModal(false)}
>
close
</button>
</div>
</Modal>
)}
<button onClick={() => setShowModal(true)}>show Modal</button>
// rest of your app
</div>
);
}
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
# React 错误处理
错误边界是 React 中的⼀种特性,它允许你在⼦组件树中捕获 JavaScript 错误,并在发⽣错误时显示备⽤内容,⽽不是让整个组件树崩溃。
错误边界在 React 中只能通过类组件来实现,使用 componentDidCatch (opens new window) 生命周期方法来实现。
react-error-boundary (opens new window) 是一个简单可重用的 React 错误边界组件,它支持所有的 React 渲染器(包括 React DOM 和 React Native)。
# React 对话框处理
利用全局状态来管理对话框。这种方式的核心思路在于从 UI 模式的角度出发,认识到对话框和页面在很多时候是非常类似的,都是一个独立功能的 UI 展现。
使用方法可以看这里 (opens new window)。
import { useCallback, useMemo, useRef } from "react";
import { Modal } from "antd";
import { useSelector, useDispatch } from "react-redux";
const modalCallbacks = {};
// 创建一个处理所有对话框状态的 reducer
export const modalReducer = (state = { hiding: {} }, action) => {
switch (action.type) {
case "nice-modal/show":
return {
...state,
[action.payload.modalId]: action.payload.args || true,
hiding: {
...state.hiding,
[action.payload.modalId]: false
}
};
case "nice-modal/hide":
return action.payload.force
? {
...state,
[action.payload.modalId]: false,
hiding: { [action.payload.modalId]: false }
}
: { ...state, hiding: { [action.payload.modalId]: true } };
default:
return state;
}
};
// action creators
function showModal(modalId, args) {
return {
type: "nice-modal/show",
payload: {
modalId,
args
}
};
}
function hideModal(modalId, force) {
return {
type: "nice-modal/hide",
payload: {
modalId,
force
}
};
}
// 创建一个 hook 处理对话框逻辑
export const useNiceModal = (modalId) => {
const dispatch = useDispatch();
// 将 show 和 resolve 两个函数通过 Promise 联系起来
const show = useCallback(
(args) => {
return new Promise((resolve) => {
// 使用一个局部的临时变量来存放 resolve 回调函数
modalCallbacks[modalId] = resolve;
dispatch(showModal(modalId, args));
});
},
[dispatch, modalId]
);
const resolve = useCallback(
(args) => {
// 调用保存的 resolve 函数,返回 show 函数的 promise 返回值
if (modalCallbacks[modalId]) {
modalCallbacks[modalId](args);
// 确保只 resolve 一次
delete modalCallbacks[modalId];
}
},
[modalId]
);
const hide = useCallback(
(force) => {
dispatch(hideModal(modalId, force));
delete modalCallbacks[modalId];
},
[dispatch, modalId]
);
const args = useSelector((s) => s[modalId]);
const hiding = useSelector((s) => s.hiding[modalId]);
return useMemo(
() => ({ args, hiding, visible: !!args, show, hide, resolve }),
[args, hide, show, resolve, hiding]
);
};
function NiceModal({ id, children, ...rest }) {
const modal = useNiceModal(id);
return (
<Modal
onCancel={() => modal.hide()}
onOk={() => modal.hide()}
afterClose={() => modal.hide(true)}
visible={!modal.hiding}
{...rest}
>
{children}
</Modal>
);
}
export const createNiceModal = (modalId, Comp) => {
return (props) => {
const { visible, args } = useNiceModal(modalId);
if (!visible) return null;
return <Comp {...args} {...props} />;
};
};
NiceModal.create = createNiceModal;
NiceModal.useModal = useNiceModal;
export default NiceModal;
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
# React 缓存机制实现
Service Worker (opens new window) 会拦截所有浏览器发送出来的请求,我们可以通过代码去控制 Rest API 这些请求发送到服务器;而 JS、CSS 等静态资源,则通过 CacheStorage (opens new window) 存储在浏览器端。
和浏览器自动的资源缓存机制相比,Service Worker 加上 CacheStorage 这个缓存机制,具有更高的准确性和可靠性。因为它可以确保两点:
缓存永远不过期。只要下载过一次,就永远不需要再重新下载,除非主动删除。
永远不会访问过期的资源。换句话说,如果发布了一个新版本,那么我们可以通过版本化的一些机制,来确保用户访问到的一定是最新的资源。
# Service Worker
Service Worker 其实是很容易被大家忽视的一种缓存机制,因为它通常是和 PWA(Progressive Web Application)联系在一起的,用于开发离线的 Web 应用。
其实 Service Worker 还提供了拦截前端请求的能力,使得它能够结合 LocalStorage,成为一个独立的缓存方案。所以它不仅可以用于实现前端静态资源的缓存,还能用来开发离线的 Web 应用。
我们可以把 Service Worker 看作一种前端的资源请求代理。每一个前端页面发出的请求都会先由这个代理进行处理,然后我们再决定请求是直接发送到服务器端,还是从本地的 LocalStorage 读取内容返回。
Service Worker 是一段独立于页面之外的 JavaScript 脚本,它并不在 Web 页面中运行,但是会在 Web 页面加载时,由一段代码去触发注册、下载和激活。一旦安装完成之后,Service Worker 就会拦截所有当前域名下的所有请求,由代码逻辑决定应该如何处理。
要使用 Service Worker,基本上分为注册、初始化、拦截请求等步骤,具体可以看这里 (opens new window)。
原生的 service worker 写起来比较麻烦,实际开发过程中可以使用 Google 提供的 Workbox (opens new window) 来辅助快速开发,同时 workbox 也有相应的 webpack 插件 workbox-webpack-plugin (opens new window) 可以用。
# CacheStorage
Cache Storage 也是浏览器提供的一种缓存机制,专门用于缓存一个请求的 request 和 response 的配对关系。此外,它还提供了 API,用来判断某个 request 是不是有对应的 response 已经缓存。所以 Cache Storage 也可以认为是专门为 Service Worker 提供的缓存机制。
# React 项目目录结构
目录结构划分 | 优势 | 劣势 | 适合项目规模 |
---|---|---|---|
单文件结构 | 代码集中 | 单文件代码量有上限 | 代码演示或微型 React 项目 |
单目录结构 | 代码集中 | 单目录下文件总数不宜过多 | 微型或小型 React 项目 |
按文件职能划分目录结构 | 方便定位特定类型的代码 | 不容易定位相关代码;任一职能目录下文件总数不宜过多 | 中小型 React 项目 |
按组件划分目录结构 | 代码共置 | 跨组件的公共逻辑缺少合适的地方 | 中小型 React 项目 |
按业务功能划分目录结构 | 代码共置,便于添加新业务功能 | 跨业务功能的公共逻辑缺少合适的地方 | 大中型 React 项目 |
扩展点机制
在任何可能产生单点复杂度的模块中,通过扩展点的方式,允许其它模块为其增加功能。
这是一种降低模块之间耦合度的架构,对于大中型项目来说可能比较有用,有助于降低大型项目的复杂度,更容易维护。有一个这样的扩展点引擎:js-plugin (opens new window),可以直接使用,也可以参考自己实现。
# React 项目性能分析
# Lighthouse
可以使用 Chrome 浏览器开发者工具中的 Lighthouse (opens new window) 工具。
# React Developer Tools
React 浏览器扩展 React Developer Tools (opens new window) 里,包含一个 Profiler 性能分析功能,也可以用来定位性能问题。建议在设置中勾选 “记录每个组件渲染的原因”,可以帮助我们巩固对组件渲染过程的理解。点击开始分析,在应用中进行一系列操作,然后点击停止,扩展就会生成火焰图和排位图,从中就可以找出与 React 相关的性能问题的根源。
# why-did-you-render
可以使用 why-did-you-render (opens new window) 这个库来帮助我们发现项目中哪些地方会触发重新渲染。
import React from 'react';
if (process.env.NODE_ENV === 'development') {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const whyDidYouRender = require('@welldone-software/why-did-you-render');
whyDidYouRender(React, {
titleColor: 'green',
trackAllPureComponents: true,
trackHooks: true,
});
}
2
3
4
5
6
7
8
9
10
11
why-did-you-render 的实现原理
当在应⽤中使⽤该库时,它会通过包装 React 的组件,重写它们的 shouldComponentUpdate (对于类组件)或 React.memo (对于函数组件)⽅法。在这些⽅法中,它会⽐较前后两次渲染时 props 和 state 的值。
如果 props 或 state 的值在两次渲染之间没有发⽣真正的变化,但组件仍然被重新渲染了,那么就会在控制台中打印⼀条警告消息。这条消息包含组件名称、prop 或 state 的前后值,以及可能的解决建议等信息。
# React 项目质量保证
# 测试金字塔
测试金字塔(Test Pyramid)最初由敏捷开发鼻祖 Mike Cohn 在其著作《Scrum 敏捷软件开发》中提出,主张分层次开展自动化测试以提高测试效率。
金字塔从上到下三层分别是 E2E 测试、整合测试和单元测试。
其中 E2E 和整合测试属于黑盒测试,整合程度更高,单元测试属于白盒测试,运行耗时更短。之所以呈金字塔形状,是因为从测试用例的比重看,E2E 的最少(占 10% 左右),单元测试最多(占 70% 左右)。
E2E 包含了前端和后端,整合测试会将软件模块和它的真实依赖一起测试,如后端的 HTTP 接口测试。而单元测试则会设定特定的输入和输出, 对软件中尽量小的构成单元进行测试,前端、后端都可以做单元测试。
# e2e 测试
端到端测试(End-to-End Testing,简称 E2E Testing),就是从最终用户体验出发,模拟真实的用户场景,验证软件行为和数据是否符合预期的测试。
常用的 e2e 测试工具有:
playwright (opens new window) 使用了现代浏览器原生支持的 CDP 协议(DevTools Protocol),标准较新,运行效率也更高一些。
cypress (opens new window) 是在 Electron 基础上运行了一个高度自定义的浏览器环境,在这个环境中加入了自动化测试的各种功能和 API。
selenium (opens new window) 是基于各个浏览器各自的 WebDriver。
# 单元测试
目前 React 技术社区最为流行的单元测试框架是 Jest + RTL(React Testing Library)。
Jest (opens new window) 是 FB 推出的一款开源 JavaScript 测试框架,用于组织和运行测试用例;
RTL(React Testing Library) (opens new window)是一款开源的轻量级 React 组件测试库,用于在内存中渲染 React 组件并提供工具库用于验证测试的结果。
# React 类组件 vs 函数组件
类组件写法比较繁琐,需要写构造函数和使用 this,函数组件写法比较简洁。
类组件可以使用 React 的状态系统,通过 this.state 来管理组件的状态,以及通过 this.setState 来触发重新渲染,同时它还支持生命周期方法。函数组件则是使用 Hooks 来实现类似于生命周期、状态管理等功能。
类组件调用时需要将组件进行实例化,然后调用实例对象的 render 方法,而函数组件调用时只需要执行函数即可。
在现代 React 开发中,函数组件和 Hooks 已经成为主流,除非有特定的需求,一般情况下更推荐使用函数组件。比如:错误边界在 React 中只能通过类组件来实现,使用 componentDidCatch 生命周期方法来实现。
# React 新老生命周期对比
- 移除的⽣命周期⽅法:componentWillMount、componentWillReceiveProps 和 componentWillUpdate。
这些⽣命周期⽅法在新的版本中被认为是不安全的,因为在异步渲染(React 16.3 引⼊的新特性)中,它们可能会被意外地多次调⽤。使⽤它们可能会导致⼀些难以调试的问题。
- 新增的⽣命周期⽅法:getDerivedStateFromProps 和 getSnapshotBeforeUpdate。
这两个⽅法是为了替代被移除的⽣命周期⽅法,同时提供更好的异步渲染⽀持。
getDerivedStateFromProps ⽣命周期函数在每次渲染前都会被调⽤,包括初始化渲染和后续更新,这使得组件可以在渲染前更新状态,以此来替换 componentWillReceiveProps 的部分功能。
getSnapshotBeforeUpdate 则在 DOM 更新前被调⽤,能够在可能的情况下捕获⼀些 DOM 信息,以此来替代 componentWillUpdate 的部分功能。
- 修改的⽣命周期⽅法:componentDidUpdate 和 componentDidCatch。
它们的功能没有改变,但是它们现在会在提交阶段被调⽤,这是异步渲染引⼊的新阶段。
- 被认为是安全的⽣命周期⽅法:componentDidMount、shouldComponentUpdate 和 componentWillUnmount。
这些⽣命周期⽅法并没有改变,⽽且在异步渲染中被认为是安全的。
# React 首次渲染流程
⾸次渲染是指在 React 应⽤初始加载时进⾏的渲染过程。
这个渲染过程主要包含以下阶段:
1. 初始化阶段
创建根 Fiber 节点,代表整个 React 应⽤的根组件。
根据根组件的类型(函数组件或类组件),创建根 Fiber 节点的初始 Fiber 节点。
调⽤根组件的 render ⽅法(函数组件则执⾏函数体),⽣成初始的虚拟 DOM 树。
2. 渲染阶段
根据初始的虚拟 DOM 树,React 开始进⾏渲染阶段。
React 会遍历虚拟 DOM 树,递归创建组件的 Fiber 节点,并构建 Fiber 树。
在 Fiber 树的构建过程中,React 会为每个组件创建对应的 Fiber 节点,建⽴组件之间的⽗⼦关系。
3. 提交阶段
渲染阶段结束后,React 进⼊提交阶段。
React 将 Fiber 树中的节点转换为真实的 DOM 节点,并将其插⼊到⻚⾯中,完成⾸次渲染。
在提交阶段,React 还会触发⼀些⽣命周期⽅法,如 componentDidMount,以处理组件的挂载逻辑。
⾸次渲染流程的关键步骤包括创建根 Fiber 节点、渲染阶段的 Fiber 树构建,以及提交阶段将虚拟 DOM 转换为真实 DOM 并插⼊⻚⾯。这个过程会触发组件的⽣命周期⽅法,完成组件的初始化和挂载。⼀旦⾸次渲染完成,后续的更新流程将进⼊到协调、更新和提交的循环中,以保持 React 应⽤的更新和渲染。
# React 整体渲染流程
React 的渲染过程可以看作是⼀个⼯作循环。在这个循环中,React 会遍历 Fiber 树,为每个节点调⽤对应的⽣命周期⽅法,并⽣成对应的 DOM 更新。如果在遍历过程中,有更⾼优先级的更新出现,React 可以将当前的⼯作暂停,去处理更⾼优先级的更新。
这个渲染过程主要包含以下阶段:
1. 初始化阶段
创建根 Fiber 节点,代表整个 React 应⽤的根组件。
调⽤根组件的 render ⽅法,创建初始的虚拟 DOM 树。
2. 调度阶段(Scheduler)
使⽤调度器(Scheduler)调度更新,决定何时执⾏更新任务。
检查是否有⾼优先级任务需要执⾏,如⽤户交互事件或优先级较⾼的异步操作。
根据优先级确定任务执⾏顺序,并根据任务的优先级将任务添加到不同的任务队列(Lane)中。
3. 协调阶段(Reconciliation)
从任务队列中取出下⼀个任务。
对任务中涉及的组件进⾏协调,⽐较前后两个虚拟 DOM 树的差异,找出需要更新的部分。
使⽤ DOM diff 算法进⾏差异计算,⽣成需要更新的操作指令。
4. ⽣命周期阶段(Lifecycle)
在协调阶段和提交阶段,React 会根据组件的⽣命周期⽅法调⽤相应的钩⼦函数。
5. 渲染阶段(Render)
在渲染阶段,React 会根据组件的状态变化、props 的更新或者⽗组件的重新渲染等触发条件,重新执⾏组件的函数体(函数组件)或者 render ⽅法(类组件)。
当 React 执⾏函数组件或 render ⽅法时,它会检测组件中是否包含了 Hooks,如果包含了 Hooks,那么 React 会根据 Hooks 的顺序依次调⽤它们。
6. 提交阶段(Commit)
在 Commit 阶段,React 将 Render 阶段⽣成的更新应⽤到真实的 DOM 中,完成⻚⾯的渲染。
在 Commit 阶段,React 可能会执⾏⼀些其他操作,⽐如调⽤⽣命周期⽅法(如 componentDidMount、componentDidUpdate 等)或执⾏其他副作⽤。
根据应⽤程序的交互和状态变化,React 会重复执⾏调度、协调、渲染、提交的步骤,实现更新的循环流程。
# React Fiber 架构
Fiber 是 React 16 中引⼊的新的调和(reconciliation)引擎。在这个新的架构下,React 能够做到调度和优先级,使得 React 可以在执⾏过程中暂停、中断和恢复⼯作,从⽽实现了时间切⽚(time slicing)和并发模式(Concurrent Mode)等特性。
在 Fiber 架构中,每⼀个 React 组件都有⼀个对应的 Fiber 节点,它是⼀个保存了组件状态、组件类型和其他信息的对象。每⼀个 Fiber 节点都链接到⼀个⽗节点、第⼀个⼦节点、兄弟节点,形成了⼀个 Fiber 树。Fiber 树是一个双向链表。
Fiber 架构中采⽤了双缓冲技术,即有两棵 Fiber 树:当前在屏幕上显示的 Fiber 树(current Fiber tree)和下⼀帧要显示的 Fiber 树(work-in-progress Fiber tree)。这种⽅式避免了在渲染过程中直接修改 DOM,提升了性能,并能在出现错误时回退到稳定状态。
在 Fiber 架构中,React 将渲染⼯作分解为多个⼩任务,每个任务的执⾏时间不超过⼀个阈值。这使得 React 可以在⻓时间的渲染任务中,让出控制权给浏览器,处理其他更重要的⼯作,如⽤户的输⼊和动画。这就是所谓的 “时间切⽚”。
在⼯作循环中,不同类型的更新可以有不同的优先级。例如,⽤户的交互会有更⾼的优先级,因为它们需要⽴即响应。这使得 React 能在需要的时候,打断当前的⼯作,去处理更紧急的任务。这就是所谓的 “并发”。
在 Fiber 架构中,如果⼀个组件在渲染过程中发⽣错误,React 会寻找最近的错误边界组件,并将错误传递给它。错误边界组件可以捕获这个错误,并显示⼀个备⽤的 UI,防⽌整个应⽤崩溃。
# React DOM Diff 原理
React DOM Diff 算法有三个策略:
Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计。
拥有相同类型的两个组件将会生成相似的树形结构,拥有不同类型的两个组件将会生成不同的树形结构。
对于同一层级的一组子节点,它们可以通过唯一 id 进行区分。
基于这三个策略,React 分别对 Tree Diff、Component Diff、Element Diff 进行算法优化。
# Tree Diff
Tree Diff 即对树进行逐层对比的过程,两棵树只会对同层次的节点进行比较。
React 通过 updateDepth 对 Virtual DOM 树进行层级控制,也就是同一层,在对比的过程中,如果发现节点不在了,会完全删除,不会对其他地方进行比较,这样只需要对树遍历一次就 OK 了。
由此可见,当出现节点跨层级的移动时,并不会出现想象中移动操作,而是会进行删除,重新创建的动作,这是一种很影响 React 性能的操作。因此 React 官方也不建议进行 DOM 节点跨层级的操作。
我们在开发组件时,保持稳定的 DOM 结构会有助于性能的提升。例如,可以通过 CSS 隐藏或显示节点,而不是真的移除或添加 DOM 节点。
# Component Diff
在进行 Tree Diff 过程中,每层组件级别的对比,叫做 Component Diff。
如果对比前后,组件的类型相同,则按照原策略继续进行 Virtual DOM 比较;
如果对比前后,组件的类型不相同,则需要移除旧组件,创建新组件,并追加到页面上。
如果是同类型的组件,有可能经过一轮 Virtual DOM 比较下来,并没有发生变化。如果我们能够提前确切知道这一点,那么就可以省下大量的 diff 运算时间。因此,React 允许用户通过 shouldComponentUpdate 来判断该组件是否需要进行 diff 算法分析。
# Element Diff
在进行组件对比的时候,如果两个组件类型相同,则需要进行元素级别的对比,这叫做 Element Diff。
Element Diff 对应三种节点操作,分别为:INSERT_MARKUP(插入)、MOVE_EXISTING(移动) 和 REMOVE_NODE(删除)。
插入:新的组件类型不在旧集合中,即全新的节点,需要对新节点进行插入操作。
移动:旧集合中有新组件类型,且 element 是可更新的类型,generateComponent 已调用 recevieComponent,这种情况下 prevChild = nextChild,这时候就需要做移动操作,可以复用以前的 DOM 节点。
比如:当组件 D 在集合 A、B、C、D 中,且集合更新时,D 没有发生更新,只是位置发生了改变,如:A、D、B、C,D 的位置由 4 变换到了 2。如果是传统的 diff,会让旧集合的第二个 B 和新集合的 D 做比较,删除第二个 B,在插入 D;React 中的 diff 并不会这么做,而是通过 key 来进行直接移动。
删除:旧组件类型,在新集合里也有,但对应的 element 不同则不能直接复用和更新,需要执行删除操作,或者旧组件不在新集合里的,也需要执行删除操作。
# React Scheduler 实现原理
React Scheduler 是⼀个 React 内部的任务调度库。它主要⽤于在⻓期执⾏的渲染任务中切分任务,让浏览器在执⾏⻓期任务的空闲时间内有机会处理其他的任务,⽐如⽤户输⼊和动画,以提⾼应⽤的响应性。这也是 React 中时间切⽚(Time Slicing)的核⼼实现。
在 React16 中,Scheduler 作为⼀个实验性的库被引⼊,⽤于实现新的 Fiber 架构和时间切⽚。
在 React17 中,Scheduler 并没有明显的变化,React17 主要在于更改了事件系统,使得 React 能和其他 JavaScript 库更好的共存。
在 React18 中 Scheduler 被更完整的利⽤,以实现并发模式(Concurrent Mode)和新的 Suspense 特性。
Batching 是 React 的⼀个重要特性,它允许 React 将多个状态更新合并为⼀次渲染,以减少不必要的渲染次数和 DOM 更新,从⽽提⾼性能。
# 传统的 batching
在 React 的历史版本中,只有在 React 的事件处理函数和⽣命周期⽅法中,多个状态更新会被⾃动合并为⼀次渲染。这是因为在这些⽅法中,React 有⾜够的上下⽂信息来决定何时开始和结束⼀次 batch。
比如以下的代码会触发⼀次渲染:
handleClick() {
this.setState({count: this.state.count + 1});
this.setState({count: this.state.count + 1});
}
2
3
4
但是在其他的异步代码中,React 没有⾜够的信息来决定何时开始和结束⼀次 batch,所以每个状态更新都会导致⼀次渲染。
比如以下的代码会触发两次渲染:
setTimeout(() => {
this.setState({ count: this.state.count + 1 });
this.setState({ count: this.state.count + 1 });
}, 1000);
2
3
4
# Automatic batching
然⽽,在 React18 中,引⼊了⼀个新的特性叫做 automatic batching。这个特性使得在任何地⽅,只要是连续的多个状态更新,都会被⾃动合并为⼀次渲染。这使得性能优化变得更简单,开发者⽆需考虑是否在⼀个 batch 中。
比如以下的代码在 React18 中也会触发⼀次渲染:
setTimeout(() => {
setCount(count + 1);
setCount(count + 1);
}, 1000);
2
3
4
这是⼀个重要的改进,它使得 React 的性能优化更加⾃动化,开发者⽆需过多的考虑性能问题。
# 使用 MessageChannel 实现
Scheduler 需要满足以下功能点:
暂停 JS 执行,将主线程还给浏览器,让浏览器有机会更新页面;
在未来某个时刻继续调度任务,执行上次还没有完成的任务。
要满足这两点就需要调度一个宏任务,因为宏任务是在下次事件循环中执行,不会阻塞本次页面更新。而微任务是在本次页面更新前执行,与同步执行无异,不会让出主线程。
React Scheduler 使用 MessageChannel (opens new window) 的目的就是为了产生宏任务。
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
2
3
4
5
6
7
8
1. 为什么不用微任务?
因为微任务将在页面更新前全部执行完,所以达不到将主线程还给浏览器的目的。
2. 为什么不用 setTimeout?
因为递归执行 setTimeout(fn, 0) 时,最后间隔时间会变成最小 4 毫秒,这会浪费一些时间。
3. 为什么不用 requestAnimationFrame?
requestAnimationFrame 是在浏览器渲染前,微任务执行后执行的,但其实浏览器并没有规定应该何时渲染页面,因此执行时机不是很准确。
有可能过了几次 loop 才调用一次 requestAnimationFrame,React Task 就会被搁置太久;
将 React Task 放到 requestAnimationFrame 中,依然有可能会阻塞渲染。
4. 为什么不用 requestIdleCallback?
requestIdleCallback (opens new window) 可以告诉浏览器,在你空闲的时候执行我的指定任务。
但是 requestIdleCallback 的兼容性太差,在苹果上根本不支持。而且它也有触发时机不稳定的问题。
# useState 是同步的还是异步的
在 React18 之前的版本中,
如果是在正常的 React 上下文环境中,setState 是异步执行的。多次执行 setState,只会调用一次重新渲染 render。
如果是在 Promise.then、setTimeout、setInterval 等异步事件中,setState 是同步执行的。多次执行 setState,每一次的执行都会调用一次重新渲染 render。
而在 React18 版本中,不管是在同步环境中使用还是在异步环境中使用,setState 都是异步执行的。
# 什么是状态撕裂
状态撕裂是指在并发渲染(Concurrent Mode)中,由于渲染的优先级不同,可能导致应⽤中的不同部分看到的同⼀份共享状态不⼀致的问题。
这是因为在 Concurrent Mode 下,React 可以选择暂停、中断或延迟某些更新,以优先处理更重要的更新。如果你的状态更新和组件的渲染不是同步的,那么就可能出现状态撕裂的问题。React 团队正在开发⼀个新的特性(React Server Components)和新的 Hook(如 useTransition 和 useDeferredValue)来帮助开发者解决这个问题。
# React 状态管理库如何选择
前端状态管理库的选择取决于多种因素,包括项⽬需求、技术栈、团队熟悉度以及应⽤的复杂度。
⽬前,流⾏的前端状态库包括 Redux (opens new window)、MobX (opens new window)、Vuex (opens new window)、Context API(React) (opens new window)、Zustand (opens new window)、Recoil (opens new window)、Jotai (opens new window)、XState (opens new window) 等。
以下是这些状态管理库的基本原理以及选择它们时的考虑因素:
# Redux
原理:Redux 使⽤单⼀不可变状态树(store)来管理状态。它通过纯函数(reducers)来描述状态的变化。
特点:⾼度可预测、透明且易于测试。有强⼤的中间件⽀持。
适⽤场景:适合⼤型应⽤,特别是当需要⾼度可预测的状态管理时。
Redux 提供了⼀个集中式的状态存储,可以让你在任何地⽅访问和修改状态。如果你的应⽤状态逻辑⽐较复杂,或者你需要在应⽤的不同部分共享⼤量的状态,那么 Redux 可能是⼀个好选择。
# MobX
原理:MobX 基于可观察(observable)对象和⾃动追踪依赖(autorun)的机制来管理状态。
特点:简单易⽤,⾃动追踪状态变化。代码量相对较少。
适⽤场景:适合追求简洁代码和灵活状态管理的应⽤。
与 Redux 的 "单⼀数据源,不可变状态" 的原则不同,MobX 允许有多个状态源,并且状态可以是可变的。
# Vuex
原理:Vuex 专为 Vue.js 设计,基于单⼀状态树和严格的同步更新。
特点:与 Vue.js 深度集成,易于理解和使⽤。
适⽤场景:适⽤于中⼤型 Vue.js 应⽤。
# Context API (React)
原理:React 的 Context API 允许组件共享状态,⽽⽆需显式地通过组件树传递。
特点:简单,⽆需额外库。适⽤于轻量级的状态共享。
适⽤场景:适合 React 应⽤中的简单状态管理。
# Zustand
原理:Zustand 是⼀个轻量级的状态管理库,使⽤原⽣的 React hooks。
特点:简洁、⽆样板代码,灵活性⾼。
适⽤场景:适合需要快速开发且状态管理不过于复杂的 React 应⽤。
# Recoil
原理:Recoil 是为 React 应⽤设计的状态管理库,使⽤原⼦(atoms)和选择器(selectors)来管理状态。
特点:可以更细粒度地控制状态,与 React 的 Concurrent Mode 兼容。
适⽤场景:适⽤于需要更细致状态控制的 React 应⽤。
# Jotai
Jotai 的主要⽬标是提供⼀个简单且轻量的全局状态管理⼯具,它对 Concurrent Mode 完全友好,并尝试解决 React 状态共享的问题。
Jotai 的 API 极其简单,它将状态分解为 atom(即最⼩的状态单元)。这个库的优势在于其原⼦状态可以被细粒度地订阅,因此,当状态改变时,只有依赖这个状态的组件才会重新渲染,这避免了⽆效的组件重新渲染。
如果你的应⽤有许多独⽴但⼜需要共享的状态,Jotai 可能是⼀个很好的选择。
// 创建一个原子状态
import { atom } from "jotai";
const countAtom = atom(0);
2
3
// 使用这个原子状态
// 每⼀个使⽤ useAtom(countAtom) 的组件只会在 countAtom 更新时重新渲染
import { useAtom } from "jotai";
import { countAtom } from "./atoms";
function Counter() {
const [count, setCount] = useAtom(countAtom);
return (
<div>
<div>Count: {count}</div>
<button onClick={() => setCount(count + 1)}>increment</button>
</div>
);
}
2
3
4
5
6
7
8
9
10
11
12
13
# XState
原理:XState 是基于有限状态机(Finite State Machines, FSM)和状态图(Statecharts)的状态管理库。它提供了⼀种形式化的⽅法来建模应⽤状态。
特点:
显式状态管理:状态及其转换在状态机中被显式定义,这使得状态的管理更加可预测和可理解。
可视化⼯具:XState 提供了可视化⼯具来帮助开发者设计和理解状态机。
复杂逻辑处理:⾮常适合处理具有复杂状态逻辑的应⽤,如⼯作流、多步骤表单、游戏等。
# React Concurrent Mode 是什么
React15 采⽤不可中断的递归⽅式更新的 Stack Reconciler (⽼架构)。
React16 采⽤可中断的遍历⽅式更新的 Fiber Reconciler (新架构)。Fiber 的主要⽬标是通过异步渲染和更细粒度的任务调度来优化性能。
React17 废弃了 React16 中处理优先级采⽤的 expirationTime 模型,⽤了 Lane。
expirationTime 模型使⽤ expirationTime (⼀个时间⻓度)来描述任务的优先级;⽽ Lane 模型则使⽤⼆进制数来表示任务的优先级,它提供了⼀个新的优先级排序的思路,相对于 expirationTime 来说,它对优先级的处理会更细腻,能够覆盖更多的边界条件。
React17 带来的 Concurrent Mode 是⼀种提供更好⽤户体验的渲染模式,它允许 React 在处理⼤型和复杂应⽤时更灵活地分配和调度⼯作。
ReactDOM.render(); // sync 模式
ReactDOM.createRoot(document.getElementById("root")).render(); // concurrent 模式
2
React18 继续废弃 React17 的开启⽅式,采⽤默认启动。但是请配合 startTransition 才能启动满⾎版本。
React 的 Concurrent Mode 是 React18 中引入的⼀种新的渲染模式,它使 React 能够在多个状态更新中进⾏ “时间切⽚”,从⽽使得⻓时间运⾏的渲染任务不会阻塞浏览器的主线程。这种模式可以提⾼应⽤的响应性,特别是在复杂的⽤户界⾯和/或设备性能较低的情况下。
在传统的同步渲染模式中,React 会在⼀个状态更新发⽣时阻塞主线程,直到所有的组件都渲染完成。在⼀些情况下,这可能会导致应⽤变得不响应,因为主线程在渲染过程中⽆法处理其他任务,⽐如⽤户输⼊和动画。
⽽在 Concurrent Mode 中,React 会把渲染任务分解成多个⼩任务,每个任务的执⾏时间都很短。在这些任务之间,React 会给出⼀些空闲的时间,让浏览器有机会处理其他的任务。这就是所谓的 “时间切⽚”。Concurrent Mode 使得 React 应⽤能够以更细粒度的⽅式进⾏渲染和更新,并提供更好的⽤户交互和响应能⼒。Concurrent Mode 的实现使得 React 能够在处理复杂应⽤时,更好地平衡渲染和性能。
Fiber 是 React 内部的⼀种架构,⽤于管理组件的渲染;⽽ Concurrent Mode 是⼀种运⾏模式,它利⽤了 Fiber 的异步渲染能⼒,使得 React 可以在处理多个任务时进⾏更好的协调。
Concurrent Mode 的实现涉及到以下⼏个⽅⾯:
1. 异步渲染:
Concurrent Mode 引⼊了异步渲染的概念,即将渲染任务分割为多个⼩任务,并使⽤调度器(Scheduler)来优先处理⽤户交互和⾼优先级任务。
异步渲染使得 React 能够以递增的⽅式对应⽤进⾏更新,并在每个更新阶段中尽快对⽤户提供反馈。
2. 调度器(Scheduler):
Concurrent Mode 使⽤了⼀个全新的调度器,称为新的调度器(New Scheduler)。
新的调度器采⽤优先级调度算法,允许 React 根据任务的优先级和类型动态地安排任务的执⾏顺序。
调度器在 Concurrent Mode 中负责任务的调度和优先级排序,确保任务按照正确的优先级和顺序执⾏。
3. 任务优先级:
Concurrent Mode 引⼊了任务优先级的概念,使得 React 能够根据任务的紧迫程度和重要性来分配优先级。
React 为不同类型的任务(如⽤户交互、动画、数据更新等)赋予不同的优先级,确保紧急任务得到更快的响应和处理。
4. 时间切⽚(Time Slicing):
时间切⽚是 Concurrent Mode 的⼀个关键特性,它将渲染任务切分为⼀系列⼩的时间⽚段。
每个时间⽚段的执⾏时间被控制在⼀定范围内,确保不会阻塞主线程,使得浏览器能够及时响应⽤户输⼊和其他⾼优先级的任务。
# useTransition 的作用和应用场景
useTransition (opens new window) 是⼀个在 React18 中引⼊的新的 Hook,它与 Concurrent Mode 紧密相关。
# 作用
useTransition 使得开发者可以告诉 React 哪些更新是可中断的。这样,React 可以先处理那些紧急的任务(如⽤户输⼊),⽽将不紧急的任务(如⼤列表的渲染)推迟到稍后处理。这有助于减少⻓任务造成的主线程阻塞,提⾼应⽤的响应速度。
在 Concurrent Mode 中, useTransition 可以让你的应⽤在等待新的数据时保持响应,同时在数据准备好之后再平滑的过渡到新的状态。
# 应用场景
1. 加载指示器
使⽤ useTransition 钩⼦来管理数据加载状态。当数据正在加载时,你可以显示⼀个加载指示器,⽽不阻⽌⽤户继续与应⽤交互。
const [isPending, startTransition] = useTransition();
const fetchData = () => {
startTransition(() => {
// 触发数据加载
});
};
return <>{isPending ? <Spinner /> : <DataComponent />}</>;
2
3
4
5
6
7
2. 输⼊响应优化
在处理⾼频更新的输⼊组件时,使⽤ useTransition 来保证输⼊的流畅性。
const [inputValue, setInputValue] = useState("");
const [isPending, startTransition] = useTransition();
const handleInputChange = (e) => {
startTransition(() => {
setInputValue(e.target.value);
});
};
return <input type="text" value={inputValue} onChange={handleInputChange} />;
2
3
4
5
6
7
8
3. ⼤列表或复杂视图渲染
对于渲染⼤型数据集或复杂视图的更新,使⽤ useTransition 来避免界⾯卡顿。
const [isPending, startTransition] = useTransition();
const updateList = (newList) => {
startTransition(() => {
// 更新列表数据
});
};
2
3
4
5
6
# Lane 是什么
React17 的 Lane 模型和 Concurrent Mode 都是为了更好地⽀持 React Suspense,它们在⼀起可以更好地处理复杂的异步更新和任务调度。
在 React16 中,即使是在启⽤了 Concurrent Mode 的情况下,Suspense 也可能在⼀些情况下表现得不够理想。当多个 Suspense 组件同时进⾏数据加载时,它们可能会阻塞其他的更新,甚⾄阻塞整个应⽤,直到所有的数据都加载完成。这可能会导致不必要的渲染延迟和⽤户体验下降。
在 React17 中,通过引⼊ Lane 模型,React 可以更智能地处理并调度各种不同的更新。Suspense 组件现在可以被分配到不同的 Lane 上,这使得 React 能够更好地管理和调度 Suspense 组件的加载和渲染。对于那些被 Suspense 捕获的异步更新,React 可以暂时将它们推迟,⽽去优先处理其他更⾼优先级的更新,从⽽改善应⽤的响应速度和性能。
# React Suspense 是什么
Suspense (opens new window) 是 React16.6 引入的一个特性,用于处理组件的异步加载以及在数据获取等异步操作时的等待状态。
主要用途:
异步组件加载:Suspense 可以用于包装异步加载的组件。当该组件及其子组件正在加载时,Suspense 可以显示一个指定的 “fallback”(等待)UI,而不是立即渲染加载未完成的组件。
数据获取等待:Suspense 还可以用于包装在组件中发起的异步操作,如数据获取。这可以让你在数据准备好之前展示一个 loading 状态,而不是在数据未准备好时渲染错误或空白状态。
应用场景:
- 按需加载组件:使用 React.lazy (opens new window) 和 Suspense (opens new window) 可以实现按需加载,只在需要时加载组件,而不是一开始就加载所有组件,从而提高应用性能。
import React, { Suspense } from "react";
const AsyncComponent = React.lazy(() => import("./AsyncComponent"));
function App() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<AsyncComponent />
</Suspense>
</div>
);
}
export default App;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- 数据获取等待:在数据获取时,可以使用 Suspense 包裹相关组件,以便在数据准备好之前显示 loading 状态,提高用户体验。
const fetchData = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve("Data loaded!");
}, 2000);
});
};
function DataFetchingComponent() {
const data = fetchData();
return <div>{data}</div>;
}
function App() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<DataFetchingComponent />
</Suspense>
</div>
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# useEffect 和 useLayoutEffect 有什么区别
useLayoutEffect (opens new window) 和 useEffect (opens new window) 是 React 中用于处理副作用的两个钩子函数,它们之间的主要区别在于执行时机。
useEffect
useEffect 是在浏览器完成绘制之后才执行的,因此不会阻塞浏览器的渲染。
适用于大多数副作用,尤其是那些不需要同步测量和修改 DOM 的情况。
useEffect(() => {
// 在浏览器完成绘制后执行
// 可能会导致视觉上的延迟
}, [dependencies]);
2
3
4
useLayoutEffect
useLayoutEffect 是在浏览器布局之后、绘制之前执行的,因此会阻塞浏览器的渲染。
如果在 useLayoutEffect 中进行 DOM 操作,这些操作会在浏览器绘制之前生效,因此可以确保在浏览器绘制之前获取到准确的 DOM 尺寸和位置。
由于 useLayoutEffect 是同步的,如果执行的操作耗时较长,可能会阻塞浏览器渲染。
useLayoutEffect(() => {
// 在浏览器完成绘制之前执行
// 可以进行 DOM 操作,确保在浏览器绘制之前生效
}, [dependencies]);
2
3
4
通常情况下,如果你的副作用不依赖于 DOM 的准确尺寸和位置,而且可以异步执行,那么使用 useEffect 是更好的选择。如果需要确保在浏览器绘制之前获取到准确的 DOM 尺寸和位置,那么可以考虑使用 useLayoutEffect。
# useEffect 的 4 种执行时机
- 不提供第二个依赖项参数
当不提供第二个参数时,useEffect 中的代码会在每次组件渲染后都执行。这可能导致一些性能问题,特别是如果 useEffect 中包含了对状态的读写操作,可能会导致不必要的重新渲染。
- 提供一个空数组作为依赖项
当提供一个空数组作为依赖项时,useEffect 中的代码仅在组件挂载时执行一次。这是一种常见的用法,用于执行只需要在组件挂载和卸载时执行的副作用,例如订阅事件、初始化等。
- 提供依赖项数组
当提供一个包含依赖项的数组时,useEffect 中的代码会在组件挂载时以及依赖项发生变化时执行。这是用于处理需要根据某些状态或属性变化而触发的副作用的情况。
- useEffect 中的 return
useEffect 中的 return 语句用于返回一个清理函数,该函数在组件卸载时或依赖项变化时执行。这可以用于清理一些副作用,例如取消订阅、清除定时器等。如果 useEffect 中的依赖项数组为空,清理函数会在组件卸载时执行。
# React Hooks 为什么不能写在条件判断里
React Hooks 使⽤⼀个单向链表来保存组件的状态和副作⽤。在每次组件渲染时,React 会遍历这个链表,按照定义的顺序依次执⾏每个 Hook 对应的状态更新和副作⽤函数。通过链表的形式,React 可以保证 Hook 的调⽤顺序⼀致,并正确地跟踪每个 Hook 的状态和更新。
# React 如何创建 Ref,创建 Ref 的⽅式有什么区别
在 React 中,Ref 主要⽤于获取和操作 DOM 元素或者 React 组件的实例,是⼀种逃脱 props 传递的⽅法。
# 类组件中创建 Ref
在类组件中,可以使用 React.createRef() 和回调 Ref 来创建 Ref。
- React.createRef():这是 React 16.3 版本后推出的新 API,使⽤这种⽅式创建的 Ref 可以在整个组件的⽣命周期中保持不变。
class MyComponent extends React.Component {
constructor(props) {
super(props);
this.myRef = React.createRef();
}
componentDidMount() {
const node = this.myRef.current;
// 现在可以访问 DOM 节点或者组件实例
}
render() {
return <div ref={this.myRef} />;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
- 回调 Ref:可以使⽤⼀个函数作为 Ref。这个函数将会在组件挂载和卸载时分别被调⽤,并将 DOM 节点或组件实例作为参数。
class MyComponent extends React.Component {
componentDidMount() {
// 现在可以访问 DOM 节点或者组件实例
}
render() {
return <div ref={(node) => (this.myRef = node)} />;
}
}
2
3
4
5
6
7
8
# 函数组件中创建 Ref
在函数组件中,可以使⽤ React.useRef() 和 回调 Ref 来创建 Ref。
- React.useRef():这是 React Hooks API 的⼀部分,可以在函数组件中使⽤。使⽤ useRef 创建的 Ref 在整个组件的⽣命周期中保持不变。
import React, { useRef } from "react";
function MyComponent() {
const myRef = useRef(null);
React.useEffect(() => {
const node = myRef.current;
// 现在可以访问 DOM 节点或者组件实例
}, []);
return <div ref={myRef} />;
}
2
3
4
5
6
7
8
9
- 回调 Ref:也可以在函数组件中使⽤回调 Ref。这种⽅式会提供⼀个函数,该函数会在组件挂载和卸载时分别被调⽤,并将 DOM 节点或组件实例作为参数。
import React, { useEffect, useState } from "react";
function MyComponent() {
const [myRef, setMyRef] = useState(null);
useEffect(() => {
if (myRef) {
// 现在可以访问 DOM 节点或者组件实例
}
}, [myRef]);
return <div ref={(node) => setMyRef(node)} />;
}
2
3
4
5
6
7
8
9
10
# 两种创建 Ref 方式的区别
React.createRef() / React.useRef() 创建的 Ref 更简洁,API 更⼀致,⽽且 Ref 的值在组件的整个⽣命周期中保持不变。
回调 Ref 更灵活,它允许你在组件挂载和卸载时执⾏⼀些额外的逻辑。但是,如果回调函数是在 render ⽅法中定义的,那么每次 render 时都会创建⼀个新的函数实例,可能会导致⼀些性能问题。
因此,除⾮有特殊需求,否则建议使⽤ React.createRef() / React.useRef() 来创建 Ref。
# React 服务端渲染
服务端渲染就是指将页面数据和页面模板组装成 html 的过程在服务端进行,客户端不需要渲染页面。
在 React 服务端渲染(Server Side Rendering,SSR)中,hydrate 是⼀个重要的步骤。
在服务器端渲染的过程中,服务器端会⽣成 HTML 字符串,然后在浏览器端,React 需要接管这些已经渲染好的 HTML,使其变得可以交互。这个过程称为 hydration。
从 React16 开始,React 引⼊了 ReactDOM.hydrate 来替代 ReactDOM.render 进⾏ hydration。如果你在服务器端渲染了⼀些内容,然后想在客户端上接管这个接⼝,你需要使⽤ ReactDOM.hydrate ⽽不是 ReactDOM.render 。
从 React18 开始,React 官⽅计划引⼊部分 hydration 的能⼒,这将改善⽤户体验,提⾼性能。部分 hydration 的主要思想是:React 应⽤在初始渲染后并不⽴即 hydrate 全部组件,⽽是根据⽤户的交互⾏为或者组件的优先级,按需进⾏ hydrate。这意味着,React18 的应⽤在启动时可能不需要进⾏全部的 hydrate,但在具体的使⽤过程中,还是需要 hydrate 操作来使得服务端渲染的 HTML 变得可交互。
# React 同构开发
React 同构开发⼜称为 Isomorphic JavaScript,是指同⼀份 React 代码能够在服务器端执⾏并⽣成 HTML,然后在浏览器端接管渲染并进⼀步响应⽤户交互的开发⽅式。
# 优势
提升⾸屏渲染速度,增强⽤户体验。
对于搜索引擎优化(SEO)有益,因为搜索引擎爬⾍可以直接解析服务器返回的 HTML 内容。
# 注水脱水
React 同构渲染涉及到 “注⽔” 和 “脱⽔” 的概念,这两个术语来源于 React 服务器端渲染(SSR)的两个重要步骤。
脱⽔(Dehydration):在服务器端,React 将组件树渲染为 HTML 字符串,并⽣成与应⽤状态相关的数据(即 "脱⽔" 数据)。这个过程就被称为 “脱⽔”。然后,服务器将渲染后的 HTML 和脱⽔数据⼀起发送到客户端。
注⽔(Rehydration):在客户端,React 使⽤服务器发送过来的脱⽔数据来恢复应⽤的状态,这个过程被称为 “注⽔”。然后 React 会将服务器渲染的 HTML 和客户端组件树进⾏匹配,如果匹配成功,React 将接管这些已经存在的 DOM 节点,使其变得可以交互。
这种同构渲染的⽅式有⼏个好处:
⾸先,⽤户可以更早地看到⻚⾯内容,因为浏览器⽆需等待所有的 JavaScript 代码下载和执⾏完毕就可以显示服务器渲染的 HTML;
其次,由于服务器已经⽣成了初始状态,客户端⽆需再次获取数据;
最后,React 可以复⽤服务器渲染的 DOM,避免了额外的 DOM 操作,从⽽提⾼了性能。
但需要注意,这种⽅式也有⼀些缺点,例如服务器端渲染可能会增加服务器的负载,以及在注⽔过程中可能会出现⼀些问题,如数据不⼀致或者错误的状态等。
# 部署方式
使⽤ Next.js:Next.js 是⼀个流⾏的、基于 React 的通⽤ JavaScript 框架。它处理了很多同构开发中的常⻅问题,包括路由、数据预取和预渲染等。使⽤ Next.js 通常可以节省⼤量开发时间,并且提供了⼀个成熟稳定的平台。
⾃⼰开发:这需要使⽤ Node.js 服务器(如 Express)来预渲染 React 应⽤,然后发送⽣成的 HTML 到浏览器。这种⽅式需要解决很多细节问题,例如:代码分割、数据预取、路由匹配等。
云平台选择:主要有 AWS 和 Cloudflare。
在部署同构应⽤时,你需要在服务器上部署 Node.js 环境,并在其中运⾏你的 Next.js 或⾃⼰开发的应⽤。你还需要配置适当的反向代理(如 Nginx),以便将请求转发到 Node.js 服务器。
在实际落地过程中,可能会遇到以下⼀些挑战:
数据预取:在服务器端,我们需要在渲染⻚⾯之前预取数据。常⻅的解决⽅案包括在 React 组件中使⽤静态的 loadData ⽅法,或者使⽤像 Next.js 这样的框架。
组件的⽣命周期⽅法:只有部分⽣命周期⽅法会在服务器端渲染过程中被调⽤,例如 componentDidMount 和 componentDidUpdate 就不会被调⽤。因此需要确保应⽤的逻辑不依赖这些只在客户端执⾏的⽣命周期⽅法。
性能和资源使⽤:服务器端渲染会占⽤更多的服务器资源(CPU 和内存)。需要通过适当的架构和优化来保证服务器的性能,例如使⽤流式渲染、设置合理的缓存策略等。
流式渲染
流式渲染是指服务器⽣成 HTML 的过程可以以数据流(stream)的形式逐步发送到客户端,⽽不是等待全部内容⽣成完成后⼀次性发送。
这样做的优势主要有两点:
提升⾸屏渲染速度:浏览器可以更早地开始解析和渲染⻚⾯,提⾼⽤户体验。
减少服务器内存使⽤:由于服务器不需要为每个请求保存完整的 HTML 字符串,因此可以显著减少服务器的内存使⽤。
# 影响的性能指标
同构渲染⼀般会对以下性能指标产⽣影响:
⾸次内容绘制(FCP,First Contentful Paint):SSR 可以减少 FCP 时间,因为浏览器可以在接收到服务器返回的 HTML 后⽴即开始渲染⻚⾯。
⾸次有意义的绘制(FMP,First Meaningful Paint):SSR 也可能减少 FMP 时间,因为服务器预渲染的⻚⾯通常包含了⻚⾯的主要内容。
不过 FMP 已经从 Web 性能指标中移除了。现在 LCP、FID 和 CLS 是核⼼ Web Vitals,它们是 Google 推荐所有⽹站关注的最重要的性能指标。
# Vue 和 React 对于组件的更新粒度有什么区别
# React 的更新粒度
React 是自顶向下的进行递归更新的,不论组件的层级多深,所有的组件都会递归重新 render(在不进行手动优化的情况下),这是性能上的灾难。因此,React 创造了 Fiber,创造了异步渲染,其实本质上是弥补被自己搞砸了的性能。
React 能用 Vue 收集依赖的这套体系吗?不能,因为 React 遵从 Immutable 的设计思想,永远不在原对象上修改属性,那么基于 Object.defineProperty 或 Proxy 的响应式依赖收集机制就无从下手了。你永远返回一个新的对象,我哪知道你修改了旧对象的哪部分?
由于没有响应式的收集依赖,React 只能递归的把所有子组件都重新 render一遍,然后再通过 diff 算法 决定要更新哪部分的视图,这个递归的过程叫做 reconciler,听起来很酷,但是性能很灾难。
# Vue 的更新粒度
Vue 中每个组件都有自己的渲染 watcher,它掌管了当前组件的视图更新,但是并不会掌管子组件的更新。这也就是为什么说 Vue 的响应式更新粒度是精细到组件级别的。