最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • React ref 从原理到应用

    正文概述 掘金(EvalStudio)   2021-08-06   406

    前言

    提到 ref或者 refs 如果你用过React 16以前的版本,第一印象都是用来访问DOM或者修改组件实例的,正如官网所介绍的这样:

    React ref 从原理到应用

    然后到了React 16.3出现的 createRef 以及16.8 hooks中的 useRef 出现时,发现这里的ref好像不仅仅只有之前的绑定到DOM/组件实例的作用?本文将尝试分析相关源码,带你彻底搞清React ref。

    前置知识

    为了方便本文理解先在这里简单提及几个知识点。

    Fiber架构

    Fiber是React更新时的最小单元,是一种包含指针的数据结构,从数据结构上看Fiber架构 ≈ 树 + 链表。 Fiber单元是从 jsx createElement之后根据ReactElement生成的,相比 ReactElement,Fiber单元具备动态工作能力。

    React 的工作流程

    使用chrome perfomance录制一个react应用渲染看函数调用栈会看到下面这张图

    React ref 从原理到应用

    这三块内容分别代表:

    1. 生成react root节点
    2. reconciler 协调生成需要更新的子节点
    3. 将节点更新 commit 到视图(commit阶段)

    Hooks基础知识

    在函数组件中每执行一次use开头的hook函数都会生成一个hook对象。

    type Hook = {
      memoizedState: any,   // 上次更新之后的最终状态值
      queue: UpdateQueue, //更新队列
      next, // 下一个 hook 对象
    };
    

    其中memoizedState会保存该hook上次更新之后的最终状态,比如当我们使用一次useState之后就会在memoizedState中保存初始值。

    React 中大部分 hook 分为两个阶段: 第一次初始化时mount阶段和更新update时阶段

    hooks函数的执行分两个阶段 mountupdate,比如 useState只会在初始化时执行一次,下文中将提到的 useImperativeHandleuseRef也包括在内。

    调试源码

    本文已梳理摘取了源码相关的函数,但你如果配合源码调试一起食用效果会更加。

    本文基于React v17.0.2。

    1. 拉取React代码并安装依赖
    2. 将react,scheduler以及react-dom打包为commonjs
    yarn build react/index,react-dom/index,scheduler --type NODE
    
    1. 进入build/node_modules/react/cjs 执行yarn link 同理 react-dom
    2. 在 build/node_modules/react/cjs/react.development.js中加入link标记console以确保检查link状态
    3. 使用create-react-app创建一个测试应用 并link react,react-dom

    下面开启正文

    ref prop

    组件上的ref属性是一个保留属性,你不能把ref当成一个普通的prop属性在一个组件中获取,比如:

    const Parent = () => {
    	return <Child ref={{test:1}}>
    }
    const Child = (props) => {
      console.log(props);
      // 这里获取不到ref属性
    	return <div></div>
    }
    

    这个ref去哪里了呢, React本身又对它做了什么呢?

    我们知道React的解析是从createElement开始的,找到了下面创建ReactElement的地方,确实有对ref保留属性的处理。

    export function createElement(type, config, children) {
    let propName;
      // Reserved names are extracted
      const props = {};
      let ref = null;
      if (config != null) {
        if (hasValidRef(config)) {
          ref = config.ref;
        }
        for (propName in config) {
          if (
            hasOwnProperty.call(config, propName) &&
            !RESERVED_PROPS.hasOwnProperty(propName)
          ) {
            props[propName] = config[propName];
          }
        }
      }
      return ReactElement(
        type,
        key,
        ref,
        props,
        ...
      );
    }
    

    从createElement开始就已经创建了对ref属性的引用。

    createElement之后我们需要构建Fiber工作树,接下来主要讲对ref相关的处理。

    React对于不同的组件有不通的处理

    先主要关注 FunctionComponent/ClassComponent/HostComponent(原生html标签)

    FunctionComponent

    function updateFunctionComponent(current, workInProgress, Component, nextProps, renderLanes) {
          try {
            nextChildren = renderWithHooks(current, workInProgress, Component, nextProps, context, renderLanes);
          } finally {
            reenableLogs();
          }
          reconcileChildren(current, workInProgress, nextChildren, renderLanes);
          return workInProgress.child;
    }
    function renderWithHooks(current, workInProgress, Component, props, secondArg, nextRenderLanes){
      			children = Component(props, secondArg); // 这里的Component就是指我们的函数组件
    				return children;
    }
    

    我们可以看到函数组件在渲染的时候就是直接执行。

    Class组件和原生标签的ref prop

    ClassComponent

    function updateClassComponent(current, workInProgress, Component, nextProps, renderLanes) {
      ...
      {
        ...
        constructClassInstance(workInProgress, Component, nextProps);
    		....
      }
      var nextUnitOfWork = finishClassComponent(current, workInProgress, Component, shouldUpdate, hasContext, renderLanes);
    	...
      return nextUnitOfWork;
    }
    
    function constructClassInstance(workInProgress, ctor, props) {
    	....
      var instance = new ctor(props, context);
      // 把instance实例挂载到workInProgress stateNode属性上
      adoptClassInstance(workInProgress, instance);
    	.....
      return instance;
    }
    function finishClassComponent(current, workInProgress, Component, shouldUpdate, hasContext, renderLanes) {
      // 标记是否有ref更新
      markRef(current, workInProgress);
    }
    
    function markRef(current, workInProgress) {
      var ref = workInProgress.ref;
    
      if (current === null && ref !== null || current !== null && current.ref !== ref) {
        // Schedule a Ref effect
        workInProgress.flags |= Ref;
      }
    }
    

    ClassComponent则是通过构造函数生成实例并标记了ref属性。

    回顾一下之前提到的React工作流程,既然是要将组件实例或者真实DOM赋值给ref那肯定不能在一开始就处理这个ref,而是根据标记到commit阶段再给ref赋值。

    function commitLayoutEffectOnFiber(finishedRoot, current, finishedWork, committedLanes) {
    	....
      {
        if (finishedWork.flags & Ref) {
          commitAttachRef(finishedWork);
        }
      }
      ....
    }
    function commitAttachRef(finishedWork) {
      var ref = finishedWork.ref;
      if (ref !== null) {
        var instance = finishedWork.stateNode;
        var instanceToUse;
        switch (finishedWork.tag) {
          case HostComponent:
            // getPublicInstance 这里调用了DOM API 返回了DOM对象
            instanceToUse = getPublicInstance(instance);
            break;
    
          default:
            instanceToUse = instance;
        } 
        // 对函数回调形式设置ref的处理
        if (typeof ref === 'function') {
          {
            ref(instanceToUse);
          }
        } else {
          ref.current = instanceToUse;
        }
      }
    }
    

    在commit阶段,如果是原生标签则将真实DOM赋值给ref对象的current属性, 如果是class componnet 则是组件instance。

    函数组件的ref prop

    如果你对function组件未做处理直接加上ref,react会直接忽略并在开发环境给出警告

    React ref 从原理到应用

    函数组件没有实例可以赋值给ref对象,而且组件上的ref prop会被当作保留属性无法在组件中获取,那该怎么办呢?

    forwardRef

    React提供了一个forwardRef函数 来处理函数组件的 ref prop,用起来就像下面这个示例:

    const Parent = () => {
    	const childRef = useRef(null)
      return <Child ref={childRef}/>
    }
    
    const Child = forWardRef((props,ref) => {
    	return <div>Child</div>
    }}
    

    这个方法的源码主体也非常简单,返回了一个新的elementType对象,这个对象的render属性包含了原本的这个函数组件,而$$typeof则标记了这个特殊组件类型。

    function forwardRef(render) {
      ....
      var elementType = {
        $$typeof: REACT_FORWARD_REF_TYPE,
        render: render
      }
      ....
      return elementType;
     }
    

    那么React对forwardRef这个特殊的组件是怎么处理的呢

    function beginWork(current, workInProgress, renderLanes) {
    	...
      switch (workInProgress.tag) {
        case FunctionComponent:
          {
           ...
            return updateFunctionComponent(current, workInProgress, _Component, resolvedProps, renderLanes);
          }
    
        case ClassComponent:
          {
    				....
            return updateClassComponent(current, workInProgress, _Component2, _resolvedProps, renderLanes);
          }
    
        case HostComponent:
          return updateHostComponent(current, workInProgress, renderLanes);
        case ForwardRef:
          {
    				....
            // 第三个参数type就是forwardRef创建的elementType
            return updateForwardRef(current, workInProgress, type, _resolvedProps2, renderLanes);
          }
    }
      
    function updateForwardRef(current, workInProgress, Component, nextProps, renderLanes) {
    	....
      var render = Component.render;
      var ref = workInProgress.ref; // The rest is a fork of updateFunctionComponent
    
      var nextChildren;
    
      {
    		...
        //	将ref引用传入renderWithHooks
        nextChildren = renderWithHooks(current, workInProgress, render, nextProps, ref, renderLanes);
        ...
      }
    
      workInProgress.flags |= PerformedWork;
      reconcileChildren(current, workInProgress, nextChildren, renderLanes);
      return workInProgress.child;
    }
    

    可以看到和上面 FunctionComponent的主要区别仅仅是把ref保留属性当成普通属性传入 renderWithHooks方法!

    那么又有一个问题出现了,如果只是传了一个ref引用,而没有像Class组件那样可以attach的实例,岂不是没有办法操作子函数组件的行为?

    用上面的例子验证一下

    const Parent = () => {	
      const childRef = useRef(null)
      useEffect(()=>{
      	console.log(childref) // { current:null }
      })
      return <Child ref={childRef}/>
    }
    
    const Child = forwardRef((props,ref) => {
    	return <div>Child</div>
    }}
                             
     const Parent = () => {	
      const childRef = useRef(null)
      useEffect(()=>{
      	console.log(childref) // { current: div }
      })
      return <Child ref={childRef}/>
    }
    
    const Child = forwardRef((props,ref) => {
    	return <div ref={ref}>Child</div>
    }}
    

    结合输出可以看出如果单独使用forwardRef仅仅只能转发ref属性。如果ref最终没有绑定到一个ClassCompnent或者原生DOM上那么这个ref将不会改变。

    假设一个业务场景,你封装了一个表单组件,想对外暴露一些接口比如说提交的action以及校验等操作,这样应该如何处理呢?

    useImperativeHandle

    react为我们提供了这个hook来帮助函数组件向外部暴露属性 先看下效果

    const Parent = () => {	
      const childRef = useRef(null)
      useEffect(()=>{
      	chilRef.current.sayName();// child
      })
      return <Child ref={childRef}/>
    }
    
    const Child = forwardRef((props,ref) => {
      useImperativeHandle(ref,()=>({
      	sayName:()=>{
        	console.log('child')
        }
      }))
    	return <div>Child</div>
    }}
    

    看一下该hook的源码部分(以hook mount阶段为例):

    useImperativeHandle: function (ref, create, deps) {
          currentHookNameInDev = 'useImperativeHandle';
          mountHookTypesDev();
          checkDepsAreArrayDev(deps);
          return mountImperativeHandle(ref, create, deps);
     }
    
    function mountImperativeHandle(ref, create, deps) {
      {
        if (typeof create !== 'function') {
          error('Expected useImperativeHandle() second argument to be a function ' + 'that creates a handle. Instead received: %s.', create !== null ? typeof create : 'null');
        }
      } // TODO: If deps are provided, should we skip comparing the ref itself?
    
    
      var effectDeps = deps !== null && deps !== undefined ? deps.concat([ref]) : null;
      var fiberFlags = Update;
    
      return mountEffectImpl(fiberFlags, Layout, imperativeHandleEffect.bind(null, create, ref), effectDeps);
    }
    
    function imperativeHandleEffect(create, ref) {
      if (typeof ref === 'function') {
        var refCallback = ref;
    
        var _inst = create();
    
        refCallback(_inst);
        return function () {
          refCallback(null);
        };
      } else if (ref !== null && ref !== undefined) {
        var refObject = ref;
    
        {
          if (!refObject.hasOwnProperty('current')) {
            error('Expected useImperativeHandle() first argument to either be a ' + 'ref callback or React.createRef() object. Instead received: %s.', 'an object with keys {' + Object.keys(refObject).join(', ') + '}');
          }
        }
    		// 这里执行了传给hook的第二个参数
        var _inst2 = create();
    
        refObject.current = _inst2;
        return function () {
          refObject.current = null;
        };
      }
    }
    

    其实就是将我们需要暴露的对象及传给useImperativeHandle的第二个函数参数执行结果赋值给了ref的current对象。

    同一份引用

    到此为止我们大致梳理了组件上ref prop 的工作流程,以及如何在函数组件中使用ref prop,貌似比想象中简单。

    上面的过程我们注意到从createElement再到构建WorkInProgess Fiber树到最后commit的过程,ref似乎是一直在被传递。

    中间过程的代码过于庞大复杂,但是我们可以通过一个简单的测试来验证一下。

    const isEqualRefDemo = () => {
    	const isEqualRef = useRef(1)
      return <input key="test" ref={isEqualRef}>
    }
    

    对于 class component 和 原生标签来说 就是 createElementcommitAttachRef之前:

    React ref 从原理到应用

    React ref 从原理到应用

    在createElement里将ref挂载给window对象,然后在commitAttachRef里判断一下这两次的ref是否全等。

    React ref 从原理到应用

    对于函数组件来说就是 createElement 到 hook执行 imperativeHandleEffect 之前:

    const Parent = () => {	
      const childRef = useRef(1)
      useEffect(()=>{
      	chilRef.current.sayName();// child
      })
      return <Child ref={childRef}/>
    }
    
    const Child = forwardRef((props,ref) => {
      useImperativeHandle(ref,()=>({
      	sayName:()=>{
        	console.log('child')
        }
      }))
    	return <div>Child</div>
    }}
    

    React ref 从原理到应用 React ref 从原理到应用

    从createElement添加ref到React整个渲染过程的末尾(commit阶段)被赋值前,这个ref都是同一份引用。 这也正如 ref单词的本意 reference引用一样。

    小节总结

    1. ref出现在组件上时是一个保留属性
    2. ref在组件存在的生命周期内维护了同一个引用(可变对象 MutableObject)
    3. 当ref挂载的对象是原生html标签时会ref对象的current属性会被赋值为真实DOM 而如果是React组件会被赋值为React"组件实例"
    4. ref挂载都在commit阶段处理

    创建ref的方式

    ref prop相当于在组件上挖了一个“坑” 来承接 ref对象,但是这样还不够我们还需要先创建ref对象

    字符串ref & callback ref

    这两种创建ref的方式不再赘述,官网以及社区优秀文章可供参考。

    • zh-hans.reactjs.org/docs/refs-a…
    • blog.logrocket.com/how-to-use-…

    createRef & useRef

    createRef

    16.3引入了createRef这个api

    React ref 从原理到应用

    createRef的源码就是一个闭包,对外暴露了 一个具有 current属性的对象。

    我们一般会这样在class component中使用createRef

    class CreateRefComponent extends React.Component {
      constructor(props) {
        super(props);
        this.myRef = React.createRef()
      }
      componentDidMount() {
        this.myRef.current.focus()
      	console.log(this.myRef.current)
        // dom input
      }
      render() {
        return <input ref={this.myRef} />
      }
    }
    

    为什么不能在函数组件中使用createRef

    结合第一节的内容以及 createRef的源码,我们发现,这不过就是在类组件内部挂载了一个可变对象。因为类组件构造函数不会被反复执行,因此这个createRef自然保持同一份引用。但是到了函数组件就不一样了,每一次组件更新, 因为没有特殊处理createRef会被反复重新创建执行,因此在函数组件中使用createRef将不能达到只有同一份引用的效果。

    const CreateRefInFC = () => {
      const valRef = React.createRef();  // 如果在函数组件中使用createRef 在这个例子中点击后ref就会被重新创建因此将始终显示为null
      const [, update] = React.useState();
      return <div>
        value: {valRef.current}
        <button onClick={() => {
          valRef.current = 80;
          update({});
        }}>+
        </button>
      </div>
    }
    

    useRef

    React 16.8中出现了hooks,使得我们可以在函数组件中定义状态,同时也带来了 useRef

    React ref 从原理到应用 React ref 从原理到应用

    再来看moutRefupdateRef所做的事:

    function mountRef(initialValue) {
      var hook = mountWorkInProgressHook();
    
      {
        var _ref2 = {
          current: initialValue
        };
        hook.memoizedState = _ref2;
        return _ref2;
      }
    }
    
    function updateRef(initialValue) {
      var hook = updateWorkInProgressHook();
      return hook.memoizedState;
    }
    

    借助hook数据结构,第一次useRef时将创建的值保存在memoizedState中,之后每次更新阶段则直接返回。

    这样在函数组件更新时重复执行useRef仍返回同一份引用。

    因此实际上和 createRef一样本质上只是创建了一个 Mutable Object,只是因为渲染方式的不同,在函数组件中做了一些处理。而挂载和卸载的行为全部交由组件本身来维护。

    被扩展的ref

    createRef开始我们可以看到,ref对象的消费不再和DOM以及组件属性所绑定了,这意味着你可以在任何地方消费他们,这也回答了本文一开始的那个问题。

    useRef的应用

    解决闭包问题

    由于函数组件每次执行形成的闭包,下面这段代码会始终打印1

    export const ClosureDemo =  () => {
        const [ count,setCount ] = useState(0);
        useEffect(()=> {
            const interval = setInterval(()=>{
              setCount(count+1)
            }, 1000)
            return () => clearInterval(interval)
          }, [])
        // count显示始终是1
        return <div>{ count }</div>
    }
    

    将 count 作为依赖传入useEffect可以解决上面这个问题

    export const ClosureDemo =  () => {
        const [ count,setCount ] = useState(0);
        useEffect(()=> {
            const interval = setInterval(()=>{
              setCount(count+1)
            }, 1000)
            return () => clearInterval(interval)
          }, [count])
        return <div>{ count }</div>
    }
    

    但是这样定时器也会随着count值的更新而被不断创建,一方面会带来性能问题(这个例子中没有那么明显),更重要的一个方面是它不符合我们的开发语义,因为很明显我们希望定时器本身是不变的。

    另外一个方式也可以处理这个问题

    export const ClosureDemo =  () => {
        const [ count,setCount ] = useState(0);
        useEffect(()=> {
            const interval = setInterval(()=>{
              setCount(count=> count + 1) // 使用setSate函数式更新可以确保每次都取到新的值
            }, 1000)
            return () => clearInterval(interval)
          }, [])
        return <div>{ count }</div>
    }
    

    这样做确实可以处理闭包带来的影响,但是仅限于需要使用setState的场景,对数据的修改和触发setState是需要绑定的,这可能会造成不必要的刷新。

    使用useRef创建引用

    export const ClosureDemo =  () => {
        const [ count,setCount ] = useState(0);
      	const countRef = useRef(0);
      	countRef.current = count
        useEffect(()=> {
            const interval = setInterval(()=>{
              // 这里将更新count的逻辑和触发更新的逻辑解耦了
              if(countRef.current < 5){
              	countRef.current++
              } else {
              	setCount(countRef.current)
              }
            }, 1000)
            return () => clearInterval(interval)
          }, [])
        return <div>{ count }</div>
    }
    

    封装自定义hooks

    useCreation

    通过factory函数来避免类似于 useRef(new Construcotr)中构造函数的重复执行

    import { useRef } from 'react';
    
    export default function useCreation<T>(factory: () => T, deps: any[]) {
      const { current } = useRef({
        deps,
        obj: undefined as undefined | T,
        initialized: false,
      });
      if (current.initialized === false || !depsAreSame(current.deps, deps)) {
        current.deps = deps;
        current.obj = factory();
        current.initialized = true;
      }
      return current.obj as T;
    }
    
    function depsAreSame(oldDeps: any[], deps: any[]): boolean {
      if (oldDeps === deps) return true;
      for (const i in oldDeps) {
        if (oldDeps[i] !== deps[i]) return false;
      }
      return true;
    }
    
    usePrevious

    通过创建两个ref来保存前一次的state

    import { useRef } from 'react';
    
    export type compareFunction<T> = (prev: T | undefined, next: T) => boolean;
    
    function usePrevious<T>(state: T, compare?: compareFunction<T>): T | undefined {
      const prevRef = useRef<T>();
      const curRef = useRef<T>();
    
      const needUpdate = typeof compare === 'function' ? compare(curRef.current, state) : true;
      if (needUpdate) {
        prevRef.current = curRef.current;
        curRef.current = state;
      }
    
      return prevRef.current;
    }
    
    export default usePrevious;
    
    useClickAway

    自定义的元素失焦响应hook

    import { useEffect, useRef } from 'react';
    
    export type BasicTarget<T = HTMLElement> =
      | (() => T | null)
      | T
      | null
      | MutableRefObject<T | null | undefined>;
      
     export function getTargetElement(
      target?: BasicTarget<TargetElement>,
      defaultElement?: TargetElement,
    ): TargetElement | undefined | null {
      if (!target) {
        return defaultElement;
      }
    
      let targetElement: TargetElement | undefined | null;
    
      if (typeof target === 'function') {
        targetElement = target();
      } else if ('current' in target) {
        targetElement = target.current;
      } else {
        targetElement = target;
      }
      return targetElement;
    }
    // 鼠标点击事件,click 不会监听右键
    const defaultEvent = 'click';
    
    type EventType = MouseEvent | TouchEvent;
    
    export default function useClickAway(
      onClickAway: (event: EventType) => void,
      target: BasicTarget | BasicTarget[],
      eventName: string = defaultEvent,
    ) {
      // 使用useRef保存回调函数
      const onClickAwayRef = useRef(onClickAway);
      onClickAwayRef.current = onClickAway;
    
      useEffect(() => {
        const handler = (event: any) => {
          const targets = Array.isArray(target) ? target : [target];
          if (
            targets.some((targetItem) => {
              const targetElement = getTargetElement(targetItem) as HTMLElement;
              return !targetElement || targetElement?.contains(event.target);
            })
          ) {
            return;
          }
          onClickAwayRef.current(event);
        };
    
        document.addEventListener(eventName, handler);
    
        return () => {
          document.removeEventListener(eventName, handler);
        };
      }, [target, eventName]);
    }
    
    

    以上自定义hooks均出自ahooks

    还有许多好用的自定义hook以及仓库比如react-use都基于useRef自定义了很多好用的hook。

    参考资料

    • React Fiber juejin.cn/post/684490…
    • React 官网ref使用 zh-hans.reactjs.org/docs/refs-a…
    • React 前生今世 zhuanlan.zhihu.com/p/40462264
    • React ref源码分析 blog.csdn.net/qq_32281471…

    最后

    微信搜索公众号Eval Studio,关注更多动态。


    起源地下载网 » React ref 从原理到应用

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元