Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
closertb opened this issue Jul 22, 2020 · 0 comments
Open

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

closertb opened this issue Jul 22, 2020 · 0 comments
Labels
JS 原生JS相关 react about react

Comments

@closertb
Copy link
Owner

开篇有奖

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

  • 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构建过程:没有设计就是最好的设计

@closertb closertb added JS 原生JS相关 react about react labels Jul 22, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
JS 原生JS相关 react about react
Projects
None yet
Development

No branches or pull requests

1 participant