Preact 作为实现大部分 React 的接口,并且专注于轻量的框架,在前一阵 React 由于专利事件受到质疑的时候,进入了大家的视野,并且成为了在不得已需要放弃 React 之后的首选。虽然在今天,React 在 Twiter 上宣布其转向了 MIT 许可证,但也不影响我们对本框架设计的学习。本文作为 Preact 源码解读系列的第一篇,将介绍一些关于 Preact 的基础源码。
注:笔者水平有限,在有的地方无法理解开发者代码的精髓,大家可以来 developit/preact | GitHub 获得最新源代码。
其中,关于 package.json
中的组成,可以查看 Preact 源码剖析(一)解读 package.json 这篇文章,讲的很好。
src/preact.js
首先,我们可以在 src
目录下找到项目的主入口代码 preact.js
,我们在 import preact from 'preact'
的时候,引入的函数,对象都是从这个入口中提供的。preact.js
中的内容也很简单,介绍了各部分的函数实现分别在哪个位置:
1 | import { h, h as createElement } from './h'; |
src/component.js
当我们需要创建一个组建的时候,就需要引入 Component
,下面我们来看一看 Component
中代码是怎样组成的:
首先,对外提供了 Component
类,用来继承,其代码为:
1 | export function Component(props, context) { |
当然,这一段代码只是提供了最基础的框架,下面使用了 extend
方法为 Component.prototype
提供了新的方法:
1 | extend(Component.prototype, { |
我们先暂时不看 setState
等方法的实现,先看看 extend
方法实现了什么功能,在 util.js
中,我们找到了 extend
方法的源代码,功能很简单,就是遍历 props
对象的属性,然后赋值给 obj
:
1 | export function extend(obj, props) { |
则上面的代码含义是,给 Component.prototype
添加 setState, forceUpdate, render
这三个方法,使得所有 Component
的对象都具有这三个方法。下面,我们来分别看看这三个方法的实现原理:
setState
1 | setState(state, callback) { |
我们在第三行代码中,可以看到,最终使用 extend
方法,将传入的新的 state
添加到现有的状态中,这里出现了一个平常我们不注意的小点,就是传给 setState
的参数,除了对象以外,还可以是一个函数:
1 | (state, this.props) => {...} |
传递的第一个参数是现在的 state
,第二个参数是现在的 props
,返回的对象是新的 nextState
。
关于传递函数给 setState
这一点,有一些文章在介绍这个技巧,比如:[译] 在 setState 中使用函数替代对象 以及 為何要在 React 的 setState() 內傳入 function 和 Functional setState,也就是说,在 setState
中传入函数,可以保证每一次使用的都是最新的 state,而不是老的 state 的叠加。
如果在 setState 中传递了回调函数 callback
,则本组件会创建一个 _renderCallbacks
数组,用来存放回调函数。
最后将本组件添加到 Render 队列中。
我们来看一看 enqueueRender
中做了什么:
1 | export function enqueueRender(component) { |
在这段函数中,其实做了一件事,就是判断传入的 component
中 _dirty
是否为 true
。如果 _dirty
为 false
,并且当前队列中没有需要渲染的组件,则添加该组件,然后将队列中的所有组件都做一次渲染。
1 | items.push(component)==1 |
这句话的意思是,原来 item
中没有元素,在推入了 component
中,返回值为 length = 1
,则说明有元素需要进行渲染,开始渲染。如果返回值不是 1
,则说明已经在一个 rerender
的进行中,则不会再次进行 rerender
,而是等待在本次 rerender
中被 pop
出来。
在默认情况下,所有组件初始化的时候,赋值 _dirty
都是 true
,也就是不立刻触发 rerender
,那什么时候 _dirty
被赋值为 false
呢?我们下面再分析。
假设,我们遇到了一个 _dirty
为 false
的组件,然后会发生什么呢,会激活以下的代码:
1 | (options.debounceRendering || defer)(rerender); |
让我们来看看 options.debounceRendering
是什么:
当我进 options
中查看,额。。。什么都没有,返回了一个空对象:
1 | export default {}; |
那我们只能在 defer
中查看它是什么了:
1 | export const defer = typeof Promise=='function' ? Promise.resolve().then.bind(Promise.resolve()) : setTimeout; |
它做的功能,就是判断现有环境中是否有 Promise
,如果有 Pormise
,则在当前 macrotask
后面的 microtask
中执行回调函数,也就是 rerender
,如果没有 Promise
,则在下一个 macrotask
中执行回调函数。
OK,我们也知道了这一句代码是做什么事情,那我们来看 rerender
中做了什么事情吧:
1 | export function rerender() { |
我们可以看到,rerender
中做的事情,就是遍历所有需要重新渲染的组件,然后对其调用 renderComponent
方法,那 renderComponent
做了什么呢?由于 renderComponent
方法太长,所以我又开了一个小结来进行分析
renderComponent
1 | isUpdate = component.base |
首先判断该组件是不是已经在一个更新中(可能是因为调用 forceUpdate
方法等原因造成),如果在一个更新中,则判断本次进入 rerender
的原因:
- 如果不是因为
forceUpdate
进入更新,则判断shouldComponentUpdate
的返回值是否为false
,如果返回值要求不更新,则设置skip = true
- 如果是因为
forceUpdate
进入更新,则运行该组件的componentWillUpdate
首先,在进行了判断是否在更新中以后,设置 component._dirty = false;
,表明该组件又可以进行 rerender
,允许在 enqueueRender
中被添加到队列里面。越早设置 _dirty = false;
,则对下一次 rerender
的响应越快。
接下来,首先判断 skip
,来决定要不要对组件进行更新。当 skip === false
的时候,说明要进行组件重新渲染的操作,操作如下:
首先,运行 rendered = component.render(props, state, context);
,获得当前组件的返回值。
然后,判断该组件中是否有子组件,如果有子组件,则更新 content
:
1 | // context to pass to the child, can be updated via (grand-)parent component |
获得子组件:
1 | let childComponent = rendered && rendered.nodeName |
下面进行判断子组件是否存在,如果子组件存在,将会进行一下的操作:
1 | if (typeof childComponent==='function') { |
如果子组件存在,首先得到子组件的 props
,我们来看看 getNodeProps
的实现:
1 | /** |
获得的是一个 VNode 对象的 attributes
,children
,以及所有的 defaultProps
。至于这三个属性分别代表什么,我会在接下来的文章中分析,现在我们就暂且认为拿到了子组件的必要属性吧~
下面,首先进行了一次 if
判断:
1 | if (inst && inst.constructor===childComponent && childProps.key==inst.__key) |
我所理解的判断内容是:
inst
的初始化为:component._component
,也就指的是现在还没有rerender
的时候,子组件是什么childComponent
的初始化为:component.render(props, state, context).nodeName
,也就指的是新的render
函数子组件是什么
所以,这个 if
语句的判断,也就是想知道该组件的子组件类型是否发生了变化?如果没有发生变化,则在原来 inst
上更新新的 props
等。如果变成了新的子组件,则将原来的 inst
删掉,然后创建新的子组件。
所以,在 if
语句中,执行的代码如下:
1 | if (inst && inst.constructor===childComponent && childProps.key==inst.__key) { |
其中,设置了同步方法为 SYNC_RENDER
,也就是将子组件添加到渲染队列中,在本次 macrotask 中进行渲染: enqueueRender(component);
setComponentProps
的代码如下,大致做的工作为,在某个已有的组件中替换新的属性:
1 | export function setComponentProps(component, props, opts, context, mountAll) { |
如果子组件的类型发生了变化,或者变成了另一个同类型的对象,则删除原来的子组件,然后创建新的子组件:
1 | else { |
其中,创建新的组件使用的是 createComponent
方法:
1 | /** Create a component. Normalizes differences between PFC's and classful Components. */ |
最后,在创建完组件以后,调用 renderComponent
方法,对该新生成的子组件进行重新渲染。
如果新生成的子组件不存在,则使用使用 diff
算法来对进行处理:
1 | if (typeof childComponent==='function') { |
现在,我们要看看 Preact 的 diff
算法是怎么样的流程了。首先,我们来看看 diff
算法的主流程:
1 | /** Apply differences in a given vnode (and it's deep children) to a real DOM Node. |
首先,diff
算法接受的参数中,第一个是当前的 DOM 结构: dom
,第二个是将要成为的 DOM 结构。在 diff
算法中,使用 diffLevel
来控制算法是否要退出。在 diff
算法要退出的时候,会通过:
1 | if (!componentRoot) flushMounts(); |
来判断是否要调用所有组件的 componentDidMount
方法。
在 diff
算法中,计算得到不同组件的功能是通过 idiff
得到的:
1 | let ret = idiff(dom, vnode, context, mountAll, componentRoot); |
由于 idiff
很长。。恩,我们只能分布拆开来看其功能。
首先,对传入的新节点的类型进行判断:
1 | // empty values (null, undefined, booleans) render as empty Text nodes |
如果传入的是 null, undefined, booleans
,则渲染的结果是一个空的 Text Node。
如果传入的是 string, number
类型,则创建,或者更新节点为一个新的 Text Node。
1 | // Fast case: Strings & Numbers create/update Text nodes. |
这里会判断,需要被更新的节点,如果本身就是一个 Text Node,则更新其中的字符串即可。如果不是一个 Text Node,则创建一个新的 Text Node,然后替换该节点。
最后要进行的一步,如果有替换 DOM 元素的行为,则将原来的 DOM 元素删除 recollectNodeTree(dom, true)
:
1 | /** Recursively recycle (or just unmount) a node and its descendants. |
recollectNodeTree
的逻辑如上,如果这个 node 本身是一个 Component,将该组件删除,并且调用该组件的 componentDidMount()
,否则,调用 removeChildren
来删除该组件。removeChildren
方法的内容很简单,就是找到 node
的父元素,然后调用 removeChild
:
1 | export function removeNode(node) { |
如果传入的 vnode
既不是 null, undefined, booleans
,也不是 string, number
,而是一个 function
类型,说明 vnode
代表一个组件,则调用 buildComponentFromVNode
直接生成新的组件:
1 | // If the VNode represents a Component, perform a component diff: |
我们可以看一看 buildComponentFromVNode
:
1 | /** Apply the Component referenced by a VNode to the DOM. |
在这段代码中,我们会比较,如果 dom
是否和 vnode
是同样类型的组件,如果是,则将 vnode
的属性赋值给 dom
。如果不是,则将 dom
元素替换为新的 vnode
元素,然后将原来的组件回收。
下一步,判断 vnode
的类型是不是 SVG,然后针对 SVG 进行处理并返回新的节点。
如果 dom
不存在或者 dom
的类型与 vnode.nodeName
不同,则创建一个新的 DOM 元素,并且把之前 dom
元素中的子元素挂载到新的 DOM 元素中,然后回收 dom
元素,代码部分如下:
1 | // If there's no existing element or it's the wrong type, create a new one: |
然后针对以上种情况,对 vnode.children
调用 innerDiffNode
,将 vnode.chilren
中的元素添加到新创建的 DOM 元素中。然后调用 diffAttributes
将 vnode
的属性添加到新创建的 DOM 元素中。
1 | vchildren = vnode.children; |
我们来看一看 innerDiffNode
做了什么事情,innerDiffNode
这个方法中的内容很多,我们分成及部分来讲解其中的过程:
1 | /** Apply child and attribute changes between a VNode and a DOM Node to the DOM. |
首先,遍历 DOM 中的子节点,将所有的子节点分成有 key
的子节点和没有 key
的子节点:
1 | // Build up a map of keyed children and an Array of unkeyed children: |
在 innerDiffNode
算法中,它的思想是以最少的dom操作使得更改后的dom与虚拟dom相匹配,所以它会尝试重用 DOM 中已有的子节点,来减少 DOM 的操作次数:
1 | for (let i=0; i<vlen; i++) { |
最后,将 DOM 节点中没有使用到的 key
和 unkeyed
节点都回收。
1 | // remove unused keyed children: |
在创建完节点了以后,我们再来给创建的完整 DOM 元素中赋值 vnode
的属性,使用的方法是 diffAttributes
:
1 | // Apply attributes/props from VNode to the DOM Element: |
其中,diffAttributes
的工作为:
1 | function diffAttributes(dom, attrs, old) { |
我们可以看到,属性值的设置都是通过 setAccessor
函数来实现的,下面,我们来看看 setAccessor
做了什么,虽然函数代码有点长,但是逻辑并不复杂:
1 | /** Set a named attribute on the given Node, with special behavior for some names and event handlers. |
至此,所有关于 diff
算法的部分我们都分析完了。
关于 diff
算法,我自己也感觉了解的不是很透彻,如果有疑问的同学,可以参考这一篇文章 从Preact了解一个类React的框架是怎么实现的(二): 元素diff
虽然 diff
算法很长,但是我们的分析还差最后一点才结束,现在让我们回到 renderComponent
中。
前面,我们讨论了,在 renderComponent
中,如果 render
返回的是一个组件:typeof childComponent==='function'
,则创建该组件,并且对该组件的子组件调用 renderComponent
;如果返回的不是组件,则调用 diff
算法,得到新的 DOM 元素 base
。
无论是以上那种情况,得到的新的 DOM 元素 base
了以后,下面要做的就是将 base
添加到 DOM Tree 中,然后将不需要的组件 unmount
:
1 | if (initialBase && base!==initialBase && inst!==initialChildComponent) { |
最后,在组件发生变化后,调用组件的 componentDidUpdate
方法,并且如果在 setState
中添加的回调函数,则回调函数会被放在 _renderCallbacks
,结尾会触发所有的回调函数:
1 | else if (!skip) { |
至此,在调用 setState
后,Preact 所有的处理都到此为止了。
本人能力所限,不能达到面面俱到,但希望这篇文章能起到抛砖引玉的作用,如果不正确指出,欢迎指出和讨论~