《深入 React 技术栈》章节试读

2 年前

《深入 React 技术栈》尽管并不是一本外文译作,但 React 与 Redux 方面的经验都是从国外的著作、博客与文档中汲取,结合对它们的理解与实践沉淀在 pure render 专栏上,所谓知识无国界,前端外刊评论亦是如此。@寸志 兄在本书写作期间参与了试读,并是本书的推荐人之一,在此非常感谢。 — 陈屹 @流形

现各大网络书店均已上架,在此放出第一章的原文一节试读,还请读者们不吝指正。

1.6 React 与 DOM

前面已经介绍完组件的组成部分了,但还缺少最后一环,那就是将组件渲染到真实 DOM 上。从 React 0.14 版本开始,React 将 React 中涉及 DOM 操作的部分剥离开,目的是为了抽象 React,同时适用于 Web 端和移动端。ReactDOM 的关注点在 DOM 上,因此只适用于 Web 端。

在 React 组件的开发实现中,我们并不会用到 ReactDOM,只有在顶层组件以及由于 React 模型所限而不得不操作 DOM 的时候,才会使用它。

1.6.1 ReactDOM

ReactDOM 中的 API 非常少,只有 findDOMNodeunmountComponentAtNoderender,我们就以 API 的角度来讲讲它们的用法。

findDOMNode

上一节我们已经讲过组件的生命周期,DOM 真正被添加到 HTML 中的生命周期方法是 componentDidMount 和 componentDidUpdate 方法。在这两个方法中,我们可以获取真正的 DOM 元素。React 提供的获取 DOM 元素的方法有两种,其中一种就是 ReactDOM 提供的 findDOMNode

DOMElement findDOMNode(ReactComponent component)

当组件被渲染到 DOM 中后,findDOMNode 返回该 React 组件实例相应的 DOM 节点。它可以被用于获取表单的 value 以及 DOM 的测量上。例如,假设要在当前组件加载完时获取当前 DOM,就可以使用 findDOMNode:

import React, { Component } from 'react';

class App extends Component {
  componentDidMount() {
    // this 为当前组件的实例
    const dom = findDOMNode(this);
  }

  render() {}
}

如果在 render 中返回 null,那么 findDOMNode 也返回 null。findDOMNode 只对已经挂载的组件有效。

涉及到复杂操作时,还有非常多的原生 DOM API 可以用。但是需要严格限制场景,在使用之前多问自己为什么要操作 DOM。

render

为什么说只有在顶层组件我们不得不使用 ReactDOM,因为我们要把 React 渲染的 Virtual DOM 渲染到浏览器的 DOM 当中,这就要使用 render 方法了。

ReactComponent render(
  ReactElement element,
  DOMElement container,
  [function callback]
)

render 方法把元素挂载到 container 中,并且返回 element 的实例(即 ref 引用)。当然,如果是无状态组件,render 会返回 null。当组件装载完毕时,callback 就会被调用。

当组件在初次渲染之后再次更新时,React 不会把整个组件重新渲染一次,而会用它高效的 DOM diff 算法做局部的更新。这也是 React 最大的亮点之一!

此外,与 render 相反,React 还提供了一个很少使用 unmountComponentAtNode 方法来做 unMount 的操作。

1.6.2 ReactDOM 不稳定方法

ReactDOM 中有两个不稳定方法,其中一个方法与 render 方法颇为相似。讲起它,还得从我们常用的 Dialog 组件在 React 中的实现讲起。

我们先来回忆一下 Dialog 组件的特点,它是不在文档流中的弹出框,一般会绝对定位在屏幕的正中央,背后有一层半透明的遮罩。因此,它往往直接渲染在 document.body 下,然而我们并不知道如何在 React 组件外进行操作。这就要从实现 Dialog 的思路以及涉及 DOM 部分的实现讲起。

这里我们引入 Portal 组件,这是一个经典的实现,最初的实现来源于 React Bootstrap 组件库中的 Overlay Mixin,后来使用越来越广泛。我们截取关键部分的源码:

import React from 'react';
import ReactDOM, { findDOMNode } from 'react-dom';
import CSSPropertyOperations from 'react/lib/CSSPropertyOperations';

export default class Portal extends React.Component {
  constructor() {
    // ...
  }

  openPortal(props = this.props) {
    this.setState({ active: true });
    this.renderPortal(props);
    this.props.onOpen(this.node);
  }

  closePortal(isUnmounted = false) {
    const resetPortalState = () => {
      if (this.node) {
        ReactDOM.unmountComponentAtNode(this.node);
        document.body.removeChild(this.node);
      }
      this.portal = null;
      this.node = null;
      if (isUnmounted !== true) {
        this.setState({ active: false });
      }
    };

    if (this.state.active) {
      if (this.props.beforeClose) {
        this.props.beforeClose(this.node, resetPortalState);
      } else {
        resetPortalState();
      }

      this.props.onClose();
    }
  }


  renderPortal(props) {
    if (!this.node) {
      this.node = document.createElement('div');
      // 在节点增加到 DOM 之前,执行 CSS 防止无效的重绘
      this.applyClassNameAndStyle(props);
      document.body.appendChild(this.node);
    } else {
      // 当新的 props 传下来的时候,更新 CSS
      this.applyClassNameAndStyle(props);
    }

    let children = props.children;
    // https://gist.github.com/jimfb/d99e0678e9da715ccf6454961ef04d1b
    if (typeof props.children.type === 'function') {
      children = React.cloneElement(props.children, { closePortal: this.closePortal });
    }

    this.portal = ReactDOM.unstable_renderSubtreeIntoContainer(
      this,
      children,
      this.node,
      this.props.onUpdate
    );
  }

  render() {
    if (this.props.openByClickOn) {
      return React.cloneElement(this.props.openByClickOn, { onClick: this.handleWrapperClick });
    }
    return null;
  }
}

从 Portal 组件看得出,我们实现了一个『壳』,包括触发事件,渲染的位置以及暴露的方法,它并不关心子组件的内容。当我们使用它的时候,可以这么写。

<Portal ref="myPortal">
  <Modal title="My modal">
    Modal content
  </Modal>
</Portal>

这个组件可以说是 Dialog 实现的精髓,我们 Dialog 的行为抽象了 Portal 这个父组件。

当我们调用上述代码时,可以注意到在 componentDidMount 的时候最后调用了 this.renderPortal() 方法,这个方法把 children 里的内容插入到 document.body 下,这就实现 children 不在标准文档流的渲染。

这之间就说到了 ReactDOM 中不稳定的 API 方法 unstable_renderSubtreeIntoContainer。它的作用很简单,就是更新组件到传入的 DOM 节点上,我们在这里使用它完成了在组件内实现跨组件的 DOM 操作。

这个方法与 render 是不是很相似,但 render 方法缺少了一个插件某一个节点的参数。从最终 ReactDOM 方法实现的源代码 react/src/renderers/dom/client/ReactMount.js 中了解 unstable_renderSubtreeIntoContainer 与 render 方法对应调用的方法区别是:

  • render: ReactMount._renderSubtreeIntoContainer(null, nextElement, container, callback);

  • unstable_renderSubtreeIntoContainer: ReactMount._renderSubtreeIntoContainer(parentComponent, nextElement, container, callback);

源代码证明了我们的猜想,也就说明两者区别在于是否传入父节点。

另一个不稳定方法 unstable_renderSubtreeIntoContainer 关于 setState 的更新策略,我们会在『$3.4 解密 setState』中详细介绍。

1.6.3 refs

刚才我们已经详述了 ReactDOM 的 render 方法,比如我们渲染了一个 App 组件到 root节点下了:

const myAppInstance = ReactDOM.render(<App />, document.getElementById('root'));
myAppInstance.doSth();

我们利用 render 方法拿到了 App 组件的实例,然后就可以对它做一些操作。但在组件内,JSX 是不会返回一个组件的实例的!它只是一个 ReactElement,只是告诉 React 被挂载的组件应该长什么样。

const myApp = <App />;

refs 就是为此而生的,它是 React 组件中非常特殊的 prop,可以附加到任何一个组件上。从字面意思来看,refs 即 reference,组件被调用时会新建一个该组件的实例,而 refs 就会指向这个实例。

它可以是一个回调函数,这个回调函数会在组件被挂载后立即执行。例如:

import React, { Component } from 'react';

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

    this.handleClick = this.handleClick.bind(this);
  }

  handleClick() {
    if (this.myTextInput !== null) {
      this.myTextInput.focus();
    }
  }

  render() {
    return (
      <div>
        <input type="text" ref={(ref) => this.myTextInput = ref} />
        <input
          type="button"
          value="Focus the text input"
          onClick={this.handleClick}
        />
      </div>
    );
  }
}

在这个例子里,我们拿到 input 的真正实例,所以我们可以在按钮被按下后调用输入框的 focus() 方法。这个例子把 refs 放到原生的 DOM 组件 input 中,我们可以通过 refs 得到 DOM 节点;而如果把 refs 放到 React 组件,比如 ,我们获得的就是 TextInput 的实例,因此我们可以调用 TextInput 的实例方法。

refs 同样支持字符串。对于 DOM 操作,我们不仅可以使用 findDOMNode 获得该组件 DOM,还可以使用 ref 获得组件内部的 DOM。比如:

import React, { Component } from 'react';

class App extends Component {
  componentDidMount() {
    // myComp 是 Comp 的一个实例,因此需要用 findDOMNode 转换为相应的 DOM
    const myComp = this.refs.myComp;
      const dom = findDOMNode(myComp);
  }

  render() {
    return (
      <div>
          <Comp ref="myComp" />
      </div>
    );
  }
}

要获取一个 React 组件的引用,既可以使用 this 来获取当前 React 组件,也可以使用 refs 来获取你拥有的子组件的引用。

我们回到 1.6.2 节中 Portal 组件里暴露的两个方法 openPortal 和 closePortal。这两个方法的调用方式为:

this.refs.myPortal.openPortal();
this.refs.myPortal.closePortal();

这种命令式调用的方式,尽管说并不是 React 推崇的,但我们仍然可以使用。这里我们原则上在组件状态维护上不建议用这种方式。

为了防止内存泄露,当一个组件卸载的时候,组件里所有的 ref 就会变为 null。

值得注意的是,findDOMNode 和 refs 都无法用于无状态组件中,原因在前面已经说过。无状态组件挂载时只是方法调用,没有新建实例。

对于 React 组件来说,refs 会指向一个组件类的实例,所以可以调用该类定义的任何方法。如果需要访问该组件的真实 DOM,可以用 ReactDOM.findDOMNode 来找到 DOM 节点,但我们并不推荐这样做。因为这在大部分情况下都打破了封装性,而且通常都能用更清晰的办法在 React 中构建代码。

1.6.4 React 之外的 DOM 操作

DOM 操作可以归纳为对 DOM 的增删改查。这里的『查』指的是对 DOM 属性、样式的查看,比如查看 DOM 的位置、宽高信息。而要对 DOM 进行增删改查,就要先 query 到 DOM。

React 的声明式渲染机制,把复杂的 DOM 操作抽象为简单的 state 和 props 的操作,因此避免了很多直接的 DOM 操作。不过,仍然有一些 DOM 操作是 React 无法避免或者正在努力避免的。

举一个明显的例子,如果要调用 HTML5 Audio/Video 的 play 方法,input 的 focus 方法,React 就无能为力了。这时我们只能使用相应的 DOM 方法来实现。

React 提供了事件绑定的功能,但是仍然有一些特殊情况需要自行绑定事件。例如 Popup 等类似组件,当点击组件其它区域可以收缩此类组件。这就要求我们对组件以外的区域(一般指 document、body)进行事件绑定。例如:

componentDidUpdate(prevProps, prevState) {
  if (!this.state.isActive && prevState.isActive) {
    document.removeEventListener('click', this.hidePopup);
  }

  if (this.state.isActive && !prevState.isActive) {
    document.addEventListener('click', this.hidePopup);
  }
}

componentWillUnmount() {
  document.removeEventListener('click', this.hidePopup);
}

hidePopup(e) {
  if (!this.isMounted()) { return false; }

  const node = ReactDOM.findDOMNode(this);
  const target = e.target || e.srcElement;
  const isInside = node.contains(target);

  if (this.state.isActive && !isInside) {
    this.setState({
      isActive: false,
    });
  }
}

React 中使用 DOM 最多的还是计算 DOM 的尺寸(即位置信息)。我们可以提供像 width,或 height 这样的工具函数:

function width(el) {
  const styles = el.ownerDocument.defaultView.getComputedStyle(el, null);
  const width = parseFloat(styles.width.indexOf('px') !== -1 ? styles.width : 0);

  const boxSizing = styles.boxSizing || 'content-box';
  if (boxSizing === 'border-box') {
    return width;
  }

  const borderLeftWidth = parseFloat(styles.borderLeftWidth);
  const borderRightWidth = parseFloat(styles.borderRightWidth);
  const paddingLeft = parseFloat(styles.paddingLeft);
  const paddingRight = parseFloat(styles.paddingRight);

  return width - borderRightWidth - borderLeftWidth - paddingLeft - paddingRight;
}

但上述计算方法并不能完全覆盖所有情况,这需要付出不少的成本去实现。值得高兴的是,React 正在自己构建一个 DOM 排列模型,来努力避免这些 React 之外的 DOM 操作。我们相信在不久的将来,React 的使用者就可以完全抛弃掉 jQuery 等 DOM 操作库。

可以说在 React 组件开发中,还有很多意料之外的情形。在这些情形中,应该如何运用 React 的方式优雅地解决问题是我们需要一直思考的。

0
推荐阅读