React ref 的前世今生

前端新能源發表於2018-07-26

眾所周知,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 整個構建流程。

React 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

相關文章