七天接手react项目 系列 —— 生命周期&受控和非受控组件&Dom 元素&Diffing 算法

其他章节请看:

七天接手react项目 系列

生命周期&受控和非受控组件&Dom 元素&Diffing 算法

生命周期

首先回忆一下 vue 中的生命周期:

vue 对外提供了生命周期的钩子函数,允许我们在 vue 的各个阶段插入一些我们的逻辑,比如:createdmountedbeforeDestroy等。

vue-生命周期图

react 中的生命周期是否也类似?请接着看:

每个组件都包含 “生命周期方法”,你可以重写这些方法,以便于在运行过程中特定的阶段执行这些方法 —— react 官网-组件的生命周期

请看一张 react 的生命周期图谱

react-生命周期图谱

从这张图我们知道:

  • 既然没有勾选”展示不常用的生命周期“,这里显示的 5 个方法就是常用的生命周期方法
  • 组件的生命周期可以分三个阶段:挂载、更新、卸载
  • 挂载时的顺序是:constructor()render()componentDidMount()

Tip

  • componentDidMount() 会在组件挂载后(插入 DOM 树中)立即调用。常做定时器、网络请求
  • componentDidUpdate() 会在更新后会被立即调用。首次渲染不会执行此方法
  • componentWillUnmount() 会在组件卸载及销毁之前直接调用。在此方法中执行必要的清理操作,例如,清除 timer,取消网络请求或清除在 componentDidMount() 中创建的订阅等

挂载和卸载

以 Clock 组件为例:

当 Clock 组件第一次被渲染到 DOM 中的时候,就为其设置一个计时器。这在 React 中被称为“挂载(mount)”。

同时,当 DOM 中 Clock 组件被删除的时候,应该清除计时器。这在 React 中被称为“卸载(unmount)”。

请看实现:

class Clock extends React.Component {
    state = { date: new Date() }
    componentDidMount() {
        this.timerID = setInterval(
            () => this.tick(),
            1000
        )
    }
    // 组件卸载前会被调用
    componentWillUnmount() {
        clearInterval(this.timerID) // {1}
    }
    tick() {
        this.setState({
            date: new Date()
        });
    }
    handleUnmount = () => {
        // 从 DOM 中卸载组件
        ReactDOM.unmountComponentAtNode(document.getElementById('root'))
    }
    render() {
        return (
            <div>
                <h1>Hello, world!</h1>
                <h2>It is {this.state.date.toLocaleTimeString()}.</h2>
                <button onClick={this.handleUnmount}>卸载</button>
            </div>
        );
    }
}

页面显示:

Hello, world!
It is 11:34:16.

卸载

时间每秒都会更新,点击按钮”卸载“,页面将不再有任何信息,对应的 html 为 <div id="root"></div>

TipunmountComponentAtNode() 从 DOM 中卸载组件,会将其事件处理器(event handlers)和 state 一并清除。

:倘若将 clearInterval(this.timerID)(行{1})注释,点击”卸载“将报错如下:

Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in the componentWillUnmount method.

警告:无法对卸载的组件执行 React 状态更新。 这是一个空操作,但它表明您的应用程序中存在内存泄漏。 要修复,请取消 componentWillUnmount 方法中的所有订阅和异步任务。
不要将定时器放入 render()

倘若将上面例子中的定时器放在 render() 中。就像这样:

render() {
    console.log(1)
    // 定时器
    this.timerID = setInterval(
        () => this.tick(),
        1000
    )
    return (
        // ...不变
    );
}

之前 render() 每秒执行一次,现在很快就会执行过万,因为每次执行都会生成一个定时器。

过时的生命周期方法

以下生命周期方法标记为“过时”。这些方法仍然有效,但不建议在新代码中使用它们 —— 官网-过时的生命周期方法

  • componentWillMount,现在改名为 UNSAFE_componentWillMount(),在挂载之前被调用

  • componentWillReceiveProps,现在改名为 UNSAFE_componentWillReceiveProps(),在已挂载的组件接收新的 props 之前被调用。第一次传的不算,以后传的才算,有人说应该叫 componentWillReceiveNewProps

  • componentWillUpdate,现在改名为 UNSAFE_componentWillUpdate(),当组件收到新的 props 或 state 时,会在渲染之前调用。

倘若用了重命名之前的方法,控制台会有详细的警告信息。请看示例:

class Clock extends React.Component {
    componentWillMount() {

    }
    UNSAFE_componentWillReceiveProps() {

    }
}

控制台输出:

Warning: componentWillMount has been renamed, and is not recommended for use. See https://reactjs.org/link/unsafe-component-lifecycles for details.

* Move code with side effects to componentDidMount, and set initial state in the constructor.
* Rename componentWillMount to UNSAFE_componentWillMount to suppress this warning in non-strict mode. In React 18.x, only the UNSAFE_ name will work. To rename all deprecated lifecycles to their new names, you can run `npx react-codemod rename-unsafe-lifecycles` in your project source folder.

Please update the following components: Clock

Tip:既然这几个方法不建议使用,所以不打算深入研究

UNSAFE_ 不是指安全性

这里的 “unsafe” 不是指安全性,而是表示使用这些生命周期的代码在 React 的未来版本中更有可能出现 bug,尤其是在启用异步渲染之后 —— 官网-异步渲染之更新

shouldComponentUpdate

shouldComponentUpdate() 默认返回 true。用法如下:

class Clock extends React.Component {
    state = { date: new Date() }
    componentDidMount() {
        this.timerID = setInterval(
            () => this.tick(),
            1000
        )
    }
    tick() {
        this.setState({
            date: new Date()
        });
    }
    render() {
        return (
            <div>
                <h1>Hello, world!</h1>
                <h2>It is {this.state.date.toLocaleTimeString()}.</h2>
            </div>
        );
    }
    // 返回 false
    shouldComponentUpdate() {
        return false
    }
}

Clock 的时间不会再变化。render() 方法也不会再执行。请看下图:

react-生命周期图2

调用 setState(),如果 shouldComponentUpdate() 返回 false 则中断,不再执行 render()

Tip:此方法仅作为性能优化的方式而存在。不要企图依靠此方法来“阻止”渲染,因为这可能会产生 bug —— 官网-shouldComponentUpdate()

forceUpdate

根据上图说明,调用 forceUpdate() 将致使组件调用 render() 方法,此操作会跳过该组件的 shouldComponentUpdate()

通常应该避免使用 forceUpdate()

新增生命周期方法

相对旧的生命周期,新增如下两个方法,但都属于不常见的情形,所以不做详细研究。

getDerivedStateFromProps

getDerivedStateFromProps() 会在调用 render 方法之前调用,并且在初始挂载及后续更新时都会被调用。它应返回一个对象来更新 state,如果返回 null 则不更新任何内容。

此方法适用于罕见的用例,即 state 的值在任何时候都取决于 props。

getDerivedStateFromProps 的存在只有一个目的:让组件在 props 变化时更新 state —— 官网-什么时候使用派生 state

getSnapshotBeforeUpdate

getSnapshotBeforeUpdate() 在最近一次渲染输出(提交到 DOM 节点)之前调用。它使得组件能在发生更改之前从 DOM 中捕获一些信息(例如,滚动位置)。

此用法并不常见,但它可能出现在 UI 处理中,如需要以特殊方式处理滚动位置的聊天线程等。

在函数组件中使用生命周期

我们可以在函数组件中使用 useEffect 来模拟常见的生命周期钩子:componentDidMount()componentDidUpdate()componentWillUnmount()

体验 useEffect

首先我们运行一个例子:

function MyButton() {
    const [count, setCount] = React.useState(0)

    const add = () => {
        setCount(count + 1)
    }

    const unMount = () => {
        ReactDOM.unmountComponentAtNode(document.getElementById('root'))
    }

    // React.useEffect() 将写在此处 {1}
    
    return (
        <div>
            <button onClick={add}>{count}</button> <button onClick={unMount}>卸载</button>
        </div>
    );
}
ReactDOM.render(
    <MyButton />,
    document.getElementById('root')
)

页面显示两个按钮:

0 卸载

第一个按钮显示一个数字,每点击一次就会自增 1,点击第二个按钮,此组件就会被卸载。

我们接下来在行{1}处添加 React.useEffect() 相关代码。请看示例:

// 相当于 componentDidMount()、componentDidUpdate()
React.useEffect(() => {
    console.log('a')
})

页面渲染后就会输出 a,之后每点击第一个按钮都会输出 a,点击卸载没有输出。

可以给 useEffect 传递第二个参数,它是 effect 所依赖的值数组 —— 官网-effect 的条件执行

倘若给 useEffect 第二个参数传递一个空数组,表明没有依赖值:

// 相当于 componentDidMount()
React.useEffect(() => {
    console.log('a')
}, [])

页面渲染后就会输出 a,但点击第一个按钮就不会再有输出。

通常,组件卸载时需要清除 effect 创建的诸如订阅或计时器 ID 等资源。要实现这一点,useEffect 函数需返回一个清除函数 —— 官网-清除 effect

倘若给 useEffect 函数返回一个函数。请看示例:

 React.useEffect(() => {
    console.log('a')
    return () => {
        console.log('b')
    }
}, [])

页面渲染后就会输出 a,但点击第一个按钮就不会再有输出,点击卸载输出 b

优化函数组件 Clock 中的定时器

在函数组件中使用 state中我们写过这么一个例子:

function Clock() {
    const [name] = React.useState('pjl')
    const [date, setDate] = React.useState(new Date())

    setInterval(() => {
        console.log('setInterval')
        setDate(new Date())
    }, 1000)

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

十秒就会输出一千多次 setInterval。定时器应该只执行一次,放在 componentDidMount 生命钩子中比较合适。以下是优化后的增强版:

function Clock() {
    // console.log('Clock')
    const [name] = React.useState('pjl')
    const [date, setDate] = React.useState(new Date())

    React.useEffect(() => {
        console.log('useEffect')
        const timerId = setInterval(() => {
            // console.log('setInterval')
            setDate(new Date())
        }, 1000)

        return () => {
            clearInterval(timerId)
        }
    }, [name])

    const unMount = () => {
        ReactDOM.unmountComponentAtNode(document.getElementById('root'))
    }

    return (
        <div>
            <h1>Hello, world! {name}</h1>
            <h2>It is {date.toLocaleTimeString()}.</h2>
            <button onClick={unMount}>卸载</button>
        </div>
    );
}

受控组件和非受控组件

在大多数情况下,我们推荐使用 受控组件 来处理表单数据。在一个受控组件中,表单数据是由 React 组件来管理的。另一种替代方案是使用非受控组件,这时表单数据将交由 DOM 节点来处理 —— 官网-非受控组件

这里我们能接收两个信息:

  1. 推荐使用受控组件
  2. 受控组件和非受控组件的区别在于:表单数据由谁来处理 —— 是 react 组件管理,还是 dom 来处理。

受控组件

将表单写为受控组件:

class NameForm extends React.Component {
    state = { value: '' }
    // 值若改变,则将其更新到 state 中
    handleChange = event => {
        this.setState({ value: event.target.value });
    }

    // 提交表单
    handleSubmit = event => {
        console.log('提交的名字: ' + this.state.value);
        event.preventDefault();
    }

    render() {
        return (
            <form onSubmit={this.handleSubmit}>
                <label>
                    名字:
                    <input type="text" value={this.state.value} onChange={this.handleChange} />
                </label>
                <input type="submit" value="提交" />
            </form>
        );
    }
}
ReactDOM.render(
    <NameForm />,
    document.getElementById('root')
);

页面显示

名字:[     输入框       ] 提交

在输入框中输入”123“,点击”提交“按钮,控制台将输出 ”提交的名字: 123“。

非受控组件

重写 NameForm 组件,改为功能相同的非受控组件:

class NameForm extends React.Component {
    input = React.createRef()
    handleSubmit = event => {
        console.log('提交的名字: ' + this.input.current.value);
        event.preventDefault();
    }

    render() {
        return (
            <form onSubmit={this.handleSubmit}>
                <label>
                    名字:
                    <input type="text" ref={this.input} />
                </label>
                <input type="submit" value="提交" />
            </form>
        );
    }
}

勿过度使用 Refs —— 官网

Tip:倘若发生事件的元素,是你要操作的元素时,可以通过 event.target 取得 dom。

高阶函数和函数柯里化优化受控组件

按照受控组件中的写法,如果我们定义多个 input,我们就得写多个 handleXxxx 处理方法。就像这样:

class NameForm extends React.Component {
    state = { name: '', age: '' }

    // 2 个 input 对应 2 个处理方法
    handleName = event => {
        this.setState({ name: event.target.value });
    }
    handleAge = event => {
        this.setState({ age: event.target.value });
    }

    handleSubmit = event => {
        console.log({ name: this.state.name, age: this.state.age });
        event.preventDefault();
    }

    render() {
        return (
            <form onSubmit={this.handleSubmit}>
                <label>
                    名字:
                    <input type="text" value={this.state.name} onChange={this.handleName} />
                </label>
                <label>
                    年龄:
                    <input type="text" value={this.state.age} onChange={this.handleAge} />
                </label>
                <input type="submit" value="提交" />
            </form>
        );
    }
}

如果我们有10个,岂不是要写10个处理方法!我们可以用高阶函数函数柯里化来对其优化。请看实现:

class NameForm extends React.Component {
    state = { name: '', age: '' }

    // saveFormField 既是`高阶函数`,也使用了`函数柯里化`
    saveFormField = (stateName) => {
        return (event) => {
            this.setState({ [stateName]: event.target.value }) // {1}
        }
    }

    handleSubmit = event => {
        console.log({ name: this.state.name, age: this.state.age });
        event.preventDefault();
    }

    render() {
        return (
            <form onSubmit={this.handleSubmit}>
                <label>
                    名字:
                    <input type="text" value={this.state.name} onChange={this.saveFormField('name')} />
                </label>
                <label>
                    年龄:
                    <input type="text" value={this.state.age} onChange={this.saveFormField('age')} />
                </label>
                <input type="submit" value="提交" />
            </form>
        );
    }
}

Tipthis.setState({ [stateName]: event.target.value }) 使用的语法是 可计算属性名

高阶函数

高阶函数是处理函数的函数,只要满足其中一个条件即可:

  • 参数是函数
  • 返回函数

js 内置的高阶函数有:Array.forEach、setInterval、Promise等。

函数柯里化

通过函数调用继续返回函数,实现多次接收参数最后统一处理的函数编码形式。

最二的一个示例是将:

function sum(a,b,c){
    return a + b + c
}

改成 sum(1)(2)(3) 的形式。就像这样:

const sum = (a) => {
    return (b) => {
        return (c) => {
            return a + b + c
        }
    }
}

// 6
console.log(sum(1)(2)(3))

DOM 元素

React 实现了一套独立于浏览器的 DOM 系统,兼顾了性能和跨浏览器的兼容性。我们借此机会完善了浏览器 DOM 实现的一些特殊情况 ——官网-DOM 元素

在 React 中,所有的 DOM 特性和属性(包括事件处理)都应该是小驼峰命名的方式。例如,与 HTML 中的 tabindex 属性对应的 React 的属性是 tabIndex。

:例外的情况是 aria-* 以及 data-* 属性,一律使用小写字母命名。比如, 你依然可以用 aria-label 作为 aria-label。

React 与 HTML 之间有很多属性存在差异,下面以 onChange 为例。

Tip:比如 react 中用 htmlFor 代替 for,其他更多介绍请看 DOM 元素

onChange

onChange 事件与预期行为一致:每当表单字段变化时,该事件都会被触发。我们故意没有使用浏览器已有的默认行为,是因为 onChange 在浏览器中的行为和名称不对应,并且 React 依靠了该事件实时处理用户输入 —— 官网-onChange

change 事件并不是每次元素的 value 改变时都会触发 —— mdn-change 事件

原生 html 中 change 事件是这样的:

<body>
    名字:<input name="name" />

    <script>
        document.querySelector('input').
            addEventListener('change', e => console.log(e.target.value))
    </script>
</body>

在输入框中输入 123,点击他处让 input 失去焦点,控制台输出 123

在上面受控组件 NameForm 中增加一行:

class NameForm extends React.Component {
    state = { value: '' }
    handleChange = event => {
      + console.log(event.target.value)
        this.setState({ value: event.target.value });
    }
}

在输入框中输入 123,控制台依次输出:

1
12
123

每当表单字段变化时,该事件都会被触发。事件名和行为相对应。

Diffing 算法

根节点

当对比两棵树时,React 首先比较两棵树的根节点 —— 官网-Diffing 算法

对比不同类型的元素

当根节点为不同类型的元素时,React 会拆卸原有的树并且建立起新的树

举个例子,当一个元素从 <a> 变成 <img>,从 <Article> 变成 <Comment>,或从 <Button> 变成 <div> 都会触发一个完整的重建流程

当卸载一棵树时,对应的 DOM 节点也会被销毁。组件实例将执行 componentWillUnmount() 方法。

在根节点以下的组件也会被卸载,它们的状态会被销毁。比如,当比对以下更变时:

<div>
  <Counter />
</div>

<span>
  <Counter />
</span>

React 会销毁 Counter 组件并且重新装载一个新的组件。

对比同类型的元素

当对比两个相同类型的 React 元素时,React 会保留 DOM 节点,仅比对及更新有改变的属性

比如:

<div className="before" title="stuff" />

<div className="after" title="stuff" />

通过对比这两个元素,React 知道只需要修改 DOM 元素上的 className 属性。

在处理完当前节点之后,React 继续对子节点进行递归。

对比同类型的组件元素

当一个组件更新时,组件实例会保持不变,因此可以在不同的渲染时保持 state 一致。React 将更新该组件实例的 props 以保证与最新的元素保持一致,并且调用该实例的 componentDidUpdate() 方法。

下一步,调用 render() 方法,diff 算法将在之前的结果以及新的结果中进行递归

对子节点进行递归

默认情况下,当递归 DOM 节点的子元素时,React 会同时遍历两个子元素的列表

在子元素列表末尾新增元素时,更新开销比较。比如:

<ul>
  <li>first</li>
  <li>second</li>
</ul>

<ul>
  <li>first</li>
  <li>second</li>
  <li>third</li>
</ul>

React 会先匹配两个 <li>first</li> 对应的树,然后匹配第二个元素 <li>second</li> 对应的树,最后插入第三个元素的 <li>third</li> 树。

如果只是简单的将新增元素插入到表头,那么更新开销会比较。比如:

<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

<ul>
  <li>Connecticut</li>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

React 并不会意识到应该保留 <li>Duke</li><li>Villanova</li>,而是会重建每一个子元素。这种情况会带来性能问题。

Keys

为了解决上述问题(新增元素插入表头开销大),React 引入了 key 属性。以下示例在新增 key 之后,使得树的转换效率得以提高:

<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

现在 React 知道只有带着 ‘2014’ key 的元素是新元素,带着 ‘2015’ 以及 ‘2016’ key 的元素仅仅移动了。

正确使用 key

倘若用元素在数组中的下标作为 key,有时不仅会造成上面所说的性能问题,有时还会造成程序的错误。请看示例:

function Demo() {
    const [todos, setTodos] = React.useState(['a', 'b'])

    const unshift = () => {
        setTodos([++seed, ...todos])
    }
    return (
        <div>
            <ul>
                {
                    todos.map((item, index) => {
                        return <li key={index} data-index={index}> {item} <input type="text" /></li>
                    })
                }
            </ul>
            <button onClick={unshift}>头部插入</button>
        </div>
    )
}

页面显示:

a [   /* input 输入框 */   ]
b [   /* input 输入框 */   ]
头部插入

在第一个输入框中输入 a,在第二个输入框中输入 b,然后点击按钮“头部插入”,界面错乱如下:

1 [a                       ]
a [b                       ]
b [                        ]
头部插入

倘若将 key 改成唯一值,使用相同的操作,界面就正常:

{
    todos.map((item, index) => {
        return <li key={item} data-index={index}> {item} <input type="text" /></li>
    })
}
1 [                        ]
a [a                       ]
b [b                       ]
头部插入

在 Codepen 有两个例子,分别为 展示使用下标作为 key 时导致的问题,以及不使用下标作为 key 的例子的版本,修复了重新排列,排序,以及在列表头插入的问题 —— 官网-Keys

Tip:如果仅做简单展示,用元素在数组中的下标作为 key 也是可以的。

其他章节请看:

七天接手react项目 系列