原文连接原文写于 2015-07-31,虽然时间比较久远,可是对于咱们理解虚拟 DOM 和 view 层之间的关系仍是有很积极的做用的。javascript
React 是 JavaScript 社区的新成员,尽管 JSX (在 JavaScript 中使用 HTML 语法)存在必定的争议,可是对于虚拟 DOM 人们有不同的见解。html
对于不熟悉的人来讲,虚拟 DOM 能够描述为某个时刻真实DOM的简单表示。其思想是:每次 UI 状态发生更改时,从新建立一个虚拟 DOM,而不是直接使用命令式的语句更新真实 DOM ,底层库将对应的更新映射到真实 DOM 上。前端
须要注意的是,更新操做并无替换整个 DOM 树(例如使用 innerHTML 从新设置 HTML 字符串),而是替换 DOM 节点中实际修改的部分(改变节点属性、添加子节点)。这里使用的是增量更新,经过比对新旧虚拟 DOM 来推断更新的部分,而后将更新的部分经过补丁的方式更新到真实 DOM 中。java
虚拟 DOM 由于高效的性能常常受到特别的关注。可是还有一项一样重要的特性,虚拟 DOM 能够把 UI 表示为状态函数的映射(PS. 也就是咱们常说的 UI = render(state)
),这也使得编写 web 应用有了新的形式。node
在本文中,咱们将研究虚拟 DOM 的概念如何引用到 web 应用中。咱们将从简单的例子开始,而后给出一个架构来编写基于 Virtual DOM 的应用。react
为此咱们将选择一个独立的 JavaScript 虚拟 DOM 库,由于咱们但愿依赖最小化。本文中,咱们将使用 snabbdom(paldepind/snabbdom),可是你也可使用其余相似的库,好比 Matt Esch 的 virtual-domwebpack
snabbdom 是一个模块化的库,因此,咱们须要使用一个打包工具,好比 webpack。git
首先,让咱们看看如何进行 snabbdom 的初始化。github
import snabbdom from 'snabbdom'; const patch = snabbdom.init([ // 指定模块初始化 patch 方法 require('snabbdom/modules/class'), // 切换 class require('snabbdom/modules/props'), // 设置 DOM 元素的属性 require('snabbdom/modules/style'), // 处理元素的 style ,支持动画 require('snabbdom/modules/eventlisteners'), // 事件处理 ]);
上面的代码中,咱们初始化了 snabbdom 模块并添加了一些扩展。在 snabbdom 中,切换 class、style还有 DOM 元素上的属性设置和事件绑定都是给不一样模块实现的。上面的实例,只使用了默认提供的模块。web
核心模块只暴露了一个 patch
方法,它由 init 方法返回。咱们使用它建立初始化的 DOM,以后也会使用它来进行 DOM 的更新。
下面是一个 Hello World 示例:
import h from 'snabbdom/h'; var vnode = h('div', {style: {fontWeight: 'bold'}}, 'Hello world'); patch(document.getElementById('placeholder'), vnode);
h
是一个建立虚拟 DOM 的辅助函数。咱们将在文章后面介绍具体用法,如今只须要该函数的 3 个输入参数:
div#id.class
。第一次调用的时候,patch 方法须要一个 DOM 占位符和一个初始的虚拟 DOM,而后它会根据虚拟 DOM 建立一个对应的真实 DO树。在随后的的调用中,咱们为它提供新旧两个虚拟 DOM,而后它经过 diff 算法比对这两个虚拟 DOM,并找出更新的部分对真实 DOM 进行必要的修改 ,使得真实的 DOM 树为最新的虚拟 DOM 的映射。
为了快速上手,我在 GitHub 上建立了一个仓库,其中包含了项目的必要内容。下面让咱们来克隆这个仓库(yelouafi/snabbdom-starter),而后运行 npm install
安装依赖。这个仓库使用 Browserify 做为打包工具,文件变动后使用 Watchify 自动从新构建,而且经过 Babel 将 ES6 的代码转成兼容性更好的 ES5。
下面运行以下代码:
npm run watch
这段代码将启动 watchify 模块,它会在 app 文件夹内,建立一个浏览器可以运行的包:build.js
。模块还将检测咱们的 js 代码是否发生改变,若是有修改,会自动的从新构建 build.js
。(若是你想手动构建,可使用:npm run build
)
在浏览器中打开 app/index.html
就能运行程序,这时候你会在屏幕上看到 “Hello World”。
这篇文中的全部案例都能在特定的分支上进行实现,我会在文中连接到每一个分支,同时 README.md 文件也包含了全部分支的连接。
本例的源代码在 dynamic-view branch
为了突出虚拟 DOM 动态化的优点,接下来会构建一个很简单的时钟。
首先修改 app/js/main.js
:
function view(currentDate) { return h('div', 'Current date ' + currentDate); } var oldVnode = document.getElementById('placeholder'); setInterval( () => { const newVnode = view(new Date()); oldVnode = patch(oldVnode, newVnode); }, 1000);
经过单独的函数 view
来生成虚拟 DOM,它接受一个状态(当前日期)做为输入。
该案例展现了虚拟 DOM 的经典使用方式,在不一样的时刻构造出新的虚拟 DOM,而后将新旧虚拟 DOM 进行对比,并更新到真实 DOM 上。案例中,咱们每秒都构造了一个虚拟 DOM,并用它来更新真实 DOM。
本例的源代码在 event-reactivity branch
下面的案例介绍了经过事件系统完成一个打招呼的应用程序:
function view(name) { return h('div', [ h('input', { props: { type: 'text', placeholder: 'Type your name' }, on : { input: update } }), h('hr'), h('div', 'Hello ' + name) ]); } var oldVnode = document.getElementById('placeholder'); function update(event) { const newVnode = view(event.target.value); oldVnode = patch(oldVnode, newVnode); } oldVnode = patch(oldVnode, view(''));
在 snabbdom 中,咱们使用 props 对象来设置元素的属性,props 模块会对 props 对象进行处理。相似地,咱们经过 on 对象进行元素的时间绑定,eventlistener 模块会对 on 对象进行处理。
上面的案例中,update 函数执行了与前面案例中 setInterval 相似的事情:从传入的事件对象中提取出 input 的值,构造出一个新的虚拟 DOM,而后调用 patch ,用新的虚拟 DOM 树更新真实 DOM。
使用独立的虚拟 DOM 库的好处是,咱们在构建本身的应用时,能够按照本身喜欢的方式来作。你可使用 MVC 的设计模式,可使用更现代化的数据流体系,好比 Flux。
在这篇文章中,我会介绍一种不太为人所知的架构模式,是我以前在 Elm(一种可编译成 JavaScript 的 函数式语言)中使用过的。Elm 的开发者称这种模式为 Elm Architecture,它的主要优势是容许咱们将整个应用编写为一组纯函数。
让咱们回顾一下上个案例的主流程:
上面的过程能够描述成一个循环。若是去掉实现的一些细节,咱们能够创建一个抽象的函数调用序列。
user
是用户交互的抽象,咱们获得的是函数调用的循环序列。注意,user
函数是异步的,不然这将是一个无限的死循环。
让咱们将上述过程转换为代码:
function main(initState, element, {view, update}) { const newVnode = view(initState, event => { const newState = update(initState, event); main(newState, newVnode, {view, update}); }); patch(oldVnode, newVnode); }
main
函数反映了上述的循环过程:给定一个初始状态(initState),一个 DOM 节点和一个顶层组件(view + update),main
经过当前的状态通过 view 函数构建出新的虚拟 DOM,而后经过补丁的方式更新到真实 DOM上。
传递给 view
函数的参数有两个:首先是当前状态,其次是事件处理的回调函数,对生成的视图中触发的事件进行处理。回调函数主要负责为应用程序构建一个新的状态,并使用新的状态重启 UI 循环。
新状态的构造委托给顶层组件的 update
函数,该函数是一个简单的纯函数:不管什么时候,给定当前状态和当前程序的输入(事件或行为),它都会为程序返回一个新的状态。
要注意的是,除了 patch 方法会有反作用,主函数内不会有任何改变状态行为发生。
main 函数有点相似于低级GUI框架的 main
事件循环,这里的重点是收回对 UI 事件分发流程的控制: 在实际状态下,DOM API经过采用观察者模式强制咱们进行事件驱动,可是咱们不想在这里使用观察者模式,下面就会讲到。
基于 Elm-architecture 的程序中,是由一个个模块或者说组件构成的。每一个组件都有两个基本函数:update
和view
,以及一个特定的数据结构:组件拥有的 model
以及更新该 model
实例的 actions
。
update
是一个纯函数,接受两个参数:组件拥有的 model
实例,表示当前的状态(state),以及一个 action
表示须要执行的更新操做。它将返回一个新的 model
实例。view
一样接受两个参数:当前 model
实例和一个事件通道,它能够经过多种形式传播数据,在咱们的案例中,将使用一个简单的回调函数。该函数返回一个新的虚拟 DOM,该虚拟 DOM 将会渲染成真实 DOM。如上所述,Elm architecture 摆脱了传统的由事件进行驱动观察者模式。相反该架构倾向于集中式的管理数据(好比 React/Flux),任何的事件行为都会有两种方式:
该架构的另外一个关键点,就是将程序须要的整个状态都保存在一个对象中。树中的每一个组件都负责将它们拥有的状态的一部分传递给子组件。
在咱们的案例中,咱们将使用与 Elm 网站相同的案例,由于它完美的展现了该模式。
本例的源代码在 counter-1 branch
咱们在 “counter.js” 中定义了 counter 组件:
const INC = Symbol('inc'); const DEC = Symbol('dec'); // model : Number function view(count, handler) { return h('div', [ h('button', { on : { click: handler.bind(null, {type: INC}) } }, '+'), h('button', { on : { click: handler.bind(null, {type: DEC}) } }, '-'), h('div', `Count : ${count}`), ]); } function update(count, action) { return action.type === INC ? count + 1 : action.type === DEC ? count - 1 : count; } export default { view, update, actions : { INC, DEC } }
counter 组件由如下属性组成:
Number
首先要注意的是,view/update 都是纯函数,除了输入以外,他们不依赖任何外部环境。计数器组件自己不包括任何状态或变量,它只会从给定的状态构造出固定的视图,以及经过给定的状态更新视图。因为其纯粹性,计数器组件能够轻松的插入任何提供依赖(state 和 action)环境。
其次须要注意 handler.bind(null, action)
表达式,每次点击按钮,事件监听器都会触发该函数。咱们将原始的用户事件转换为一个有意义的操做(递增或递减),使用了 ES6 的 Symbol 类型,比原始的字符串类型更好(避免了操做名称冲突的问题),稍后咱们还将看到更好的解决方案:使用 union 类型。
下面看看如何进行组件的测试,咱们使用了 “tape” 测试库:
import test from 'tape'; import { update, actions } from '../app/js/counter'; test('counter update function', (assert) => { var count = 10; count = update(count, {type: actions.INC}); assert.equal(count, 11); count = update(count, {type: actions.DEC}); assert.equal(count, 10); assert.end(); });
咱们能够直接使用 babel-node 来进行测试
babel-node test/counterTest.js
本例的源代码在 counter-2 branch
咱们将和 Elm 官方教程保持同步,增长计数器的数量,如今咱们会有2个计数器。此外,还有一个“重置”按钮,将两个计数器同时重置为“0”;
首先,咱们须要修改计数器组件,让该组件支持重置操做。为此,咱们将引入一个新函数 init
,其做用是为计数器构造一个新状态 (count)。
function init() { return 0; }
init
在不少状况下都很是有用。例如,使用来自服务器或本地存储的数据初始化状态。它经过 JavaScript 对象建立一个丰富的数据模型(例如,为一个 JavaScript 对象添加一些原型属性或方法)。
init
与 update
有一些区别:后者执行一个更新操做,而后从一个状态派生出新的状态;可是前者是使用一些输入值(好比:默认值、服务器数据等等)构造一个状态,输入值是可选的,并且彻底无论前一个状态是什么。
下面咱们将经过一些代码管理两个计数器,咱们在 towCounters.js
中实现咱们的代码。
首先,咱们须要定义模型相关的操做类型:
//{ first : counter.model, second : counter.model } const RESET = Symbol('reset'); const UPDATE_FIRST = Symbol('update first'); const UPDATE_SECOND = Symbol('update second');
该模型导出两个属性:first 和 second 分别保存两个计数器的状态。咱们定义了三个操做类型:第一个用来将计数器重置为 0,另外两个后面也会讲到。
组件经过 init 方法建立 state。
function init() { return { first: counter.init(), second: counter.init() }; }
view 函数负责展现这两个计数器,并为用户提供一个重置按钮。
function view(model, handler) { return h('div', [ h('button', { on : { click: handler.bind(null, {type: RESET}) } }, 'Reset'), h('hr'), counter.view(model.first, counterAction => handler({ type: UPDATE_FIRST, data: counterAction})), h('hr'), counter.view(model.second, counterAction => handler({ type: UPDATE_SECOND, data: counterAction})), ]); }
咱们给 view 方法传递了两个参数:
UPDATE_FIRST
封装在 action 中,当父类的 update 方法被调用时,咱们会将计数器须要的 action(存储在 data 属性中)转发到正确的计数器,并调用计数器的 update 方法。下面看看 update 函数的实现,并导出组件的全部属性。
function update(model, action) { return action.type === RESET ? { first : counter.init(), second: counter.init() } : action.type === UPDATE_FIRST ? {...model, first : counter.update(model.first, action.data) } : action.type === UPDATE_SECOND ? {...model, second : counter.update(model.second, action.data) } : model; } export default { view, init, update, actions : { UPDATE_FIRST, UPDATE_SECOND, RESET } }
update 函数处理3个操做:
RESET
操做会调用 init 将每一个计数器重置到默认状态。UPDATE_FIRST
和 UPDATE_SECOND
,会封装一个计数器须要 action。函数将封装好的 action 连同其 state 转发给相关的子计数器。 {...model, prop: val};
是 ES7 的对象扩展属性(如object .assign),它老是返回一个新的对象。咱们不修改参数中传递的 state ,而是始终返回一个相同属性的新 state 对象,确保更新函数是一个纯函数。
最后调用 main 方法,构造顶层组件:
main( twoCounters.init(), // the initial state document.getElementById('placeholder'), twoCounters );
“towCounters” 展现了经典的嵌套组件的使用模式:
本例的源代码在 counter-3 branch
让咱们继续来看 Elm 的教程,咱们将进一步扩展咱们的示例,能够管理任意数量的计数器列表。此外还提供新增计数器和删除计数器的按钮。
“counter” 组件代码保持不变,咱们将定义一个新组件 counterList
来管理计数器数组。
咱们先来定义模型,和一组关联操做。
/* model : { counters: [{id: Number, counter: counter.model}], nextID : Number } */ const ADD = Symbol('add'); const UPDATE = Symbol('update counter'); const REMOVE = Symbol('remove'); const RESET = Symbol('reset');
组件的模型包括了两个参数:
nextID
用来维护一个作自动递增的基数,每一个新添加的计数器都会使用 nextID + 1
来做为它的 ID。接下来,咱们定义 init
方法,它将构造一个默认的 state。
function init() { return { nextID: 1, counters: [] }; }
下面定义一个 view 函数。
function view(model, handler) { return h('div', [ h('button', { on : { click: handler.bind(null, {type: ADD}) } }, 'Add'), h('button', { on : { click: handler.bind(null, {type: RESET}) } }, 'Reset'), h('hr'), h('div.counter-list', model.counters.map(item => counterItemView(item, handler))) ]); }
视图提供了两个按钮来触发“添加”和“重置”操做。每一个计数器的都经过 counterItemView
函数来生成虚拟 DOM。
function counterItemView(item, handler) { return h('div.counter-item', {key: item.id }, [ h('button.remove', { on : { click: e => handler({ type: REMOVE, id: item.id}) } }, 'Remove'), counter.view(item.counter, a => handler({type: UPDATE, id: item.id, data: a})), h('hr') ]); }
该函数添加了一个 remove 按钮在视图中,并引用了计数器的 id 添加到 remove 的 action 中。
接下来看看 update 函数。
const resetAction = {type: counter.actions.INIT, data: 0}; function update(model, action) { return action.type === ADD ? addCounter(model) : action.type === RESET ? resetCounters(model) : action.type === REMOVE ? removeCounter(model, action.id) : action.type === UPDATE ? updateCounter(model, action.id, action.data) : model; } export default { view, update, actions : { ADD, RESET, REMOVE, UPDATE } }
该代码遵循上一个示例的相同的模式,使用冒泡阶段存储的 id 信息,将子节点的 actions 转发到顶层组件。下面是 update 的一个分支 “updateCounter” 。
function updateCounter(model, id, action) { return {...model, counters : model.counters.map(item => item.id !== id ? item : { ...item, counter : counter.update(item.counter, action) } ) }; }
上面这种模式能够应用于任何树结构嵌套的组件结构中,经过这种模式,咱们让整个应用程序的结构进行了统一。
在前面的示例中,咱们使用 ES6 的 Symbols 类型来表示操做类型。在视图内部,咱们建立了带有操做类型和附加信息(id,子节点的 action)的对象。
在真实的场景中,咱们必须将 action 的建立逻辑移动到一个单独的工厂函数中(相似于React/Flux中的 Action Creators)。在这篇文章的剩余部分,我将提出一个更符合 FP 精神的替代方案:union 类型。它是 FP 语言(如Haskell)中使用的 代数数据类型 的子集,您能够将它们看做具备更强大功能的枚举。
union类型能够为咱们提供如下特性:
union 类型在 JavaScript 中不是原生的,可是咱们可使用一个库来模拟它。在咱们的示例中,咱们使用 union-type (github/union-type) ,这是 snabbdom 做者编写的一个小而美的库。
先让咱们安装这个库:
npm install --save union-type
下面咱们来定义计数器的 actions:
import Type from 'union-type'; const Action = Type({ Increment : [], Decrement : [] });
Type
是该库导出的惟一函数。咱们使用它来定义 union 类型 Action
,其中包含两个可能的 actions。
返回的 Action
具备一组工厂函数,用于建立全部可能的操做。
function view(count, handler) { return h('div', [ h('button', { on : { click: handler.bind(null, Action.Increment()) } }, '+'), h('button', { on : { click: handler.bind(null, Action.Decrement()) } }, '-'), h('div', `Count : ${count}`), ]); }
在 view 建立递增和递减两种 action。update 函数展现了 uinon 如何对不一样类型的 action 进行模式匹配。
function update(count, action) { return Action.case({ Increment : () => count + 1, Decrement : () => count - 1 }, action); }
Action
具备一个 case
方法,该方法接受两个参数:
而后,case方法将提供的 action 与全部指定的变量名相匹配,并调用相应的处理函数。返回值是匹配的回调函数的返回值。
相似地,咱们看看如何定义 counterList
的 actions
const Action = Type({ Add : [], Remove : [Number], Reset : [], Update : [Number, counter.Action], });
Add
和Reset
是空数组(即它们没有任何字段),Remove
只有一个字段(计数器的 id)。最后,Update
操做有两个字段:计数器的 id 和计数器触发时的 action。
与以前同样,咱们在 update 函数中进行模式匹配。
function update(model, action) { return Action.case({ Add : () => addCounter(model), Remove : id => removeCounter(model, id), Reset : () => resetCounters(model), Update : (id, action) => updateCounter(model, id, action) }, action); }
注意,Remove
和 Update
都会接受参数。若是匹配成功,case
方法将从 case 实例中提取字段并将它们传递给对应的回调函数。
因此典型的模式是:
case
方法来匹配 union 类型的可能值。在这个仓库中(github/yelouafi/snabbdom-todomvc),使用本文提到的规范进行了 todoMVC 应用的实现。应用程序由2个模块组成:
task.js
定义一个呈现单个任务并更新其状态的组件todos.js
,它管理任务列表以及过滤和更新咱们已经了解了如何使用小而美的虚 拟DOM 库编写应用程序。当咱们不想被迫选择使用React框架(尤为是 class),或者当咱们须要一个小型 JavaScript 库时,这将很是有用。
Elm architecture 提供了一个简单的模式来编写复杂的虚拟DOM应用,具备纯函数的全部优势。这为咱们的代码提供了一个简单而规范的结构。使用标准的模式使得应用程序更容易维护,特别是在成员频繁更改的团队中。新成员能够快速掌握代码的整体架构。
因为彻底用纯函数实现的,我确信只要组件代码遵照其约定,更改组件就不会产生不良的反作用。
想查看更多前端技术相关文章能够逛逛个人博客:天然醒的博客