眾所周知,React 通過宣告式的渲染機制把複雜的 DOM 操作抽象成為簡單的 state 與 props 操作,一時圈粉無數,一夜間將前端工程師從麵條式的 DOM 操作中拯救出來。儘管我們一再強調在 React 開發中儘量避免 DOM 操作,但在一些場景中仍然無法避免。當然 React 並沒有把路堵死,它提供了 ref 用於訪問在 render 方法中建立的 DOM 元素或者是 React 元件例項。
ref 的三駕馬車
在 React v16.3 之前,ref 通過字串(string ref)或者回撥函式(callback ref)的形式進行獲取,在 v16.3 中,經 0017-new-create-ref 提案引入了新的 React.createRef API。
注意:本文以下程式碼示例以及原始碼均基於或來源於 React v16.3.2 release 版本。
// string ref
class MyComponent extends React.Component {
componentDidMount() {
this.refs.myRef.focus();
}
render() {
return <input ref="myRef" />;
}
}
// callback ref
class MyComponent extends React.Component {
componentDidMount() {
this.myRef.focus();
}
render() {
return <input ref={(ele) => {
this.myRef = ele;
}} />;
}
}
// React.createRef
class MyComponent extends React.Component {
constructor(props) {
super(props);
this.myRef = React.createRef();
}
componentDidMount() {
this.myRef.current.focus();
}
render() {
return <input ref={this.myRef} />;
}
}
複製程式碼
string ref 之殤
在 React.createRef 出現之前,string ref 就已被詬病已久,React 官方文件直接提出 string ref 將會在未來版本被移出,建議使用者使用 callback ref 來代替,為何需要這麼做呢?主要原因集中於以下幾點:
- 當 ref 定義為 string 時,需要 React 追蹤當前正在渲染的元件,在 reconciliation 階段,React Element 建立和更新的過程中,ref 會被封裝為一個閉包函式,等待 commit 階段被執行,這會對 React 的效能產生一些影響。
function coerceRef(
returnFiber: Fiber,
current: Fiber | null,
element: ReactElement,
) {
...
const stringRef = '' + element.ref;
// 從 fiber 中得到例項
let inst = ownerFiber.stateNode;
// ref 閉包函式
const ref = function(value) {
const refs = inst.refs === emptyObject ? (inst.refs = {}) : inst.refs;
if (value === null) {
delete refs[stringRef];
} else {
refs[stringRef] = value;
}
};
ref._stringRef = stringRef;
return ref;
...
}
複製程式碼
- 當使用 render callback 模式時,使用 string ref 會造成 ref 掛載位置產生歧義。
class MyComponent extends Component {
renderRow = (index) => {
// string ref 會掛載在 DataTable this 上
return <input ref={'input-' + index} />;
// callback ref 會掛載在 MyComponent this 上
return <input ref={input => this['input-' + index] = input} />;
}
render() {
return <DataTable data={this.props.data} renderRow={this.renderRow} />
}
}
複製程式碼
- string ref 無法被組合,例如一個第三方庫的父元件已經給子元件傳遞了 ref,那麼我們就無法再在子元件上新增 ref 了,而 callback ref 可完美解決此問題。
/** string ref **/
class Parent extends React.Component {
componentDidMount() {
// 可獲取到 this.refs.childRef
console.log(this.refs);
}
render() {
const { children } = this.props;
return React.cloneElement(children, {
ref: 'childRef',
});
}
}
class App extends React.Component {
componentDidMount() {
// this.refs.child 無法獲取到
console.log(this.refs);
}
render() {
return (
<Parent>
<Child ref="child" />
</Parent>
);
}
}
/** callback ref **/
class Parent extends React.Component {
componentDidMount() {
// 可以獲取到 child ref
console.log(this.childRef);
}
render() {
const { children } = this.props;
return React.cloneElement(children, {
ref: (child) => {
this.childRef = child;
children.ref && children.ref(child);
}
});
}
}
class App extends React.Component {
componentDidMount() {
// 可以獲取到 child ref
console.log(this.child);
}
render() {
return (
<Parent>
<Child ref={(child) => {
this.child = child;
}} />
</Parent>
);
}
}
複製程式碼
- 在根元件上使用無法生效。
ReactDOM.render(<App ref="app" />, document.getElementById('main'));
複製程式碼
-
對於靜態型別較不友好,當使用 string ref 時,必須顯式宣告 refs 的型別,無法完成自動推導。
-
編譯器無法將 string ref 與其 refs 上對應的屬性進行混淆,而使用 callback ref,可被混淆。
createRef vs callback ref
對比新的 createRef 與 callback ref,並沒有壓倒性的優勢,只是希望成為一個便捷的特性,在效能上會會有微小的優勢,callback ref 採用了元件 render 過程中在閉包函式中分配 ref 的模式,而 createRef 則採用了 object ref。
createRef 顯得更加直觀,類似於 string ref,避免了 callback ref 的一些理解問題,對於 callback ref 我們通常會使用行內函數的形式,那麼每次渲染都會重新建立,由於 react 會清理舊的 ref 然後設定新的(見下圖,commitDetachRef -> commitAttachRef),因此更新期間會呼叫兩次,第一次為 null,如果在 callback 中帶有業務邏輯的話,可能會出錯,當然可以通過將 callback 定義成類成員函式並進行繫結的方式避免。
class App extends React.Component {
state = {
a: 1,
};
componentDidMount() {
this.setState({
a: 2,
});
}
render() {
return (
<div ref={(dom) => {
// 輸出 3 次
// <div data-reactroot></div>
// null
// <div data-reactroot></div>
console.log(dom);
}}></div>
);
}
}
class App extends React.Component {
state = {
a: 1,
};
constructor(props) {
super(props);
this.refCallback = this.refCallback.bind(this);
}
componentDidMount() {
this.setState({
a: 2,
});
}
refCallback(dom) {
// 只輸出 1 次
// <div data-reactroot></div>
console.log(dom);
}
render() {
return (
<div ref={this.refCallback}></div>
);
}
}
複製程式碼
不過不得不承認,createRef 在能力上仍遜色於 callback ref,例如上一節提到的組合問題,createRef 也是無能為力的。在 React v16.3 中,string ref/callback ref 與 createRef 的處理略有差別,讓我們來看一下 ref 整個構建流程。
// markRef 前會進行新舊 ref 的引用比較
if (current.ref !== workInProgress.ref) {
markRef(workInProgress);
}
// effectTag 基於位操作,其中有 ref 的變更標誌位
function markRef(workInProgress: Fiber) {
workInProgress.effectTag |= Ref;
}
// effectTag 與 Ref 的 & 操作表示當前 fiber 有 ref 變更
if (effectTag & Ref) {
commitAttachRef(nextEffect);
}
function commitAttachRef(finishedWork: Fiber) {
const ref = finishedWork.ref;
if (ref !== null) {
const instance = finishedWork.stateNode;
let instanceToUse;
switch (finishedWork.tag) {
// 當前 Host 環境為 DOM 環境,HostComponent 即為 DOM 元素,需要藉助例項獲取原生 DOM 元素
case HostComponent:
instanceToUse = getPublicInstance(instance);
break;
// 對於 ClassComponent 等而言,直接返回例項即可
default:
instanceToUse = instance;
}
// string ref 與 callback 都會去執行 ref 閉包函式
// createRef 會直接掛在 object ref 的 current 上
if (typeof ref === 'function') {
ref(instanceToUse);
} else {
ref.current = instanceToUse;
}
}
}
複製程式碼
以上會涉及 react fiber 的一些概念與細節,比如:fiber 物件含義,fiber tree 構建更新過程,effectTag 的含義與收集過程等等,如果讀者對上述細節不熟悉,可暫時跳過此段內容,不影響對於 ref 的掌握與理解。
穿雲箭 React.forwardRef
除了 createRef 以外,React16 還另外提供了一個關於 ref 的 API React.forwardRef,主要用於穿過父元素直接獲取子元素的 ref。在提到 forwardRef 的使用場景之前,我們先來回顧一下,HOC(higher-order component)在 ref 使用上的問題,HOC 的 ref 是無法通過 props 進行傳遞的,因此無法直接獲取被包裹元件(WrappedComponent),需要進行中轉。
function HOCProps(WrappedComponent) {
class HOCComponent extends React.Component {
constructor(props) {
super(props);
this.setWrappedInstance = this.setWrappedInstance.bind(this);
}
getWrappedInstance() {
return this.wrappedInstance;
}
// 實現 ref 的訪問
setWrappedInstance(ref) {
this.wrappedInstance = ref;
}
render() {
return <WrappedComponent ref={this.setWrappedInstance} {...this.props} />;
}
}
return HOCComponent;
}
const App = HOCProps(Wrap);
<App ref={(dom) => {
// 只能獲取到 HOCComponent
console.log(dom);
// 通過中轉後可以獲取到 WrappedComponent
console.log(dom.getWrappedInstance());
}} />
複製程式碼
在擁有 forwardRef 之後,就不需要再通過 getWrappedInstance 了,利用 forwardRef 能直接穿透 HOCComponent 獲取到 WrappedComponent。
function HOCProps(WrappedComponent) {
class HOCComponent extends React.Component {
render() {
const { forwardedRef, ...rest } = this.props;
return <WrappedComponent ref={forwardedRef} {...rest} />;
}
}
return React.forwardRef((props, ref) => {
return <HOCComponent forwardedRef={ref} {...props} />;
});
}
const App = HOCProps(Wrap);
<App ref={(dom) => {
// 可以直接獲取 WrappedComponent
console.log(dom);
}} />
複製程式碼
React.forwardRef 的原理其實非常簡單,forwardRef 會生成 react 內部一種較為特殊的 Component。當進行建立更新操作時,會將 forwardRef 元件上的 props 與 ref 直接傳遞給提前注入的 render 函式,來生成 children。
const nextChildren = render(workInProgress.pendingProps, workInProgress.ref);
複製程式碼
React refs 到此就全部介紹完了,在 React16 新版本中,新引入了 React.createRef 與 React.forwardRef 兩個 API,有計劃移除老的 string ref,使 ref 的使用更加便捷與明確。如果你的應用已經升級到 React16.3+ 版本,那就放心大膽使用 React.createRef 吧,如果暫時沒有的話,建議使用 callback ref 來代替 string ref。
我們團隊目前正在深入研究 React16,歡迎社群小夥伴和我們一起探討與前行,如果想加入我們,歡迎私聊或投遞簡歷到 dancang.hj@alibaba-inc.com。