赞
踩
学过 vue 如果不了解响应式的原理,怎么能说自己熟练使用 vue,要是没有写过一个简易版的 vue 怎么能说自己精通vue,这篇文章通过300多行代码,带你写一个简易版的 vue,主要实现 vue 数据响应式 (数据劫持结合发布者-订阅者)、数组的变异方法、编译指令,数据的双向绑定的功能。
本文需要有一定 vue 基础,并不适合新手学习。在文章最后附有vue学习链接。因为本文用到了很多知识点,在文章最后也有相关知识点链接和 vue 源码地址,大家自己到文章最后看哦~
文章较长,且有些难度,建议大家,找一个安静的环境,并在看之前沐浴更衣,保持编程的神圣感。下面是实现的简易版 vue 的源码地址,一定要先下载下来!因为文章中的并非全部的代码。
github 源码地址:https://github.com/young-monk/myvue.git
在开始学习之前,我们先来了解一下什么是 MVVM ,什么是数据响应式。
我们都知道 vue 是一个典型的 MVVM 思想,由数据驱动视图。
那么什么是 MVVM 思想呢?
MVVM 是 Model-View-ViewModel,是把一个系统分为了模型( model )、视图( view )和 view-model 三个部分。
vue 在 MVVM 思想下,view 和model 之间没有直接的联系,但是 view 和 view-model 、model 和 view-model之间时交互的,当 view 视图进行 dom 操作等使数据发生变化时,可以通过 view-model 同步到 model 中,同样的 model 数据变化也会同步到 view 中。
那么实现数据响应式都有什么方法呢?
1、发布者-订阅者模式:当一个对象(发布者)状态发生改变时,所有依赖它的对象(订阅者)都会得到通知。通俗点来讲,发布者就相当于报纸,而订阅者相当于读报纸的人。
2、脏值检查:通过存储旧的数据,和当前新的数据进行对比,观察是否有变更,来决定是否更新视图。angular.js
就是通过脏值检查的方式。最简单的实现方式就是通过 setInterval()
定时轮询检测数据变动,但这样无疑会增加性能,所以,angular
只有在指定的事件触发时进入脏值检测。
3、数据劫持:通过Object.defineProperty()
来劫持各个属性的setter
,getter
,在数据变动时触发相应的方法。
vue 是如何实现数据响应式的呢?
vue.js 则是通过数据劫持结合发布者-订阅者模式的方式。
(图片来自网络)
当执行 new Vue() 时,Vue 就进入了初始化阶段,vue会对指令进行解析(初始化视图,增加订阅者,绑定更新函数),同时通过Obserber 会遍历数据并通过Object.defineProperty 的 getter 和 setter 实现对的监听, 当数据发生变化的时候,Observer 中的 setter 方法被触发,setter 会立即调用Dep.notify(),Dep 开始遍历所有的订阅者,并调用订阅者的 update 方法,订阅者收到通知后对视图进行相应的更新。
我来依次介绍一下图中的重要的名词:
Observer :数据监听器,能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者,内部采用Object.defineProperty
的 getter
和 setter
来实现
Compile :指令解析器,它的作用对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数
Dep :订阅者收集器或者叫消息订阅器都可以,它在内部维护了一个数组,用来收集订阅者,当数据改变触发 notify 函数,再调用订阅者的 update 方法
Watcher :订阅者,它是连接 Observer 和 Compile 的桥梁,收到消息订阅器的通知,更新视图
Updater:视图更新
所以我们想要实现一个vue响应式,需要完成 数据劫持、依赖收集、 发布者订阅者模式。
下面我来介绍我模仿源码实现的功能:
@
和 :
)我们想要完成以上的功能,需要实现如下类和方法:
我是使用了webpack作为构建工具来协同开发的,所以在我实现的vue响应式中会用到ES6模块化,webpack的相关知识。知识链接在文章最后。
我们都知道要用 Obeject.defineProperty()
来监听属性的数据变化,我们需要对 Observer 的数据对象进行递归遍历,包括子属性对象的属性,都加上 setter
和 getter
,这样的话,当给这个对象的某个值赋值,就会触发 setter
,那么就能监听到了数据变化。当然我们在新增加数据的时候,也要对新的数据对象进行递归遍历,加上 setter
和 getter
。
但我们要注意数组,在处理数组时并不是把数组中的每一个元素都加上 setter
和 getter
,我们试想一下,一个从后端返回的数组数据是非常庞大的,如果为每个属性都加上 setter
和getter
,性能消耗是十分巨大的。我们想要得到的效果和所消耗的性能不成正比,所以在数组方面,我们通过对数组的7 个变异方法来实现数据的响应式。只有通过数组变异方法来修改和删除数组时才会重新渲染页面。
那么监听到变化之后是如何通知订阅者来更新视图的呢?我们需要实现一个Dep(消息订阅器),其中有一个 notify()
方法,是通知订阅者数据发生了变化,再让订阅者来更新视图。
我们怎么添加订阅者呢?我们可以通过 new Dep()
,通过 Dep 中的 addSaubs()
方法来添加订阅者。我们来看一下具体代码。
我们首先需要声明一个Observer类,在创建类的时候,我们需要创建一个消息订阅器,判断一下是否是数组,如果是数组,我们便改造数组,如果是对象,我们便需要为对象的每一个属性都加入 setter
和getter
。
import { arrayMethods } from './array' //数组变异方法处理 class Observer { constructor(data) { //用于对数组进行处理,存放数组的观察者watcher this.dep = new Dep() if (Array.isArray(data)) { //如果是数组,使用数组的变异方法 data.__proto__ = arrayMethods //把数组数据添加 __ob__ 一个Observer,当使用数组变异方法时,可以更新视图 data.__ob__ = this //给数组的每一项添加数据劫持(setter/getter处理) this.observerArray(data) } else { //非数组数据添加数据劫持(setter/getter处理) this.walk(data) } } }
在上面,我们给 data 的__proto__
原型链重新赋值,我们来看一下 arrayMethods 是什么,arrayMethods 是 array.js 文件中,抛出的一个新的 Array 原型
// 获取Array的原型链 const arrayProto = Array.prototype; // 重新创建一个含有对应原型的对象,在下面称为新Array const arrayMethods = Object.create(arrayProto); // 处理7个数组变异方法 ['push', 'pop', 'shift', 'unshift', 'reverse', 'sort', 'splice'].forEach(ele => { //修改新Array的对应的方法 arrayMethods[ele] = function () { // 执行数组的原生方法,完成其需要完成的内容 arrayProto[ele].call(this, ...arguments) // 获取Observer对象 const ob = this.__ob__ // 更新视图 ob.dep.notify() } }) export { arrayMethods }
此时呢,我们就拥有了数组的变异方法,我们还需要通过 observerArray 方法为数组的每一项添加 getter 和setter ,注意,此时的每一项只是最外面的一层,并非递归遍历。
//循环遍历数组,为数组每一项设置setter/getter
observerArray(items) {
for (let i = 0; i < items.length; i++) {
this.observer(items[i])
}
}
如果是一个对象的话,我们就要对 对象 的每一个属性递归遍历,通过walk()
方法
walk(data) {
//数据劫持
if (data && typeof data === "object") {
for (const key in data) {
//绑定setter和getter
this.defineReactive(data, key, data[key])
}
}
}
在上面的调用了 defineReactive()
,我们来看看这个方法是干什么的?这个方法就是设置数据劫持的,每一行都有注释。
//数据劫持,设置 setter/getteer defineReactive(data, key, value) { //如果是数组的话,需要接受返回的Observer对象 let arrayOb = this.observer(value) //创建订阅者/收集依赖 const dep = new Dep() //setter和getter处理 Object.defineProperty(data, key, { //可枚举的 enumerable: true, //可修改的 configurable: false, get() { //当 Dep 有 watcher 时, 添加 watcher Dep.target && dep.addSubs(Dep.target) //如果是数组,则添加上数组的观察者 Dep.target && arrayOb && arrayOb.dep.addSubs(Dep.target) return value }, set: (newVal) => { //新旧数据不相等时更改 if (value !== newVal) { //为新设置的数据添加setter/getter arrayOb = this.observer(newVal); value = newVal //通知 dep 数据发送了变化 dep.notify() } } }) } }
我们需要注意的是,在上面的图解中,在 Observer 中,如果数据发生变化,会通知消息订阅器,那么在何时绑定消息订阅器呢?就是在设置 setter
和 getter
的时候,创建一个Dep,并为Dep添加订阅者,Dep.target && dep.addSubs(Dep.target)
,通过调用 dep 的 addSubs 方法添加订阅者
Dep是消息订阅器,它的作用就是维护一个订阅者数组,当数据发送变化是,通知对应的订阅者,Dep中有一个notify()
方法,作用就是通知订阅者,数据发送了变化
// 订阅者收集器
export default class Dep {
constructor() {
//管理的watcher的数组
this.subs = []
}
addSubs(watcher) {
//添加watcher
this.subs.push(watcher)
}
notify() {
//通知watcher更新dom
this.subs.forEach(w => w.update())
}
}
Watcher就是订阅者,watcher是 Observer 和 Compile 之间通信的桥梁,当数据改变时,接收到 Dep 的通知(Dep 的notify()
方法),来调用自己的update()
方法,触发 Compile 中绑定的回调,达到更新视图的目的。
import Dep from './dep' import { complieUtils } from './utils' export default class Watcher { constructor(vm, expr, cb) { //当前的vue实例 this.vm = vm; //表达式 this.expr = expr; //回调函数,更新dom this.cb = cb //获取旧的数据,此时获取旧值的时候,Dep.target会绑定上当前的this this.oldVal = this.getOldVal() } getOldVal() { //将当前的watcher绑定起来 Dep.target = this //获取旧数据 const oldVal = complieUtils.getValue(this.expr, this.vm) //绑定完成后,将绑定的置空,防止多次绑定 Dep.target = null return oldVal } update() { //更新函数 const newVal = complieUtils.getValue(this.expr, this.vm) if (newVal !== this.oldVal || Array.isArray(newVal)) { //条用更新在compile中创建watcher时传入的回调函数 this.cb(newVal) } } }
上面中用到了 complieUtils 中的 getValue() 方法,会在下面讲,主要作用是获取到指定表达式的值。
我们把整个流程分成两条路线的话:
new Vue() ==> Observer数据劫持 ==> 绑定Dep ==> 通知watcher ==> 更新视图
new Vue() ==> Compile解析模板指令 ==> 初始化视图 和 绑定watcher
此时,我们第一条线的内容已经实现了,我们再来实现一下第二条线。
compile主要做的事情是解析模板指令,将模板中的变量替换成数据,初始化渲染页面视图。同时也要绑定更新函数,添加订阅者。
因为在解析的过程中,会多次的操作dom,为提高性能和效率,会先将vue实例根节点的 el 转换成文档碎片fragment
进行解析编译操作,解析完成,再将fragment
添加回原来的真实dom节点中,文档碎片知识点在文章最后面的知识点链接中。
class Complie {
constructor(el, vm) {
this.el = this.isNodeElement(el) ? el : document.querySelector(el);
this.vm = vm;
// 1、将所有的dom对象放到fragement文档碎片中,防止重复操作dom,消耗性能
const fragments = this.nodeTofragments(this.el)
// 2、编译模板
this.complie(fragments)
// 3、追加子元素到根元素
this.el.appendChild(fragments)
}
}
我们可以看到,Complie 中主要进行了三步,第一步 nodeTofragments 是讲所有的dom节点放到文档碎片中操作,最后一步,是把解析好的dom元素,从文档碎片重新加入到页面中,这两步的具体方法,大家去下载我的源码,看一下就明白了,有注释。我就不再解释 了。
我们来看一下第二步,编译模板:
complie(fragments) { //获取所有节点 const nodes = fragments.childNodes; [...nodes].forEach(ele => { if (this.isNodeElement(ele)) { //1. 编译元素节点 this.complieElement(ele) } else { //编译文本节点 this.complieText(ele) } //如果有子节点,循环遍历,编译指令 if (ele.childNodes && ele.childNodes.length) { this.complie(ele) } }) }
我们要知道,模板可能有两种情况,一种是文本节点(含有双大括号的插值表达式)和元素节点(含有指令)。我们获取所有节点后对每个节点进行判断,如果是元素节点,则用解析元素节点的方法,如果是文本节点,则调用解析文本的方法。
complieElement(node) { //1.获取所有的属性 const attrs = node.attributes; //2.筛选出是属性的 [...attrs].forEach(attr => { //attr是一个对象,name是属性名,value是属性值 const {name,value} = attr //判断是否含有v-开头 如:v-html if (name.startsWith("v-")) { //将指令分离 text, html, on:click const [, directive] = name.split("-") //处理on:click或bind:name的情况 on,click const [dirName, paramName] = directive.split(":") //编译模板 complieUtils[dirName](node, value, this.vm, paramName) //删除属性,在页面中的dom中不会再显示v-html这种指令的属性 node.removeAttribute(name) } else if (name.startsWith("@")) { // 如果是事件处理 @click='handleClick' let [, paramName] = name.split('@'); complieUtils['on'](node, value, this.vm, paramName); node.removeAttribute(name); } else if (name.startsWith(":")) { // 如果是事件处理 :href='...' let [, paramName] = name.split(':'); complieUtils['bind'](node, value, this.vm, paramName); node.removeAttribute(name); } }) }
我们在编译模板中调用了 complieUtils[dirName](node, value, this.vm, paramName)
方法,这是工具类中的一个方法,用于处理指令
我们再来看看文本节点,文本节点就相对比较简单,只需要匹配{{}}
形式的插值表达式就可以了,同样的调用工具方法,来解析。
complieText(node) {
//1.获取所有的文本内容
const text = node.textContent
//匹配{{}}
if (/\{\{(.+?)\}\}/.test(text)) {
//编译模板
complieUtils['text'](node, text, this.vm)
}
}
上面用来这么多工具方法,我们来看看到底是什么
这个方法主要是对指令进行处理,获取指令中的值,并在页面中更新相应的值,同时我们在这里要绑定watcher的回调函数。
我来以v-text指令来解释,其他指令都有注释,大家自己看。
import Watcher from './watcher' export const complieUtils = { //处理text指令 text(node, expr, vm) { let value; if (/\{\{.+?\}\}/.test(expr)) { //处理 {{}} value = expr.replace(/\{\{(.+?)\}\}/g, (...args) => { //绑定观察者/更新函数 new Watcher(vm, args[1], () => { //第二个参数,传入回调函数 this.updater.updaterText(node, this.getContentVal(expr, vm)) }) return this.getValue(args[1], vm) }) } else { //v-text new Watcher(vm, expr, (newVal) => { this.updater.updaterText(node, newVal) }) //获取到value值 value = this.getValue(expr, vm) } //调用更新函数 this.updater.updaterText(node, value) }, }
text处理函数是对dom元素的textContent进行操作的,所以有两种情况,一种是使用v-text指令,会更新元素的textContent,另一种情况是{{}} 的插值表达式,也是更新元素的 textContent。
在此方法中我们先判断是哪一种情况,如果是v-text指令,那么就绑定一个watcher的回调,获取到textContent的值,调用updater.updaterText
在下面讲,是更新元素的方法。如果是双大括号的话,我们就要对其进行特殊处理,首先是将双大括号替换成指定的变量的值,同时为其绑定 watcher 的回调。
//通过表达式, vm获取data中的值, person.name
getValue(expr, vm) {
return expr.split(".").reduce((data, currentVal) => {
return data[currentVal]
}, vm.$data)
},
获取 textContent 的值是用一个 reduce 函数,用法在最后面的链接中,因为数据可能是 person.name
我们需要获取到最深的对象的值。
//更新dom元素的方法
updater: {
//更新文本
updaterText(node, value) {
node.textContent = value
}
}
updater.updaterText
更新dom的方法,其实就是对 textContent 重新赋值。
我们再来将一下v-model指令,实现双向的数据绑定,我们都知道,v-model其实实现的是 input 事件和 value 之间的语法糖。所以我们这里同样的监听一下当前dom元素的 input 事件,当数据改变时,调用设置新值的方法
//处理model指令
model(node, expr, vm) {
const value = this.getValue(expr, vm)
//绑定watcher
new Watcher(vm, expr, (newVal) => {
this.updater.updaterModel(node, newVal)
})
//双向数据绑定
node.addEventListener("input", (e) => {
//设值方法
this.setVal(expr, vm, e.target.value)
})
this.updater.updaterModel(node, value)
},
这个方法同样是通过 reduce 方法,为对应的变量设置成新的值,此时数据改变了,会自动调用更新视图的方法,我们在之前已经实现了。
//通过表达式,vm,输入框的值,实现设置值,input中v-model双向数据绑定
setVal(expr, vm, inputVal) {
expr.split(".").reduce((data, currentVal) => {
data[currentVal] = inputVal
}, vm.$data)
},
最后呢,我们就要来整合这些类和工具方法,在创建一个vue实例的时候,我们先获取options中的参数,然后对起进行数据劫持和编译模板
class Vue {
constructor(options) {
//获取模板
this.$el = options.el;
//获取data中的数据
this.$data = options.data;
//将对象中的属性存起来,以便后续使用
this.$options = options
//1.数据劫持,设置setter/getter
new Observer(this.$data)
//2.编译模板,解析指令
new Complie(this.$el, this)
}
}
此时我们想要使用 vue 中的数据,比如我们想要在 vm 对象中使用person.name
, 必须用 this.$data.person.name
才能获取到,如果我们想在vm对象中使用 this.person.name
直接修改数据,就需要代理一下 this.$data
。其实就是将当前的 this.$data
中的数据放到全局中进行监听。
export default class Vue { constructor(options) { //... //1.数据劫持,设置setter/getter //2.编译模板,解析指令 if (this.$el) { //如果有模板 //代理this this.proxyData(this.$data) } } proxyData(data) { for (const key in data) { //将当前的数据放到全局指向中 Object.defineProperty(this, key, { get() { return data[key]; }, set(newVal) { data[key] = newVal } }) } } }
文章到了这里,就实现了一个简易版的vue,建议大家反复学习,仔细体验,细细品味。
在文章的最后,我通过问、答的形式,来解答一些常见的面试题:
问:什么时候页面会重新渲染?
答:数据发生改变,页面就会重新渲染,但数据驱动视图,数据必须先存在,然后才能实现数据绑定,改变数据,页面才会重新渲染。
问:什么时候页面不会重新渲染?
答:有3种情况不会重新渲染
未经声明和未使用的变量,修改他们,都不会重新渲染页面
通过索引的方式和更改长度的方式更改数组,都不会重新渲染页面
增加和删除对象的属性,不会重新渲染页面
问:如何使 未声明/未使用的变量、增加/删除对象属性可以使页面重新渲染?
答:添加利用vm.$set/Vue.set,删除利用vm.$delete/Vue.delete方法
问:如何更改数组可以使页面重新渲染?
答:可以使用数组的变异方法(共 7 个):push
、pop
、unshift
、shift
、splice
、sort
、reverse
问:数据更新后,页面会立刻重新渲染么?
答:更改数据后,页面不会立刻重新渲染,页面渲染的操作是异步执行的,执行完同步任务后,才会执行异步的
同步队列,异步队列(宏任务、微任务 )
问:如果更改了数据,想要在页面重新渲染后再做操作,怎么办?
答:可以使用 vm.$nextTick
或 Vue.nextTick
问:来介绍一下vm.$nextTick
和 Vue.nextTick
吧
答:我们来看个小例子就明白啦
<div id="app">{{ name }}</div> <script> const vm = new Vue({ el: '#app', data: { name: 'monk' } }) vm.name = 'the young monk'; console.log(vm.name); // the young monk 此时数据已更改 console.log(vm.$el.innerHTML); // monk 此时页面还未重新渲染 // 1. 使用vm.$nextTick vm.$nextTick(() => { console.log(vm.$el.innerHTML); // the young monk 此时数据已更改 }) // 2. 使用Vue.nextTick Vue.nextTick(() => { console.log(vm.$el.innerHTML); // the young monk 此时数据已更改 }) </script>
问: vm.$nextTick
和 Vue.nextTick
有什么区别呢 ?
答:Vue.nextTick
内部函数的this
指向window
,vm.$nextTick
内部函数的this
指向Vue
实例对象。
Vue.nextTick(function () {
console.log(this); // window
})
vm.$nextTick(function () {
console.log(this); // vm实例
})
问: vm.$nextTick
和 Vue.nextTick
是通过什么实现的呢 ?
答:二者都是等页面渲染后执行的任务,都是使用微任务。
if(typeof Promise !== 'undefined') { // 微任务 // 首先看一下浏览器中有没有promise // 因为IE浏览器中不能执行Promise const p = Promise.resolve(); } else if(typeof MutationObserver !== 'undefined') { // 微任务 // 突变观察 // 监听文档中文字的变化,如果文字有变化,就会执行回调 // vue的具体做法是:创建一个假节点,然后让这个假节点稍微改动一下,就会执行对应的函数 } else if(typeof setImmediate !== 'undefined') { // 宏任务 // 只在IE下有 } else { // 宏任务 // 如果上面都不能执行,那么则会调用setTimeout }
同样的这也是vue的一个小缺点:vue一直是等主线程执行完以后再执行渲染任务,如果主线程卡死,则永远渲染不出来。
问:利用 Object.defineProperty
实现响应式有什么缺点?
答:
vue前置知识学习链接:
webpack 知识点链接:https://blog.csdn.net/Newbie___/article/details/104782796
es6 模块化相关知识链接:https://blog.csdn.net/Newbie___/article/details/104523097
es6 类的相关知识:https://blog.csdn.net/Newbie___/article/details/104450311
DocumentFragment 文档碎片知识点:https://developer.mozilla.org/zh-CN/docs/Web/API/DocumentFragment
Obeject.defineProperty方法的使用:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty
Array.reduce() 方法使用:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce
事件循环相关知识:https://blog.csdn.net/Newbie___/article/details/105450311
vue基础知识学习链接:
好似大家都在用vue,vue好在哪?https://blog.csdn.net/Newbie___/article/details/105028758
vue中插值表达式和14个vue指令详解 https://blog.csdn.net/Newbie___/article/details/105179351
一篇文章带你“深入”了解vue的计算属性computed和侦听属器watch https://blog.csdn.net/Newbie___/article/details/105224887
axios在企业级vue项目中的应用(附带axios正确打开方式)https://blog.csdn.net/Newbie___/article/details/105238226
vue的生命周期详解:从入门到精通 https://blog.csdn.net/Newbie___/article/details/105347733
vue组件和父子组件间通信的13种方式(包含动态、异步组件和常用的prop、$emit、插槽等)https://blog.csdn.net/Newbie___/article/details/105480827
vueRouter路由详解:从入门到精通 https://blog.csdn.net/Newbie___/article/details/105766504
vue中的vuex状态管理:从入门到精通 https://blog.csdn.net/Newbie___/article/details/105766702
5450311
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。