React ref 的前世此生

衆所周知,React 經過聲明式的渲染機制把複雜的 DOM 操做抽象成爲簡單的 state 與 props 操做,一時圈粉無數,一晚上間將前端工程師從麪條式的 DOM 操做中拯救出來。儘管咱們一再強調在 React 開發中儘可能避免 DOM 操做,但在一些場景中仍然沒法避免。固然 React 並無把路堵死,它提供了 ref 用於訪問在 render 方法中建立的 DOM 元素或者是 React 組件實例。javascript

ref 的三駕馬車

在 React v16.3 以前,ref 經過字符串(string ref)或者回調函數(callback ref)的形式進行獲取,在 v16.3 中,經 0017-new-create-ref 提案引入了新的 React.createRef API。前端

注意:本文如下代碼示例以及源碼均基於或來源於 React v16.3.2 release 版本。java

// 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 來代替,爲什麼須要這麼作呢?主要緣由集中於如下幾點:react

  • 當 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 的類型,沒法完成自動推導。git

  • 編譯器沒法將 string ref 與其 refs 上對應的屬性進行混淆,而使用 callback ref,可被混淆。github

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 整個構建流程。app

// 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 的掌握與理解。dom

穿雲箭 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

相關文章
相關標籤/搜索