关于ref的一切

1,219 阅读5分钟

作为React开发者,你能回答如下几个问题么?

  1. 为什么string类型的ref prop将会被废弃?

  2. function类型的ref prop会在什么时机被调用?

  3. React.createRefuseRef的返回值有什么不同?

其实,这三个问题中的ref包含两个不同概念:

  • 不管是stringfunction类型或是React.createRefuseRef创建的ref,都是作为数据结构看待

  • 问题2探讨的时机是将ref作为生命周期看待

接下来本文会分别从数据结构生命周期两个角度探讨ref

这,就是关于ref的一切。

ref的数据结构

为什么string类型的ref prop将会被废弃?

string类型的ref使用方式如下:

点击input标签会打印inputvalue

class Foo extends Component {
  render() {
    return (
      <input
        onClick={() => this.action()} 
        ref='input' 
      />
    );
  }
  action() {
    console.log(this.refs.input.value);
  }
}

string类型ref prop最主要的两个问题是:

  1. 由于是string的写法,无法直接获得this的指向。

所以,React需要持续追踪当前render的组件。这会让React在性能上变慢。

  1. 当使用render回调函数的开发模式,获得ref的组件实例可能与预期不同。

比如:

class App extends React.Component {
  renderRow = (index) => {
    // ref会绑定到DataTable组件实例,而不是App组件实例上
    return <input ref={'input-' + index} />;

    // 如果使用function类型ref,则不会有这个问题
    // return <input ref={input => this['input-' + index] = input} />;
  }
 
  render() {
    return <DataTable data={this.props.data} renderRow={this.renderRow} />
  }
}

还有其他原因使React团队决定在未来放弃string Ref,详见#1373#8333

React.createRef

我们直接看React.createRef的源码:

function createRef(): RefObject {
  const refObject = {
    current: null,
  };
  return refObject;
}

可见,ref对象就是仅仅是包含current属性的普通对象。

useRef

为了验证这个观点,我们再看useRef的源码。

对于mountupdateuseRef分别对应两个函数。

对于hook如何保存数据如果不了解,可以看本系列第一篇文章关于useState的一切

function mountRef<T>(initialValue: T): {|current: T|} {
  // 获取当前useRef hook
  const hook = mountWorkInProgressHook();
  // 创建ref
  const ref = {current: initialValue};
  hook.memoizedState = ref;
  return ref;
}

function updateRef<T>(initialValue: T): {|current: T|} {
  // 获取当前useRef hook
  const hook = updateWorkInProgressHook();
  // 返回保存的数据
  return hook.memoizedState;
}

可以看到,ref对象确实仅仅是包含current属性的对象。

function ref

除了{current: any}类型外,ref还能作为function

作为function时,仅仅是在不同生命周期阶段被调用的回调函数。

在我们接下来的讨论中,只涉及function | {current: any}这两种ref数据结构

ref的生命周期

React中,HostComponentClassComponentForwardRef可以赋值ref属性。

这个属性在ref生命周期的不同阶段会被执行(对于function)或赋值(对于{current: any})。

// HostComponent
<div ref={domRef}></div>
// ClassComponent / ForwardRef
<App ref={cpnRef} />

其中,ForwardRef只是将ref作为第二个参数传递下去,没有别的特殊处理。

// 对于ForwardRef,secondArg为传递下去的ref
const children = forwardRef(
  (props, secondArg) => {
    //render逻辑...
  }
);

所以接下来讨论ref生命周期时不会单独讨论ForwardRef

在本系列文章中我们讲过,React的渲染包含两个阶段:

  • render阶段:为需要更新的组件对应fiber打上标签(effectTag

  • commit阶段:执行effectTag对应更新操作

// 部分effectTag定义
// 插入DOM
export const Placement = /* */ 0b0000000000000010;
// 更新DOM的属性
export const Update = /*    */ 0b0000000000000100;
// 删除DOM
export const Deletion = /*  */ 0b0000000000001000;
// 有ref操作
export const Ref = /*       */ 0b0000000010000000;
// ...

对于HostComponentClassComponent如果包含ref操作,那么也会赋值相应的effectTag

同其他effectTag对应操作的执行一样,ref的更新也是发生在commit阶段

所以,ref生命周期可以分为两个大阶段:

  • render阶段为含有ref属性的Component对应fiber添加Ref effectTag

  • commit阶段为包含Ref effectTagfiber执行对应操作

render阶段

render阶段组件对应fiber被赋值Ref effectTag需要满足的条件:

  • fiber类型为HostComponentClassComponentScopeComponent

ScopeComponent是一种用于管理focus的测试特性,这种情况我们不讨论。详见PR

  • 对于mountworkInProgress.ref !== null,即组件首次render时存在ref属性

  • 对于updatecurrent.ref !== workInProgress.ref,即组件更新时ref属性改变

commit阶段

commit阶段ref生命周期分为两个子阶段:

  • 移除之前的ref

  • 更新ref

移除之前的ref

  • 对于ref属性改变的情况,需要先移除之前的ref

调用的是commitDetachRef

function commitDetachRef(current: Fiber) {
  const currentRef = current.ref;
  if (currentRef !== null) {
    if (typeof currentRef === 'function') {
      // function类型ref,调用他,传参为null
      currentRef(null);
    } else {
      // 对象类型ref,current赋值为null
      currentRef.current = null;
    }
  }
}

可以看到,function{current: any}类型的ref生命周期并没有什么不同,只是一种会被调用,一种会被赋值。

  • 对于Deletion effectTagfiber(对应需要删除的DOM节点),需要递归他的子树,对子孙fiberref执行类似commitDetachRef的操作。

更新ref

接下来进入ref的更新阶段。

执行这一步的操作叫commitAttachRef

function commitAttachRef(finishedWork: Fiber) {
  // finishedWork为含有Ref effectTag的fiber
  const ref = finishedWork.ref;
  
  // 含有ref prop,这里是作为数据结构
  if (ref !== null) {
    // 获取ref属性对应的Component实例
    const instance = finishedWork.stateNode;
    let instanceToUse;
    switch (finishedWork.tag) {
      case HostComponent:
        // 对于HostComponent,实例为对应DOM节点
        instanceToUse = getPublicInstance(instance);
        break;
      default:
        // 其他类型实例为fiber.stateNode
        instanceToUse = instance;
    }

    // 赋值ref
    if (typeof ref === 'function') {
      ref(instanceToUse);
    } else {
      ref.current = instanceToUse;
    }
  }
}

可以看到,对于包含ref属性的fiber,针对ref的不同类型,执行调用/赋值操作。

至此,ref生命周期完成。

总结

通过本文我们学习了ref数据结构生命周期

对于赋值了ref属性的HostComponentClassComponent,他会依次经历:

  • render阶段赋值Ref effectTag

  • 如果ref变化,在commit阶段会先删除之前的ref

  • 接下来,会进入ref的更新流程。

所以,对于内联函数ref

<div ref={dom => this.dom = dom}></div>

由于每次render ref都对应一个全新的内联函数,所以在commit阶段会先执行commitDetachRef删除再执行commitAttachRef更新。

内联函数会被调用两次,第一次传参dom的值为null,第二次为更新的DOM