Hyperapp 是最近热度颇高的一款迷你 JS 框架,其源码不到 400 行,压缩 gzip 后只有 1kB,却具备至关高的完成度,拿来实现简单的 web 应用也不在话下。总体实现上,Hyperapp 的思路与 React 比较相似,都是借助 Virtual DOM 来实现高效的 DOM 更新。在探究 Hyperapp 背后的实现原理以前,咱们先看一下如何使用它。html
注:本文基于 Hyperapp 1.2.5 版本。前端
官方的文档中给出了一个示例应用(在线 demo 点我),代码以下:node
import { h, app } from "hyperapp"
const state = {
count: 0
}
const actions = {
down: value => state => ({ count: state.count - value }),
up: value => state => ({ count: state.count + value })
}
const view = (state, actions) => (
<div> <h1>{state.count}</h1> <button onclick={() => actions.down(1)}>-</button> <button onclick={() => actions.up(1)}>+</button> </div>
)
app(state, actions, view, document.body)
复制代码
几点简单的说明帮助你快速上手 Hyperapp:react
首先,Hyperapp 对外只暴露两个函数:h
和 app
。其中 app
用于将应用挂载到 DOM 节点上,至关于启动函数。而 h
则用于处理 view,返回 Virtual DOM 节点。因为浏览器并不能理解上面示例中 view 函数使用的 JSX 语法,所以须要经过 Babel 等编译工具进行处理(React 党应该对这些比较熟悉)。安装 transform-react-jsx
插件后,在 .babel.rc
中指定该插件,同时将 pragma
设置为 h
:git
{
"plugins": [["transform-react-jsx", { "pragma": "h" }]]
}
复制代码
如此,通过 Babel 编译后,上面的 view
函数就变成了以下这样:github
const view = (state, actions) =>
h("div", {}, [
h("h1", {}, state.count),
h("button", { onclick: () => actions.down(1) }, "-"),
h("button", { onclick: () => actions.up(1) }, "+")
])
复制代码
咱们的 h
函数一顿操做后,返回的 Virtual DOM 节点的结构长这样:web
{
nodeName: "div",
attributes: {},
children: [
{
nodeName: "h1",
attributes: {},
children: [0]
},
{
nodeName: "button",
attributes: { ... },
children: ["-"]
},
{
nodeName: "button",
attributes: { ... },
children: ["+"]
}
]
}
复制代码
说白了 Virtual DOM 听起来高大上,实际上就是用 JavaScript 中的 Object 数据类型去描述一个DOM 节点,由于保存在内存中,因此更新修改很快,同时加上一些 diff 算法的优化,可以最大程度地下降 DOM 节点的渲染耗费。算法
固然,Hyperapp 也支持 @hyperapp/html, hyperx 等其余能够生成 Virtual DOM 的库,此处不表。json
回到源码上来,因为 Hyperapp 全部的操做都在 app
函数中完成,下面就来探究一下 app
函数都作了什么。该函数主流程至关简单,源码总计十来行,先贴在下面,后面慢慢分析:数组
export function app(state, actions, view, container) {
var map = [].map
var rootElement = (container && container.children[0]) || null
var oldNode = rootElement && recycleElement(rootElement)
var lifecycle = []
var skipRender
var isRecycling = true
var globalState = clone(state)
var wiredActions = wireStateToActions([], globalState, clone(actions))
scheduleRender()
return wiredActions
}
复制代码
首先咱们先从总体来看一下 Hyperapp 在调用 app 函数启动应用后的生命周期,以下图所示:
app
函数执行后,通过一系列准备动做后,会调用 scheduleRender
函数进行视图渲染。顾名思义,该函数是调度渲染的意思。咱们看一下源码:
function scheduleRender() {
if (!skipRender) {
skipRender = true
setTimeout(render)
}
}
复制代码
能够看到,实际执行渲染的操做交由 render
函数来处理,执行的时机由 setTimeout(function(){}, 0)
决定,也就是下一个 event loop 开始后,是异步进行的。而这里 skipRender
是一个锁变量,保证在每个 event loop 中 state
不管有多少次改变只会进行一次渲染。想象一下这样一个场景:咱们在一个循环中执行了 1000 次 actions
中的某个方法来改变 state
中的值,若是不进行以上的操做,那么视图会渲染 1000 次,至关消耗性能,而这是很是不合理的。实际上 Hyperapp 的处理也略显粗糙,在更为复杂的前端框架中,会有很是完备的方案,好比 Vue 的 $nextTick 实现就复杂许多,详情能够参考这篇文章——Vue nextTick 机制。
render
调用 resolveNode
以获取最新的 Virtual DOM 形式的节点,再交由 patch
函数进行新旧节点的对比而后更新视图,同时把新节点的值赋给旧节点,方便下次比较更新。除了在最后 patch
更新视图时会进行 DOM 操做,其余时候,节点都是以 Virtual DOM 形式保存于内存中,只要新旧节点的 diff 算法足够高效,就能保持较高的视图更新效率。
除了初始化时的渲染以外,每当 actions
中的方法修改了 state
中的数据时,也会触发渲染。固然,Hyperapp 并无去 “observe” state
,而是经过对 actions
中的方法进行包装实现了这个功能(这也是 Hyperapp 规定只有 actions
中的方法可以修改 state
中的数据的缘由)。
下面就来看一下 Hyperapp 如何对 actions
中的方法进行处理以使其在调用后可以触发 scheduleRender
的。app
函数执行初次渲染以前的准备工做里,最重要的操做就是处理 actions
中的方法。在研究其源码前,咱们先看一下 Hyperapp 对 actions
中的方法制定的规范,当 state
中无嵌套对象时,总结起来大体是如下几条:
state
中部分状态的 object。新的 state
将是原有的 state
与该返回值的浅合并(shallow merge)。例如:const state = {
name: 'chris',
age: 20
}
const actions = {
setAge: newAge => ({ age: newAge })
}
复制代码
state
和 actions
为参数的函数,该函数的返回值必须为“a partial state object”。注意此时不能将接受的 state
参数直接修改后返回。正确的示例以下:const actions = {
down: value => state => ({ count: state.count - value }),
up: value => state => ({ count: state.count + value })
}
复制代码
当 state
中有嵌套对象时,actions
中对应的属性值为一个 partial state object,其实本质上没有区别,看下面的示例应该就能理解:
const state = {
counter: {
count: 0
}
}
const actions = {
counter: {
down: value => state => ({ count: state.count - value }),
up: value => state => ({ count: state.count + value })
}
}
复制代码
如今咱们来看一下 Hyperapp 对 actions
中方法的处理:
/** * * @param {Array} path 储存 state 中每层的 key,用于获取和设置 partial state object * @param {Object} state * @param {Object} actions */
function wireStateToActions(path, state, actions) {
// 遍历 actions
for (var key in actions) {
typeof actions[key] === "function"
// actions 中属性值为函数时,从新封装
? (function(key, action) {
actions[key] = function(data) {
// 执行方法
var result = action(data)
/*返回值是函数时,传入 state 和 actions 再次执行之 获得 partial state object */
if (typeof result === "function") {
result = result(getPartialState(path, globalState), actions)
}
/* result 不是 Promise/null/undefined 意味着 result 返回的是 partial state object 同时 result 与当前的 globalState(保存在全局的 state 的副本)中的 partial state object 不一致时 调用 scheduleRender 从新渲染视图 */
if (
result &&
result !== (state = getPartialState(path, globalState)) &&
!result.then // !isPromise
) {
// globalState 当即更新
// 安排视图渲染
scheduleRender(
(globalState = setPartialState(
path,
clone(state, result),
globalState
))
)
}
return result
}
})(key, actions[key])
// 直接返回 partial state object
: wireStateToActions(
// 当 state 有嵌套时,规范要求 actions 中也有相同的嵌套层级
path.concat(key),
(state[key] = clone(state[key])),
(actions[key] = clone(actions[key]))
)
}
// 返回处理以后的全部函数
// 做为对外接口
return actions
}
复制代码
注释已经说的比较详细,总结一下就是 Hyperapp 把 actions
中的全部方法遍历了一遍,在其执行完对 state
中数据的“修改”后,调用 scheduleRender
从新渲染视图。这里之因此给“修改”打上引号,是由于实际上 actions
并无真的去修改 state
中数据的值,而是每次用一个新的 object 去替换了 state
。这里涉及到一个 “Immutability” 的概念,也就是不可变性。这种特性使得咱们能够像时光穿梭通常去调试代码(由于每一步操做的 state
都保存在内存中,相似快照通常)。这也是为何上面的代码中咱们能够直接用 ===
去比较两个 object 的缘由。
继续顺着生命周期看下去,在页面渲染开始前,Hyperapp 会将初始化时传入 app
函数的根节点以及 view
函数生成的节点所有处理为 Virtual DOM,其形式如文章开头第一节所示。在此基础上,Hyperapp 提供了 createElement
/updateElement
/removeElement
/removeChildren
/updateAttribute
等方法,用于处理从 Virtual DOM 到真实 DOM 节点的映射。
下面就是最关键的节点更新的部分了。能够说,diff 更新是决定类 React 框架性能最重要的部分。咱们来看 Hyperapp 是如何作的。新旧节点的 diff 和更新都由 patch
函数完成。其接受如下 4 个参数(实际为 5 个,第 5 个参数为 svg 相关,此处暂不讨论):parent
(当前层级根节点的父节点,DOM 节点)、element
(当前层级的根节点,DOM 节点,初始由 oldNode 映射生成)、oldNode
(Virtual DOM)、newNode
(Virtual DOM)。patch
函数根据新旧节点的不一样能够按照前后优先级进行如下四种操做:
===
判断)时:不进行任何操做,直接返回nodeName
判断)时: 调用 createElement
建立新节点,并插入到 parent
的子元素中。若是旧节点存在,调用 removeElement
删除之。element
的 nodeValue
值赋为 newNode
。根据 DOM Level 2 规范,除 text,comment,CDATA 和 attribute 节点以外的其余类型节点,其 nodeValue
均为 null
。而对于以上四种节点,直接更新其 nodeValue
值便可完成节点更新nodeName
相同但两者不是同一节点,区别于状况一): 逻辑上是先更新节点属性,而后进入 children 数组中递归调用 patch
函数进行更新。不过 Hyperapp 为了提升性能,为节点提供了 key
属性。拥有 key
属性的 Virtual DOM 将对应特定的 DOM 节点(每一个节点的 key
属性值须要保证在兄弟节点中中惟一 )。这样在更新时能够直接将其插入到新的位置,而不用低效率地删除再新建节点。下面的流程图说明了这里的策略:Hyperapp 是一个颇有意思的框架,除了以上分析的特色,借助 JSX 其还实现了组件化、组件懒加载、子组件插槽、节点生命周期钩子函数等高级特性。项目地址在此,你们能够自行查看学习。
本文首发于个人博客(点此查看),欢迎关注。