作者:mirone
链接:https://zhuanlan.zhihu.com/p/24451202
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

MVVM这两年在前端届掀起了一股热潮,火热的Vue和Angular带给了开发者无数的便利,本文将实现一个简单的MVVM,用200多行代码探索MVVM的秘密。您可以先点击本文的JS Bin查看效果,代码使用ES6,所以你可能需要转码。

什么是MVVM?

MVVM是一种程序架构设计。把它拆开来看应该是Model-View-ViewModel

Model

Model指的是数据层,是纯净的数据。对于前端来说,它往往是一个简单的对象。例如:

{
name: 'mirone',
age: 20,
friends: ['singleDogA', 'singleDogB'],
details: {
type: 'notSingleDog',
tags: ['fff', 'sox']
}
}

数据层是我们需要渲染后呈现给用户的数据,数据层本身是可变的。数据层不应该承担逻辑操作和计算的功能。

View

View指视图层,是直接呈现给用户的部分,简单的来说,对于前端就是HTML。例如上面的数据层,它对应的视图层可能是:

<div>
<p>
<b>name: </b>
<span>mirone</span>
</p>
<p>
<b>age: </b>
<span>20</span>
</p>
<ul>
<li>singleDogA</li>
<li>singleDogB</li>
</ul>
<div>
<p>notSingleDog</p>
<ul>
<li>fff</li>
<li>sox</li>
</ul>
</div>
</div>

当然视图层是可变的,你完全可以在其中随意添加元素。这不会改变数据层,只会改变视图层呈现数据的方式。视图层应该和数据层完全分离。

ViewModel

既然视图层应该和数据层分离,那么我们就需要设计一种结构,让它们建立起某种联系。当我们对Model进行修改的时候,ViewModel就会把修改自动同步到View层去。同样当我们修改View,Model同样被ViewModel自动修改。

可以看出,如何设计能够高效自动同步View与Model的ViewModel是整个MVVM框架的核心和难点。

MVVM的原理

差异

不同的框架对于MVVM的实现是不同的。

数据劫持

Vue的实现方式,对数据(Model)进行劫持,当数据发生变动时,数据会触发劫持时绑定的方法,对视图进行更新。

脏检查机制

Angular的实现方式,当发生了某种事件(例如输入),Angular会检查新的数据结构和之前的数据结构是否发生了变动,来决定是否更新视图。

发布订阅模式

Knockout的实现方式,实现了一个发布订阅器,解析时会在对应视图节点绑定订阅器,而在数据上绑定发布器,当修改数据时,就出发了发布器,视图收到后进行对应更新。

相同点

但是还是有很多相同点的,它们都有三个步骤:

  • 解析模版

  • 解析数据

  • 绑定模版与数据

解析模版

何谓模版?我们可以看一下主流MVVM的模版:

<!-- Vue -->
<div id="mobile-list">
<h1 v-text="title"></h1>
<ul>
<li v-for="item in brands">
<b v-text="item.name"></b>
<span v-show="showRank">Rank: {{item.rank}}</span>
</li>
</ul>
</div>
<!-- Angular -->
<ul>
<li ng-repeat="phone in phones">
{{phone.name}}
<p>{{phone.snippet}}</p>
</li>
</ul>
<!-- Knockout -->
<tbody data-bind="foreach: seats">
<tr>
<td data-bind="text: name"></td>
<td data-bind="text: meal().mealName"></td>
<td data-bind="text: meal().price"></td>
</tr>
</tbody>

可以看到它们都定义了自己的模版关键字,这一模块的作用就是根据这些关键字解析模版,将模版对应到期望的数据结构。

解析数据

Model中的数据经过劫持或绑定发布器来解析。数据解析器的编写要考虑VM的实现方式,但是无论如何解析数据只要做好一件事:定义数据变动时要通知的对象。解析数据时应保证数据解析后的一致性,对于每种数据解析后暴露的接口应该保持一致。

绑定模版与数据

这一部分定义了数据结构以何种方式和模版进行绑定,就是传说中的“双向绑定”。绑定之后我们直接对数据进行操作时,应用就能自动更新视图了。数据和模版往往是多对多的关系,而且不同的模版更新数据的方式往往不同。例如有的是改变标签的文本节点,有的是改变标签的className。

动手实现MVVM

经过一番分析,来动手实现MVVM吧。

期望效果

对于我的MVVM,我希望对应一个数据结构:

let data = {
title: 'todo list',
user: 'mirone',
todos: [
{
creator: 'mirone',
content: 'write mvvm'
done: 'undone',
date: '2016-11-17',
members: [
{
name: 'kaito'
}
]
}
]
}

我可以对应的编写模版:

<div id="root">
<h1 data-model="title"></h1>
<div>
<div data-model="user"></div>
<ul data-list="todos">
<li data-list-item="todos">
<p data-class="todos:done" data-model="todos:creator"></p>
<p data-model="todos:date"></p>
<p data-model="todos:content"></p>
<ul data-list="todos:members">
<li data-list-item="todos:members">
<span data-model="todos:members:name"></span>
</li>
</ul>
</li>
</ul>
</div>
</div>

然后通过调用:

new Parser('#root', data)

就可以完成mvvm的绑定,之后可以直接操作data对象来对View进行更改。

解析模版

模版的解析其实是一个树的遍历过程。

遍历

众所周知,DOM是一个树状结构,这也是为什么它被称为“DOM树”。对于树的遍历,只要递归,便能很轻松的完成一个深度优先遍历,请看代码:

function scan(node) {
console.log(node)
for(let i = 0; i < node.children.length; i++) {
const _thisNode = node.children[i]
console.log(_thisNode)
if(_thisNode.children.length) {
scan(_thisNode)
}
}
}

这个函数遍历了一个DOM节点,依次打印遍历得到的节点。

遍历不同结构

知道了如何遍历一个DOM树,那么我们如何获取需要分析的DOM树?根据之前的构想,我们需要这么几种标识:

  • data-model——用于将DOM的文本节点替换为制定内容

  • data-class——用于将 DOM的className替换为制定内容

  • data-list——用于标识接下来将出现一个列表,列表为制定结构

  • data-list-item——用于标识列表项的内部结构

  • data-event——用于为DOM节点绑定指定事件

简单的归类一下:data-model、data-class和data-event应该是一类,它们都只影响当前节点;而data-list和data-item作为列表应该要单独考虑。那么我们可以这样遍历:

 function scan(node) {
if(!node.getAttribute('data-list')) {
for(let i = 0; i < node.children.length; i++) {
const _thisNode = node.children[i]
parseModel(node)
parseClass(node)
parseEvent(node)
if(_thisNode.children.length) {
scan(_thisNode)
}
}
} else {
parseList(node)
}
}
function parseModel(node) {
//TODO:解析Model节点
}
function parseClass(node) {
//TODO:解析className
}
function parseEvent(node) {
//TODO:解析事件
}
function parseList(node) {
//TODO: 解析列表
}

这样我们就搭好了遍历器的大概框架

不同结构的处理方法

parseModel,parseClass和parseEvent的处理方式比较相似,唯一值得注意的就是对于嵌套元素的处理,回忆一下我们的模版设计:

<!--遇到嵌套部分-->
<div data-model="todos:date"></div>

这里的todos:date其实大大方便了我们解析模版,因为它展示了当前数据在Model结构中的位置。

//event要有一个eventList,大概结构为:
const eventList = {
typeWriter: {
type: 'input', //事件的种类
fn: function() {
//事件的处理函数,函数的this代表函数绑定的DOM节点
}
}
}
function parseEvent(node) {
if(node.getAttribute('data-event')) {
const eventName = node.getAttribute('data-event')
node.addEventListener(eventList[eventName].type, eventList[eventName].fn.bind(node))
}
}
//根据在模版中的位置解析模版,这里的Path是一个数组,代表了当前数据在Model中的位置
function parseData(str, node) {
const _list = str.split(':')
let _data,
_path
let p = []
_list.forEach((key, index) => {
if(index === 0) {
_data = data[key]
p.push(key)
} else {
_path = node.path[index-1]
p.push(_path)
_data = _data[_path][key]
p.push(key)
}
})
return {
path: p,
data: _data
}
}
function parseModel(node) {
if(node.getAttribute('data-model')) {
const modelName = node.getAttribute('data-model')
const _data = parseData(modelName, node)
if(node.tagName === 'INPUT') {
node.value = _data.data
} else {
node.innerText = _data.data
}
}
}
function parseClass(node) {
if(node.getAttribute('data-class')) {
const className = node.getAttribute('data-class')
const _data = parseData(className, node)
if(!node.classList.contains(_data.data)) {
node.classList.add(_data.data)
}
}
}

接下来解析列表,我们遇到列表时,应该先递归找出列表项的结构

 parseListItem(node) {
let target
!function getItem(node) {
for(let i = 0; i < node.children.length; i++) {
const _thisNode = node.children[i]
if(node.path) {
_thisNode.path = node.path.slice()
}
parseEvent(_thisNode)
parseClass(_thisNode)
parseModel(_thisNode)
if(_thisNode.getAttribute('data-list-item')) {
target = _thisNode
} else {
getItem(_thisNode)
}
}
}(node)
return target
}

之后在用这个列表项来按需拷贝出一定数量的列表项,并填充数据

function parseList(node) {
const _item = parseListItem(node)
const _list = node.getAttribute('data-list')
const _listData = parseData(_list, node)
_listData.data.forEach((_dataItem, index) => {
const _copyItem = _item.cloneNode(true)
if(node.path) {
_copyItem.path = node.path.slice()
}
if(!_copyItem.path) {
_copyItem.path = []
}
_copyItem.path.push(index)
scan(_copyItem)
node.insertBefore(_copyItem, _item)
})
node.removeChild(_item)
}

这样我们就完成了模版的渲染,scan函数会扫描模版对模版进行渲染

解析数据

解析了模版之后,我们就要研究如何进行数据解析了,这里我采用劫持数据的方法来进行。

普通对象的劫持

如何劫持数据?一般对数据的劫持都是通过Object.defineProperty方法进行的,先看一个小例子:

 var obj = {
name: 'mi'
}
function observe(obj, key) {
let old = obj[key]
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function() {
return old
},
set: function(now) {
if(now !== old) {
console.log(`${old} ---> ${now}`)
old = now
}
}
})
}
observe(obj, 'name')
obj.name = 'mirone'
//输出结果:
//"mi ---> mirone"

这样我们就通过object.defineProperty进行了数据劫持,如果我们想自定义劫持数据时发生的操作,只要添加一个回调函数参数即可:

function observer(obj, k, callback) {
let old = obj[k]
Object.defineProperty(obj, k, {
enumerable: true,
configurable: true,
get: function() {
return old
},
set: function(now) {
if(now !== old) {
callback(old, now)
}
old = now
}
})
}

嵌套对象的劫持

对于对象中的对象,我么还需要多进行一个步骤,使用递归来劫持对象中的对象:

//实现一个observeAllKey函数,劫持该对象的所有属性
function observeAllKey(obj, callback) {
Object.keys(obj).forEach(function(key){
observer(obj, key, callback)
})
}
function observer(obj, k, callback) {
let old = obj[k]
if (old.toString() === '[object Object]') {
observeAllKey(old, callback)
} else {
//...同前文,省略
}
}

对象中数组的劫持

对于对象中的数组,我们使用重写数组的prototype的方法来劫持它

function observeArray(arr, callback) {
const oam = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
const arrayProto = Array.prototype
const hackProto = Object.create(Array.prototype)
oam.forEach(function(method){
Object.defineProperty(hackProto, method, {
writable: true,
enumerable: true,
configurable: true,
value: function(...arg) {
let me = this
let old = arr.slice()
let now = arrayProto[method].call(me, ...arg)
callback(old, me, ...arg)
return now
},
})
})
arr.__proto__ = hackProto
}

写完劫持数组的函数后,将它添加进主函数:

function observer(obj, k, callback) {
let old = obj[k]
if(Object.prototype.toString.call(old) === '[object Array]') {
observeArray(old, callback)
} else if (old.toString() === '[object Object]') {
observeAllKey(old, callback)
} else {
//...
}
}

处理路径参数

之前我们所有的方法都是面对单个key值的,回想一下我们的模版,有很多例如todos:todo:member这样的路径,我们应该允许传入一个路径数组,根据路径数组来监听指定的对象数据

function observePath(obj, path, callback) {
let _path = obj
let _key
path.forEach((p, index) => {
if(parseInt(p) === p) {
p = parseInt(p)
}
if(index < path.length - 1) {
_path = _path[p]
} else {
_key = p
}
})
observer(_path, _key, callback)
}

之后再将它添加进主函数:

function observer(obj, k, callback) {
if(Object.prototype.toString.call(k) === '[object Array]') {
observePath(obj, k, callback)
} else {
let old = obj[k]
if(Object.prototype.toString.call(old) === '[object Array]') {
observeArray(old, callback)
} else if (old.toString() === '[object Object]') {
observeAllKey(old, callback)
} else {
//...
}
}
}

这样,我们就完成了监听函数。

绑定模版与数据

现在,我们要在解析过程中添加对数据的监视了,还记得之前的parse系列函数吗?

function parseModel(node) {
if(node.getAttribute('data-model')) {
//...之前逻辑不变
observer(data, _data.path, function(old, now) {
if(node.tagName === 'INPUT') {
node.value = now
} else {
node.innerText = now
}
//添加console便于调试
console.log(`${old} ---> ${now}`)
})
}
}
function parseClass(node) {
if(node.getAttribute('data-class')) {
//...
observer(data, _data.path, function(old, now) {
node.classList.remove(old)
node.classList.add(now)
console.log(`${old} ---> ${now}`)
})
}
}
//当列表发生变化时,为了简单直接重新渲染了当前列表
function parseList(node) {
//...
observer(data, _listData.path, () => {
while(node.firstChild) {
node.removeChild(node.firstChild)
}
const _listData = parseData(_list, node)
_listData.data.forEach((_dataItem, index) => {
node.appendChild(_item)
const _copyItem = _item.cloneNode(true)
if(node.path) {
_copyItem.path = node.path.slice()
}
if(!_copyItem.path) {
_copyItem.path = []
}
_copyItem.path.push(index)
scan(_copyItem)
node.insertBefore(_copyItem, _item)
})
node.removeChild(_item)
})
}

至此我们就基本完成了一个简单的MVVM,之后我进行了一点细微的细节优化,源码放在我的Gist上。各位也可以去本教程的JSBin查看效果。水平有限,欢迎吐槽。

感谢您的阅读,如果有所帮助,请点个赞吧。

230行实现一个简单的MVVM的更多相关文章

  1. 如何实现一个简单的MVVM框架

    接触过web开发的同学想必都接触过MVVM,业界著名的MVVM框架就有AngelaJS.今天闲来无事,决定自己实现一个简单的MVVM框架玩一玩.所谓简单,就是仅仅实现一个骨架,仅表其意,不摹其形. 分 ...

  2. 一个简单的 MVVM 实现

    简介 一个简单的带有双向绑定的 MVVM 实现. 例子 使用 新建一个 ViewModel 对象, 参数分别为 DOM 元素以及绑定的数据即可. 指令 本 MVVM 的指令使用 data 数据, 即 ...

  3. 撸一个简单的MVVM例子

    我个人以为mvvm框架里面最重要的一点就是VM这部分,它要与Model层建立联系,将Model层转换成可以被View层识别的数据结构:其次也要同View建立联系,将数据及时更新到View层上,并且响应 ...

  4. JavaScript 实现一个简单的MVVM前端框架(ES6语法)

    前言 随着前端各大框架的崛起,为我们平时的开发带来了相当的便利,我们不能一直停留在应用层面,今天就自己动手实现一个乞丐版的MVVM小框架 完整代码github地址 效果 html代码 <div ...

  5. 基于vue实现一个简单的MVVM框架(源码分析)

    不知不觉接触前端的时间已经过去半年了,越来越发觉对知识的学习不应该只停留在会用的层面,这在我学jQuery的一段时间后便有这样的体会. 虽然jQuery只是一个JS的代码库,只要会一些JS的基本操作学 ...

  6. 一个简单的MVVM雏形

    这是@尚春实现的MVVM,使用定时器轮询,只支持{{}}与input.value的修改. 这只能算是一个玩具,真正的MVVM需要有更复杂的扫描机制,JS解析器,双向绑定链什么的. <!DOCTY ...

  7. 用js实现一个简单的mvvm

    这里利用的object.defineproperty() 方法; <input     id='input'><p id='p'><p/>js: const dat ...

  8. 输出多行字符的一个简单JAVA小程序

    public class JAVA { public static void main(String[] args) { System.out.println("-------------- ...

  9. MVVM之旅(1)创建一个最简单的MVVM程序

    这是MVVM之旅系列文章的第一篇,许多文章和书喜欢在开篇介绍某种技术的诞生背景和意义,但是我觉得对于程序员来说,一个能直接运行起来的程序或许能够更直观的让他们了解这种技术.在这篇文章里,我将带领大家一 ...

随机推荐

  1. Spring mvc 中使用ftl引用共通文件出错 FreeMarker template error: Error reading included file "/WEB-INF/ftl/common/errormessage.ftl"

    初次接触spring mvc,想做一个小的练习项目,结果在ftl文件中引用其它的共通ftl文件时出错.

  2. 【九度OJ】题目1061:成绩排序

    题目描述: 有N个学生的数据,将学生数据按成绩高低排序,如果成绩相同则按姓名字符的字母序排序,如果姓名的字母序也相同则按照学生的年龄排序,并输出N个学生排序后的信息. 输入: 测试数据有多组,每组输入 ...

  3. gulp 配置自动化前端开发

    有的人说,grunt已经廉颇老矣,尚能饭否.gulp已经成为了未来的趋势,或许将撼动grunt的地位. 那么就得看看gulp到底优势在哪里,在我最近的使用中发现,我的到了一个结论:“grunt廉颇老矣 ...

  4. About MTU,TCP-MSS (转)

    MSS是Maxitum Segment Size 最大分段大小的缩写,意为TCP数据包每次能够传输的最大数据分段,是TCP协议里面的一个概念.MSS值所表示的是TCP报文的净载荷数据大小.通过设置其大 ...

  5. HDU 1051 Wooden Sticks

    题意: 有 n 根木棒,长度和质量都已经知道,需要一个机器一根一根地处理这些木棒. 该机器在加工过程中需要一定的准备时间,是用于清洗机器,调整工具和模板的. 机器需要的准备时间如下: 1.第一根需要1 ...

  6. HttpServletRequest的Attribute和Parameter区别

    HttpServletRequest类既有getAttribute()方法,也由getParameter()方法,这两个方法有以下的组件通过getParameter()方法来获得请求参数,例如假定we ...

  7. Cannot refer to an instance field pageTitle while explicitly invoking a cons

    当下面这样时在第7行会提示:Cannot refer to an instance field pageTitle while explicitly invoking a cons public cl ...

  8. JAVA学习博客---2015-7

    @Updata 2015.7.17  开始熟悉API.WPS首字母自动大写,有的没有加#编号的,其实方法首字母不是大写例如Char charAt 实际上是char charAt.当然骆驼写法charA ...

  9. fallacies of distributed computing

    The network is reliable. Latency is zero. Bandwidth is infinite. The network is secure. Topology doe ...

  10. 你以为的ASP.NET文件上传大小限制是你以为的吗

    我们以为的文件大小限制 我们大家都知道ASP.NET为我们提供了文件上传服务器控件FileUpload,默认情况下可上传的最大文件为4M,如果要改变可上传文件大小限制,那么我们可以在web.confi ...