本文会带大家手动实现一个双向绑定过程(仅仅涵盖一些简单的指令解析,如: v-text
, v-model
,插值),当然借鉴的是Vue1的源码,相信大家在阅读完本文后对Vue1会有一个更好的理解, 源代码 放到了github,由于本人水平有限,理解不到位的地方还请大家指出。
MVVM
使开发可以更加关注于数据,减少了很大的工作量,也使代码可读性,可维护性更高, MVVM
核心的思想就是视图是状态的函数: View = ViewModel(Model) ,所以当Model发生改变时,ViewModel会来操作View来怎么做,而非是自己写代码来做。无论是双向绑定还是单向绑定,都是符合 MVVM
思想的。Vue提倡的是双向绑定,也就是允许View到Model的变化,其实这个场景出现在的也就是表单操作上, 看个例子 ,例子中分别利用了Vue和React实现了一下表单 value
变化,影响页面与其相关的 dom
节点发生变化, 可以发现的是双向绑定的Vue是 input
的 value
发生变化则 h1
的 innerText
就发生了变化,变化是由View->Model,而提倡单向数据流的 React
需要手动监听事件,事件触发后,更改Model的值,从而使 input
的 value
发生了变化。看了Vue的源码后不难发现Vue的双向绑定的实现也就是在表单元素上添加了 input
事件,可以说双向绑定是单向绑定的一个语法糖。
上图是一个大体的流程,下面按照流程来实现下:
利用 observer
对 data
进行了监听,并且提供订阅某个数据项的变化的能力
这点的实现,需要借助的是 Object.defineProperty()
来为对象的属性绑定 get/set
特性(由于利用了 Object.defineProperty()
,所以Vue不支持ie8), observer
需要将 data
的所有属性都绑定 get/set
,很容易想到的就是利用递归来实现,具体代码就不贴出,请参见 这里 。
利用 Compile
对模板进行解析
这点实现的是将我们的模板转化为 html
,过程中会将数据与View中的节点相关联起来,最终会将编译好的 html
页面替换到页面上。首先来看解析,首先从根节点开始,根据不同的节点类型采用不同的解析方式:
function compileNode(node, vm) { const type = node.nodeType; if (type === 1 && !isScript(node)) { compileElement(node, vm); } else if (type === 3 && node.data.trim()) { compileTextNode(node, vm); } else { return null; } }
与数据不相关不用操作
含有插值,需要与数据进行关联
{{}}
文本插值
{{{}}}
纯 html
插值
利用下面正就可以将插值找出:
//{/{/{(.*?)/}/}/}|/{/{(.*?)/}/}/g
采用下面函数来对文本节点的内容解析:
function parseText(node) { var text = node.wholeText; if (!tagRE.test(text)) { return void 0; } const tokens = []; var lastIndex = tagRE.lastIndex = 0, match, index, html, value; while (match = tagRE.exec(text)) { index = match.index; if (index > lastIndex) { tokens.push({ value: text.slice(lastIndex, index) }) } html = htmlRE.test(match[0]); value = html ? match[1] : match[2]; tokens.push({ value: value, tag: true, html: html }); lastIndex = index + match[0].length; } if (lastIndex < text.length) { tokens.push({ value: text.slice(lastIndex) }) } return tokens; }
返回了 tokens
,里面存储了每一个块内容,一个插值or一个普通文本, tag
来标记是否为插值, html
来标记是否为纯 html
插值。遍历返回的 tokens
,根据不同的类型,来采用不同的方式将其添加到其父节点上:
function compileTextNode(node, vm) { const tokens = parseText(node); if (tokens == null) return void 0; var frag = document.createDocumentFragment(); tokens.forEach(token => { var el; if (token.tag) { if (token.html) { el = document.createDocumentFragment(); el.$parent = node.parentNode; el.$oneTime = true; dirCollection["html"](el, vm, token.value); } else { el = document.createTextNode(" "); dirCollection["text"](el, vm, token.value); } } else { el = document.createTextNode(token.value); } el && frag.appendChild(el); }); return replace(node, frag); }
dirCollection
是一个指令集合,也就是决定了如何初始化以及如何更新该节点。对于 nodeType
为 1
的节点来说,指令全部存储在其属性中,遍历属性,假若指令中含有 v-html,v-model,v-text
,则停止遍历其子树,直接将调用相应指令即可,否则,则需要遍历其子节点,对其子节点应用 compileNode
进行解析:
function compileNodeList(nodes, vm) { for (let val of nodes) { compileNode(val, vm); } } function compileElement(node, vm) { var flag = false; const attrs = Array.prototype.slice.call(node.attributes); attrs.forEach((val) => { const name = val.name, value = val.value; if (dirRE.test(name)) { var dir; // 事件指令 if ( (dir = name.match(eventRE)) && (dir = dir[1]) ) { dirCollection["eventDir"](node, dir, vm, value); } else { dir = name.match(dirRE)[1]; dirCollection[dir](node, vm, value); } // 指令中为v-html or v-text or v-model终止递归 flag = flag || name === vhtml || name === vtext; node.removeAttribute(name); } }); const childs = node.childNodes; if (!flag && childs && childs.length) { compileNodeList(childs, vm); } }
在 dirCollections
中还会做的就是将数据与View的 dom
节点相关联,利用的就是 Dep
与 Watcher
,页面上每一个与数据相关联的节点都含有一个 Watcher
,当数据发生变化是 Watcher
用于计算,是否需要更新该节点;数据的每一个属性都有一个 Dep
,当该属性发生变化时, Dep
会通知与该数据相关联的 Watcher
来进行计算是否需要更新对应页面。 Dep代码 , Watcher代码 。
异步更新队列,是一个优化,将更新 dom
的操作变为异步的,放到下一个事件循环来做,这样做可以减少不必要的 dom
更新,看下面情况:
vm.value++; vm.value++; vm.value++;
三次数据改变,假若同步更新的话,则每次数据改变会立即更新 dom
,而异步更新的话,可以先将更新推入一个队列中,由于是异步,也可以保证每一个 Watcher
只被推入到一次,这样就避免了不必要的更新,异步更新主要利用的是 nextTick
,这个函数会优先使用 Promise
,不兼容则利用 MutationObserver
,再不兼容的话会利用 setTimeout
。
看过了Vue的源码不得不感叹Vue的优美,而Vue2又增加了虚拟dom,这样就可以做到服务端渲染,给了我们更多的可能!
这篇博客最好配合着源码来看,关于源码 欢迎star