前言: 三月四月是招聘旺季,相信很多面试前端岗的同窗都有被问到vue的原理是什么吧?本文就以最简单的方式教你如何实现vue框架的基本功能。为了减小你们的学习成本,我就以最简单的方式教你们撸一个vue框架。javascript
但愿准备阅读本文的你最好具有如下技能:html
Object.defineProperty()
方法的使用首先,咱们按照如下代码建立一个HTML文件,本文主要就是教你们如何实现如下功能。前端
<script src="../src/vue.js"></script>
</head>
<body>
<div id="app">
<!-- 解析插值表达式 -->
<h2>title 是 {{title}}</h2>
<!-- 解析常见指令 -->
<p v-html='msg1' title='混淆属性1'>混淆文本1</p>
<p v-text='msg2' title='混淆属性2'>混淆文本2</p>
<input type="text" v-model="something">
<!-- 双向数据绑定 -->
<p>{{something}}</p>
<!-- 复杂数据类型 -->
<p>{{dad.son.name}}</p>
<p v-html='dad.son.name'></p>
<input type="text" v-model="dad.son.name">
<button v-on:click='sayHi'>sayHi</button>
<button @click='printThis'>printThis</button>
</div>
</body>
复制代码
let vm = new Vue({
el: '#app',
data: {
title: '手把手教你撸一个vue框架',
msg1: '<a href="#">应该被解析成a标签</a>',
msg2: '<a href="#">不该该被解析成a标签</a>',
something: 'placeholder',
dad: {
name: 'foo',
son: {
name: 'bar',
son: {}
}
}
},
methods: {
sayHi() {
console.log('hello world')
},
printThis() {
console.log(this)
}
},
})
复制代码
准备工做作好了,那咱们就一块儿来实现vue框架的基本功能吧!vue
咱们都知道,vue是基于MVVM设计模式的渐进式框架。那么在JavaScript中,咱们该如何实现一个MVVM框架呢? 主流的实现MVVM框架的思路有三种:java
发布者-订阅者模式,通常经过pub和sub的方式实现数据和视图的绑定。node
Angular.js是经过脏值监测的方式对比数据是否有变动,来决定是否更新视图。相似于经过定时器轮寻监测数据是否发生了额改变。面试
Vue.js是采用数据劫持结合发布者-订阅者模式的方式。在vue2.6以前,是经过Object.defineProperty() 来劫持各个属性的setter和getter方法,在数据变更时发布消息给订阅者,触发相应的回调。这也是IE8如下的浏览器不支持vue的根本缘由。正则表达式
上述流程以下图所示:设计模式
把逻辑捋顺清楚后,咱们会发现,其实咱们要在这个入口文件作的事情很简单:数组
/** * vue.js (入口文件) * 1. 将data,methods里面的属性挂载根实例中 * 2. 监听 data 属性的变化 * 3. 编译挂载点内的全部指令和插值表达式 */
class Vue {
constructor(options={}){
this.$el = options.el;
this.$data = options.data;
this.$methods = options.methods;
debugger
// 将data,methods里面的属性挂载根实例中
this.proxy(this.$data);
this.proxy(this.$methods);
// 监听数据
// new Observer(this.$data)
if(this.$el) {
// new Compile(this.$el,this);
}
}
proxy(data={}){
Object.keys(data).forEach(key=>{
// 这里的this 指向vue实例
Object.defineProperty(this,key,{
enumerable: true,
configurable: true,
set(value){
if(data[key] === value) return
return value
},
get(){
return data[key]
},
})
})
}
}
复制代码
compile主要作的事情是解析指令(属性节点)与插值表达式(文本节点),将模板中的变量替换成数据,而后初始化渲染页面视图,并将每一个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变更,收到通知,更新视图。
由于遍历解析的过程有屡次操做dom节点,这会引起页面的回流与重绘的问题,为了提升性能和效率,咱们最好是在内存中解析指令和插值表达式,所以咱们须要遍历挂载点下的全部内容,把它存储到DocumentFragments中。
DocumentFragments 是DOM节点。它们不是主DOM树的一部分。一般的用例是建立文档片断,将元素附加到文档片断,而后将文档片断附加到DOM树。由于文档片断存在于内存中,并不在DOM树中,因此将子元素插入到文档片断时不会引发页面回流(对元素位置和几何上的计算)。所以,使用文档片断一般会带来更好的性能。
因此咱们须要一个node2fragment()
方法来处理上述逻辑。
node2fragment(node) {
let fragment = document.createDocumentFragment()
// 把el中全部的子节点挨个添加到文档片断中
let childNodes = node.childNodes
// 因为childNodes是一个类数组,因此咱们要把它转化成为一个数组,以使用forEach方法
this.toArray(childNodes).forEach(node => {
// 把全部的字节点添加到fragment中
fragment.appendChild(node)
})
return fragment
}
复制代码
this.toArray()
是我封装的一个类方法,用于将类数组转化为数组。实现方法也很简单,我使用了开发中最经常使用的技巧:
toArray(classArray) {
return [].slice.call(classArray)
}
复制代码
接下来咱们要作的事情就是解析fragment里面的节点:compile(fragment)
。
这个方法的逻辑也很简单,咱们要递归遍历fragment里面的全部子节点,根据节点类型进行判断,若是是文本节点则按插值表达式进行解析,若是是属性节点则按指令进行解析。在解析属性节点的时候,咱们还要进一步判断:是否是由v-
开头的指令,或者是特殊字符,如@
、:
开头的指令。
// Compile.js
class Compile {
constructor(el, vm) {
this.el = typeof el === "string" ? document.querySelector(el) : el
this.vm = vm
// 解析模板内容
if (this.el) {
// 为了不直接在DOM中解析指令和差值表达式所引发的回流与重绘,咱们开辟一个Fragment在内存中进行解析
const fragment = this.node2fragment(this.el)
this.compile(fragment)
this.el.appendChild(fragment)
}
}
// 解析fragment里面的节点
compile(fragment) {
let childNodes = fragment.childNodes
this.toArray(childNodes).forEach(node => {
// 若是是元素节点,则解析指令
if (this.isElementNode(node)) {
this.compileElementNode(node)
}
// 若是是文本节点,则解析差值表达式
if (this.isTextNode(node)) {
this.compileTextNode(node)
}
// 递归解析
if (node.childNodes && node.childNodes.length > 0) {
this.compile(node)
}
})
}
}
复制代码
接下来咱们要作的就只剩下解析指令,并把解析后的结果通知给视图了。
当数据发生改变时,经过Watcher对象监听expr数据的变化,一旦数据发生变化,则执行回调函数。
new Watcher(vm,expr,callback)
// 利用Watcher将解析后的结果返回给视图.
咱们能够把全部处理编译指令和插值表达式的逻辑封装到compileUtil
对象中进行管理。
这里有两个坑点你们须要注意一下:
{{dad.son.name}}
或者<p v-text='dad.son.name'></p>
,咱们拿到v-text
的属性值是字符串dad.son.name
,咱们是没法经过vm.$data['dad.son.name']
拿到数据的,而是要经过vm.$data['dad']['son']['name']
的形式来获取数据。所以,若是数据是复杂数据的情形,咱们须要实现getVMData()
和setVMData()
方法进行数据的获取与修改。v-on
指令给节点绑定方法的时候,咱们须要把该方法的this指向绑定为vue实例。// Compile.js
let CompileUtils = {
getVMData(vm, expr) {
let data = vm.$data
expr.split('.').forEach(key => {
data = data[key]
})
return data
},
setVMData(vm, expr,value) {
let data = vm.$data
let arr = expr.split('.')
arr.forEach((key,index) => {
if(index < arr.length -1) {
data = data[key]
} else {
data[key] = value
}
})
},
// 解析插值表达式
mustache(node, vm) {
let txt = node.textContent
let reg = /\{\{(.+)\}\}/
if (reg.test(txt)) {
let expr = RegExp.$1
node.textContent = txt.replace(reg, this.getVMData(vm, expr))
new Watcher(vm, expr, newValue => {
node.textContent = txt.replace(reg, newValue)
})
}
},
// 解析v-text
text(node, vm, expr) {
node.textContent = this.getVMData(vm, expr)
new Watcher(vm, expr, newValue => {
node.textContent = newValue
})
},
// 解析v-html
html(node, vm, expr) {
node.innerHTML = this.getVMData(vm, expr)
new Watcher(vm, expr, newValue => {
node.innerHTML = newValue
})
},
// 解析v-model
model(node, vm, expr) {
let that = this
node.value = this.getVMData(vm, expr)
node.addEventListener('input', function () {
// 下面这个写法不能深度改变数据
// vm.$data[expr] = this.value
that.setVMData(vm,expr,this.value)
})
new Watcher(vm, expr, newValue => {
node.value = newValue
})
},
// 解析v-on
eventHandler(node, vm, eventType, expr) {
// 处理methods里面的函数fn不存在的逻辑
// 即便没有写fn,也不会影响项目继续运行
let fn = vm.$methods && vm.$methods[expr]
try {
node.addEventListener(eventType, fn.bind(vm))
} catch (error) {
console.error('抛出这个异常表示你methods里面没有写方法\n', error)
}
}
}
复制代码
其实在Observer模块中,咱们要作的事情也很少,就是提供一个walk()
方法,递归劫持vm.$data
中的全部数据,拦截setter和getter。若是数据变动,则发布通知,让全部订阅者更新内容,改变视图。
须要注意的是,若是设置的值是一个对象,则咱们须要保证这个对象也要是响应式的。 用代码来描述即:walk(aObjectValue)
。关于如何实现响应式对象,咱们采用的方法是Object.defineProperty()
完整代码以下:
// Observer.js
class Observer {
constructor(data){
this.data = data
this.walk(data)
}
// 遍历walk中全部的数据,劫持 set 和 get方法
walk(data) {
// 判断data 不存在或者不是对象的状况
if(!data || typeof data !=='object') return
// 拿到data中全部的属性
Object.keys(data).forEach(key => {
// console.log(key)
// 给data中的属性添加 getter和 setter方法
this.defineReactive(data,key,data[key])
// 若是data[key]是对象,深度劫持
this.walk(data[key])
})
}
// 定义响应式数据
defineReactive(obj,key,value) {
let that = this
// Dep消息容器在Watcher.js文件中声明,将Observer.js与Dep容器有关的代码注释掉并不影响相关逻辑。
let dep = new Dep()
Object.defineProperty(obj,key,{
enumerable:true,
configurable: true,
get(){
// 若是Dep.target 中有watcher 对象,则存储到订阅者数组中
Dep.target && dep.addSub(Dep.target)
return value
},
set(aValue){
if(value === aValue) return
value = aValue
// 若是设置的值是一个对象,那么这个对象也应该是响应式的
that.walk(aValue)
// watcher.update
// 发布通知,让全部订阅者更新内容
dep.notify()
}
})
}
}
复制代码
Watcher的做用就是将Compile解析的结果和Observer观察的对象关联起来,创建关系,当Observer观察的数据发生变化是,接收通知(dep.notify
)告诉Watcher,Watcher在经过Compile更新DOM。这里面涉及一个发布者-订阅者模式的思想。
Watcher是链接Compile和Observer的桥梁。
咱们在Watcher的构造函数中,须要传递三个参数:
vm
:vue实例expr
:vm.$data中数据的名字(key)callback
:当数据发生改变时,所执行的回调函数注意,为了获取深层数据对象,这里咱们须要引用以前声明的getVMData()
方法。
constructor(vm,expr,callback){
this.vm = vm
this.expr = expr
this.callback = callback
//
this.oldValue = this.getVMData(vm,expr)
//
}
复制代码
咱们应该在什么状况更新页面呢?
咱们应该在Watcher中实现一个update方法,对新值和旧值进行比较。当数据发生改变时,执行回调函数。
update() {
// 对比expr是否发生改变,若是改变则调用callback
let oldValue = this.oldValue
let newValue = this.getVMData(this.vm,this.expr)
// 变化的时候调用callback
if(oldValue !== newValue) {
this.callback(newValue,oldValue)
}
}
复制代码
vm.msg
的值的时候,须要从新渲染DOM,因此咱们还须要经过Watcher侦听expr值的变化。
// compile.js
mustache(node, vm) {
let txt = node.textContent
let reg = /\{\{(.+)\}\}/
if (reg.test(txt)) {
let expr = RegExp.$1
node.textContent = txt.replace(reg, this.getVMData(vm, expr))
// 侦听expr值的变化。当expr的值发生改变时,执行回调函数
new Watcher(vm, expr, newValue => {
node.textContent = txt.replace(reg, newValue)
})
}
},
复制代码
那么咱们应该在何时调用update方法,触发回调函数呢?
因为咱们在上文中已经在Observer实现了响应式数据,因此在数据发生改变时,必然会触发set方法。因此咱们在触发set方法的同时,还须要调用watcher.update方法,触发回调函数,修改页面。
// observer.js
defineReactive(obj,key,value) {
...
set(aValue){
if(value === aValue) return
value = aValue
// 若是设置的值是一个对象,那么这个对象也应该是响应式的
that.walk(aValue)
watcher.update
}
}
复制代码
那么问题来了,咱们在解析不一样的指令时,new 了不少个Watcher,那么这里要调用哪一个Watcher的update方法呢?如何通知全部的Watcher,告诉他数据发生了改变了呢?
因此这里又引出了一个新的概念:发布者-订阅者模式。
发布者-订阅者模式也叫观察者模式。 他定义了一种一对多的依赖关系,即当一个对象的状态发生改变时,全部依赖于他的对象都会获得通知并自动更新,解决了主体对象与观察者之间功能的耦合。
这里咱们用微信公众号为例来讲明这种状况。
譬如咱们一个班级都订阅了公众号,那么这个班级的每一个人都是订阅者(subscriber),公众号则是发布者(publisher)。若是某一天公众号发现文章内容出错了,须要修改一个错别字(修改vm.$data中的数据),是否是要通知每个订阅者?总不能学委那里的文章发生了改变,而班长的文章没有发生改变吧。在这个过程当中,发布者不用关心谁订阅了它,只须要给全部订阅者推送这条更新的消息便可(notify)。
因此这里涉及两个过程:
addSub(watcher)
notify(){ sub.update() }
在这个过程当中,充当发布者角色的是每个订阅者所共同依赖的对象。
咱们在Watcher中定义一个类:Dep(依赖容器)。在咱们每次new一个Watcher的时候,都往Dep里面添加订阅者。一旦Observer的数据发生改变了,则通知Dep发起通知(notify),执行update函数更改DOM便可。
// watcher.js
// 订阅者容器,依赖收集
class Dep {
constructor(){
// 初始化一个空数组,用来存储订阅者
this.subs = []
}
// 添加订阅者
addSub(watcher){
this.subs.push(watcher)
}
// 通知
notify() {
// 通知全部的订阅者更改页面
this.subs.forEach(sub => {
sub.update()
})
}
}
复制代码
接下来咱们的思路就很明确了,就是在每次new一个Watcher的时候,将它存储到Dep容器中。即将Dep与Watcher关联到一块儿。咱们能够为Dep添加一个类属性target来存储Watcher对象,即咱们须要在Watcher的构造函数中,将this赋给Dep.target。
this.oldValue = this.getVMData(vm, expr)
方法会在一次进入Observer中的get方法,而后程序执行完毕。因此咱们也就不难发现添加订阅者的时机,代码以下:
// Observer.js
// 定义响应式数据
defineReactive(obj,key,value) {
// defineProperty 会改变this指向
let that = this
let dep = new Dep()
Object.defineProperty(obj,key,{
enumerable:true,
configurable: true,
get(){
// 若是Dep.target存在,即存在watcher 对象,则存储到订阅者数组中
// debugger
Dep.target && dep.addSub(Dep.target)
return value
},
set(aValue){
if(value === aValue) return
value = aValue
// 若是设置的值是一个对象,那么这个对象也应该是响应式的
that.walk(aValue)
// watcher.update
// 发布通知,让全部订阅者更新内容
dep.notify()
}
})
}
复制代码
// Watcher.js
constructor(vm,expr,callback){
this.vm = vm
this.expr = expr
this.callback = callback
Dep.target = this
// debugger
this.oldValue = this.getVMData(vm,expr)
Dep.target = null
}
复制代码
Watcher.js完整代码以下:
// Watcher.js
class Watcher {
/** * * @param {*} vm 当前的vue实例 * @param {*} expr data中数据的名字 * @param {*} callback 一旦数据改变,则须要调用callback */
constructor(vm,expr,callback){
this.vm = vm
this.expr = expr
this.callback = callback
Dep.target = this
this.oldValue = this.getVMData(vm,expr)
Dep.target = null
}
// 对外暴露的方法,用于更新页面
update() {
// 对比expr是否发生改变,若是改变则调用callback
let oldValue = this.oldValue
let newValue = this.getVMData(this.vm,this.expr)
// 变化的时候调用callback
if(oldValue !== newValue) {
this.callback(newValue,oldValue)
}
}
// 只是为了说明原理,这里偷个懒,就不抽离出公共js文件了
getVMData(vm,expr) {
let data = vm.$data
expr.split('.').forEach(key => {
data = data[key]
})
return data
}
}
class Dep {
constructor(){
this.subs = []
}
// 添加订阅者
addSub(watcher){
this.subs.push(watcher)
}
// 通知
notify() {
this.subs.forEach(sub => {
sub.update()
})
}
}
复制代码
至此,咱们就已经实现了Vue框架的基本功能了。
本文只是经过用最简单的方式来模拟vue框架的基本功能,因此在细节上的处理和代码质量上确定会牺牲不少,还请你们见谅。
文中不免会有一些不严谨的地方,欢迎你们指正,有兴趣的话你们能够一块儿交流下