当我们在用Hooks时,我们到底在用什么?

开篇有奖

如果你最近一年出去面过试,很可能面临这些问题:

  • react 16到底做了哪些更新;
  • react hooks用过么,知道其原理么;

第一个问题如果你提到了Fiber reconciler,fiber,链表,新的什么周期,可能在面试官眼里这仅仅是一个及格的回答。以下是我整理的,自我感觉还良好的回答:

分三步:

  • react作为一个ui库,将前端编程由传统的命令式编程转变为声明式编程,即所谓的数据驱动视图,但如果简单粗暴的操作,比如讲生成的html直接采用innerHtml替换,会带来重绘重排之类的性能问题。为了尽量提高性能,React团队引入了虚拟dom,即采用js对象来描述dom树,通过对比前后两次的虚拟对象,来找到最小的dom操作(vdom diff),以此提高性能。
  • 上面提到的vDom diff,在react 16之前,这个过程我们称之为stack reconciler,它是一个递归的过程,在树很深的时候,单次diff时间过长会造成JS线程持续被占用,用户交互响应迟滞,页面渲染会出现明显的卡顿,这在现代前端是一个致命的问题。所以为了解决这种问题,react 团队对整个架构进行了调整,引入了fiber架构,将以前的stack reconciler替换为fiber reconciler。采用增量式渲染。引入了任务优先级(expiration)requestIdleCallback的循环调度算法,简单来说就是将以前的一根筋diff更新,首先拆分成两个阶段:reconciliationcommit;第一个reconciliation阶段是可打断的,被拆分成一个个的小任务(fiber),在每一侦的渲染空闲期做小任务diff。然后是commit阶段,这个阶段是不拆分且不能打断的,将diff节点的effectTag一口气更新到页面上。
  • 由于reconciliation是可以被打断的,且存在任务优先级的问题,所以会导致commit前的一些生命周期函数多次被执行, 如componentWillMount、componentWillReceiveProps 和 componetWillUpdate,但react官方已申明这些问题,并将其标记为unsafe,在React17中将会移除
  • 由于每次唤起更新是从根节点(RootFiber)开始,为了更好的节点复用与性能优化。在react中始终存workInprogressTree(future vdom) 与 oldTree(current vdom)两个链表,两个链表相互引用。这无形中又解决了另一个问题,当workInprogressTree生成报错时,这时也不会导致页面渲染崩溃,而只是更新失败,页面仍然还在。

以上就是我上半年面试自己不断总结迭代出的答案,希望能对你有所启发。

接着来回答第二个问题,hooks本质是什么?

hooks 为什么出现

当我们在谈论React这个UI库时,最先想到的是,数据驱动视图,简单来讲就是下面这个公式:

view = fn(state)

我们开发的整个应用,都是很多组件组合而成,这些组件是纯粹,不具备扩展的。因为React不能像普通类一样直接继承,从而达到功能扩展的目的。

出现前的逻辑复用

在用react实现业务时,我们复用一些组件逻辑去扩展另一个组件,最常见比如Connect,Form.create, Modal。这类组件通常是一个容器,容器内部封装了一些通用的功能(非视觉的占多数),容器里面的内容由被包装的组件自己定制,从而达到一定程度的逻辑复用。

在hooks 出现之前,解决这类需求最常用的就两种模式:HOC高阶组件Render Props

高阶组件类似于JS中的高阶函数,即输入一个函数,返回一个新的函数, 比如React-Redux中的Connect:

class Home extends React.Component {
  // UI
}

export default Connect()(Home);

高阶组件由于每次都会返回一个新的组件,对于react来说,这是不利于diff和状态复用的,所以高阶组件的包装不能在render 方法中进行,而只能像上面那样在组件声明时包裹,这样也就不利于动态传参。而Render Props模式的出现就完美解决了这个问题,其原理就是将要包裹的组件作为props属性传入,然后容器组件调用这个属性,并向其传参, 最常见的用props.children来做这个属性。举个🌰:

class Home extends React.Component {
  // UI
}

<Route path = "/home" render= {(props) => <Home {...props} } />

更多关于render 与 Hoc,可以参见以前写的一片弱文:React进阶,写中后台也能写出花

已存方案的问题

嵌套地狱

上面提到的高阶组件和RenderProps, 看似解决了逻辑复用的问题,但面对复杂需求时,即一个组件需要使用多个复用包裹时,两种方案都会让我们的代码陷入常见的嵌套地狱, 比如:

class Home extends React.Component {
  // UI
}

export default Connect()(Form.create()(Home));

除了嵌套地狱的写法让人困惑,但更致命的深度会直接影响react组件更新时的diff性能。

函数式编程的普及

Hooks 出现前的函数式组件只是以模板函数存在,而前面两种方案,某种程度都是依赖类组件来完成。而提到了类,就不得不想到下面这些痛点:

  • JS中的this是一个神仙级的存在, 是很多入门开发趟不过的坑;
  • 生命周期的复杂性,很多时候我们需要在多个生命周期同时编写同一个逻辑
  • 写法臃肿,什么constructor,super,render

所以React团队回归view = fn(state)的初心,希望函数式组件也能拥有状态管理的能力,让逻辑复用变得更简单,更纯粹。

架构的更新

为什么在React 16前,函数式组件不能拥有状态管理?其本质是因为16以前只有类组件在更新时存在实例,而16以后Fiber 架构的出现,让每一个节点都拥有对应的实例,也就拥有了保存状态的能力,下面会详讲。

hooks 的本质

有可能,你听到过Hooks的本质就是闭包。但是,如果满分100的话,这个说法最多只能得60分。

哪满分答案是什么呢?闭包 + 两级链表

下面就来一一分解, 下面都以useState来举例剖析。

闭包

JS 中闭包是难点,也是必考点,概括的讲就是:

闭包是指有权访问另一个函数作用域中变量或方法的函数,创建闭包的方式就是在一个函数内创建闭包函数,通过闭包函数访问这个函数的局部变量, 利用闭包可以突破作用链域的特性,将函数内部的变量和方法传递到外部。
export default function Hooks() {
  const [count, setCount] = useState(0);
  const [age, setAge] = useState(18);

  const self = useRef(0);

  const onClick = useCallback(() => {
    setAge(19);
    setAge(20);
    setAge(21);
  }, []);

  console.log('self', self.current);
  return (
    <div>
      <h2>年龄: {age} <a onClick={onClick}>增加</a></h2>
      <h3>轮次: {count} <a onClick={() => setCount(count => count + 1)}>增加</a></h3>
    </div>
  );
}

以上面的示例来讲,闭包就是setAge这个函数,何以见得呢,看组件挂载阶段hook执行的源码:

// packages/react-reconciler/src/ReactFiberHooks.js
function mountReducer(reducer, initialArg, init) {
  const hook = mountWorkInProgressHook();
  let initialState;
  if (init !== undefined) {
    initialState = init(initialArg);
  } else {
    initialState = initialArg;
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue = (hook.queue = {
    last: null,
    dispatch: null,
    lastRenderedReducer: reducer,
    lastRenderedState: initialState,
  });
  // 重点
  const dispatch = (queue.dispatch = (dispatchAction.bind(
    null,
    currentlyRenderingFiber,
    queue,
  )));
  return [hook.memoizedState, dispatch];
}

所以这个函数就是mountReducer,而产生的闭包就是dispatch函数(对应上面的setAge),被闭包引用的变量就是currentlyRenderingFiberqueue

  • currentlyRenderingFiber: 其实就是workInProgressTree, 即更新时链表当前正在遍历的fiber节点(源码注释:The work-in-progress fiber. I've named it differently to distinguish it from the work-in-progress hook);
  • queue: 指向hook.queue,保存当前hook操作相关的reducer 和 状态的对象,其来源于mountWorkInProgressHook这个函数,下面重点讲;

这个闭包将 fiber节点与action, action 与 state很好的串联起来了,举上面的例子就是:

  • 当点击增加执行setAge, 执行后,新的state更新任务就储存在fiber节点的hook.queue上,并触发更新;
  • 当节点更新时,会遍历queue上的state任务链表,计算最终的state,并进行渲染;

ok,到这,闭包就讲完了。

第一个链表:hooks

在ReactFiberHooks文件开头声明currentHook变量的源码有这样一段注释。

/*
Hooks are stored as a linked list on the fiber's memoizedState field.  
hooks 以链表的形式存储在fiber节点的memoizedState属性上
The current hook list is the list that belongs to the current fiber.
当前的hook链表就是当前正在遍历的fiber节点上的
The work-in-progress hook list is a new list that will be added to the work-in-progress fiber.
work-in-progress hook 就是即将被添加到正在遍历fiber节点的hooks新链表
*/
let currentHook: Hook | null = null;
let nextCurrentHook: Hook | null = null;

从上面的源码注释可以看出hooks链表与fiber链表是极其相似的;也得知hooks 链表是保存在fiber节点的memoizedState属性的, 而赋值是在renderWithHooks函数具体实现的;

export function renderWithHooks(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  props: any,
  refOrContext: any,
  nextRenderExpirationTime: ExpirationTime,
): any {
  renderExpirationTime = nextRenderExpirationTime;
  currentlyRenderingFiber = workInProgress;
  // 获取当前节点的hooks 链表;
  nextCurrentHook = current !== null ? current.memoizedState : null;
  // ...省略一万行
}

有可能代码贴了这么多,你还没反应过来这个hooks 链表具体指什么?

其实就是指一个组件包含的hooks, 比如上面示例中的:

const [count, setCount] = useState(0);
const [age, setAge] = useState(18);
const self = useRef(0);
const onClick = useCallback(() => {
  setAge(19);
  setAge(20);
  setAge(21);
}, []);

形成的链表就是下面这样的:

20200717112830

所以在下一次更新时,再次执行hook,就会去获取当前运行节点的hooks链表;

const hook = updateWorkInProgressHook();
// updateWorkInProgressHook 就是一个纯链表的操作:指向下一个 hook节点

到这 hooks 链表是什么,应该就明白了;这时你可能会更明白,为什么hooks不能在循环,判断语句中调用,而只能在函数最外层使用,因为挂载或则更新时,这个队列需要是一致的,才能保证hooks的结果正确。

第二个链表:state

其实state 链表不是hooks独有的,类操作的setState也存在,正是由于这个链表存在,所以有一个经(sa)典(bi)React 面试题:

setState为什么默认是异步,什么时候是同步?

结合实例来看,当点击增加会执行三次setAge

const onClick = useCallback(() => {
  setAge(19);
  setAge(20);
  setAge(21);
}, []);

第一次执行完dispatch后,会形成一个状态待执行任务链表:
20200720111316

如果仔细观察,会发现这个链表还是一个(会在updateReducer后断开), 这一块设计相当有意思,我现在也还没搞明白为什么需要环,值得细品,而建立这个链表的逻辑就在dispatchAction函数中。

function dispatchAction(fiber, queue, action) {
  // 只贴了相关代码
  const update = {
    expirationTime,
    suspenseConfig,
    action,
    eagerReducer: null,
    eagerState: null,
    next: null,
  };
  // Append the update to the end of the list.
  const last = queue.last;
  if (last === null) {
    // This is the first update. Create a circular list.
    update.next = update;
  } else {
    const first = last.next;
    if (first !== null) {
      // Still circular.
      update.next = first;
    }
    last.next = update;
  }
  queue.last = update;

  // 触发更新
  scheduleWork(fiber, expirationTime);
}

上面已经说了,执行setAge 只是形成了状态待执行任务链表,真正得到最终状态,其实是在下一次更新(获取状态)时,即:

// 读取最新age
const [age, setAge] = useState(18);

而获取最新状态的相关代码逻辑存在于updateReducer中:

function updateReducer(reducer, initialArg,init?) {
  const hook = updateWorkInProgressHook();
  const queue = hook.queue;
  // ...隐藏一百行
  // 找出第一个未被执行的任务;
  let first;
  // baseUpdate 只有在updateReducer执行一次后才会有值
  if (baseUpdate !== null) {
    // 在baseUpdate有值后,会有一次解环的操作;
    if (last !== null) {
      last.next = null;
    }
    first = baseUpdate.next;
  } else {
    first = last !== null ? last.next : null;
  }

  if (first !== null) {
    let newState = baseState;
    let newBaseState = null;
    let newBaseUpdate = null;
    let prevUpdate = baseUpdate;
    let update = first;
    let didSkip = false;
    // do while 遍历待执行任务的状态链表
    do {
      const updateExpirationTime = update.expirationTime;
      if (updateExpirationTime < renderExpirationTime) {
        // 优先级不足,先标记,后面再更新
      } else {
        markRenderEventTimeAndConfig(
          updateExpirationTime,
          update.suspenseConfig,
        );

        // Process this update.
        if (update.eagerReducer === reducer) {
          // 简单的说就是状态已经计算过,那就直接用
          newState = update.eagerState;
        } else {
          const action = update.action;
          newState = reducer(newState, action);
        }
      }
      prevUpdate = update;
      update = update.next;
      // 终止条件是指针为空 或 环已遍历完
    } while (update !== null && update !== first);  
    // ...省略100行
    return [newState, dispatch];
  }
}

最后来看,状态更新的逻辑似乎是最绕的。但如果看过setState,这一块可能就比较容易。至此,第二个链表state就理清楚了。

读到这里,你就应该明白hooks 到底是怎么实现的:

闭包加两级链表

虽然我这里只站在useState这个hooks做了剖析,但其他hooks的实现基本类似。

另外分享一下在我眼中的hooks,与类组件到底到底是什么联系:

  • useState: 状态的存储及更新,状态更新会触发组件更新,和类的state类似,只不过setState更新时是采用Object.assign(oldstate, newstate); 而useState的set是直接替代式的
  • useEffect: 类似于以前的componentDidMount 和 componentDidUpdate生命周期钩子(即render 执行后,再执行Effect, 所以当组件与子组件都有Effect时,子组件的Effect先执行), Update需要deps依赖来唤起;
  • useRefs: 用法类似于以前直接挂在类的this上,像this.selfCount 这种,用于变量的临时存储,而又不至于受函数更新,而被重定义;与useState的区别就是,refs的更新不会导致Rerender
  • useMemo: 用法同以前的componentWillReceiveProps与getDerivedStateFromProps中,根据state和props计算出一个新的属性值:计算属性
  • useCallback: 类似于类组件中constructor的bind,但比bind更强大,避免回调函数每次render造成回调函数重复声明,进而造成不必要的diff;但需要注意deps,不然会掉进闭包的坑
  • useReducer: 和redux中的Reducer相像,和useState一样,执行后可以唤起Rerender

第一次写源码解析,出发点主要两点:

  • 最近半年自己在react确实下了一些功夫,有一个输出也是为了自己以后更好的回忆;
  • 网上太多的人用一个闭包来概括hooks,我觉得这是对技术的亵渎(个人意见);

文章中若有不详或不对之处,欢迎斧正;

推荐阅读: 源码解析React Hook构建过程:没有设计就是最好的设计

首发链接:当我们在用Hooks时,我们到底在用什么?

阅读 1.4k

推荐阅读