React 基础

React 入门:从 MV* 到组件化

React 是一个声明式的 JavaScript 库,用于构建用户界面。它的核心公式很简单:UI = render(data)——数据单向流动,你描述 UI 应该长什么样,React 负责把它渲染出来。

但在理解 React 之前,有必要先搞清楚它试图解决什么问题。我们来从 MVC 和 MVVM 这两种经典模式说起。

从 MVC 到 MVVM

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
// model

var myapp = {} // 创建这个应用对象

myapp.Model = function () {
var val = 0

this.add = function (v) {
if (val < 100) val += v
}

this.sub = function (v) {
if (val > 0) val -= v
}

this.getVal = function () {
return val
}

/* 观察者模式 */

var self = this
var views = []

this.register = function (view) {
views.push(view)
}

this.notify = function () {
for (var i = 0; i < views.length; i++) {
views[i].render(self)
}
}
}

// view
myapp.View = function (controller) {
var $num = document.getElementById('num')
var $incBtn = document.getElementById('increase')
var $decBtn = document.getElementById('decrease')

this.render = function (model) {
$num.innerText = model.getVal() + 'rmb'
}

/* 绑定事件 */
$incBtn.addEventListener('click', controller.increase)
$decBtn.addEventListener('click', controller.decrease)
}

// controller
myapp.Controller = function () {
var model = null, view = null

this.init = function () {
/* 初始化 Model 和 View */
model = new myapp.Model()
view = new myapp.View(this)
/* View 向 Model 注册,当 Model 更新就会去通知 View */
model.register(view)
model.notify()
}

/* 让 Model 更新数据并通知 View 更新视图 */
this.increase = function () {
model.add(1)
model.notify()
}

this.decrease = function () {
model.sub(1)
model.notify()
}
}

// init

(function () {
var controller = new myapp.Controller()
controller.init()
}())

MVVM(Model-View-ViewModel)在 MVC 基础上引入了数据绑定——ViewModel 层自动同步 Model 和 View 的状态,开发者不需要手动操作 DOM。Vue 和 Angular 都走的这条路。

React 选的是另一条路:不管 MVC 还是 MVVM,它只专注 View 层。数据变化时,React 重新计算整个 UI 的描述(VDOM),然后通过 diff 算法找出最小变更,只更新必要的 DOM 节点。这就是”声明式”的含义——你描述结果,框架处理过程。

JSX 模版语法

JSX 是 JavaScript 的语法扩展,让你在 JS 中直接写类似 HTML 的标记。因为本质上是 JS,所以使用 camelCase 命名属性(className 而不是 classtabIndex 而不是 tabindex)。

JSX 里的 {} 中可以放任何 JS 表达式:

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 name = 'Josh Perez'
const element = <h1>Hello, {name}</h1>


function formatNmae (user) {
return user.firstName + ' ' + user.lastName
}
// 方法
const user = {
firstName: 'Harper',
lastName: 'Perez'
}

const element = (
<h1>
Hello, {formatNmae(user)}!
</h1>
)

function getGreeting (user) {
if (user) {
return <h1>Hello, {formatNmae(user)}!</h1>
}
return <h1>Hello, Stranger.</h1>
}

JSX 指定属性

1
const element = <img src={user.avatarUrl}></img>

JSX 最终会被 Babel 编译为 React.createElement 调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const element = (
<h1 className="greeting">
Hello, world!
</h1>
)
// 等同于 React.createElement
const element = React.createElement(
'h1',
{className: 'greeting'},
'Hello, world!'
)

const element = {
type: 'h1',
props: {
className: 'greeting',
children: 'Hello, world!'
}
}

JSX 可以当做语法糖体验,在 babel repl 可以看到编译结果。实际项目中使用 @babel/preset-react

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 使用 ReactDOM.render
const element = <h1>Hello, world</h1>
ReactDOM.render(element, document.getElementById('root'))

// render 只能代表当前时刻的状态
// 更新元素只能再次 ReactDOM.render

function tick () {
const element = (
<div>
<h1>Hello, world</h1>
<h2>It is {new Date().toLocaleTimeString()}.</h2>
</div>
)
ReactDOM.render(element, document.getElementById('root'))
}

setInterval(tick, 1000) // 不建议多次 render

JSX 转 JS

JSX 可以当做语法糖,可以在 babel 官⽹中尝试,https://babeljs.io/repl 可以使⽤官⽹提供的 create-react-app npm run eject 来看 babelrc 中的配置,主要使⽤https://www.babeljs.cn/docs/babel-preset-react

1
2
# 安装 babel 及 react 的依赖
npm install core-js @babel/core @babel/preset-env @babel/preset-react @babel/regiser babel-loader @babel/plugin-transform-runtime --sabe-dev

.babelrc

1
2
3
4
5
6
7
8
9
10
{
"presets": [
"@babel/preset-env",
"@babel/preset-es2015",
"@babel/preset-react"
],
"plugins": [
"@babel/plugin-transform-runtime"
]
}

组件、Props 与 State

React 组件在概念上等同于 JavaScript 函数——接受输入(props),返回 React 元素。

函数组件与 Class 组件

1
2
3
4
5
6
7
8
9
function Welcome (props) {
return <h1>Hello, {props.name}</h1>
}

class Welcome extends React.Component{
render() {
return <h1>Hello, {this.props.name}</h1>
}
}
渲染组件
1
2
3
4
5
6
7
8
9
function Welcome (props) {
return <h1>Hello, {props.name}</h1>
}

const element = <Welcome name="Sara" />
ReactDOM.render(
element, document.getElementById('root')
)

Props:只读的输入

所有 React 组件都必须像纯函数一样保护它们的 props 不被更改。传入的 props 是只读的。

State:组件的内存

State 让组件拥有自己的数据。和 props 不同,state 完全由组件自己控制。

下面是一个时钟的例子——对比用 props 驱动(需要外部反复调用 ReactDOM.render)和用 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
55
56
57
58
59
// 使用 props 形式
function Clock(props) {
return (
<div>
<h1>Hello, world!</h1>
<h2>It is {props.date.toLocaleTimeString()}.</h2>
</div>
);
}

function tick() {
ReactDOM.render(
<Clock date={new Date()} />,
document.getElementById('root')
);
}

setInterval(tick, 1000);

// 如何避免多次 ReactDOM.render ?
// 引入生命周期,根组件保留一个

class Clock extends React.Component {
constructor(props) {
super(props);
this.state = {date: new Date()};
}

componentDidMount() {
this.timerID = setInterval(
() => this.tick(),
1000
);
}

componentWillUnmount() {
clearInterval(this.timerID);
}

tick() {
this.setState({
date: new Date()
});
}

render() {
return (
<div>
<h1>Hello, world!</h1>
<h2>It is {this.state.date.toLocaleTimeString()}.</h2>
</div>
);
}
}

ReactDOM.render(
<Clock />,
document.getElementById('root')
);

关于 setState 的几个关键行为

  1. 构造函数是唯一可以直接给 this.state 赋值的地方,其他时候必须用 setState
  2. setState 的更新可能是异步的——React 会批量合并多个 setState 调用来优化性能。因此不要依赖 this.state 的当前值来计算下一个状态,改用函数式写法:
1
2
3
4
// 错误
this.setState({ counter: this.state.counter + this.props.increment });
// 正确
this.setState((state, props) => ({ counter: state.counter + props.increment }));
  1. State 更新是浅合并的——只更新你传入的字段,其他字段保持不变
  2. 数据向下流动:state 只在当前组件内生效,每个组件实例的 state 是独立的

生命周期

React 的 Class 组件有一系列生命周期方法,按阶段分为:挂载、更新、卸载。

常用的几个

  • render:Class 组件唯一必须的方法。应该是纯函数——不修改 state,每次调用返回相同结果
  • constructor:初始化 state 或绑定方法。不要在这里调用 setState,也不要直接把 props 赋值给 state
  • componentDidMount:组件挂载后立即调用。适合做 DOM 初始化、网络请求、订阅。可以在这里调用 setState,但会触发额外渲染
  • componentDidUpdate(prevProps, prevState):更新后调用。常用于比较 props 变化后执行副作用。务必加条件判断,否则死循环:
1
2
3
4
5
componentDidUpdate(prevProps) {
if (this.props.userID !== prevProps.userID) {
this.fetchData(this.props.userID);
}
}
  • componentWillUnmount:组件卸载前调用。清除 timer、取消网络请求、取消订阅。不要在这里调用 setState

不常用的

  • shouldComponentUpdate(nextProps, nextState):性能优化,返回 false 跳过渲染
  • getDerivedStateFromProps(props, state):根据 props 变化同步更新 state(少用,通常有更好的方案)
  • getSnapshotBeforeUpdate:在 DOM 更新前捕获信息(如滚动位置)
  • getDerivedStateFromError + componentDidCatch:实现错误边界

已废弃的UNSAFE_componentWillMountUNSAFE_componentWillReceivePropsUNSAFE_componentWillUpdate——不要在新代码中使用。

事件处理

React 事件命名的几个规则:

  1. 使用 camelCase(onClick 而不是 onclick
  2. 传入函数而不是字符串
  3. 阻止默认行为必须调用 preventDefault(),不能通过 return false

Class 组件中要注意 this 绑定。下面的代码中,如果不绑定 thishandleClick 里的 this 会是 undefined

1
2
3
4
5
6
7
8
9
10
11
12
function ActionLink() {
function handleClick(e) {
e.preventDefault();
console.log('The link was clicked.');
}

return (
<a href="#" onClick={handleClick}>
Click me
</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
class Toggle extends React.Component {
constructor(props) {
super(props);
this.state = {isToggleOn: true};

// 为了在回调中使用 `this`,这个绑定是必不可少的
this.handleClick = this.handleClick.bind(this);
}

handleClick() {
this.setState(state => ({
isToggleOn: !state.isToggleOn
}));
}

render() {
return (
// class 的方法默认不会绑定 this。如果没有绑定 this.handleClick 并把它传入了 onClick
// this 的值为 undefined。
<button onClick={this.handleClick}>
{this.state.isToggleOn ? 'ON' : 'OFF'}
</button>
);
}
}

ReactDOM.render(
<Toggle />,
document.getElementById('root')
);

为什么要绑定 this? React 的 JSX 事件处理本质上等价于 domObj.onclick = params.onclick——函数被赋值给了 DOM 元素,this 丢失了原来的上下文。

三种绑定方式

  1. constructor 中 bind(推荐,只绑定一次)
  2. 箭头函数 class property(实验性语法,但 create-react-app 默认支持)
  3. 箭头函数包裹(不推荐,每次 render 创建新函数,可能导致子组件不必要的重渲染)

接收参数

  1. 事件对象 e 会被作为第⼆个参数传递;
  2. 通过箭头函数的⽅式,事件对象必须显式的进⾏传递;
  3. 通过 Function.prototype.bind 的⽅式,事件对象以及更多的参数将会被隐式的进⾏传递;
1
2
<button onClick={(e) => this.deleteRow(id, e)}>Delete Row</button>
<button onClick={this.deleteRow.bind(this, id)}>Delete Row</button>

条件渲染与列表

条件渲染的四种方式

  1. if/else:在 render 方法中使用 JS 条件判断,返回不同的 JSX
  2. 三元运算符{isLoggedIn ? <LogoutButton /> : <LoginButton />}
  3. && 运算符{unreadMessages.length > 0 && <h2>...</h2>}。注意:左侧为 0 时会渲染出 0,因为 0 是 falsy 但不是 React 会跳过的值
  4. 返回 null:组件返回 null 则什么都不渲染

列表与 Key

渲染列表时必须给每一项指定 key。key 帮助 React 在 diff 时识别哪些元素变化了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function NumberList(props) {
const numbers = props.numbers;
const listItems = numbers.map((number) =>
<li key={number.toString()}>
{number}
</li>
);
return (
<ul>{listItems}</ul>
);
}

const numbers = [1, 2, 3, 4, 5];
ReactDOM.render(
<NumberList numbers={numbers} />,
document.getElementById('root')
);

若没有 key,会 warning a key should be provided for list items;
key 可以帮助 react diff,最好不要用 index 作为 key,会导致性能变差;
如果不指定显式的 key 值,那么 React 将默认使用索引用作为列表项目的 key 值。

Key 的三个注意点

  • key 要放在 map() 最外层的元素上,不要放在被遍历组件内部
  • key 只需要在兄弟节点之间唯一,全局不需要唯一
  • 不要用 index 作为 key(除非列表是静态的、不会重新排序的)。用 index 会导致错误的复用,在列表项增删移动时出 bug

深入理解

React 如何防御 XSS

React DOM 在渲染所有输入内容之前,默认会进行转义。所以直接写 {userInput} 是安全的:

1
2
const title = response.potentiallyMaliciousInput; // 只是字符串
const element = <h1>{title}</h1>; // 安全,React 会转义

源码中的转义逻辑对 <>"'& 做了处理,防止注入 HTML。

setState 的”异步”真相

setState 的异步不是真正的异步——它本身是同步执行的,但 React 出于性能优化,在合成事件和生命周期中对多个 setState 做了批量处理

具体表现:

  • 合成事件和生命周期中:setState 是”异步”的,连续多次调用会被合并,只能拿到更新前的值。但可以用 setState(partialState, callback) 中的 callback 获取更新后的结果
  • 原生事件和 setTimeout 中:setState 是同步的,不会批量更新
  • 多次 setState 同一值:后面的覆盖前面的
  • 多次 setState 不同值:合并后批量更新

这是 React 18 以前的行为。React 18 的 Automatic Batching 让所有更新都默认批量处理(包括 setTimeout 和原生事件)。