React(2)之——React组件化

551 阅读10分钟

  组件,从概念上类似于 JavaScript 函数。它接受任意的入参(即 “props”),并返回用于描述页面展示内容的 React 元素。
  React没有多少API可以用,基本上都是组件来完成一个项目。React的工作方式类似于UI=F(state),即一旦组件维护的state发生改变,render函数就会重新执行,导致试图改变。因此学好React组件是至关重要的。

容器组件VS展示组件

  我们希望尽可能让更多的组件变成傻瓜组件,它只负责将数据展示到视图上,而它所用的数据全部都提升到父级组件。父级组件负责逻辑处理和状态维护,将子组件所需的回调事件和状态通过props传递给子组件。这样单纯的展示组件就会有很好的易用性、复用性和维护性。

import React, { Component } from 'react'

//容器组件
export default class BookList extends Component{
    constructor(props){
        super(props)
        this.state = {
            books:[]
        }
    }
    componentDidMount() {
        setTimeout(() => {
         this.setState({
            books:[
            { name: '《你不知道的javascript》', price: 50 },
            { name: '《ES6标准入门》', price: 99 } 
            ]
         })
        }, 1000)
    }
    render(){
        return(
            <div>
            {this.state.books.map((book, index) => <Book key={index} {...book}></Book>)}
            </div>
        )
    }
}

//展示组件
function Book({ name, price }){
    return (
        <div>
            <p>书名:{name}</p>
            <p>价格:{price}</p>
        </div>  
    )
}

展示组件存在的问题

  上述展示组件存在一个问题,因为React中数据维持着不变性的原则,只要setState改变books,都会触发render函数的调用以及虚拟DOM的比较。如果books中的值只有一条变化,它也会引发每条数据都引发一次render函数调用。我们怎么去规避展示组件无谓的数据消耗和渲染函数的调用呢?首先我们看这段代码:

import React, { Component, PureComponent } from 'react'

//容器组件
export default class BookList extends Component {
  constructor(props) {
    super(props)
    this.state = {
      books: []
    }
  }
  componentDidMount () {
    setTimeout(() => {
      this.setState({
        books: [
          { name: '《你不知道的javascript》', price: 50 },
          { name: '《ES6标准入门》', price: 99 }
        ]
      })
    }, 1000)
    setTimeout(() => {
      this.setState({
        books: [
          { name: '《哈利波特》', price: 25 },
          { name: '《ES6标准入门》', price: 99 }
        ]
      })
    }, 2000)
  }
  render () {
    return (
      <div>
        {this.state.books.map((book, index) => <Book key={index} {...book}></Book>)}
      </div>
    )
  }
}
// 展示组件
function Book ({ name, price }) {
  console.log('渲染了')
  return (
    <div>
      <p>书名:{name}</p>
      <p>价格:{price}</p>
    </div>
  )
}

  它在浏览器上打的log如下:


  首先由一个空数组变成有两条数据的数组肯定会导致两次render函数调用,但是第二次变化时,只有第一条数据改变,但还是引起了两次render函数的调用。这是因为我们为了维持数据的不变性,每次都会更新books为一个全新的数组。
  我们要明确一点,肯定是要维持数据的不变性的。有三种方法可以规避这种无谓的render的调用。在PureComponent没有出现之前,我们在shouldComponentUpdate这个生命周期钩子函数中比较下一个值和当前值,如果相等,则不需要更新该条数据,返回false,写法比较累赘。React15.3之后出现了PureComponent纯组件,它就是在内部实现了在shouldComponentUpdate中比较值。还有一种是React16.6之后出现的React.memo,与使用PureComponent方法的原理和效果是等价的,它是一个高阶组件。
  用上述方法在浏览器上打的log为:

shouldComponentUpdate
class Book extends Component {
  shouldComponentUpdate ({ name, price }) {
    if (this.props.name === name && this.props.price === price) {
      return false
    }
    return true
  }
  render () {
    console.log('渲染了')
    return (
      <div>
        <p>书名:{this.props.name}</p>
        <p>价格:{this.props.price}</p>
      </div>
    )
  }
}
PureComponent
class Book extends PureComponent {
  render () {
    console.log('渲染了')
    return (
      <div>
        <p>书名:{this.props.name}</p>
        <p>价格:{this.props.price}</p>
      </div>
    )
  }
}
React.memo
const Book = React.memo(
  function ({ name, price }) {
    console.log('渲染了')
    return (
      <div>
        <p>书名:{name}</p>
        <p>价格:{price}</p>
      </div>
    )
  }
)

高阶组件

  上面我们讲到了高阶组件,它是一个函数,接收一个组件,返回一个加强后的组件。组件是将 props 转换为 UI,而高阶组件是将组件转换为另一个组件。高阶组件可以提高组件的复用率,重写生命周期函数。
  基础写法:

import React, { Component } from 'react'
//一个简单的展示组件
function Pure (props) {
  return (
    <div>{props.name} -- {props.age}</div>
  )
}
//高阶组件
const withLog = (Comp) => {
  console.log(Comp.name + '渲染了')
  return (props) => {
    return <Comp {...props}></Comp>
  }
}
//生成一个新的组件
const NewPure = withLog(Pure)
//使用这个新组件
export default class Hoc extends Component {
  render () {
    return (
      <div>
        <NewPure age='19' name='zhunny'></NewPure>
      </div>
    )
  }
}

高阶组件的链式调用

  高阶组件可以链式调用,且可以在一个链式调用中调用多次同一个高阶组件。

import React, { Component } from 'react'

function Pure (props) {
  return (
    <div>{props.name} -- {props.age}</div>
  )
}

const strengthenPure = Comp => {
  const name = 'zhunny'
  //返回类组件
  return class extends React.Component {
    componentDidMount () {
      console.log('do something')
    }
    render () {
      return <Comp {...this.props} name={name}></Comp>
    }
  }
}

const withLog = (Comp) => {
  console.log(Comp.name + '渲染了')
  return (props) => {
    return <Comp {...props}></Comp>
  }
}

//高阶组件可以链式调用
const NewPure = withLog(strengthenPure(withLog(Pure)))
export default class Hoc extends Component {
  render () {
    return (
      <div>
        <NewPure age='19'></NewPure>
      </div>
    )
  }
}

高阶组件的装饰器写法

  ES7的装饰器可以简化高阶组件的写法,不过需要引入一个转义decorator语法的插件,并在根目录配置config-overrides.js文件。安装react-app-rewired取代react-scripts,可以扩展webpack的配置 ,类似vue.config.js

npm install --save-dev babel-plugin-transform-decorators-legacy
npm install react-app-rewired@2.0.2-next.0 babel-plugin-import --save
const { injectBabelPlugin } = require("react-app-rewired");
module.exports = function override (config, env) {
  //装饰器
  config = injectBabelPlugin(
    ["@babel/plugin-proposal-decorators", { legacy: true }],
    config
  );

  return config;
};

  因为decorator只能装饰类,因此只能装饰基于类的组件。

@withLog
@strengthenPure
@withLog
class Pure extends React.Component {
  render () {
    return (
      <div>{this.props.name} -- {this.props.age}</div>
    )
  }
}

组件的组合composition

  React 有十分强大的组合模式。我们推荐使用组合而非继承来实现组件间的代码重用。组合组件给予你足够的敏捷去定义自定义组件的外观和行为,而且是以一种明确和安全的方式进行。如果组件间有公用的非UI逻辑,将它们抽取为JS模块导入使用而不是继承它。

包含关系

  有些组件无法提前知晓它们子组件的具体内容。这些组件可以使用一个特殊的 children prop 来将他们的子组件传递到渲染结果中。

function Dialog (props) {
  return (<div style={{ border: `1px solid ${props.color || "blue"}` }}>
    {props.children}
    <footer>{props.footer}</footer>
  </div>)
}

//可以看作一个特殊的Dialog
function WelcomeDialog (props) {
  console.log(props)
  return (
    <Dialog {...props}>
      {/*类似于匿名slot插槽*/}
      <h1>欢迎光临</h1>
      <p>感谢使用React</p>
    </Dialog>
  )
}

export default function () {
  //footer类似于具名slot插槽
  const footer = <button onClick={() => { alert('1') }}>footer</button>
  return (
   <WelcomeDialog color='green' footer={footer}></WelcomeDialog>
  )
}

  props.children可以是任意js表达式,可以是一个函数。

const Api = {
  getUser () {
    return { name: 'jerry', age: 20 }
  }
}

function Fetcher (props) {
  const user = Api[props.name]()
  return props.children(user)
}
export default function () {
  //类似于作用域插槽
  return (
   <Fetcher name="getUser">
      {({ name, age }) => (
          <p>
            {name}-{age}
          </p>
      )}
    </Fetcher>
  )
}

示例

function GroupRadio (props) {
  //因为props的内容是不可修改的,因此在Radio上增加一个属性需要拷贝一份
  return <div>
    {React.Children.map(props.children, child => {
      return React.cloneElement(child, { name: props.name })
    })}
  </div>
}

function Radio ({ children, ...rest }) {
  return (
    <label>
      <input type="radio" {...rest} />
      {children}
    </label>
  )
}
export default function () {
  return (
   <GroupRadio name="mvvm">
      <Radio value="vue">vue</Radio>
      <Radio value="angular">angular</Radio>
      <Radio value="react">react</Radio>
    </GroupRadio>
  )
}

Hook

  Hook是React16.8的新特性,它支持我们在不写class组件的情况下使用State以及其他的一些React的特性。Hook是一些可以让你在函数组件里“钩入” React state 及生命周期等特性的函数。Hook 不能在 class 组件中使用, Hook用起来更加简洁,也使我们function组件不再是傻瓜组件。当然,Hook是可选的,完全可以不在项目中使用它。

StateHook-状态钩子

  StateHook是我们能在函数组件中使用State以及SetState方法。它的用法如下:

//第一步:引入useState方法
import React, { useState } from 'react'

export default function StateHook () {

  //第二步:使用useState方法,声明一个state变量以及改变它的方法,state的初始值作为参数传入useState函数。
  //[]是数组解构的写法,useState返回一个数组,数组有两项,我们直接把它们结构出来。
  const [count, setCount] = useState(0)
  const [age] = useState(20)
  const [fruit, setFruit] = useState('bananas')
  const [input, setInput] = useState("")
  const [fruits, setFruits] = useState(['bananas', 'apple'])
  return (
    <div>
      <!--第三步:直接使用声明的state变量名来读取state值。-->
      <p>点击了{count}</p>
      <!--第四步:更新state的函数也直接使用声明的函数名来调用。-->
      <button onClick={() => { setCount(count + 1) }}>点击</button>

      <p>年龄{age}</p>
      <p>水果{fruit}</p>
      <p>
        <input type='text' value={input} onChange={(e) => { setInput(e.target.value) }} />
        <button onClick={() => { setFruits([...fruits, input]); setInput("") }}>添加</button>
      </p>
      <ul>
        {fruits.map((f, i) => (<li key={i} onClick={() => setFruit(f)}>{f}</li>))}
      </ul>
    </div>
  )
}
  1. useState方法可以定义一个state变量以及一个改变该state变量的方法,它的参数是该变量的初始值。
  2. useState方法可以在函数组件中调用多次,每次返回一个变量。
  3. 读取state:在class中我们使用形如{this.state.count}读取,但在函数组件中,我们可以直接读取{count}。
  4. 更新state:在 class 中,我们需要调用 this.setState() 来更新state值,在函数组件中我们使用定义好的形如{setCount}方法改变state的值。
  5. 我们仍然要注意,更新state的方法更新的数据不是之前的数据,而是一个全新的数据。

EffectHook-副作用钩子

  EffectHook能在函数组件中执行副作用,并且它与 class 中的生命周期函数极为类似。它将生命周期函数componentDidMount、componentDidUpdate和componentWillUmount合并到一个Api中。这样我们不用再思考到底是在“挂载之后”还是“更新之后”做副作用操作,统一为“渲染之后”。

无需清除的Effect

  在 React 更新 DOM 之后运行一些额外的代码。比如发送网络请求,手动变更 DOM,记录日志,这些都是常见的无需清除的操作。在执行完这些操作之后,就可以忽略他们了。在class组件中我们一般在生命周期函数中做这些副作用操作。

//第一步:引入useEffect
import React, { useState, useEffect } from 'react'

export default function EffectHook () {
  const [count, setCount] = useState(0)
  //第二步:副作用钩子会在每次渲染时都执行,第二个参数指定依赖数组,以后只有count发生变化才会执行这个useEffect
  useEffect(() => {
    document.title = `您点击了${count}次`
  }, [count])

  //如果仅打算执行一次,传递第二个参数为[]
  //类似于componentDidMount
  useEffect(() => {
    console.log('在这里仅一次的Api调用');
  }, [])

  return (
    <div>
      <p>点击了:{count}</p>
      <button onClick={() => setCount(count + 1)}>点击</button>
    </div>
  )
}
  1. useEffect的第一个参数是一个函数,函数体内容是副作用操作。第二个参数是一个依赖数组,依赖数组中的值改变,该useEffect才会在DOM更新后调用。依赖数组是一种性能优化的手段,避免每次DOM更新都调用无关的effect。
  2. useEffect可以定义多个,便于我们把不同的业务逻辑分为多个。
  3. 与 componentDidMount 或 componentDidUpdate 不同,使用 useEffect 调度的 effect 不会阻塞浏览器更新屏幕。
需要清除的Effect

  一些副作用是需要清除的。例如订阅外部数据源。这种情况下,清除工作是非常重要的,可以防止引起内存泄露。在class组件中,我们一般在 componentDidMount 中设置订阅,并在 componentWillUnmount 中清除它。在effect钩子函数中,effect 返回一个函数,React 将会在执行清除操作时调用它。它会在调用一个新的 effect 之前对前一个 effect 进行清理。

Hook规则

  Hook的使用规则:(1)只能在函数最外层调用 Hook。不要在循环、条件判断或者子函数中调用。(2)只能在 React 的函数组件中调用 Hook或者在自定义 Hook 中调用其他 Hook 。不要在其他 JavaScript 函数中调用。
  我们可以在单个组件中使用多个 State Hook 或 Effect Hook。React怎么知道哪个 state 对应哪个 useState?答案是 React 靠的是 Hook 调用的顺序。因此如果你在某些循环中调用,导致某个Effect不能执行的时候,这个顺序就会被打乱,React 就不能定位之后的state和effect了。
引用一个官网的例子:

function Form() {
  // 1. Use the name state variable
  const [name, setName] = useState('Mary');

  // 2. Use an effect for persisting the form
  useEffect(function persistForm() {
    localStorage.setItem('formData', name);
  });

  // 3. Use the surname state variable
  const [surname, setSurname] = useState('Poppins');

  // 4. Use an effect for updating the title
  useEffect(function updateTitle() {
    document.title = name + ' ' + surname;
  });

  // ...
}  
// ------------
// 首次渲染
// ------------
useState('Mary')           // 1. 使用 'Mary' 初始化变量名为 name 的 state
useEffect(persistForm)     // 2. 添加 effect 以保存 form 操作
useState('Poppins')        // 3. 使用 'Poppins' 初始化变量名为 surname 的 state
useEffect(updateTitle)     // 4. 添加 effect 以更新标题

// -------------
// 二次渲染
// -------------
useState('Mary')           // 1. 读取变量名为 name 的 state(参数被忽略)
useEffect(persistForm)     // 2. 替换保存 form 的 effect
useState('Poppins')        // 3. 读取变量名为 surname 的 state(参数被忽略)
useEffect(updateTitle)     // 4. 替换更新标题的 effect

// ...  
// 🔴 在条件语句中使用 Hook 违反第一条规则
if (name !== '') {
  useEffect(function persistForm() {
    localStorage.setItem('formData', name);
  });
}  
useState('Mary')           // 1. 读取变量名为 name 的 state(参数被忽略)
// useEffect(persistForm)  // 🔴 此 Hook 被忽略!
useState('Poppins')        // 🔴 2 (之前为 3)。读取变量名为 surname 的 state 失败
useEffect(updateTitle)     // 🔴 3 (之前为 4)。替换更新标题的 effect 失败

自定义Hook

  自定义钩子也是函数,它的应用场景就是需要提取两个函数的公用逻辑,且这两个函数中用到了Hook。使用自定义钩子需要注意:(1)自定义Hook函数名已use开头。(2)自定义钩子中可以使用其他的Hook。(3)两个组件中调用同一个自定义钩子时,其中的所有 state 和副作用都是完全隔离的。

import React, { useState, useEffect } from 'react'

//自定义hook是一个函数,名称用use开头,函数内部可以调用其他钩子
function useAge () {
  const [age, setAge] = useState(0)
  useEffect(() => {
    setTimeout(() => {
      setAge(20)
    }, 2000)
  }, [age])
  return age
}
export default function OwnHook () {

  const age = useAge()
  return (
    <div>
      <p>年龄:{age ? age : 'loading'}</p>
    </div>
  )
}

Context

  Context使我们可以跨层级通信,而不需要一层一层传递props。Context 设计目的是为了共享那些对于一个组件树而言是“全局”的数据,例如当前认证的用户、主题或首选语言。

ContextAPI

  1. React.createContext: 创建上下文
  2. Context.Provider: 每个 Context 对象都会返回一个 Provider React 组件,它允许消费组件订阅 context 的变化
  3. Class.contextType: 挂载在 class 上的 contextType 属性会被重赋值为一个由 React.createContext() 创建的 Context 对象。这能让你使用 this.context 来消费最近 Context 上的那个值。
  4. Context.Consumer: 每个 Context 对象都会返回一个 Consumer React 组件,消费context的变化。

使用Context的三种方法

import React, { useContext } from 'react'

//1.创建上下文
const MyContext = React.createContext()
const { Provider, Consumer } = MyContext

//Consumer消费
function Child (props) {
  return (
    <div>
      Child {props.foo}
    </div>
  )
}
//使用hook消费
function Child2 () {
  const ctx = useContext(MyContext)
  return (
    <div>
      Child2 {ctx.foo}
    </div>
  )
}

//使用class指定静态contextType
class Child3 extends React.Component {
  static contextType = MyContext;
  render () {
    return <div>Child3:{this.context.foo}</div>
  }
}

export default function ContextTest () {
  return (
    <div>
      <Provider value={{ foo: 'bar' }}>
        {/*消费方法1:Consumer */}
        <Consumer>
          {value => <Child {...value}></Child>}
        </Consumer>
        {/*消费方法2:hook */}
        <Child2></Child2>
        {/*消费方式3: contextType */}
        <Child3></Child3>
      </Provider>
    </div>
  )
}