前言
当初前端面试Vue中都会问到响应式原理以及如何实现的,如果你还只是简略答复通过Object.defineProperty()来劫持属性可能曾经不够了。
本篇文章通过学习文档及视频教程实现手写
一个繁难的Vue源码实现数据双向绑定,解析指令等。
几种实现双向绑定的做法
目前几种支流的mvc(vm)框架都实现了单向数据绑定,而我所了解的双向数据绑定无非就是在单向绑定的根底上给可输出的元素(input, textare等)增加了change(input)事件,来动静批改model和view,并没有多浅近,所以无需太过介怀是实现的单向或双向绑定。
实现数据绑定的做法有大抵如下几种:
发布者-订阅者模式(backbone.js)
脏值查看(angular.js)
数据劫持(Vue.js)
- 发布者-订阅者模式
个别是通过sub, pub的形式来实现数据和试图的绑定坚听,更细数据办法通常做法是vm.set('property', value)
这种形式当初毕竟太low来,咱们更心愿通过vm.property = value这种形式更新数据,同时自动更新视图,于是有来上面两种形式。
- 脏值查看
angular.js是通过脏值检测的形式比照数据是否有变更,来决定是否更新视图,最简略的形式就是通过setInterval()定时轮询检测数据变动,当然Google不会这么low,angular只有在制订的事件触发时进入脏值检测,大抵如下
* DOM事件,臂如用户输出文本,点击按钮等(ng-click)* XHR响应事件($http)* 浏览器location变更事件($location)* Timer事件($timeout, $interval)* 执行$diaest()或¥apply()
- 数据劫持
Vue.js则是通过数据劫持联合发布者-订阅者模式的形式,通过Object.defineProperty()
来劫持各个属性的setter
,getter
,在数据变动时公布音讯给订阅者,触发相应的监听回调。
vue全家桶视频解说:进入学习
Vue源码实现
index.html
<!DOCTYPE html><html> <head> <meta charset="utf-8" /> <title></title> <script type="text/javascript" src="./compile.js"></script> <script type="text/javascript" src="./observe.js"></script> <script type="text/javascript" src="./myvue.js"></script> </head> <body> <div id="app"> <h2>{{person.name}} -- {{person.age}}</h2> <h3>{{person.sex}}</h3> <ul> <li>1</li> <li>2</li> <li>3</li> </ul> <div v-text="msg"></div> <div>{{msg}}</div> <div v-text="person.name"></div> <div v-html="htmlStr"></div> <input type="text" v-model="msg" /> <button type="button" v-on:click="btnClick">v-on:事件</button> <button type="button" @click="btnClick">@事件</button> </div> <script type="text/javascript"> let vm = new Myvue({ el: '#app', data: { person: { name: '只会番茄炒蛋', age: 18, sex: '男' }, msg: '学习MVVM实现原理', htmlStr: '<h1>我是html指令渲染的</h1>' }, methods: { btnClick() { console.log(this.msg) } } }) </script> </body></html>
第一步 - 实现一个指令解析器(Compile)
compile次要做的事件是解析模板指令,将模板中的变量替换成数据,而后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,增加监听数据的订阅者,一旦数据有变动,收到告诉,更新视图
myvue.js
// 工具类依据指令执行对应办法const compileUtils = { /* * node 以后元素节点 * expr 以后指令的value * vm 以后Myvue实例, * eventName 以后指令事件名称 */ // 因为指令绑定的属性有可能是原始类型,也有可能是援用类型, 因而要取到最终渲染的值 getValue(expr, vm) { // reduce() 办法对数组中的每个元素执行一个由您提供的reducer函数(升序执行),将其后果汇总为单个返回值。 return expr.split('.').reduce((data, currentVal) => { return data[currentVal] }, vm.$data) }, // input双向数据绑定 setValue(expr, vm, inputVal) { // reduce() 办法对数组中的每个元素执行一个由您提供的reducer函数(升序执行),将其后果汇总为单个返回值。 return expr.split('.').reduce((data, currentVal) => { // 将以后扭转的值赋值 data[currentVal] = inputVal console.log(data); }, vm.$data) }, // 解决{{person.name}}--{{person.age}}这种格局的数据,不更新值的时候会全副替换了 getContentVal(expr, vm) { return expr.replace(/\{\{(.+?)\}\}/g, (...args) => { // 获取{{}}中的属性 return this.getValue(args[1], vm) }) }, // 这里简略就封装了几个指令办法 text(node, expr, vm) { let value; // 解决{{}}的格局 if (expr.indexOf('{{') !== -1) { value = expr.replace(/\{\{(.+?)\}\}/g, (...args) => { // 绑定观察者 new Watcher(vm, args[1], (newValue) => { // 解决{{person.name}}--{{person.age}}这种格局的数据,不然更新值的时候会全副替换了 this.upDater.textUpDater(node, this.getContentVal(expr, vm)) }) // 获取{{}}中的属性 return this.getValue(args[1], vm) }) } else { new Watcher(vm, expr, (newValue) => { this.upDater.textUpDater(node, newValue) }) // 获取以后要节点要更新展现的值 value = this.getValue(expr, vm) } // 更新的工具类 this.upDater.textUpDater(node, value) }, html(node, expr, vm) { const value = this.getValue(expr, vm) // 绑定观察者 new Watcher(vm, expr, (newValue) => { this.upDater.htmlUpDater(node, newValue) }) // 更新的工具类 this.upDater.htmlUpDater(node, value) }, model(node, expr, vm) { const value = this.getValue(expr, vm) // 绑定观察者 new Watcher(vm, expr, (newValue) => { this.upDater.modelUpDater(node, newValue) }) node.addEventListener('input', (e) => { // 设置值 this.setValue(expr, vm, e.target.value) }) // 更新的工具类 this.upDater.modelUpDater(node, value) }, on(node, expr, vm, eventName) { // 获取以后指令对应的办法 const fn = vm.$options.methods && vm.$options.methods[expr] // console.log(fn); node.addEventListener(eventName, fn.bind(vm), false) }, // 更新的工具类 upDater: { // v-text指令的更新函数 textUpDater(node, value) { node.textContent = value }, // v-html指令的更新函数 htmlUpDater(node, value) { node.innerHTML = value }, // v-model指令的更新函数 modelUpDater(node, value) { node.value = value } }}// Myvueclass Myvue { constructor(options) { this.$el = options.el; this.$data = options.data; this.$options = options; if (this.$el) { // 1.实现一个数据观察者 new Observe(this.$data) // 2.实现一个指令解析器 new Compile(this.$el, this) // 3.实现this代理, 拜访数据能够间接通过this拜访 this.proxyData(this.$data) } } proxyData(data) { for (const key in data) { Object.defineProperty(this, key, { get() { return data[key] }, set(newValue) { data[key] = newValue } }) } }}
compile.js
// 指令解析器class Compile { constructor(el, vm) { // 判断以后传入的el是不是一个元素节点 // document.querySelector返回与指定的选择器组匹配的元素的后辈的第一个元素。 this.el = this.isElementNode(el) ? el : document.querySelector(el) this.vm = vm // 1.匹配节点内容及指令替换相应的内容, 因为每次匹配替换会导致页面回流和重绘, 所以应用文档碎片对象 // 获取文档碎片对象, 放入内存中会缩小页面的回流和重绘 const fragment = this.node2Fragment(this.el) // 2.编译模版 this.compile(fragment) // 3.追加子元素到根元素 this.el.appendChild(fragment) } // 判断是否是元素节点 isElementNode(node) { return node.nodeType === 1 } // 将以后根元素中的所有子元素一层层取出来放到文档碎片中, 以缩小页面回流和重绘 node2Fragment(el) { // 创立文档碎片对象 const fragment = document.createDocumentFragment() let firstChild; // 将以后el节点对象的所有子节点追加到文档碎片对象中 while (firstChild = el.firstChild) { fragment.appendChild(firstChild) } return fragment } // 编译模版, 解析指令 compile(fragment) { // 1.获取到所有的子节点, 以后获取的子节点数组是一个伪数组, 须要转为数组 const childNodes = [...fragment.childNodes] childNodes.forEach(child => { // 判断以后节点是元素节点还是文本节点 if (this.isElementNode(child)) { // 编译元素节点 this.compileElement(child) } else { // 编译文本节点 this.compileText(child) } // 递归遍历以后节点时候还有子节点对象 if (child.childNodes && child.childNodes.length) { this.compile(child) } }) } // 编译元素节点 compileElement(node) { // 依据不同指令属性, 编译模版信息 const attributes = [...node.attributes]; attributes.forEach(attr => { // 通过解构将指令的name和value获取到 const { name, value } = attr // 判断以后属性是指令还是原生属性 if (this.isDirective(name)) { // 截取指令, 不须要v- const directive = name.split('-')[1] // 因为指令格局有 v-text v-html v-bind:属性 v-on:事件等等, 依照 : 再次宰割 const [dirName, eventName] = directive.split(':') // 更新数据, 数据驱动视图 compileUtils[dirName](node, value, this.vm, eventName) // 删除有指令的标签上的属性 node.removeAttribute('v-' + directive) } else if (this.isEventName(name)) { // 判断指令是以@结尾绑定的事件 // 截取指令, 不须要@, 这里就省略解决里 @click.stop.prevent等事件修饰符, 原理不难 const eventName = name.split('@')[1] // 更新数据, 数据驱动视图 compileUtils['on'](node, value, this.vm, eventName) } }) } // 编译文本节点 compileText(node) { // node.textContent获取文本并且匹配{{}} 模版字符串类型的 const content = node.textContent if (/\{\{(.+?)\}\}/.test(content)) { compileUtils['text'](node, content, this.vm) } } // 判断以后属性是指令还是原生属性 isDirective(attrName) { // startsWith() 办法用来判断以后字符串是否以另外一个给定的子字符串结尾,并依据判断后果返回 true 或 false。 return attrName.startsWith('v-') } // 判断指令是以@结尾绑定的事件 isEventName(attrName) { return attrName.startsWith('@') }}
第二步 - 实现一个数据监听器(Observer)
利用Obeject.defineProperty()来监听属性变动 那么将须要observe的数据对象进行递归遍历,包含子属性对象的属性,都加上 setter和getter 这样的话,给这个对象的某个值赋值,就会触发setter,那么就能监听到了数据变动。
observer.js
// 数据劫持class Observe { constructor(data) { this.observe(data) } // 应用object.defineProperty监听对象, 数组临时不思考,太简单 observe(data) { if (data && typeof data === 'object') { // console.log(data); Object.keys(data).forEach(key => { this.defineReactive(data, key, data[key]) }) } } // 劫持属性 defineReactive(obj, key, value) { // 递归遍历 this.observe(value) // 创立依赖收集器 const dep = new Dep() // console.log(dep); Object.defineProperty(obj, key, { // obj为已有对象, key为属性, 第三个参数为属性描述符 enumerable: true, // enumerable:是否能够被枚举(for in),默认false configurable: false, // 是否能够被删除,默认false // 获取 get() { // console.log(dep.target); // 订阅数据变动时, 往Dep中增加观察者 Dep.target && dep.addSub(Dep.target) return value }, // 设置 set: (newValue) => { // 这里要留神新设置的值也须要劫持他的属性 this.observe(newValue) if (newValue !== value) { value = newValue } // 告诉订阅器找到对应的观察者,告诉观察者更新视图 dep.notify() } }) }}
第三部 - 实现一个Watcher去更新视图
在初始化myvue实例的时候,通过object。defineProperty()的get属性时去增加观察者,在set更改属性的时候去触发notify()来调用upDate办法更新视图
// 观察者class Watcher { constructor(vm, expr, cb) { this.vm = vm this.expr = expr this.cb = cb // 存储旧值 this.oldValue = this.getOldValue() } // 获取旧值 getOldValue() { // 在获取旧值的时候将观察者挂在到Dep订阅器上 Dep.target = this const oldValue = compileUtils.getValue(this.expr, this.vm) // 销毁Dep上的观察者 Dep.target = null } // 更新视图 upDate() { // 获取新值 const newValue = compileUtils.getValue(this.expr, this.vm) if (newValue !== this.oldValue) { this.cb(newValue) } }}// 订阅器class Dep { constructor() { this.subs = [] } // 收集观察者 addSub(watcher) { this.subs.push(watcher) } // 告诉观察者去更新视图 notify() { this.subs.forEach(watcher => { watcher.upDate() }) }}
面试题-论述你所了解的MVVM响应式原理
Vue是采纳数据劫持配合发布者-订阅者模式,通过Object.defineProperty来()来劫持各个属性的getter和setter,在数据发生变化的时候,公布音讯给依赖收集器,去告诉观察者,做出对应的回调函数去更新视图。
具体就是:MVVM作为绑定的入口,整合Observe,Compil和Watcher三者,通过Observe来监听model的变动,通过Compil来解析编译模版指令,最终利用Watcher搭起Observe和Compil之前的通信桥梁,从而达到数据变动 => 更新视图,视图交互变动(input) => 数据model变更的双向绑定成果。
总结
本篇文章次要以几种实现双向绑定的做法
、实现Observer
、实现Compile
、实现Watcher
、实现MVVM
这几个模块来论述了双向绑定的原理和实现。并依据思路流程渐进梳理解说了一些细节思路和比拟要害的内容点,当然必定有很多不欠缺的中央,然而对于如何实现双向数据绑定你必定有了更加粗浅的理解。
本篇文章也是通过查看Vue源码解析文章,以及B站相干视频总结进去的,俗话说好忘性不如烂笔头, 本人即便照着抄一遍也能更加印象粗浅。