查看原文
其他

【第2014期】仿照React源码流程打造90行代码的Hooks

苏畅 前端早读课 2020-10-15

前言

今日早读文章由奇舞团@卡卡颂投稿分享。

正文从这开始~~

你可能已经看过其他简易的Hooks实现。那么本文和其他实现有什么区别呢?

本文的实现完全参照React源码的运行流程。学懂本文,去看React源码,你会发现流程基本一致。

这是本实现的在线Demo:https://code.h5jun.com/woniq/1/edit?js,console,建议对照着代码来看本文。

工作原理

对于useState Hook,考虑如下例子:

  1. function App() {

  2. const [num, updateNum] = useState(0);


  3. return <p onClick={() => updateNum(num => num + 1)}>{num}</p>;

  4. }

可以将工作分为两部分:

  • 通过一些途径产生更新,更新会造成组件render。

  • 组件render时useState返回的num为更新后的结果。

其中步骤1的更新可以分为mount和update:

  • 调用ReactDOM.render会产生mount的更新,更新内容为useState的initialValue(即0)。

  • 点击p标签触发updateNum会产生一次update的更新,更新内容为num => num + 1。

接下来讲解这两个步骤如何实现。

更新是什么

通过一些途径产生更新,更新会造成组件render。

首先我们要明确更新是什么。

在我们的极简例子中,更新就是如下数据结构:

  1. const update = {

  2. // 更新执行的函数

  3. action,

  4. // 与同一个Hook的其他更新形成链表

  5. next: null

  6. }

对于App来说,点击p标签产生的update的action为num => num + 1。

如果我们改写下App的onClick:

  1. // 之前

  2. return <p onClick={() => updateNum(num => num + 1)}>{num}</p>;


  3. // 之后

  4. return <p onClick={() => {

  5. updateNum(num => num + 1);

  6. updateNum(num => num + 1);

  7. updateNum(num => num + 1);

  8. }}>{num}</p>;

那么点击p标签会产生三个update。

update数据结构

这些update是如何组合在一起呢?

答案是:他们会形成环状单向链表。

调用updateNum实际调用的是dispatchAction.bind(null, hook.queue),我们先来了解下这个函数:

  1. function dispatchAction(queue, action) {

  2. // 创建update

  3. const update = {

  4. action,

  5. next: null

  6. }


  7. // 环状单向链表操作

  8. if (queue.pending === null) {

  9. update.next = update;

  10. } else {

  11. update.next = queue.pending.next;

  12. queue.pending.next = update;

  13. }

  14. queue.pending = update;


  15. // 模拟React开始调度更新

  16. schedule();

  17. }

环状链表操作不太容易理解,这里我们详细讲解下。

当产生第一个update(我们叫他u0),此时queue.pending === null。

update.next = update;即u0.next = u0,他会和自己首尾相连形成单向环状链表。

然后queue.pending = update;即queue.pending = u0

  1. queue.pending = u0 ---> u0

  2. ^ |

  3. | |

  4. ---------

当产生第二个update(我们叫他u1),update.next = queue.pending.next;,此时queue.pending.next === u0, 即u1.next = u0。

queue.pending.next = update;,即u0.next = u1。

然后queue.pending = update;即queue.pending = u1

  1. queue.pending = u1 ---> u0

  2. ^ |

  3. | |

  4. ---------

你可以照着这个例子模拟插入多个update的情况,会发现queue.pending始终指向最后一个插入的update。

这样做的好处是,当我们要遍历update时,queue.pending.next指向第一个插入的update。

状态如何保存

现在我们知道,更新产生的update对象会保存在queue中。

不同于ClassComponent的实例可以存储数据,对于FunctionComponent,queue存储在哪里呢?

答案是:FunctionComponent对应的fiber中。

fiber为React16中组件对应的虚拟DOM

我们使用如下精简的fiber结构:

  1. // App组件对应的fiber对象

  2. const fiber = {

  3. // 保存该FunctionComponent对应的Hooks链表

  4. memoizedState: null,

  5. // 指向App函数

  6. stateNode: App

  7. };

Hook数据结构

接下来我们关注fiber.memoizedState中保存的Hook的数据结构。

可以看到,Hook与update类似,都通过链表连接。不过Hook是无环的单向链表。

  1. hook = {

  2. // 保存update的queue,即上文介绍的queue

  3. queue: {

  4. pending: null

  5. },

  6. // 保存hook对应的state

  7. memoizedState: initialState,

  8. // 与下一个Hook连接形成单向无环链表

  9. next: null

  10. }

注意区分update与hook的所属关系:

每个useState对应一个hook对象。

调用const [num, updateNum] = useState(0);时updateNum(即上文介绍的dispatchAction)产生的update保存在useState对应的hook.queue中。

模拟React调度更新流程

在上文dispatchAction末尾我们通过schedule方法模拟React调度更新流程。

  1. function dispatchAction(queue, action) {

  2. // ...创建update


  3. // ...环状单向链表操作


  4. // 模拟React开始调度更新

  5. schedule();

  6. }

现在我们来实现他。

我们用isMount变量指代是mount还是update。

  1. // 首次render时是mount

  2. isMount = true;


  3. function schedule() {

  4. // 更新前将workInProgressHook重置为fiber保存的第一个Hook

  5. workInProgressHook = fiber.memoizedState;

  6. // 触发组件render

  7. fiber.stateNode();

  8. // 组件首次render为mount,以后再触发的更新为update

  9. isMount = false;

  10. }

通过workInProgressHook变量指向当前正在工作的hook。

  1. workInProgressHook = fiber.memoizedState;

在组件render时,每当遇到下一个useState,我们移动workInProgressHook的指针。

  1. workInProgressHook = workInProgressHook.next;

这样,只要每次组件render时useState的调用顺序及数量保持一致,那么始终可以通过workInProgressHook找到当前useState对应的hook对象。

到此为止,我们已经完成第一步。

通过一些途径产生更新,更新会造成组件render。

接下来实现第二步。

组件render时useState返回的num为更新后的结果。

计算state

组件render时会调用useState,他的大体逻辑如下:

  1. function useState(initialState) {

  2. // 当前useState使用的hook会被赋值该该变量

  3. let hook;


  4. if (isMount) {

  5. // ...mount时需要生成hook对象

  6. } else {

  7. // ...update时从workInProgressHook中取出该useState对应的hook

  8. }


  9. let baseState = hook.memoizedState;

  10. if (hook.queue.pending) {

  11. // ...根据queue.pending中保存的update更新state

  12. }

  13. hook.memoizedState = baseState;


  14. return [baseState, dispatchAction.bind(null, hook.queue)];

  15. }

我们首先关注如何获取hook对象:

  1. if (isMount) {

  2. // mount时为该useState生成hook

  3. hook = {

  4. queue: {

  5. pending: null

  6. },

  7. memoizedState: initialState,

  8. next: null

  9. }


  10. // 将hook插入fiber.memoizedState链表末尾

  11. if (!fiber.memoizedState) {

  12. fiber.memoizedState = hook;

  13. } else {

  14. workInProgressHook.next = hook;

  15. }

  16. // 移动workInProgressHook指针

  17. workInProgressHook = hook;

  18. } else {

  19. // update时找到对应hook

  20. hook = workInProgressHook;

  21. // 移动workInProgressHook指针

  22. workInProgressHook = workInProgressHook.next;

  23. }

当找到该useState对应的hook后,如果该hook.queue.pending不为空(即存在update),则更新其state。

  1. // update执行前的初始state

  2. let baseState = hook.memoizedState;


  3. if (hook.queue.pending) {

  4. // 获取update环状单向链表中第一个update

  5. let firstUpdate = hook.queue.pending.next;


  6. do {

  7. // 执行update action

  8. const action = firstUpdate.action;

  9. baseState = action(baseState);

  10. firstUpdate = firstUpdate.next;


  11. // 最后一个update执行完后跳出循环

  12. } while (firstUpdate !== hook.queue.pending)


  13. // 清空queue.pending

  14. hook.queue.pending = null;

  15. }


  16. // 将update action执行完后的state作为memoizedState

  17. hook.memoizedState = baseState;

完整代码如下:

  1. function useState(initialState) {

  2. let hook;


  3. if (isMount) {

  4. hook = {

  5. queue: {

  6. pending: null

  7. },

  8. memoizedState: initialState,

  9. next: null

  10. }

  11. if (!fiber.memoizedState) {

  12. fiber.memoizedState = hook;

  13. } else {

  14. workInProgressHook.next = hook;

  15. }

  16. workInProgressHook = hook;

  17. } else {

  18. hook = workInProgressHook;

  19. workInProgressHook = workInProgressHook.next;

  20. }


  21. let baseState = hook.memoizedState;

  22. if (hook.queue.pending) {

  23. let firstUpdate = hook.queue.pending.next;


  24. do {

  25. const action = firstUpdate.action;

  26. baseState = action(baseState);

  27. firstUpdate = firstUpdate.next;

  28. } while (firstUpdate !== hook.queue.pending)


  29. hook.queue.pending = null;

  30. }

  31. hook.memoizedState = baseState;


  32. return [baseState, dispatchAction.bind(null, hook.queue)];

  33. }

对触发事件进行抽象

最后,让我们抽象一下React的事件触发方式。

通过调用App返回的click方法模拟组件click的行为。

  1. function App() {

  2. const [num, updateNum] = useState(0);


  3. console.log(`${isMount ? 'mount' : 'update'} num: `, num);


  4. return {

  5. click() {

  6. updateNum(num => num + 1);

  7. }

  8. }

  9. }

在线Demo

至此,我们完成了一个不到100行代码的Hooks。重要的是,他与React的运行逻辑相同。

在线Demo:https://code.h5jun.com/woniq/1/edit?js,console

在Demo中,调用window.app.click()模拟组件点击事件。

你也可以使用多个useState。

  1. function App() {

  2. const [num, updateNum] = useState(0);

  3. const [num1, updateNum1] = useState(100);


  4. console.log(`${isMount ? 'mount' : 'update'} num: `, num);

  5. console.log(`${isMount ? 'mount' : 'update'} num1: `, num1);


  6. return {

  7. click() {

  8. updateNum(num => num + 1);

  9. },

  10. focus() {

  11. updateNum1(num => num + 3);

  12. }

  13. }

  14. }

与React的区别

我们用尽可能少的代码模拟了Hooks的运行,但是相比React Hooks,他还有很多不足。以下是他与React Hooks的区别:

  • React Hooks没有使用isMount变量,而是在不同时机使用不同的dispatcher。换言之,mount时的useState与update时的useState不是同一个函数。

  • React Hooks有中途跳过更新的优化手段。

  • React Hooks有batchedUpdates,当在click中触发三次updateNum,精简React会触发三次更新,而React只会触发一次。

  • React Hooks的update有优先级概念,可以跳过不高优先的update。

如果想了解React源码更多运行细节,欢迎观看开源电子书React技术揭秘:https://react.iamkasong.com/

他曾分享过


【第1999期】深入源码剖析componentWillXXX为什么UNSAFE


欢迎自荐投稿,前端早读课等你来

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存