ReactDOM
ReactDOM 中的 API 非常少,只有 findDOMNode、unmountComponentAtNode 和 render。下面我们就从 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';
import ReactDOM from 'react-dom';
class App extends Component {
componentDidMount() {
// this 为当前组件的实例
const dom = ReactDOM.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]
)
该方法把元素挂载到 container 中,并且返回 element 的实例(即 refs 引用)。当然,如果是无状态组件,render 会返回 null。当组件装载完毕时,callback 就会被调用。
当组件在初次渲染之后再次更新时,React 不会把整个组件重新渲染一次,而会用它高效的 DOM diff 算法做局部的更新。这也是 React 最大的亮点之一!
此外,与 render 相反,React 还提供了一个很少使用的 unmountComponentAtNode 方法来进行卸载操作。
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() 方法,这个方法把子组件里的内容插入到 document.body 下,这就实现了子组件不在标准文档流的渲染。
这就说到了 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)。
源码证明了我们的猜想,这也说明了两者的区别在于是否传入父节点。
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';
import ReactDOM from 'react-dom';
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 的实例,因此就可以调用 TextInput 的实例方法。
refs 同样支持字符串。对于 DOM 操作,不仅可以使用 findDOMNode 获得该组件 DOM,还可以使用 refs 获得组件内部的 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 来获取你拥有的子组件的引用。
我们回到上面 Portal 组件里暴露的两个方法 openPortal 和 closePortal。这两个方法的调用方式为:
this.refs.myPortal.openPortal();
this.refs.myPortal.closePortal();
这种命令式调用的方式,尽管说并不是 React 推崇的,但我们仍然可以使用。原则上,在组件状态维护中不建议用这种方式。
为了防止内存泄漏,当卸载一个组件的时候,组件里所有的 refs 就会变为 null。
值得注意的是,findDOMNode 和 refs 都无法用于无状态组件中,原因在前面已经说过。无状态组件挂载时只是方法调用,没有新建实例。
对于 React 组件来说,refs 会指向一个组件类的实例,所以可以调用该类定义的任何方法。如果需要访问该组件的真实 DOM,可以用 ReactDOM.findDOMNode 来找到 DOM 节点,但我们并不推荐这样做。因为这在大部分情况下都打破了封装性,而且通常都能用更清晰的办法在 React 中构建代码。
React 之外的 DOM 操作
DOM 操作可以归纳为对 DOM 的增、删、改、查。这里的“查”指的是对 DOM 属性、样式的查看,比如查看 DOM 的位置、宽、高等信息。而要对 DOM 进行增、删、改,就要先到 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 的方式优雅地解决问题是我们需要一直思考的。
网友评论