超性感的React Hooks(二)再谈闭包
如果你一天没有真正理解它,你就应该继续学习它。
曾经我去找工作面试的时候,我最讨厌别人问我闭包,因为我说不清楚。现在我面试别人了,却又最爱问闭包,因为闭包真的能直接的检验你对JS的理解深度。可能够回答上来的人真的很少。
两年以来我面试过估计200多人,其中技术能力最强的是阿里P6的一个胖胖的哥们儿,这里简称PP。PP的JS基础很扎实,对React的理解比较深刻,其他问题上我们聊得很开心。可即使是这样的高手,在闭包的问题上也有些犯难,没有第一时间回答出来我想要的答案。
因此,如果有这么一篇两篇文章,能够帮助大家将闭包吃透,我觉得是一件非常了不起的事。在JS基础进阶系列中,我已经将闭包的基础,定义,特点,以及如何在chrome浏览器中观察闭包都一一跟大家分享了,这一篇就着眼于实践继续学习。
就以我和PP同学在面试过程中的对话为引子,对话内容大概如下:
我:能聊聊你对闭包的理解吗
PP:函数执行时访问上层作用域的变量,就能形成闭包,闭包可以持久化保持变量。
我:还有其他的吗?
PP:没了
我:我如果说闭包在我们的实践中几乎无处不在,你认同这样的说法吗?
PP(有点犹豫):认同
我:那哪些场景有涉及到呢?
PP:一时想不起来。
我(不太甘心,继续引导):模块化你应该知道吧,你认为模块和闭包有没有可能存在什么联系?
PP:没有
我:确定吗?
PP:确定没有!
OK,到这里,如果你是面试官,你觉得PP同学的回答怎么样?达到你的要求了吗?
当然,买过我书并且认真看过的同学应该知道,回答得并不让人满意。这里,我们结合React Hooks的实际情况,接着聊聊这个话题。
也许有的同学会比较奇怪,这系列文章明明就是介绍React Hooks,跟闭包有半毛钱的关系?
事实却相反,闭包,是React Hooks的核心。不理解闭包,React Hooks的使用就无法达到炉火纯青的地步。如果只是基于表面的去使用,看官方文档就可以了,这也不是我们这系列文章的目的。
在接着聊闭包与模块之间的联系之前,我们先来回顾几个的概念。
闭包是一个特殊的对象
它由两部分组成,执行上下文A以及在A中创建的函数B。
当B执行时,如果访问了A中的变量对象,那么闭包就会产生。
在大多数理解中,包括许多著名的书籍、文章里都以函数B的名字代指这里生成的闭包。而在chrome中,则以执行上下文A的函数名代指闭包。
许多地方喜欢用词法环境,或者词法作用域来定义闭包的概念,但是闭包是代码执行过程中才会产生的特殊对象,因此我认为使用执行上下文更为准确。当然,这并不影响闭包的理解与使用。
还有另外一个重要的知识点:
本质上,JavaScript中并没有自己的模块概念,我们只能使用函数/自执行函数来模拟模块。
现在的前端工程中(ES6的模块语法规范),使用的模块,本质上都是函数或者自执行函数。
webpack等打包工具会帮助我们将其打包成为函数
思考一下,定义一个React组件,并且在其他模块中使用,这和闭包有关系吗?来个简单的例子分析试试看。
在模块Counter.jsx中定义一个Counter组件
// Counter.jsx export default function Counter() {}
然后在App模块中使用Counter组件
// App.jsx import Counter from './Counter'; export default function App() { // todo return ( <Counter /> ) }
结合上面的几个知识点,基础扎实的同学到这里应该能够知道答案了,如果还没想明白,没关系,更详细一步。
上面的代码我们可以手动转换成伪代码
const CounterModule = (function() { return function Counter() {} })() const AppModule = (function() { const Counter = CounterModule; return function App() { return Counter(); } })()
我们将上面闭包定义的A,B用本例中的名称替换一下:
自执行函数AppModule以及在AppModule中创建的函数App。
当App在render中执行时,访问了AppModule中的变量对象(定义了变量Counter),那么闭包就会产生。
所以,闭包跟模块之间的关系,到这里,就非常清晰了。根据闭包的生成条件与实践场景,我们会发现,模块中,非常容易生成闭包。每一个JS模块都可以认为是一个独立的作用域,当代码执行时,该词法作用域创建执行上下文,如果在模块内部,创建了可供外部引用访问的函数时,就为闭包的产生提供了条件,只要该函数在外部执行访问了模块内部的其他变量,闭包就会产生。
再来一个例子。
定义一个名为State的模块,代码如下:
// state.js let state = null; export const useState = (value: number) => { // 第一次调用时没有初始值,因此使用传入的初始值赋值 state = state || value; function dispatch(newValue) { state = newValue; // 假设此方法能触发页面渲染 render(); } return [state, dispatch]; }
在其他模块中引入并使用。
import React from 'react'; import {useState} from './state'; function Demo() { // 使用数组解构的方式,定义变量 const [counter, setCounter] = useState(0); return ( <div onClick={() => setCounter(counter + 1)}>hello world, {counter}</div> ) } export default Demo();
执行上下文state(模块state)以及在state中创建的函数useState
当useState在Demo中执行时,访问了state中的变量对象,那么闭包就会产生。
思考题:setCounter的执行会产生闭包吗?
根据闭包的特性,state模块中的state变量,会持久存在。因此当Demo函数再次执行时,我们也能获取到上一次Demo函数执行结束时state的值。
这就是React Hooks能够让函数组件拥有内部状态的基本原理。
此处案例中的useState的实现原理与用法,与React Hooks基本一致。但是真正的源码实现肯定不会这么简单粗暴。
我们来简单分析一下React Hooks源码是如何实现的。
需要注意的是,我们这里分析源码的重点,是感悟闭包在React Hooks中扮演的角色。如果要更进步要了解Fiber的原理,以后再跟大家分享。
另外一个值得大家重视的点是,要有意识的总结我在阅读源码过程中的思路,这会对大家想要阅读别人的代码时帮助很大。我就不把方法直接写出来了,具体以后再分享
通过断点调试,发现React Hooks的各种逻辑处理都在ReactCurrentDispatcher[1]这个模块。
这个文件共有两千多行,是一个非常复杂的模块。
第一步,要搞清楚这个模块的作用。
具体的方法是观察模块返回了什么内容。搜索export。export表示这个模块会对外抛出的接口,这是模块与外部沟通的唯一方式。
搜索结果发现大多数export都是type类型声明,我们这里不关注。经过简单的分析,所有的核心逻辑都写在renderWithHooks
中。通过断点调试也能定位到这个方法。
快速分析一个函数的作用,一个思路是看它返回了什么,二个思路是看它改变了什么。
分析结果发现,该函数修改了外层作用域中的变量,这就是我们想要的重要讯息。
之前从ReactHooks.js
模块中发现useState的实现非常简单,如下
export function useState<S>(initialState: (() => S) | S) { const dispatcher = resolveDispatcher(); return dispatcher.useState(initialState); }
继续查看resolveDispatcher的实现
function resolveDispatcher() { const dispatcher = ReactCurrentDispatcher.current; return dispatcher; }
到这里,其实基本上就对上号了。当然具体原理还要结合Fiber调度来理解,这里不继续深入。我们本文关注的重点仍然在闭包。
从上图中知道,在某种条件下(更新时),ReactCurrentDispatcher.current
就是HooksDispatcherOnUpdateInDEV
,这个方法在ReactFiberHooks
模块中声明。
继续阅读源码,发现HooksDispatcherOnUpdateInDEV
是在该模块中定义的一个变量。
这个时候,我们就应该很自然的想到,奥,这里利用了闭包。
继续通过关键字,发现该变量被赋予了具体值。这些,就全是ReactHooks支持的api。如图
我们暂时只关注useState,去看看它是如何实现的。
useState<S>( initialState: (() => S) | S, ): [S, Dispatch<BasicStateAction<S>>] { currentHookNameInDev = 'useState'; updateHookTypesDev(); const prevDispatcher = ReactCurrentDispatcher.current; ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; try { return updateState(initialState); } finally { ReactCurrentDispatcher.current = prevDispatcher; } },
这里的关键是updateState(initialState)
function updateState<S>( initialState: (() => S) | S, ): [S, Dispatch<BasicStateAction<S>>] { return updateReducer(basicStateReducer, (initialState: any)); }
继续找到updateReducer
,updateReducer的逻辑比较复杂。不过我们基于上面提到过的两个思路,看他修改了什么,与返回了什么,就能很快理清它。
function updateReducer<S, I, A>( reducer: (S, A) => S, initialArg: I, init?: I => S, ): [S, Dispatch<A>] { const hook = updateWorkInProgressHook(); const queue = hook.queue; // ... queue.lastRenderedReducer = reducer; if (numberOfReRenders > 0) { // This is a re-render. Apply the new render phase updates to the previous // work-in-progress hook. const dispatch: Dispatch<A> = (queue.dispatch: any); if (renderPhaseUpdates !== null) { // ... return [hook.memoizedState, dispatch]; } // The last update in the entire queue const last = queue.last; // The last update that is part of the base state. const baseUpdate = hook.baseUpdate; const baseState = hook.baseState; // Find the first unprocessed update. let first; if (baseUpdate !== null) { if (last !== null) { // For the first update, the queue is a circular linked list where // `queue.last.next = queue.first`. Once the first update commits, and // the `baseUpdate` is no longer empty, we can unravel the list. last.next = null; } first = baseUpdate.next; } else { first = last !== null ? last.next : null; } if (first !== null) { // ... hook.memoizedState = newState; hook.baseUpdate = newBaseUpdate; hook.baseState = newBaseState; queue.lastRenderedState = newState; } const dispatch: Dispatch<A> = (queue.dispatch: any); return [hook.memoizedState, dispatch]; }
简化一下源代码,发现逻辑虽然复杂,但是核心的两个东西,还是在于修改了一个叫做hook
的变量,以及返回了[hook.memoizedState, dispatch]
。
这个hook是什么呢?在updateWorkInProgressHook
方法中发现,hook是包含了memoizedState, baseState, queue, baseUpdate, next
属性的一个对象。
function updateWorkInProgressHook(): Hook { if (nextWorkInProgressHook !== null) { workInProgressHook = nextWorkInProgressHook; nextWorkInProgressHook = workInProgressHook.next; currentHook = nextCurrentHook; nextCurrentHook = currentHook !== null ? currentHook.next : null; } else { invariant( nextCurrentHook !== null, 'Rendered more hooks than during the previous render.', ); currentHook = nextCurrentHook; const newHook: Hook = { memoizedState: currentHook.memoizedState, baseState: currentHook.baseState, queue: currentHook.queue, baseUpdate: currentHook.baseUpdate, next: null, }; if (workInProgressHook === null) { workInProgressHook = firstWorkInProgressHook = newHook; } else { workInProgressHook = workInProgressHook.next = newHook; } nextCurrentHook = currentHook.next; } return workInProgressHook; }
updateReducer
返回的数组中,第一个值就是memoizedState
。
因此可以得出结论,其实我们的状态,就缓存在hook.memoizedState
这个值里。
继续观察updateWorkInProgressHook
方法,发现该方法在内部修改了很多外部的变量,workInProgressHook,nextWorkInProgressHook,currentHook
等。而memoizedState: currentHook.memoizedState
。
因此,最终我们的状态,在update时,其实就是存在于currentHook
。这也是利用了闭包。
OK,按照这个思路,React Hooks的源码逻辑很快就能分析出来,不过我们这里的重点是关注闭包在React Hooks中是如何扮演角色的。如果你已经体会到了闭包的作用,本文的目的就基本达到了。
需要注意的是,在更新时,调用的是updateReducer
,但是在初始化时,调用的方法却不一样,如图。
闭包无处不在,你要体会到这句话的真正含义。
源码阅读并非学习的必要过程,如果JS基础还不够扎实,不用着急纠结于自己读不懂怎么办。慢慢来就可以了。
最后,给大家留一个思考题。著名的状态管理器redux,或者vue中的vuex,他们的实现有没有利用闭包呢?