React学习—React漫谈

6,858 阅读30分钟

前端阅读室

事件系统

React基于Virtual DOM实现了一个SyntheticEvent(合成事件)层,我们定义的处理器会接收一个SyntheticEvent对象的实例,它完全符合W3C标准,不会存在任何IE的兼容性问题。并且与原生的浏览器事件一样拥有同样的接口,同样支持事件的冒泡机制,我门可以使用stopPropagation()和preventDefault()来中断它。如果需要访问原生事件对象,可以使用nativeEvent属性。

合成事件的绑定方式

React事件的绑定方式与原生的HTML事件监听器属性很相似。

<button onClick={this.handleClick}>Test</button>

合成事件的实现机制

在React底层,主要对合成事件做了两件事:事件委派和自动绑定。

1.事件委派

React不会把事件处理函数直接绑定到真实的节点上,而是把所有事件绑定到结构的最外层,使用一个统一的事件监听器,这个事件监听器上维持了一个映射来保存所有组件内部的事件监听和处理函数。当组件挂载或卸载时,只是在这个统一的事件监听器上插入或删除一些对象;当事件发生时,首先被这个统一的事件监听器处理,然后在映射里找到真正的事件处理函数并调用。(实现原理:对最外层的容器进行绑定,依赖事件的冒泡机制完成委派。)这样简化了事件处理和回收机制,效率也有很大提升。

2.自动绑定

在React组件中,每个方法的上下文都会指向该组件的实例,即自动绑定this为当前组件。而且React还会对这种引用进行缓存。在使用ES6 classes或者纯函数时,这种自动绑定就不复存在了,我们需要手动实现this的绑定。 我们来看几种绑定方法 bind方法

class App extends Component {
  constuctor() {
    super(props)
    this.handleClick = this.handleClick.bind(this)
  }
}

箭头函数可以自动绑定此函数的作用域的this

class App extends Component {
  handleClick= () => {}
}

在React中使用原生事件

class NativeEventDemo extends Component {
  componentDidMount() {
    this.refs.button.addEventListener('click', this.handleClick)
  }
  componentWillUnmout() {
    this.refs.button.removeEventListener('click', this.handleClick)
  }
}

对比React合成事件与JavaScript原生事件

1.事件传播与阻止事件传播

浏览器原生DOM事件的传播可以分为3个阶段:事件捕获阶段、目标对象本身的事件处理程序调用、事件冒泡。可以将e.addEventListener的第三个参数设置为true时,为元素e注册捕获事件处理程序。事件捕获在IE9以下无法使用。事件捕获在应用程序开发中意义不大,React在合成事件中并没有实现事件捕获,仅仅支持了事件冒泡机制。

阻止原生事件传播需要使用e.stopPropagation,不过对于不支持该方法的浏览器(IE9以下)只能使用e.cancelBubble = true来阻止。而在React合成事件中,只需要使用stopPropagation()即可。阻止React事件冒泡的行为只能用于React合成事件系统中,且没有办法阻止原生事件的冒泡。反之,原生事件阻止冒泡,可以阻止React合成事件的传播。

2.事件类型

React合成事件的事件类型是JavaScript原生事件类型的一个子集。它仅仅实现了DOM Level3的事件接口,并且统一了浏览器的兼容问题。有些事件React没有实现,或者受某些限制没办法去实现,如window的resize事件。

3.事件绑定方式

受到DOM标准影响,浏览器绑定原生事件的方式有很多种。React合成事件的绑定方式则简单很多

<button onClick={this.handleClick}>Test</button>

4.事件对象

在React合成事件系统中,不存在兼容性问题,可以得到一个合成事件对象。

表单

在React中,一切数据都是状态,当然也包括表单数据。接下来我们讲讲React是如何处理表单的。

应用表单组件

html表单中的所有组件在React的JSX都有实现,只是它们在用法上有些区别,有些是JSX语法上的,有些则是由于React对状态处理上导致的一些区别。

1.文本框

import React, { Component } from 'react';

class App extends Component {
  constructor(props) {
    super(props);

    this.state = {
      inputValue: '',
      textareaValue: ''
    }
  }

  handleInputChange = (e) => {
    this.setState({
      inputValue: e.target.value
    });
  }

  handleTextareaChange = (e) => {
    this.setState({
      textareaValue: e.target.value
    })
  }

  render() {
    const { inputValue, textareaValue } = this.state;
    return (
      <div>
        <p>
          单行输入框:
          <input type="text" value={inputValue} onChange={this.handleInputChange}/>
        </p>
        <p>
          多行输入框:
          <textarea type="text" value={textareaValue} onChange={this.handleTextareaChange}/>
        </p>
      </div>
    )
  }

}

在HTML中textarea的值是通过children来表示的,而在react中是用一个value prop来表示表单的值的。

2.单选按钮与复选框

在HTML中,用类型为radio的input标签表示单选按钮,用类型为checkbox的input标签表示复选框。这两种表单的value值一般是不会改变的,而是通过一个布尔类型的checked prop来表示是否为选中状态。在JSX中这些是相同的,不过用法上还是有些区别。

单选按钮的示例

import React, { Component } from 'react';

class App extends Component {
  construtor(props) {
    super(props);
    this.state = {
      radioValue: '',
    }
  }

  handleChange = (e) => {
    this.setState(
      radioValue: e.target.value
    )
  }

  render() {
    const { radioValue } = this.state;

    return (
      <div>
        <p>gender:</p>
        <label>
          male:
          <input 
            type="radio"
            value="male"
            checked={radioValue === 'male'}
            onChange={this.handleChange}
          />
        </label>
        <label>
          female:
          <input 
            type="radio"
            value="female"
            checked={radioValue === 'female'}
            onChange={this.handleChange}
          />
        </label>
      </div>
    )
  }
}

复选按钮的示例

import React, { Component } from 'react';

class App extends Component {
  constructor(props) {
    super(props)
    
    this.state = {
      coffee: []
    }
  }

  handleChange = (e) => {
    const { checked, value } = e.target;
    let { coffee } = this.state;

    if (checked && coffee.indexOf(value) === -1) {
      coffee.push(value)
    } else {
      coffee = coffee.filter(i => i !== value)
    }

    this.setState({
      coffee,
    })
  }

  render() {
    const { coffee } = this.state;
    return (
      <div>
        <p>请选择你最喜欢的咖啡</p>
        <label>
          <input 
            type="checkbox"
            value="Cappuccino"
            checked={coffee.indexOf('Cappuccino') !== -1}
            onChange={this.handleChange}
          />
          Cappuccino
        </label>
        <br />
        <label>
          <input 
            type="checkbox"
            value="CafeMocha"
            checked={coffee.indexOf('CafeMocha') !== -1}
            onChange={this.handleChange}
          />
          CafeMocha
        </label>
      </div>
    )
  }
}

3.Select组件

在HTML的select元素中,存在单选和多选两种。在JSX语法中,同样可以通过设置select标签的multiple={true}来实现一个多选下拉列表。

class App extends Component {
  constructor(props) {
    super(props)

    this.state = {
      area: ''
    }
  }

  handleChange = (e) => {
    this.setState({
      area: e.target.value
    })
  }

  render() {
    const { area } = this.state;

    return (
      <select value={area} onChange={this.handleChange}>
        <option value='beijing'>北京</option>
        <option value='shangehai'>上海</option>
      </select>
    )
  }
}

select元素设置multiple={true}的示例

class App extends Component {
  constructor(props) {
    super(props)

    this.state = {
      area: ['beijing', 'shanghai']
    }
  }

  handleChange = (e) => {
    const { options } = e.target;
    const area = Object.keys(options)
      .filter(i => options[i].selected === true)
      .map(i => options[i].value);

    this.setState({
      area,
    })
  }

  render () {
    const { area } = this.state;

    return (
      <select multiple={true} value={area} onChange={this.handleChange}>
        <option value="北京">北京</option>
        <option value="上海">上海</option>
      </select>
    )
  }
}

在HTMl的option组件需要一个selected属性来表示默认选中的列表项,而React的处理方式是通过为select组件添加value prop来表示选中的option,在一定程度上统一了接口。

实际上,也可以写成这种形式,不过开发体验就会差很多,React也会抛警告。

<select multiple={true} onChange={this.handleChange}>
  <option value="beijing" selected={area.indexOf('beijing') !== -1}>北京</option>
  <option value="shanghai" selected={area.indexOf('shanghai') !== -1}>上海</option>
</select>

受控组件

每当表单的状态发生变化,都会被写入到组件的state中,这种组件在React中被称为受控组件。在受控组件中,组件渲染出的状态与它的value或checked prop相对应。React通过这种方式消除了组件的局部状态,使得应用的整个状态更加可控。

非受控组件

如果一个表单组件没有value prop(或checked prop),就可以称之为非受控组件。相应的你可以使用defaultValue和defaultChecked prop来表示组件的默认状态。

class App extends Compoent {
  constructor(props) {
    super(props)

  }

  handleSubmit = (e) => {
    e.preventDefault();

    const { value } = this.refs.name;
    console.log(value)
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <input ref="name" type="text" defaultValue="Hangzhou" />
        <button type="submit">submit</button>
      </form>
    )
  }
}

在React中,非受控组件是一种反模式,它的值不受组件自身的state或props控制。通常,需要为其添加ref prop来访问渲染后的底层DOM元素。

对比受控组件和非受控组件

受控组件和非受控组件的最大区别是:非受控组件的状态并不会受应用状态的控制,应用中也多了局部组件状态,而受控组件的值来自于组件的state。

1.性能上的问题

受控组件onChange后,调用setState会重新渲染,确实会有一些性能损耗。

2.是否需要事件绑定

受控组件需要为每个组件绑定一个change事件,并且定义一个事件处理器来同步表单值和组件的状态。

尽管如此,在React仍然提倡使用受控组件,因为它可以使得应用的整个状态更加可控。

表单组件的几个重要属性

1.状态属性

React的form组件提供了几个重要的属性,用于展示组件的状态。 value: 类型为text的input组件、textarea组件以及select组件都借助value prop来展示应用的状态。 checked: 类型为radio或checkbox的组件借助值为boolean类型的checked prop来展示应用的状态。 selected: 该属性可作用于select组件下面的option上,React并不建议使用功这种方式,推荐使用value.

2.事件属性

在状态属性发生变化时,会触发onChange事件属性。实际上,受控组件中的change事件与HTML DOM中提供的input事件更为类似。React支持DOM Level3中定义的所有表单事件。

样式处理

基本样式设置

React可以通过设置className prop给html设置class,也可以通过style prop给组件设置行内样式。

使用classnames库

我们可以通过classnames库来设置html的类名

CSS Modules

CSS模块化的解决方案有很多,但主要有两类。

  1. Inline Style。这种方案彻底抛弃CSS,使用JavaScript或JSON来写样式,能给CSS提供JavaScript同样强大的模块化能力。但缺点同样明显,它几乎不能利用CSS本身的特性,比如级联、媒体查询等,:hover和:active等伪类处理起来比较复杂。另外,这种方案需要依赖框架实现,其中与React相关的有Radium、jsxstyle和react-style
  2. CSS Modules。依旧使用CSS,但使用JavaScript来管理样式依赖。CSS Modules能最大化地结合现有CSS生态和JavaScript模块化能力,其API非常简洁。发布时依旧编译出单独的JavaScript和CSS文件。现在,webpack css-loader内置CSS Modules功能。

1.CSS模块化遇到了哪些问题

CSS模块化重要的是解决好以下两个问题:CSS样式的导入与导出。灵活按需导入以便复用代码,导出时要隐藏内部作用域,以免造成全局污染。Sass、Less、PostCSS等试图解决CSS编程能力弱的问题,但并没有解决模块化这个问题。React实际开发需要的CSS相关问题有:

  1. 全局污染:CSS使用全局选择器机制来设置样式,优点是方便重写样式。缺点是所有的样式全局生效,样式可能被错误覆盖。因此产生了非常丑陋的!important,甚至inline !important和复杂的选择器权重计数表,提高犯错概率和使用成本。Web Component标准中的Shadow DOM能彻底解决这个问题,但它把样式彻底局部化,造成外部无法重写样式,损失了灵活性。
  2. 命名混乱:由于全局污染的问题,多人协同开发时为了避免样式冲突,选择器越来越复杂,容易形成不同的命名风格,样式变多后,命名将更加混乱。
  3. 依赖管理不彻底:组件应该相互独立,引入一个组件时,应该只引入它所需要的CSS样式。现在的做法是除了引入JavaScript,还要再引入它的CSS,而且Sass/Less很难实现对每个组件都编译出单独的CSS,引入所有模块的CSS又造成浪费。JavaScript的模块化已经非常成熟,如果能让JavaScript来管理CSS依赖是很好的解决办法,而webpack的css-loader提供了这种能力。
  4. 无法共享变量:复杂组件要使用JavaScript和CSS来共同处理样式,就会造成有些变量在JavaScript和CSS中冗余,而预编译语言不能提供跨JavaScript和CSS共享变量的这种能力。
  5. 代码压缩不彻底:对与非常长的类名压缩无能为力。

2.CSS Modules模块化方案

CSS Modules内部通过ICSS来解决样式导入和导出这两个问题,分别对应:import和:export两个新增的伪类。

:import("path/to/dep.css") {
  localAlias: keyFromDep;
}

:export {
  exportedKey: exportedValue;
}

但直接使用这两个关键字编程太烦琐,项目中很少会直接使用它们,我们需要的是用JavaScript来管理CSS的能力。结合webpack的css-loader,就可以在CSS中定义样式,在JavaScript文件中导出。

启用CSS Modules

css?modules&localIdentName=[name]__[local]-[hash:base64:5]

加上modules即为启用,其中localIdentName是设置生成样式命名规则

下面我们看看js是怎么引入CSS的:

/* button相关的所有样式 */
.normal {}
import styles from './Button.css'

buttonElm.outerHTML = `<button class=${styles.normal}>Submit</button>`

最终生成的HTML是这样的

<button class="button--normal-abc5436">Processing...</button>

这样class的名称基本就是唯一的。 CSS Modules对CSS中的class名都做了处理,使用对象来保存原class和混淆后class的对应关系。通过这些简单的处理,CSS Modules实现了以下几点:

  1. 所有样式都是局部化的,解决了命名冲突和全局污染问题
  2. class名生成规则配置灵活,可以以此来压缩class名
  3. 只需要引用组件的JavaScript,就能搞定组件所有的JavaScript和CSS
  4. 依然是CSS,学习成本几乎为零

样式默认局部

使用CSS Modules相当于给每个class名外加了:local,以此来实现样式的局部化。如果我们想切换到全局模式,可以使用:global包裹

.normal {
  color: green;
}
/* 与上面等价 */
:local(.normal) {
  color: green;
}
/* 定义全局样式 */
:global(.btn) {
  color: red;
}
/* 定义多个全局样式 */
:global {
  .link {
    color: green;
  }
  .box {
    color: yellow;
  }
}

使用composes来组合样式

对于样式复用,CSS Modules只提供了唯一的方式来处理——composes组合。

/* components/Button.css */
.base { /* 所有通用的样式 */ }

.normal {
  composes: base;
  /* normal其他样式 */
}

此外,使用composes还可以组合外部文件中的样式

/* settings.css */
.primary-color {
  color: #f40;
}

/* component/Button.css */
.base { /* 所有通用样式 */ }

.primary {
  composes: base;
  composes: $primary-color from './settings.css'
}

对于大多数项目,有了composes后,已经不再需要预编译处理器了。但是如果想用的话,由于composes不是标准的CSS语法,编译会报错,此时只能使用预处理自己的语法做样式复用了。

class命名技巧

CSS Modules的命名规范是从BEM扩展而来的。BEM把样式名分为3个级别

  1. Block: 对应模块名,如Dialog
  2. Element: 对应模块中的节点名 Confirm Button
  3. Modifier: 对应节点相关的状态,如disabled和highlight 如dialog__confirm-button--highlight。

实现CSS与JavaScript变量共享

:export关键字可以把CSS中的变量输出到JavaScript中

$primary-color: #f40;

:export {
  primaryColor: $primary-color;
}
// app.js
import style from 'config.scss'

console.log(style.primaryColor);

CSS Modules使用技巧

建议遵循如下原则

  1. 不使用选择器,只使用class名来定义样式
  2. 不层叠多个class,只使用一个class把所有样式定义好
  3. 所有样式通过composes组合来实现复用
  4. 不嵌套 常见问题 1.如果在一个style文件使用同名class? 虽然编译后可能是随机码,但仍是同名的。 2.如果在style文件中使用了id选择器、伪类和标签选择器等呢? 这些选择器不被转换,原封不动地出现在编译后的CSS中。也就是CSS Moudles只会转换class名相关的样式

CSS Modules结合历史遗留项目实践

1.外部如何覆盖局部样式

因为无法预知最终的class名,不能通过一般选择器覆盖样式。我们可以给组件关键节点加上data-role属性,然后通过属性选择器来覆盖样式。

// dialog.js
return (
  <div className={styles.root} data-role="dialog-root"></div>
);
// dialog.css
[data-role="dialog-root"] {
  // override style
}

2.如何与全局样式共存

修改webpack配置

module: {
  loaders: [{
    test: /\.scss$/,
    exclude: path.resolve(__dirname, 'src/views'),
    loader: 'style!css?modules&localIdentName=[name]__[local]!sass?sourceMap=true',
  }, {
    test: /\.scss$/,
    include: path.resolve(__dirname, 'src/styles'),
    loader: 'style!css!sass?sourceMap=true'
  }]
}
/* src/app.js */
import './styles/app.scss';
import Component from './view/Component'

/* src/views/Component.js */
import './Component.scss'

CSS Modules结合React实践

import styles from './dialog.css';

class Dialog extends Component {
  render() {
    return (
      <div className={styles.root}></div>
    )
  }
}

如果不想频繁地输入styles.**,可以使用react-css-modules

组件间通信

父组件向子组件通信

父组件可以通过props向子组件传递需要的信息

子组件向父组件通信

有两种方法:1.利用回调函数。2.利用自定义事件机制:这种方法更通用,设计组件时考虑加入事件机制往往可以达到简化组件API的目的。

在React中,可以使用任意一种方法,在简单场景下使用自定义事件过于复杂,一般利用回调函数。

跨级组件通信

当需要让子组件跨级访问信息时,若像之前那样向更高级别的组件层层传递props,此时代码显得不那么优雅,甚至有些冗余。在React中,我们还可以使用context来实现跨级父子组件间的通信。

class ListItem extends Component {
  static contextTypes = {
    color: PropTypes.string,
  }

  render () {
    return (
      <li style={{ background: this.context.color }}></li>
    )
  }
}
class List extends Component {
  static childContextTypes = {
    color: PropTypes.string,
  }

  getChildContext() {
    return {
      color: 'red'
    }
  }
  render() {

  }
}

React官方并不建议大量使用context,因为当组件结构复杂的时候,我们很难知道context是从哪传过来的。使用context比较好的场景是真正意义上的全局信息且不会更改,例如界面主题、用户信息等。总体的原则是如果我们真的需要它,那么建议写成高阶组件来实现。

没有嵌套关系的组件通信

没有嵌套关系的,那只能通过可以影响全局的一些机制去考虑。之前讲的自定义事件机制不失为一种上佳的方法。

我们在处理事件过程中需要注意,在componentDidMount事件中,如果组件挂载完成,再订阅事件;当组件卸载的时候,在componentWillUnmount事件中取消事件的订阅。

对于React使用的场景,EventEmitter只需要单例就可以了

import { EventEmitter } from 'events';

export default new EventEmitter();
import emitter from './events';

emitter.emit('ItenChange', entry)
class App extends Component {
  componentDidMount() {
    this.itemChange = emitter.on('ItemChange', (data) => {
      console.log(data)
    })
  }
  componentWillUnmount() {
    emitter.removeListener(this.itemChange)
  }
}

一般来说,程序中出现多级传递或跨级传递,那么要个重新审视一下是否有更合理的方式。Pub/Sub的模式可能也会带来逻辑关系的混乱。

跨级通信往往是反模式的,应该尽量避免仅仅通过例如Pub/Sub实现的设计思路,加入强依赖与约定来进一步梳理流程是更好的方法。(如使用Redux)

组件间抽象

常常有这样的场景,有一类功能需要被不同的组件公用,此时就涉及抽象的话题。我们重点讨论两种:mixin和高阶组件

封装mixin方法

const mixin = function(obj, mixins) {
  const newObj = obj;
  newObj.prototype = Object.create(obj.prototype);

  for (let prop in mixins) {
    if (mixins.hasOwnProperty(prop)) {
      newObj.prototype[prop] = mixins[prop]
    }
  }
}

const BigMixin = {
  fly: () => {
    console.log('fly');
  }
}

const Big = function() {
  console.log('new big');
}

consg FlyBig = mixin(Big, BigMixin)

const flyBig = new FlyBig();
flyBig.fly(); // => 'fly'

对于广义的mixin方法,就是用赋值的方式将mixin对象里的方法都挂载到原对象上,来实现对对象的混入。

看到上述实现,你可能会联想到underscore库中的extend或lodash库中的assign方法,或者说ES6中的Object.assign()方法。MDN上的解释是把任意多个源对象所拥有的自身可枚举属性复制给目标对象,然后返回目标对象。

在React中使用mixin

React在使用createClass构建组件时提供了mixin属性,比如官方封装的PureRenderMixin

import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';

React.createClass({
  mixins: [PureRenderMixin],
  render() {}
})

mixins数组也可以增加多个mixin,其若mixin方法之间有重合,对于普通方法,在控制台里会报一个ReactClassInterface的错误。对于生命周期方法,会将各个模块的生命周期方法叠加在一起顺序执行。

mixin为组件做了两件事:

  1. 工具方法。如果你想共享一些工具类方法,可以定义它们,直接在各个组件中使用。
  2. 生命周期继承,props与state合并,mixin也可以作用在getInitialState的结果上,作state的合并,而props也是这样合并的。

ES6 Classes与decorator

ES6 classes形式构建组件,它并不支持mixin。decorator语法糖可以实现class上的mixin。

core-decorators库为开发者提供了一些实用的decorator,其中实现了我们正想要的@mixin

import { getOwnPropertyDescriptors } from './private/utils';

const { defineProperty } = Object;

function handleClass(target, mixins) {
  if (!mixins.length) {
    // throw error;
  }

  for(let i = 0, l = mixins.length; i < l; i ++) {
    const descs = getOwnPropertyDescriptors(mixins[i])

    for (const key in descs) {
      if (!(key in target.prototype)) {
        defineProperty(target.prototype, key, descs[key])
      }
    }
  }
}

export default function mixin(...mixins) {
  if (typeof mixins[0] === 'function') {
    return handleClass(mixins[0], [])
  } else {
    return target => {
      return handleClass(target, mixins)
    }
  }
}

原理也很简单,它将每一个mixin对象的方法都叠加到target对象的原型上以达到mixin的目的。这样就可以用@mixin来做多个重用模块的叠加了。

const PureRender = {
  shouldComponentUpdate() {}
}

const Theme = {
  setTheme() {}
}

@mixin(PureRender, Theme)
class MyComponent extends Component {
  render() {}
}

mixin的逻辑和最早实现的简单逻辑很相似,之前直接给对象的prototype属性赋值,但这里用了getOwnPropertyDescriptor和defineProperty这两个方法,有什么区别呢?

这样实现的好在于definedProperty这个方法,也就是定义和赋值的区别,定义是对已有的定义,赋值是覆盖已有的定义。前者并不会覆盖已有方法,但后者会。本质上与官方的mixin方法都很不一样,除了定义方法级别不能覆盖外,还得加上对生命周期方法的继承,以及对state的合并。

decorator还有作用在方法上的,它可以控制方法的自有属性,也可以作decorator的工厂方法。

mixin的问题

mixin存在很多问题,已经被官方弃用了,由高阶组件替代。

1.破坏的原有组件的封装

我们知道mixin方法会混入方法,给原有组件带来新的特性,比如mixin中有一个renderList方法,给我们带来了渲染List的能力,但它也可能带来新的state和props,这意味着组件有一些"不可见"的状态需要我们去维护,但我们在使用的时候并不清楚。此外renderList中的方法会调用组件中方法,但很可能被其他mixin截获,带来很多不可知。

2.不同mixin的命名冲突

3.增加复杂性

我们设计一个组件,引入PopupMixin的mixin,这样就给组件引进了PopupMixin生命周期方法。当我们再引入HoverMixin,将有更多的方法被引进。当然我们可以进一步抽象出TooltipMixin,将两个整合在一起,但我们发现它们都有compomentDidUpdate方法。过一段时间,你会发现它的逻辑已经复杂到难以理解了。

我们写React组件时,首先考虑的往往是单一的功能、简洁的设计和逻辑。当加入功能的时候,可以继续控制组件的输入和输出。如果说因为复杂性,我们不断加入新的状态,那么组件会变得非常难维护。

高阶组件

高阶函数是函数式编程中的一个基本概念,这种函数接受函数作为输入,或是输出一个函数。 高阶组件类似高阶函数,它接受React组件作为输入,输出一个新的React组件。

高阶组件让我们的代码更具有复用性、逻辑性与抽象特性,它可以对render方法作劫持,也可以控制props和state。

实现高阶组件的方法有两种:

  1. 属性代理:通过被包裹的React组件来操作props
  2. 反向继承:继承于被包裹的React组件

1.属性代理

const MyContainer = (WrappedComponent) => {
  class extends Component {
    render() {
      return <WrappedComponent {...this.props} />
    }
  }
}

这样,我们就可以通过高阶组件来传递props,这种方法即为属性代理。 这样组件就可以一层层地作为参数被调用,原始组件就具备了高阶组件对它的修饰。保持了单个组件封装性同时还保留了易用性。当然,也可以用decorator来转换

@MyContainer
class MyComponent extends Component {
  render() {}
}

export default MyComponent

上述执行生命周期的过程类似于堆栈调用: didmount -> HOC didmount -> (HOCs didmount) -> (HOCs will unmount) -> HOC will unmount -> unmount 从功能上,高阶组件一样可以做到像mixin对组件的控制,包括控制props、通过refs使用引用、抽象state和使用其他元素包裹WrappedComponent.

1.控制props

我们可以读取、增加、编辑或是移除从WrappedComponent传进来的props,但需要小心删除与编辑重要的props。我们应该尽可能对高阶组件的props作新的命名以防止混淆。

例如,我们需要增加一个新的prop:

const MyContainer = (WrappedComponent) => {
  class extends Component {
    render() {
      const newProps = {
        text: newText,
      };
      return <WrappedComponent {...this.props} {...newProps} />;
    }
  }
}

2.通过refs使用引用

const MyContainer = (WrappedComponent) => {
  class extends Component {
    proc(wrappedComponentInstance) {
      wrappedComponentInstance.method();
    }

    render() {
      const props = Object.assign({}, this.props, {
        ref: this.proc.bind(this),
      })
      return <WrappedComponent {...props} />
    }
  }
}

这样就可以方便地用于读取或增加实例的props,并调用实例的方法。 3.抽象state 高阶组件可以将原组件抽象为展示型组件,分离内部状态

const MyContainer = (WrappedComponent) => {
  class extends Component {
    constructor(props) {
      super(props)
      this.state = {
        name: ''
      }

      this.onNameChange = this.onNameChange.bind(this)
    }

    onNameChange(event) {
      this.setState({
        name: event.target.value
      })
    }

    render() {
      const newProps = {
        name: {
          value: this.state.name,
          onChange: this.onNameChange,
        }
      }
      return <WrappedComponent {...this.props} {...newProps}>
    }
  }
}

这样就有效地抽象了同样的state操作。 4.使用其他元素包裹WrappedComponent 这既可以是为了加样式,也可以是为了布局

const MyContainer = (WrappedComponent) => {
  class extends Component {
    render() {
      return (
        <div style={{ display: 'block' }}>
          <WrappedComponent {...this.props} />
        </div>
      )
    }
  }
}

反向继承

const MyContainer = (WrappedComponent) => {
  class extends WrappedComponent{
    render() {
      return super.render()
    }
  }
}

高阶组件返回的组件继承于WrappedComponent,因为被动地继承了WrappedComponent,所有调用都会反向,这也是这种方法的由来。 因为依赖于继承的机制,HOC的调用顺序和队列是一样的 didmount->HOC didmount->(HOCs didmount)->will unmount->HOC will unmount->(HOCs will unmount)

在反向继承方法中,高阶组件可以使用WrappedComponent引用,这意味着它可以使用WrappedComponent的state、props、生命周期、和render。但它不能保证完整的子组件树被解析。

它有两大特点

1.渲染劫持

高阶组件可以控制WrappedComponent的渲染过程。可以在这个过程中在任何React元素输出的结果中读取;增加、修改。删除props,或读取或修改React元素树,或条件显示元素树,又或是用样式控制包裹元素树。

如果元素树中包括了函数类型的React组件,就不能操作组件的子组件。

条件渲染示例

const MyContainer = (WrappedComponent) => {
  class extends WrappedComponent {
    render() {
      if (this.props.loggedIn) {
        return super.render();
      } else {
        return null;
      }
    }
  }
}

对render的输出结果进行修改

const MyContainer = (WrappedComponent) => {
  class extends WrappedComponent {
    render() {
      const elementsTree = super.render();
      let newProps;
      if (elementsTree && elementsTree.type === 'input') {
        newProps = { value: 'may the force be with you' }
      }
      const props = Object.assign({}, elementsTree.props, newProps);
      const newElementsTree = React.cloneElement(elementsTree, props, elementsTree.props.children);
      return newElementsTree;
    }
  }
}

2.控制state

高阶组件可以读取、修改或删除WrappedComponent实例中的state,如果需要的话,也可以增加state。但这样做,可能会让WrappedComponent组件内部状态变得一团糟。大部分高阶组件都应该限制读取或增加state,尤其是后者,可以通过重命名state,以防止混淆

const MyContainer = (WrappedComponent) => {
  class extends WrappedComponent {
    render() {
      return (
        <div>
          <h2>HOC Debugger Component</h2>
          <p>Props</p><pre>{JSON.stringify(this.props, null, 2)}</pre>
          <p>State</p><pre>{JSON.stringify(this.state, null, 2)}</pre>
          {super.render()}
        </div>
      )
    }
  }
}

在这个例子中,显示了WrappedComponent的props和state,方便我们调试。

组件命名

高阶组件失去了原始的diplayName,我们应该为高阶组件命名

HOC.displayName = `HOC(${getDisplayName(WrappedComponent)})`

class HOC extends ... {
  static displayName = `HOC(${getDisplayName(WrappedComponent)})`
}
function getDisplayName(WrappedComponent) {
  return WrappedComponent.displayName ||
         WrappedComponent.name || 
         'Component'
}

组件参数

function HOCFactoryFactory(...params) {
  return function HOCFactory(WrappedComponent) {
    return class HOC extends Component {
      render() {
        return <WrappedComponent {...this.props} />
      }
    }
  }
}

组合式组件开发实践

我们多次提到,使用React开发组件时利用props传递参数。也就是说,用参数来配置组件时我们最常用的封装方式。随着场景发生变化,组件的形态也发生变化,我们必须不断增加props去应对变化,此时便会导致props的泛滥,而在拓展中又必须保证组件向下兼容,只增不减,使组件的可维护性降低。

我们就可以利用上述高阶组件的思想,提出组件组合式开发模式,有效地解决了配置式所存在的一些问题。

1.组件再分离

SelectInput、SearchInput与List三个颗粒度更细的组件可以组合成功能丰富的组件,而每个组件可以是纯粹的、木偶式的组件。

2.逻辑再抽象

组件中相同交互逻辑和业务逻辑也应该抽象

// 完成SearchInput与List的交互
const searchDecorator = WrappedComponent => {
  class SearchDecorator extends Component {
    constructor(props) {
      super(props)

      this.handleSearch = this.handleSearch.bind(this)
    }

    handleSearch(keyword) {
      this.setState({
        data: this.props.data,
        keyword,
      })

      this.props.onSearch(keyword)
    }

    render() {
      const { data, keyword } = this.state;
      return (
        <WrappedComponent
          {...this.props}
          data={data}
          keyword={keyword}
          onSearch={this.handleSearch}
        />
      )
    }
  }

  return SearchDecorator;
}

// 完成List数据请求
const asyncSelectDecorator = WrappedComponent => {
  class AsyncSelectDecorator extends Component {
    componentDidMount() {
      const { url, params } = this.props;

      fetch(url, { params }).then(data => {
        this.setState({
          data
        })
      })
    }

    render() {
      return (
        <WrappedComponent
          {...this.props}
          data={this.state.data}
        />
      )
    }
  }

  return AsyncSelectDecorator;
}

最后,我们用compose将高阶组件层层包裹,将页面和逻辑完美结合在一起

const FinalSelector = compose(asyncSelectDecorator, searchDecorator, selectedItemDecorator)(Selector)

组件性能优化

从React的渲染过程中,如何防止不必要的渲染是最需要去解决的问题。 针对这个问题,React官方提供了一个便捷的方法来解决,那就是PureRender

纯函数

纯函数由三大原则构成

1.给定相同的输入,它总是返回相同的输出

2.过程没有副作用(我们不能改变外部状态)

3.没有额外的状态依赖

纯函数也非常方便进行方法级别的测试以及重构,可以让程序具有良好的扩展性及适应性。

PureRender

PureRender中的Pure指的就是组件满足纯函数的条件,即组件的渲染是被相同的props和state渲染进而得到相同的结果。

1.PureRender本质

官方在早期为开发者提供了一个react-addons-pure-render-mixin的插件,其原理为重新实现了shouldComponentUpdate生命周期方法,让当前传入的props和state与之前的作浅比较,如果返回false,那么组件就不会执行render方法。(若做深度比较,也很耗性能)

PuerRender源码中,只对新旧props作了浅比较,以下是shallowEqual的示例代码

function shallowEqual(obj, newObj) {
  if (obj === newObj) {
    return true;
  }

  const objKeys = Object.keys(obj);
  const newObjKeys = Object.keys(newObj);
  if (objKeys.length !== newObjKeysl.length) {
    return false;
  }

  return objKeys.every(key => {
    return newObj[key] === obj[key];
  })
}

3.优化PureRender

如果说props或state中有以下几种类型的情况,那么无论如何,它都会触发PureRender为true。

3.1直接为props设置对象或数组

引用的地址会改变

<Account style={{ color: 'black' }} />

避免这个问题

const defaultStyle = {};
<Account style={{ this.props.style || defaultStyle }} />

3.2设置props方法并通过事件绑定在元素上

class MyInput extends Component {
  constructor(props) {
    super(props)

    this.handleChange = this.handleChange.bind(this)
  }

  handleChange(e) {
    this.props.update(e.target.value)
  }

  render() {
    return <input onChange={this.handleChange} />
  }

}

3.3设置子组件

对于设置了子组件的React组件,在调用shouldComponentUpdate时,均返回true。

class NameItem extends Component {
  render() {
    return (
      <Item>
        <span>Arcthur</span>
      </Item>
    )
  }
}
<Item 
  children={React.createElement('span', {}, 'Arcthur')}
/>

怎么避免重复渲染覆盖呢?我们在NameItem设置PureRender,也就是提到父级来判断。

Immutable

在传递数据时可以直接使用Immutable Data来进一步提升组件的渲染性能。

JavaScript中的对象一般是可变的,因为使用了引用赋值,新的对象简单地引用了原始对象,改变新的对象将影响到原始对象。

使用浅拷贝或深拷贝可以避免修改,但这样做又造成了内存和CPU的浪费。

1.Immutable Data一旦被创建,就不能再更改数据,对Immutable对象进行修改,添加或删除操作,都会返回一个新的Immutable对象。Immutable实现的原理是持久化的数据结构,也就是使用旧数据创建新数据,要保存旧数据同时可用且不变,同时为了避免深拷贝把所有节点复制一遍带来的性能损耗,Immutable使用了结构共享,即如果对象树中一个节点发生变化,只修改这个节点和受它影响的父节点,其他节点则进行共享。

Immutable的优点

1.降低了可变带来的复杂度。

可变数据耦合了time和value的概念,造成了数据很难被回溯

function touchAndLog (touchFn) {
  let data = { key: '1' }
  touchFn(data);
  console.log(data.key)
}

若data是不可变的,能打印的结果是什么。

2.节省内存

Immutable使用的结构共享尽量复用内存。没有引用的对象会被垃圾回收。

import { map } from 'immutable';

let a = Map({
  select: '1',
  filter: Map({ name: '2' }),
});

let b = a.set('select', 'people');

a === b
a.get('filter') === b.get('filter')// true

3.撤销/重做,复制/粘贴,甚至时间旅行这些功能都很容易实现。

因为每次数据都是不一样的,那么只要把这些数据放到一个数组里存储起来,就能自由回退。

4.并发安全

数据天生是不可变的,后端常用的并发锁就不需要了,然而现在并没有用,因为一般JavaScript是单线程运行的。

5.拥抱函数式编程

Immutable本身就是函数式编程中的概念,只要输入一致,输出必然一致。

使用Immutable的缺点

容易与原生对象混淆是使用Immutale的过程中遇到的最大问题。 下面给出了一些办法

1.使用FlowType或TypeScript静态类型检查工具

2.约定变量命名规则,如Immutable类型对象以?开头

3.使用Immutable.fromJS而不是Immutable.Map或Immutable.List来创建对象,这样可以避免Immutable对象和原生对象间的混用

Immutable.js

两个Immutable对象可以使用===来比较,这样是直接比较内存地址,其性能最好。但是即使两个对象的值是一样的,也会返回false。

Immutable提供了Immutable.is来作"值比较",Immutable比较的是两个对象的hasCode或valueOf,由于Immutable内部使用了trie数据结构来存储,只要两个对象的hasCode相等,值就是一样的。这样的算法避免了深度遍历比较,因此性能非常好。

Immutable与cursor

这里的cursor和数据库中的游标是完全不同的概念。由于Immutable数据一般嵌套非常深,所以为了便于访问深层数据,cursor提供了可以直接访问这个深层数据的引用:

let data = Immutable.fromJS({ a: { b: { c: 1 } } });
let cursor = Cursor.from(data, ['a', 'b'], newData => {
  // 当cursor或其子cursor执行更新时调用
  console.log(newData)
})

// 1
cursor.get('c');
cursor = cursor.update('c', x => x + 1)
// 2
cursor.get('c');

Immutable与PureRender

React做性能优化时最常用的就是shouldComponentUpdate,使用深拷贝和深比较是非常昂贵的选择。而使使用Immutable.js,===和is是高效地判断数据是否变化的方法。

import { is } from 'immutable'

shouldComponentUpdate(nextProps, nextState) {
  const thisProps = this.props || {};
  const thisState = this.state || {};

  if (Object.keys(thisProps).length !== Object.keys(nextProps).length || Object.keys(thisState).length !== Object.keys(nextState).length)  {
    return true;
  }

  for (const key in nextProps) {
    if (nextProps.hasOwnProperty(key) && !is(thisProps[key], nextProps[key])) {
      return true;
    }
  }

  for (const key in nextState) {
    if (nextState.hasOwnProperty(key) && !is(thisState[key], nextState[key])) {
      return true;
    }
  }
}

Immutable与setState

React建议把this.state当作不可变的,因此修改前需要做一个深拷贝

import _ from 'lodash';

class App extends Component {
  this.state = {
    data: { time: 0 }
  }

  handleAdd() {
    let data = _.cloneDeep(this.state.data);
    data.time = data.time + 1;
    this.setState({ data });
  }
}

使用Immutable后,操作变得很简单

import { Map } from 'immutable';

class App extends Component {
  this.state = {
    data: Map({ time: 0 })
  }

  handleAdd() {
    this.setState(({ data }) => {
      data: data.update('times', v => v + 1)
    })
  }
}

Immutable可以给应用带来极大的性能提升。

key

如果每一个组件是一个数组或迭代器的话,那么必须有一个唯一的key prop。它是用来标识当前项的唯一性的。如果使用index,它相当于一个随机键,无论有没有相同的项,更新都会渲染。如果key相同,react会抛警告,且只会渲染第一个key。

若有两个子组件需要渲染,可以用插件createFragment包裹来解决。

react-addons-perf

react-addons-perf通过Perf.start和Perf.stop两个API设置开始和结束的状态来作分析,它会把各组件渲染的各阶段的时间统计出来,然后打印出一张表格。

参考

《深入React技术栈》

前端阅读室