【React】进阶React的重要知识点
这里只是 React 的相关知识点整理,也算是老生常谈了吧,通过这样的整理让自己温故知新。说是进阶,其实也算是基础的重要知识点。好了,于是我们一起来看看吧:
- 组件
- 组件与 state
- 几种组件通信
- ref
- 虚拟 DOM 相关
- 性能优化
- 高阶组件
组件间的通信
React 里的组件通信方式大致有以下几种情况:
父子组件的通信通过 props
class UserList extends Component { render() { return ( <div> <ul className="user-list"> {this.props.users.map(user => ( <li key={user.id}> <span>{user.name}</span> </li> ))} </ul> </div> ) }}
// UserListContainer.jsximport UserList from './UserList'
class UserListContainer extends Component { constructor(props) { super(props) this.state = { users: [] } } render() { return ( {/* 通过props传递users */} <UserList users={this.state.users} /> ) } componentDidMount() { fetch('/path/api').then(res => { res.json().then(data => { this.setState({ users: data }) }) }) }}子向父组件通信
父组件可以通过子组件的 props 传递给子组件一个回调函数,子组件在需要改变父组件数据时,调用该回调即可。下面为 UserList 增加一个添加新用户功能:
class UserList extends Component { constructor(props) { super(props) this.state = { newUser: '', } } handleChange = (e) => { this.setState({ newUser: e.target.value, }) } handleClick = () => { if (this.state.newUser && this.state.newUser.length > 0) { this.props.onAddUser(this.state.newUser) } } render() { return ( <div> <ul className="user-list"> {this.props.users.map((user) => ( <li key={user.id}> <span>{user.name}</span> </li> ))} </ul> <input onChange={this.handleChange} value={this.state.newUser} /> <button onClick={this.handleClick}>add uesr</button> </div> ) }}兄弟组件通信
兄弟组件不能直接相互传送数据,需要通过状态提升的方式实现兄弟组件的通信:即把组件之间需要共享的 state 保存到距离它们最近的共同父组件内,任意一个兄弟组件都可以通过父组件传递的回调修改共享状态。
组件从服务器上获取数据,不包含组件向服务器提交数据的情况。
组件挂载阶段通信
React 组件的正常运转本质上是组件不同生命周期方法的有序执行,因此组件与服务器的通信也必定依赖组件的声明周期方法。
export default class UserListContainer extends Component { // 省略无关代码 render() { return ( <div> <h1>UserListContainer</h1> </div> ) } componentDidMount() { fetch('/path/api').then((res) => { res.json().then((data) => { this.setState({ users: data, }) }) }) }}componentDidMount 是官方推荐的通信周期,当然 componentWillMount 也是可以的。
-
componentDidMount 中执行服务器通信可以保证获取数据时,组件已经处于挂载状态,即便操作 DOM 也是安全的,而 componentWillMount 无法保证
-
当组件在服务端渲染时,componentWillMount 会被调用两次,一次是服务端,一次是浏览器端,而 componentDidMount 能保证在任何情况下只调用一次,从而不会发送多余的请求
组件更新阶段通信
componentWillReceiveProps 非常适合做更新阶段的服务器通信。
class UserListContainer extends Component { // 省略 componentWillReceiveProps(nextProps) { if (nextProps.category !== this.props.category) { fetch(`/path/api?category=${nextProps.category}`).then((res) => { res.json().then((data) => { this.setState({ users: data, }) }) }) } }}这里需注意:在执行 fetch 请求时,要先对新老 props 中的 category 作比较,有不一致才进行更新。componentWillReceiveProps 并不能保证 props 一定发生了修改
深入理解组件
state 所代表的一个组件 UI 呈现的完整状态又可以分成两类数据:用作渲染组件时使用到的数据的来源以及用作组件 UI 展现形式的判断依据。
如何定义 state
在组件中需要用到一个变量,且与组件的渲染无关时,就应该把这个变量定义为组件的普通属性,直接挂载到 this 下,而不是作为组件的 state。还有就是看 render 方法有无用到该变量,没有就同样挂载到 this 下
state 和 props 都直接和组件的 UI 渲染有关,它们的变化会触发组件的重新渲染,但 props 对于使用它的组件来说是只读的,是通过父组件传递过来的,想要修改 props,只能在父组件中修改,而 state 是组件内部自己维护的状态,是可变的
总结一下,组件中用到一个变量是不是应该作为 state 可通过下面 4 条依据进行判断:
- 该变量是否通过 props 从父组件中获取?若是,则不是 state
- 该变量是否在组件的整个生命周期中保持不变?若是,则不是 state
- 该变量是否可通过其他 state 或 props 计算得到?若是,则不是 state
- 该变量是否在 render 方法中使用?若是,则为 state,反之则不是,该情况下,变量更适合定义为组件的一个普通属性
- 不能直接修改 state,需要用 setState
- state 的更新是异步的
调用 setState 时,组件的 state 不会立即改变,setState 只是把要修改的状态放入一个队列中,React 会优化真正的执行时机,出于性能考虑,可能会将多次 setState 状态修改合并成一次状态修改。所以不要依赖当前 state 去计算下一个 state。当真正执行修改时,依赖的 this.state 并不能保证是最新的 state。同样不能依赖当前 props 计算下一状态,因为 props 的更新也是异步的。
state 与不可变对象
- state 类型是不可变类型(数字、字符串、布尔值、null、undefined)
因 state 是不可变类型,所以直接给要修改的状态赋一个新值即可
this.setState({ count: 1, title: 'React', success: true,})- state 类型是数组
法一:使用 preState、concat 创建新数组
this.setState((preState) => ({ books: preState.books.concat(['React Guide']),}))法二:ES6 spread syntax
this.setState((preState) => ({ books: [...preState.books, 'React Guide'],}))当从 books 中截取部分元素作为新状态时,可使用数组的 slice 方法
this.setState((preState) => ({ books: preState.books.slice(1, 3),}))当从 books 中过滤部分元素后作为新状态,可使用数组的 filter 方法
this.setState((preState) => ({ books: preState.books.filter((item) => { return item !== 'React' }),}))注意,不要使用 push、pop、shift、unshift、splice 等方法修改数组类型的状态,因为这些方法都是在原数组的基础上修改的,而 concat、slice、filter 会返回一个新的数组
state 的类型是普通对象(不包含字符串、数组)
- 使用 ES6 的 Object.assign
this.setState((preState) => ({ owner: Object.assign({}, preState.owner, { name: 'Jason' }),}))- 使用对象扩展语法 Object spread properties
this.setState((preState) => ({ owner: { ...preState.owner, name: 'Jason' },}))总结下,创建新的 state 的关键是,避免使用会直接修改原对象的方法,而是使用可以返回一个新对象的方法。当然也可以使用一些 Immutable 的 JS 库来实现
React状态为什么要是不可变对象呢? 一方面是因为不可变对象的修改会返回一个新对象,不用担心原有对象在不小心情况下被修改导致的错误,方便程序管理与调试。另一方面是出于性能考虑,当对象组件状态都是不可变对象时,在组件的 shouldComponentUpdate 方法中仅需要比较前后两次状态引用就可以判断状态是否真的改变,从而避免不必要的 render 调用。
React 之所以执行效率高,其重要原因是虚拟 DOM 机制。React 应用常用的性能优化也与虚拟 DOM 机制有关。
虚拟 DOM 相关
在 Web 环境中,DOM 也就是对 HTML 文本的一种抽象描述。在传统开发中,通过调用浏览器提供 API 对 DOM 执行增删查改操作。这些操作看似只执行了一条 JS 语法,但其效率要慢得多。因为对 DOM 的修改会引起页面的重布局和重渲染,这过程很耗时,这也是前端性能优化的一条原则,尽量减少 DOM 操作。
虚拟 DOM 是一层抽象(对真实 DOM),建立在真实的 DOM 上。(虚拟 DOM 是一项独立的技术)
<div class="foo"> <h1>Hello React</h1></div>可以用 JS 对象来描述
{ type: 'div', props: { className: 'foo', children: { type: 'h1', props: { children: 'Hello React' } } }}有了虚拟 DOM 这一层,当我们需要操作 DOM 时,就可以操作虚拟 DOM 而不是操作真实 DOM。
Diff 算法
React 采用声明式地 API 描述 UI 结构,每次组件的状态或属性更新,组件的 render 方法都会返回一个新的虚拟 DOM 对象,用来表述新的 UI 结构。如果每次 render 都直接使用新的虚拟 DOM 来生成真实 DOM 结构,那么会带来大量对真实 DOM 的操作,影响执行效率。
事实上,React 通过比较两次虚拟 DOM 的变化找出差异部分,更新到真实 DOM 上,从而减少最终要在真实 DOM 上执行的操作,提高程序执行效率。这一过程就是 React 的调和过程 Reconcliliation,其中的关键就是比较两个树型结构的 diff 算法。
在 diff 算法中,比较的两方是新的虚拟 DOM 和旧的虚拟 DOM,而表示虚拟 DOM 和真实 DOM,只不过 Diff 的结果会更新到真实的 DOM 上。
正常情况下,比较两个树形结构差异的算法时间复杂度是 O(N^3)。React 通过总结 DOM 的实际使用场景提出了两个在绝大多数实践场景下都成立的假设,基于这两个假设,React 实现了 O(N)时间复杂度内完整两棵虚拟 DOM 树的比较:
- 如果两个元素的类型不同,那么它们将生成两棵不同的树
- 为列表中的元素设置 key,用 key 标识对应的元素在多次 render 过程中是否发生变化
当根节点是不同类型时
从 div 变成 p,ComponentA 变成 ComponentB,或者从 ComponentA 变成 div 这些都是节点类型发生变化的情况。
根节点类型变化,React 会认为新的树和旧的树完全不同,不会再继续比较其他属性和子节点,而是把整棵树拆掉重建(包括虚拟 DOM 树和真实 DOM 树)。需要注意的是,虚拟 DOM 节点类型分为两类:一类是 DOM 元素类型,一类是 React 组件类型。
在旧的虚拟 DOM 树被拆除过程中,旧的 DOM 元素类型的节点会被销毁,旧的 React 组件的实例 componentWillUnmount 会被调用,在重建过程中,新的 DOM 元素会被插入到 DOM 树中,新的组件实例的 componentWillMount 和 componentDidMount 方法会被调用。重建后的新的虚拟 DOM 树会被整体更新到真实 DOM 树中,这种情况需要大量 DOM 操作,更新效率最低。
当根节点是相同的 DOM 元素类型时
React 会保留根节点,而比较根节点的属性,然后只更新那些变化了的属性。
当根节点是相同的组件类型时
对应的组件实例不会被销毁,只是会执行更新操作,同步变化的属性到虚拟 DOM 树上,这一过程组件实例的 componentWillReceiveProps 和 componentWillUpdate 会被调用。注意,对于组件类型的节点,React 是无法直接知道如何更新真实 DOM 树的,需要在组件更新并且 render 方法执行完成后,根据 render 返回的虚拟 DOM 结构决定如何更新真实 DOM 树。
比较完根节点后,React 会以同样的原则继续递归比较子节点,每一个子节点相对于其层级以下的节点来说又是一个根节点。如此递归比较,直到比较完两棵树上的所有节点,计算得到最终差异,更新到 DOM 树中。
高阶组件
高阶组件是 React 中一个重要而复杂的概念。主要用来实现组件逻辑的抽象和复用,在很多第三方库被使用到。合理使用高阶组件也能显著提高代码质量。
基本概念 高阶函数是函数作为参数,并且返回值也是函数的函数。类似地,高阶组件简称(HOC)接受 React 组件作为参数,并且返回一个新的 React 组件。高阶组件本质上也是一个函数,并不是一个组件,高阶组件的函数形式如下:
const EnhancedComponent = higherOrderComponent(WrappedComponent)从例子来看。MyComponent 组件需要从 LocalStorage 中读取数据然后渲染到界面,一般来说:
class MyComponent extends Component { render() { return <div>{this.state.data}</div> } componentWillMount() { let data = localStorage.getItem('data') this.setState({ data }) }}但当其他组件也需要从 LocalStorage 中获取同样的数据展示时,每个组件都需要重写一次 componentWillMount 中的代码,这是很冗余的。于是用高阶组件来改写这部分:
function withPersistentData(WrappedComponent) { return class extends Component { componentWillMount() { let data = localStorage.getItem('data') this.setState({ data }) } render() { // 通过 {...this.props}把传递给当前组件的属性传递给被包装的组件 return ( <WrappedComponent data={this.state.data} {...this.props} /> ) } }}
class MyComponent extends Component { render() { return <div>{this.props.data}</div> }}const MyComponentWithPersistenData = withPersistentData(MyComponent)withPersistenData 就是一个高阶组件,它返回一个新的组件,在新组件的 componentWillMount 中统一处理从 localStorage 中获取数据的逻辑,然后将获取到的数据通过 props 传递给被包装的租金啊 WrappedComponent。这样在 WrappedComponent 中就可以直接使用 this.props.data 获取需要展示的数据。
高阶组件的主要功能是封装被分离组件的通用逻辑,让通用逻辑在组件间更好地被复用。高阶组件的这种实现方式的本质其实就是装饰者设计模式。
使用场景
- 操作 props
- 通过 ref 访问组件实例
- 组件状态提升
- 从其他元素包装组件
操作 props
在被包装组件接受 props 前,高阶组件可以先拦截 props,对 props 执行增、删、改等操作,然后将处理后的 props 再传递给被包装组件。
通过 ref 访问组件实例
高阶组件通过 ref 获取被包装组件实例的引用,然后高阶组件就具备了直接操作被包装组件的属性或方法的能力。
function withRef(WrappedComponent) { return class extends Component { someMehod = () => { this.wrappedInstance = this.someMethodInWrappedComponent() } render() { // 为被包装组件添加ref , 从而获取该组件实例并赋值给this.wrappedInstance return ( <WrappedComponent ref={(instance) => (this.wrappedInstance = instance)} {...this.props} /> ) } }}当 WrappedComponent 被渲染时,执行 ref 的回调函数,高阶组件通过 this.wrappedInstance 保存 WrappedComponent 实例的引用,在 someMethod 方法中,通过 this.wrappedInstance 调用 WrappedComponent 实例中的方法。
组件状态提升
无状态组件更容易被复用。高阶组件可以通过将被包装组件的状态及相应的状态处理方法提升到高阶组件自身内部实现被包装组件的无状态化。一个典型场景是,利用高阶组件将原本受控组件需要自己维护的状态统一提升到高阶组件中:
function withControlledState(WrappedComponent) { return class extends Component { constructor() { super() this.state = { value: '', } } handleValueChange = (e) => { this.setState({ value: e.target.value, }) } render() { // newProps保存受控组件需要使用的属性和事件处理函数 const newProps = { controlledProps: { value: this.state.value, onChange: this.handleValueChange, }, } return ( <WrappedComponent {...this.props} {...newProps} /> ) } }}这个例子把受控组件的 value 属性用到的状态和处理 value 变化的回调都提升到了高阶组件中,当我们再使用受控组件时,可以这么使用:
class SimpleControlledComponent extends Component { render() { // 此时的SimpleControlledComponent 为无状态组件,状态由高阶组件维护 return ( <input nmae="simple" {...this.props.controlledProps} /> ) }}const ComponentWithControlledState = withControlledState( SimpleControlledComponent)用其他元素包装组件
我们还可以在高阶组件渲染 WrappedComponent 时添加额外的元素,这种情况通常用于为 WrappedCompoent 增加布局或修改样式
function withRedBackground(WrappedComponent) { return class extends Component { render() { return ( <div style={{ backgroundColor: 'red' }}> <WrappedComponent {...this.props} /> </div> ) } }}高阶组件的参数并非只是一个组件,它还可以接受其他参数。
function withPersistentData(WrappedComponent, key) { return class extends Component { componentWillMount() { let data = localStorage.getItem(key) this.setState({ [`local/${key}`]: data, }) } render() { // 通过{...this.props}把传递给当前组件的属性继续传递给被包装的组件 return ( <WrappedComponent data={this.state.data} {...this.props} /> ) } }}HOC(...params)返回值是一个高阶组件,高阶组件需要的参数是先传递给 HOC 函数的。用这种形式改写 withPersistentData 如下
const withPersistentData = (key) => (WrappedCompnent) => { return class extends Component { componentWillMount() { let data = localStorage.getItem(key) this.setState({ data }) } render() { // 通过 {...this.props} 把传递给当前组件的属性继续传递给被包装的组件 return ( <WrappedComponent data={this.state.data} {...this.props} /> ) } }}class MyComponent extends Component { render() { return <div>{this.props.data}</div> }}// 获取key='data'的数据const MyComponentPersistentData = withPersistentData('data')(MyCompoent)实际上,这种形式的高阶组件大量出现在第三方库中,如 react-redux 的 connect 函数就是一个典型的例子。connect 的简化定义如下:
connect(mapStateToProps, mapDispatchToProps)(WrappedComponent)
这个函数会将一个 React 组件连接到 Redux 的 store 上,在连接的过程中,connect 通过函数参数 mapStateToProps 从全局 store 中取出当前组件需要的 state,并把 state 转化成当前组件的 props,传递给当前组件。 connect 并不会修改传递进去的组件的定义,而是会返回一个新的组件。
connect 的参数 mapStateToProps、mapDispatchToProps 是函数类型,说明高阶组件的参数也可以是函数类型。
const connectedComponentA = connect(mapStateToProps, mapDispatchToProps)(ComponentA)
我们可以把它拆分来看
// connect是一个函数,返回值 enhance也是一个函数const enhance = connect(mapState, mapDispatch)// enhance是一个高阶组件const ConnectedComponentA = enhance(ComponentA)这种形式的高阶组件易组合使用。因为当多个函数的输出和它的输入类型相同时,这些函数易组合到一起。
// connect的参数是可选参数,这里省略了mapDispatch参数const ConnectedComponentA = connect(mapStateToProps)(withLog(ComponentA))我们还可以定义一个工具函数 compose(…funcs)
function compose(...fns) { if (fns.length === 0) return arg => arg if (fns.length === 1) return fns[0] return fns.reduce((acc, cur) => (...args) => acc(cur(args)))调用compose(f, g, h) 等价于 (...args) => f(g(h(...args)))
用 compose 函数可以把高阶组件嵌套的写法打平:
const enhance = compose(connect(mapState), withLog())const ConnectedComponentA = enhance(ComponentA)像 Redux 等第三方库都提供了 compose 的实现,compose 结合高阶组件使用可以显著提高代码的可读性和逻辑的清晰度
继承方式实现高阶组件
上面这类由高阶组件处理通用逻辑,然后再将相关属性传递给被包装组件,我们称这种实现方式为属性代理。
除此外,还可以通过继承方式实现高阶组件,通过继承被包装组件实现逻辑的复用。继承方式实现的高阶组件常用于渲染劫持。例如:当用户处于登录状态,允许组件渲染,否则渲染一个空组件:
function withAuth(WrappedComponent) { return class extends WrappedComponent { render() { if (this.props.loggedIn) { return super.render() } else { return null } } }}根据 WrappedComponent 的this.props.loggedIn判断用户是否已经登录,若登录就通过 super.render()调用 WrappedComponent 的 render 方法正常渲染,否则返回 null。
继承方式实现的高阶组件对被包装组件具有侵入性,当组合多个高阶组件使用时,很容易因为子类组件忘记通过 super 调用父类组件方法而导致逻辑丢失。因此,在使用高阶组件时,应尽量通过代理方式实现高阶组件。
注意事项
1. 为了在开发和调试阶段更好地区别包装了不同组件的高阶组件,需要对高阶组件的显示名称做自定义处理。
常用的处理方式是,把被包装组件的显示名称也包到高阶组件的显示名称中:
function withPersistentData(WrappedComponent) { return class extends Component { // 结合被包装组件的名称,自定义高阶组件的名称 static displayName = `HOC(${getDisplayName(WrappedComponent)})` render() { return // 省略 } }}
function getDisplayName(WrappedComponent) { return WrappedComponent.displayName || WrappedComponent.name || 'Component'}2. 不要在组件的 render 方法中使用高阶组件,尽量也不要在组件的其他生命周期方法中使用高阶组件。
因为调用高阶组件,每次都会返回一个新的组件,于是每次 render 前一次高阶组件创建的组件都会被卸载 unmount,然后重新挂载(mount)本次创建的新组件,影响效率又丢失子组件状态,如下:
render() { // 每次render, enhance都会创建一个新租金啊,尽管被包装的组件没有变 const EnhancedComponent = enhance(MyComponent) // 因为是新的组件,所以会经历旧组件的卸载和新组件的重新挂载 return <EnhancedComopnent />}所以高阶组件最适合的地方是在组件定义的外部,这样就不会收到组件生命周期的影响。
3. 如果需要使用被包装组件的静态方法,那么必须手动复制这些静态方法。
因为高阶组件返回的新组件不包含被包装组件的静态方法,如:
// WrappedComponent组件定义了一个静态方法staticMethodWrappedComponent.staticMethod = function () { // ...}function withHOC(WrappedComponent) { class Enhance extends Component { // ... } // 手动复制静态方法到Enhance上 Enhance.staticMethod = WrappedComponent.staticMethod}4. refs 不会被传递给被包装组件
尽管在定义高阶组件时,我们会把所有的属性都传递给被包装组件,但是 ref 并不会传递给被包装组件。如果在高阶组件的返回数组中定义了 ref,那么它指向的是这个返回的新组件,而不是内部被包装的组件。如果希望获取被包装组件的引用,那么可以自定义一个属性,属性的值是一个函数,传递给被包装的 ref。下面的例子就是用 inputRef 这个属性名替代常规的 ref 命名:
function FocusInput({ inputRef, ...rest }) { // 使用高阶组件传递的inputRef作为ref的值 return ( <input ref={inputRef} {...rest} /> )}// enhance是一个高阶组件const EnhanceInput = enhance(FocusInput)// 在一个组件的render方法中,自定义属性inputRef代替ref// 保证inputRef可以传递给被包装组件return <EnhanceInput inputRef={(input) => (this.input = input)} />// 组件内 让FocusInput自动获取焦点this.input.focus()5. 与父组件的区别
高阶组件在一些方面与父组件类似。我们可以把高阶组件中的逻辑放到一个父组件中执行,执行完成的结果再传递给子组件,但是高阶组件强调的是逻辑的抽象。
高阶组件是一个函数,函数关注的是逻辑;父组件是一个组件,组件主要关注的是 UI/DOM。如果逻辑是与 DOM 直接相关的,那么这部分逻辑适合放到父组件中实现;如果逻辑是与 DOM 不直接相关的,那么这部分逻辑使用用高阶组件抽象,如数据校验、请求发送等。
高阶组件用于封装组件的通用逻辑,常用在操作组件 props、通过 ref 访问组件实例、组件状态提升和用其他元素包装组件等场景中。
高阶组件可以接受被包装组件以外的其他参数,多个高阶组件还可以组合使用。高阶组件一般通过代理实现,少量场景中也会使用继承等方式实现。灵活地使用高阶组件可以显著提高代码质量和效率。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!