《深入 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 非常少,只有 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';
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
,可以附加到任何一個組件上。從字面意思來看,efs
即 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
的實例,因此我們可以調用 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 的方式優雅地解決問題是我們需要一直思考的。