阅读 401

学习 React 第二节课:进阶

前言

文档目录:

  1. 实例引用 Refs
  2. 上下文 Context
  3. 高阶组件 Higher-Order Components
  4. 钩子 Hooks

一、实例引用 Refs

Refs 提供了一种方式,允许我们访问 DOM 节点 或在 render 方法中创建的 React 元素

本文更关注于告诉你“有哪些“、“怎么用”,而不是“什么时候用”、“为什么用”

官方文档链接,在这里

使用流程如下:

  1. 创建实例。创建一个 Refs 实例,譬如 this.myRef = React.createRef()
  2. 挂载实例。通过标签中的 ref 属性将上面创建的实例挂载到目标元素,譬如 <input ref={this.myRef}/>
  3. 访问实例。通过访问 Refs 实例上的 current 属性来获取元素,譬如 this.myRef.current.focus()

其实下面所说的不同形式、不同方式,均是这“三步走”,只不过“创建”、“访问”方式有多种,仅此而已

“挂载”的方式只有一种哦,就是把实例放在元素的 ref 属性上

从 Refs 实例的 创建 方式来划分,有以下几种:

  • React.createRef(在 ClassComponent 中使用,React 16.3 引入)
  • React.useRef(在 FunctionComponent 中使用,React 16.8 引入)
  • 回调 Refs
  • 字符串 Refs(已经过时了,忘掉她吧)

可以在 FunctionComponent 中创建实例,但是不能将实例挂载到 FunctionComponent 上(虽然可以通过 React.forwardRef 使 FunctionComponent 能接受一个 ref 属性并往下传递,也就是向下“转发”而已,并非真的挂载在该组件上)

React.createRef

React.createRef 仅能在 ClassComponent 中使用,因为该 api 并没有 Hooks 的效果,其值会随着 FunctionComponent 重复执行而不断被初始化。

当然,如果你非要在 FunctionComponent 中使用,也是能在 current 访问到挂载的元素的,只不过会导致随着组件的渲染不断的实例化。

这种非 Hooks 的方法都得注意“重复执行”的问题。在下面的例子中,就是在构造函数中调用 React.createRef,保证只会实例化一次。

React 会在组件挂载时给 current 属性传入 DOM 元素,并在组件卸载时传入 null 值。ref 会在 componentDidMount 或 componentDidUpdate 生命周期钩子触发前更新。

下面用一个例子快速掌握如何通过 React.createRef创建访问

class MyClassComponent extends React.Component {
  constructor(props) {
    super(props);
    this.myRef = React.createRef(); // 实例化一个 ref 对象
  }

  handleInputFocus = () => {
    if (this.myRef.current) {
      /**
       * 在整个流程中,myRef.current 不一定有值
       * 是因为譬如
       *  - 你实例化了一个 ref 对象,但是你并没有将该对象挂载到对应的元素上
       *  - 或者此刻该元素被移除了
       * 所以访问 ref 的时候,一般都是要先判断是否存在的哦~
       */

      this.myRef.current.focus(); // 聚焦

      // 此时 current 为一个 HTMLElement(你可以理解为事件监听里的 e.target)
      console.log(this.myRef.current); 
    }
  }

  render() {
    return (
      <div>
        <input
          /**
           * 通过元素上的 ref 属性将 myRef 传入
           * 在元素初始化或者重新渲染时会更新 myRef 的值
           * myRef 你可以理解为一个对象,其中有一个 current 属性,元素发生变化时就是更新这个 current 属性
           * 
           * p.s. “元素”可以是“DOM节点”或者“React组件”
           */
          ref={this.myRef}
        />
        <span
          // 点中 span 时聚焦上方的 input
          onClick={this.handleInputFocus}
        >聚焦</span>
      </div>
    );
  }
}
复制代码

React.useRef

React.useRef 为 Hooks,仅能在 FunctionComponent 中使用,因为 Hooks 不能用在 ClassComponent 中。

React.useRef 在 FunctionComponent 中使用时,会随着组件的渲染而重复初始化,这也是 Hooks 的独特之处,虽然用在普通函数中,但在 React 引擎中会得到超出普通函数的表现,比如初始化仅执行一次,或者引用不变

let outterRef = null;

function MyFunctionComponent() {
  const [count, setCount] = React.useState(0);
  const innerRef = React.useRef(null);
  
  useEffect(
    () => {
      // 初始化时执行
      outterRef = innerRef
    },
    []
  );
  
  useEffect(
    () => {
      /**
       * 这里始终会输出 true
       * 因为 Hooks 的特性,即使当前不断重新渲染,也就是不断调用 React.useRef 后,获取的实例仍然是同一个
       */
      console.log(outterRef === innerRef)
    },
    [count]
  )
  
  return (
    <>
      <input ref={innerRef}/>
      <button onClick={() => setCount(count+1)}>{count}</button>
    </>
  )
}
复制代码

如果这里看不明白的话,可以先跳过,先去学习 React Hooks

回调 Refs

ref 属性传递一个回调函数,React 在不同时机调用该回调函数,并将元素(或组件)作为参数传入:

  • 挂载前
  • 触发更新前
  • 卸载前(传 null)

支持在 FunctionComponent 和 ClassComponent 内部使用

// 在 ClassComponent 中使用
class MyClassComponent extends React.Component {
  constructor(props) {
    super(props);
    this.inputRef = null;
  }
  componentDidMount() {
    this.inputRef && this.inputRef.focus();
  }
  setMyRef = (ref) => {
    this.inputRef = ref;
  }
  render() {
    return (
      <input type="text" ref={this.setMyRef}/>
    )
  }
}

// 在 FunctionComponent 中使用
function MyFuncComponent (props) {
  let inputRef = null;
  const handleClick = () => {
    inputRef && inputRef.focus();
  }
  const setMyRef = (ref) => {
    inputRef = ref
  }
  return (
    <div>
      <input type="text" ref={setMyRef}/>
      <button onClick={handleClick}>聚焦</button>
    </div>
  )
}
复制代码

二、上下文 Context

Context 提供了一个无需为每层组件手动添加 props,就能在组件树间进行数据传递的方法。一般情况下,祖先组件想要将某一个值传递给后代组件,都是通过 props 一层层的向下传递,但是这种方式极其繁琐。而 Context 提供了一种在组件之间共享此类值的方式,而不必显式地通过组件树的逐层传递 props

Mobx 和 Redux 这种状态管理机其实就是使用 Context 来实现数据的跨组件传递

目前有两种使用 Context 的方式:

  • 广播模式(慎用,这个东西你把握不了)
  • 生产者/消费者(React 16.3 引入)

广播模式

使用方式分两步:

  • 提供:祖先组件中设置 childContextTypesgetChildContext
  • 获取:后代组件中声明 contextType

不多bb,来个例子:

import React from "react";
import PropTypes from "prop-types"; // prop-types 是 react 中自带的库

// 祖父组件
class CompGrandFather extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      products: []
    }
  }
  componentDidMount() {
    this.setState({
      products: [1, 2, 3, 4]
    })
  }

  // 通过 childContextTypes 定义往下传递的 context 中数据的类型
  static childContextTypes = {
    myContextProducts: PropTypes.array,
    // myContextFunc: PropTypes.func   // prop-types 库中还有各种各样的类型哦
  };

  // 在 getChildContext 方法中返回对应的数据(对象)
  getChildContext() {
    return {
      myContextProducts: this.state.products,
    };
  }

  render() {
    return (
      <div>
        <CompFather>
          <CompChild/>
        </CompFather>
      </div>
    )
  }
}

// 父亲组件
function CompFather(props){
  console.log('CompFather 重新渲染')
  return (
    <div>{props.children}</div>
  )
}

// 孩子组件
class CompChild extends React.Component {
  /**
   * 后代组件通过 contextTypes 来定义所能接受到的 context
   * 组件中需要获取 context 中哪些数据,就需要在这里声明,否则获取不到的
   **/
  static contextTypes = {
    myContextProducts: PropTypes.array,
  };

  render() {
    return (
      <div>
        {this.context.myContextProducts
          .map(id => (
            <p>{id}</p>
          ))}
      </div>
    )
  }
}
复制代码

相信你尝试照着这个例子来写过之后,已经基本掌握了使用方式~

但是这种方式官方并不建议在项目中使用哦,原因有下面几点:

  • 破坏了 React 的分形架构思想
    • 组件没办法随意复用(组件中如果使用到 Context 就意味着祖先组件必须要传递相应的 Context)
    • 数据的来源难以溯源(React 是可以在任意一层祖先组件中提供 Context,并且当前组件如果重复提供同样的 Context,是会覆盖祖先传递下来的 Context 的,最终后代组件是获得距离其“最近”的祖先组件提供的 Context,这样子是根本没办法明确的找到 Context 数据的来源到底是哪一个)
    • 传递流程可能会被中断(Context 传递过程中某个组件在 shouldComponentUpdate 中返回 false 时,下面的组件将无法触发 rerender,从而导致新的 Context 值无法更新到下面的组件)
  • 性能问题
    • 无法通过 React 的复用算法进行复用(一旦有一个节点提供了 Context,那么他的所有子节点都会被视为有 side effect 的,因为 React 本身并不判断子节点是否有使用 Context,以及提供的 Context 是否有变化,所以一旦检测到有节点提供了 Context,那么他的子节点则将会被认为需要更新)

生产者/消费者

这是 Context 二代目,在 React 16.3 引入,可以看到一代目的方式是通过给组件本身提供一些特性,用以拓展组件的功能,而且这些拓展是有副作用的(side effect),但是其实我们使用 Context 只是为了解决数据透穿的问题,所以就有人提出用组件的形式来实现数据的传递,分别为生产者(Provider)组件和消费者(Consumer)组件,改变 Provider 中提供的数据发生改变只会触发 Consumer 的重新渲染。

“生产/消费者模型”只是一种设计模式,是通过一个第三方容器来解决生产者和消费者的强耦合问题,生产者只负责生产数据并推送到第三方容器,消费者只负责从第三方容器中获取数据。

在不同的语言中对其的具体实现都不尽相同,不要去钻牛角尖,尝试去用 OOP 的角度去思考这种解决问题的思路有何优点,同时也希望您能去了解面向对象编程中各种经典的设计模式~

涉及到的关键字先来预览一下:

  • React.createContext
    • Context.Provider
    • Context.Consumer
  • Class.contextType

下面用二代目 context 来写一个例子:

/**
 * 通过 createContext 方法创建一个 Context 对象
 * 其接受一个参数,可以任意值,我这里比较建议传一个对象,因为这样比较容易拓展
 * p.s. 此时传入的值是会作为“默认值”的哦,并非初始化值
 * 
 * 此时 MyCtx 有两个属性,是一个组件来的,分别是:
 *  - MyCtx.Provider 生产者
 *  - MyCtx.Consumer 消费者
 **/
const MyCtx = React.createContext({
  innerProducts: [],
  innerName: '默认名字',
  innerAge: 0
})

// 祖父组件
class CompGrandFather extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      products: []
    }
  }
  componentDidMount() {
    this.setState({
      products: [1, 2, 3, 4]
    })
  }
  render() {
    return (
      <MyCtx.Provider
        value={{
          // 将想要传递的值放到 value 属性上
          innerProducts: this.state.products,
          // innerName: '张大炮', // 这里少传一个参数时,将会使用默认值
          innerAge: 18
        }}
      >
        <CompFather>
          <CompChild/>
        </CompFather>
      </MyCtx.Provider>
    )
  }
}

// 父亲组件
function CompFather(props) {
  // 有意思的是,CompGrandFather 修改 context 并不会触发 CompFather 的重新渲染
  console.log('CompFather 重新渲染')
  return (
    <div>{props.children}</div>
  )
}

// 孩子组件
function CompChild(props) {
  return (
    <div>
      <MyCtx.Consumer>
        {(ctx) => {
          /**
           * Consumer 的 props.children 是一个方法,并且会接受一个参数,参数就是 Provider 那边传递过来的 value 值
           * 当 Provider 的 value 发生变化时,Consumer 会重新调用 props.children,并传递新的值
           **/
          return (
            <div>
              {ctx.innerProducts
                .map(id => (
                  <p>{id}</p>
                ))}
              <p>默认值演示:{ctx.innerName}</p>
            </div>
          )
        }}
      </MyCtx.Consumer>
    </div>
  )
}
复制代码

总结一下,使用起来就三个流程:

  1. 使用 React.createContext 创建一个(独立于组件的)状态机实例,同时定义默认值,实例中有两个属性,他们都是一个 React 组件,分别是 ProviderConsumer
  2. 在祖先组件中使用 Provider 组件,并向其传递数据
  3. 在后代组件中使用 Consumer 组件,从中获取数据

Class.contextType

在上面的例子中,孩子组件中如果要获取数据都是需要通过 Consumer 组件,这里 React 还提供了一种方式,就是 Class.contextType

挂载在 class 上的 contextType 属性会被重赋值为一个由 React.createContext() 创建的 Context 对象。此属性能让你使用 this.context 来消费最近 Context 上的那个值。你可以在任何生命周期中访问到它,包括 render 函数中。

下面用 Class.contextType 的方式重新实现上面的孩子组件:

class CompChild extends React.Component {
  static contextType = MyCtx; // 在 contextType 静态属性中声明关联的 Context
  componentDidMount() {
    console.log("在生命周期中也能获取到context哦", this.context)
  }
  render() {
    const {innerProducts, innerName} = this.context;
    return (
      <div>
        <div>
          {innerProducts
            .map(id => (
              <p>{id}</p>
            ))}
          <p>默认值演示:{innerName}</p>
        </div>
      </div>
    )
  }
}
复制代码

三、高阶组件 HOC

高阶组件(HOC)是 React 中用于复用组件逻辑的一种高级技巧。HOC 自身不是 React API 的一部分,它是一种基于 React 的组合特性而形成的设计模式。

高阶组件其实就是一个函数,其接受组件作为参数,然后返回一个新的组件。也就是说其实高阶组件就是一个高阶函数嘛:

  • 将组件(函数)作为参数被传递
  • 组件(函数)作为返回值输出

React 组件不就是一个函数(function)嘛,你看:

function MyReactComp(){return (<p>组件</p>)}

组件工厂

HOC 的实现方式主要有两种:

  • 属性代理(函数返回一个我们自己定义的组件,代理上层传递过来的 props)
  • 反向继承(返回一个继承原组件的组件,并且通过 super 访问原组件的 render 来进行渲染)

下面就通过一个例子来演示如何通过“属性代理”创建一个 HOC 并使用:

// 有一把武器(普通组件)
function Weapon(props) {
  return (
    <div>
      <p>名字:{props.name}</p>
      <p>等级:{props.level}</p>
      <p>标签:{props.effect}</p>
    </div>
  )
}

/**
 * 给增加点特效(高阶组件)
 * 这个高阶组件接受两个参数,其中 NormalComp 为组件
 **/
function WithEffectHOC(NormalComp, effect) {
  // 返回一个新的组件
  return function(props) {
    /**
     * 对 props 进行代理
     * 这里只是通过 {...props} 写法将上层传递的 props 进行解构并原封不动地将其全部往下传递
     * 下面的写法中,先写 effect 再写 props 的解构,如此如果上层所传递的 props 中也含有 effect 属性的话,将会覆盖前面写的 effect 哦~
     * 
     * p.s. 这里只是单纯地全部传递,但是实际使用中,一般会对 props 做各种处理啥的
     **/
    return (
      <NormalComp
        effect={effect}
        {...props}
      />
    )
  }
}

/**
 * 通过 WithEffectHOC 对 Weapon 进行不同的“拓展”
 * 最后得到两个新的组件
 **/
const WeaponLight = WithEffectHOC(Weapon, '发光的')
const WeaponDark = WithEffectHOC(Weapon, '黑暗版')

function App() {
  return (
    <div>
      <WeaponLight name="武器A" level="99"/>
      <WeaponDark name="武器B" level="10"/>

      <WeaponLight name="武器C" level="98" effect="不是一般的发光"/>
    </div>
  )
}
复制代码

功能增强

将一些公共逻辑提取出来,构造一个高阶组件,然后根据业务的需要来决定普通组件是否需要通过该高阶组件进行“升级”,譬如:

  • 额外的生命周期
  • 额外的事件
  • 额外的业务逻辑

举一个简单的例子,就是埋点:

function WithSentryHOC (InnerComp) {
  return class extends React.Component {
    myDivRef = React.createRef()
    componentDidMount() {
      this.myDivRef.current.addEventListener('click', this.handleClick)
    }
    componentWillUnmount() {
      this.myDivRef.current.removeEventListener('click', this.handleClick)
    }
    handleClick = () => {
      console.log(`发送埋点:点击了${this.props.name}组件`)
    }
    render() {
      return (
        <div ref={this.myDivRef}>
          <InnerComp {...this.props}/>
        </div>
      )
    }
  }
}

function MyNormalComp (props) {
  return (
    <div>普通组件</div>
  )
}

/**
 * 给 MyNormalComp 组件“升级”一下
 * 每次点击这个组件都会 console.log 一下
 * 对于 MyNormalComp 组件来说,这个功能它是“不知道”的
 **/
const MyCompWithSentry = WithSentryHOC(MyNormalComp);

function App(){
  return (
    <MyCompWithSentry name="我的一个组件"/>
  )
}
复制代码

渲染劫持

HOC 里面不单单可以对原组件进行功能拓展,还能增加条件判断,来修改渲染结果

下面使用一个简单的 demo 来演示一下如果做到延时渲染的:

function WithDelayRenderHOC (InnerComp) {
  return class extends React.Component {
    constructor(props) {
      super(props);
      this.state = {
        show: false
      }
    }
    componentDidMount(){
      window.setTimeout(() => {
        this.setState({
          show: true
        })
      }, 3000)
    }
    render() {
      // 当某些条件下渲染的不再是 InnerComp
      if (!this.state.show) {
        return <div>等待中...</div>
      }
      return <InnerComp {...this.props}/>
    }
  }
}
复制代码

总结

目前只是抽几个比较典型的场景来演示,在实际使用中,设计一个 HOC 往往不会如此简单。这又涉及到 面向切面编程(AOP) 思想,AOP 的主要作用就是把一些和核心业务逻辑模块无关的功能抽取出来,然后再通过“动态织入”的方式掺到业务模块种。


四、钩子 Hooks

Hooks 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。

以往使用 Class Component 来编写组件会有以下问题:

  • 在组件之间复用状态逻辑很难
  • 复杂组件变得难以理解

从前的项目代码中往往是以组件的生命周期来划分成一座座“代码山”,现在将组件中相互关联的部分拆分成更小的函数(就像 Mobx store 一样),其中还能通过 React 提供各种 Hooks 来实现诸如生命周期监听等操作,如此则将代码以业务逻辑进行分割

下面用较短的篇幅简单演示几种常用 Hooks 的使用方式:

  • React.useState 状态钩子
  • React.useEffect 副作用钩子
  • React.useCallback 回调函数钩子
  • React.useContext 上下文钩子(前面讲过了)
  • React.useRef 访问钩子(前面讲过了)

React 中还有其他不同作用 Hooks,这里提供 Hook API 索引,同时推荐一篇文章 React Hooks 详解

React.useState

通过调用 React.useState 方法,并向其传入参数作为默认值,返回一个数组,数组第一个元素为当前值,第二个元素为 set 方法

class MyClassComponent extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0,
    }
  }
  handleClick = () => {
    this.setState({
      count: this.state.count + 1
    })
  }
  render(){
    return (
      <div>
        <p>你点击了{this.state.count}次</p>
        <button onClick={this.handleClick}>点击</button>
      </div>
    )
  }
}

// 下面同时使用 Hooks 的方式来编写一个效果一摸一样的组件
function MyFuncComponent() {
  // 声明一个叫 “count” 的 state 变量。
  const [count, setCount] = React.useState(0);
  return (
    <div>
      <p>你点击了{count}次</p>
      <button onClick={() => setCount(count + 1)}>点击</button>
    </div>
  );
}
复制代码

React.useEffect

用法如下:

React.useEffect(() => {
  // do something
  return () => {
    // trigger when unmount
  }
}, [dependencies])
复制代码

React.useEffect 接受两个参数:

  • 函数,会在特定时机被触发
  • 数组,为依赖项,也就是当依赖项中数据发生变化时,会触发第一个参数所传递的函数
    • 不传递参数,每次重新渲染时都会执行
    • 传递非空数组,当其中一项发生变化就会执行
    • 传递空数组,仅在组件挂载和卸载时执行

同时,第一个参数能返回一个函数,该返回函数会在组件 unmount 之前执行

function Welcome(props) {
  useEffect(() => {
    // 每次组件重新渲染时都会再次执行本函数
    document.title = '加载完成';
  });
  return <p>Hello</p>;
}
复制代码

React.useCallback

返回一个 memoized 回调函数。

function MyFuncComp(props){
  const [count, setCount] = React.useState(0);
  const handleClick = () => setCount(count + 1)
  return (
    <div>
      <p>你点击了{count}次</p>
      <button onClick={handleClick}>点击</button>
    </div>
  );
}
复制代码

上面的例子中,每次 MyFuncComp 重新渲染时,里面的 handleClick 都会被重新声明,最致命的是,这样每次 div 上绑定的 onClick 都不一样了,这样将会导致不必要的重新渲染!

既然 React 都推崇使用 FunctionComponent 的方式写编写组件了,那么其肯定得解决这个问题咯,所以 React.useCallback 等一系列有 memoized 特性的 Hook 就应运而生。

再来改写一下刚刚的例子:

function MyFuncComp(props){
  const [count, setCount] = React.useState(0);
  const handleClick = React.useCallback(
    () => setCount(count + 1),
    [count],
  );
  return (
    <div>
      <p>你点击了{count}次</p>
      <button onClick={handleClick}>点击</button>
    </div>
  );
}
复制代码

自定义 Hook

通过自定义 Hook,可以将组件逻辑提取到可重用的函数中。

譬如“获取当前浏览器尺寸(同时监听 resize)”这部分逻辑封装成一个自定义 Hook,供不同的组件同时使用:


/**
 * 封装一个获取 client 的 Hook
 * 
 * p.s. Hook 内部也可以使用别的 Hook 的,不断套娃
 **/
function useWindowSize() {

  // 使用 React.useState 声明一个变量
  const [windowSize, setWindowSize] = React.useState<IWindowSize>({
    width: document.documentElement.clientWidth,
    height: document.documentElement.clientHeight,
  });

  // 使用 React.useCallback 声明一个回调函数
  const onResize = React.useCallback(() => {
    setWindowSize({
      width: document.documentElement.clientWidth,
      height: document.documentElement.clientHeight,
    });
  }, []);

  // 使用 React.useEffect 来触发事件绑定
  React.useEffect(() => {
    window.addEventListener('resize', onResize);
    return () => {
      // unmount 时还要移除监听哦~
      window.removeEventListener('resize', onResize);
    };
  }, [onResize]);

  return windowSize; // 只返回值(不用返回 set 方法)
}

// 组件A
function MyCompA() {
  const windowSize = useWindowSize();
  return (
    <div>
      <p>组件A</p>
      <p>宽度:{windowSize.width}</p>
      <p>高度:{windowSize.height}</p>
    </div>
  )
}

// 组件B,跟别的 Hook 一起使用
function MyCompB(props){
  const [count, setCount] = React.useState(0);
  const handleClick = React.useCallback(
    () => setCount(count + 1),
    [count],
  );
  const windowSize = useWindowSize();
  return (
    <div>
      <p>你点击了{count}次</p>
      <button onClick={handleClick}>点击</button>
      <p>宽度:{windowSize.width}</p>
      <p>高度:{windowSize.height}</p>
    </div>
  );
}
复制代码

如果将不同的业务或者功能逻辑都封装成一个个 Hook,然后组件中只需一个个调用,而无需关心内部逻辑,则可实现逻辑平铺的编码风格~

文章分类
前端
文章标签