前言: 三月四月是招聘旺季,相信不少面试前端岗的同学都有被问到vue的原理是什么吧?本文就以最简单的方式教你如何实现vue框架的基本功能。为了减少大家的学习成本,我就以最简单的方式教大家撸一个vue框架。
一、准备
希望准备阅读本文的你最好具备以下技能:
- 熟悉ES6语法
- 了解HTML DOM 节点类型
- 熟悉
Object.defineProperty()
方法的使用 - 正则表达式的基本使用。(例如分组)
首先,我们按照以下代码创建一个HTML文件,本文主要就是教大家如何实现以下功能。
<script src="../src/vue.js">script>
head>
<body>
<div id="app">
<h2>title 是 {{title}}h2>
<p v-html='msg1' title='混淆属性1'>混淆文本1p>
<p v-text='msg2' title='混淆属性2'>混淆文本2p>
<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'>sayHibutton>
<button @click='printThis'>printThisbutton>
div>
body>
复制代码
let vm = new Vue({
el: '#app',
data: {
title: '手把手教你撸一个vue框架',
msg1: '应该被解析成a标签',
msg2: '不应该被解析成a标签',
something: 'placeholder',
dad: {
name: 'foo',
son: {
name: 'bar',
son: {}
}
}
},
methods: {
sayHi() {
console.log('hello world')
},
printThis() {
console.log(this)
}
},
})
复制代码
准备工作做好了,那我们就一起来实现vue框架的基本功能吧!
MVVM 实现思路
我们都知道,vue是基于MVVM设计模式的渐进式框架。那么在JavaScript中,我们该如何实现一个MVVM框架呢? 主流的实现MVVM框架的思路有三种:
- backbone.js
发布者-订阅者模式,一般通过pub和sub的方式实现数据和视图的绑定。
- Angular.js
Angular.js是通过脏值监测的方式对比数据是否有变更,来决定是否更新视图。类似于通过定时器轮寻监测数据是否发生了额改变。
- Vue.js
Vue.js是采用数据劫持结合发布者-订阅者模式的方式。在vue2.6之前,是通过Object.defineProperty() 来劫持各个属性的setter和getter方法,在数据变动时发布消息给订阅者,触发相应的回调。这也是IE8以下的浏览器不支持vue的根本原因。
Vue实现思路
- 实现一个Compile模板解析器,能够对模板中的指令和插值表达式进行解析,并赋予对应的操作
- 实现一个Observer数据监听器,能够对数据对象(data)的所有属性进行监听
- 实现一个Watcher 侦听器。讲Compile的解析结果,与Observer所观察的对象连接起来,建立关系,在Observer观察到数据对象变化时,接收通知,并更新DOM
- 创建一个公共的入口对象(Vue),接收初始化配置,并协调Compile、Observer、Watcher模块,也就是Vue。
上述流程如下图所示:
二、Vue入口文件
把逻辑捋顺清楚后,我们会发现,其实我们要在这个入口文件做的事情很简单:
- 把data和methods挂载到根实例中;
- 用Observer模块监听data所有属性的变化
- 如果存在挂载点,则用Compile模块编译该挂载点下的所有指令和插值表达式
/**
* 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模块
compile主要做的事情是解析指令(属性节点)与插值表达式(文本节点),将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图。
因为遍历解析的过程有多次操作dom节点,这会引发页面的回流与重绘的问题,为了提高性能和效率,我们最好是在内存中解析指令和插值表达式,因此我们需要遍历挂载点下的所有内容,把它存储到DocumentFragments中。
DocumentFragments 是DOM节点。它们不是主DOM树的一部分。通常的用例是创建文档片段,将元素附加到文档片段,然后将文档片段附加到DOM树。因为文档片段存在于内存中,并不在DOM树中,所以将子元素插入到文档片段时不会引起页面回流(对元素位置和几何上的计算)。因此,使用文档片段通常会带来更好的性能。
所以我们需要一个node2fragment()
方法来处理上述逻辑。
实现node2fragment,将挂载点内的所有节点存储到DocumentFragment中
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里面的节点
接下来我们要做的事情就是解析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)
}
})
}
}
复制代码
处理解析指令的逻辑:CompileUtils
接下来我们要做的就只剩下解析指令,并把解析后的结果通知给视图了。
当数据发生改变时,通过Watcher对象监听expr数据的变化,一旦数据发生变化,则执行回调函数。
new Watcher(vm,expr,callback)
// 利用Watcher将解析后的结果返回给视图.
我们可以把所有处理编译指令和插值表达式的逻辑封装到compileUtil
对象中进行管理。
这里有两个坑点大家需要注意一下:
- 如果是复杂数据的情形,例如插值表达式:
{{dad.son.name}}
或者,我们拿到
v-text
的属性值是字符串dad.son.name
,我们是无法通过vm.$data['dad.son.name']
拿到数据的,而是要通过vm.$data['dad']['son']['name']
的形式来获取数据。因此,如果数据是复杂数据的情形,我们需要实现getVMData()
和setVMData()
方法进行数据的获取与修改。 - 在vue中,methods里面的方法里面的this是指向vue实例,因此,在我们通过
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模块
其实在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模块
Watcher的作用就是将Compile解析的结果和Observer观察的对象关联起来,建立关系,当Observer观察的数据发生变化是,接收通知(dep.notify
)告诉Watcher,Watcher在通过Compile更新DOM。这里面涉及一个发布者-订阅者模式的思想。
Watcher是连接Compile和Observer的桥梁。
我们在Watcher的构造函数中,需要传递三个参数:
vm
:vue实例expr
:vm.$data中数据的名字(key)callback
:当数据发生改变时,所执行的回调函数
注意,为了获取深层数据对象,这里我们需要引用之前声明的getVMData()
方法。
定义Watcher
constructor(vm,expr,callback){
this.vm = vm
this.expr = expr
this.callback = callback
//
this.oldValue = this.getVMData(vm,expr)
//
}
复制代码
暴露update()方法,用于在数据更新时更新页面
我们应该在什么情况更新页面呢?
我们应该在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)
}
}
复制代码
关联Watcher与Compile
以插值表达式为例:(下文也会以这个例子进行说明) 当我们在控制台修改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。
还是以上面这个图为例,我们分析下解析插值表达式的流程:- 首先我们会进入Observer劫持data中的数据msg,这里我们会进入Observer中的get方法;
- 劫持后我们会判断el是否存在,存在的话则编译插值表达式进入Compile;
- 如果此时劫持的数据msg发生改变,则会通过mustache中的Watcher来侦听数据的改变;
- 在Watcher的构造函数中,通过
this.oldValue = this.getVMData(vm, expr)
方法会在一次进入Observer中的get方法,然后程序执行完毕。
所以我们也就不难发现添加订阅者的时机,代码如下:
- 将Watcher添加到订阅者数组中,如果数据发生改变,则为所有订阅者发起通知
// 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存储到Dep容器中后,将Dep.target置为空,以便下一次存储Watcher
// 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框架的基本功能,所以在细节上的处理和代码质量上肯定会牺牲很多,还请大家见谅。
文中难免会有一些不严谨的地方,欢迎大家指正,有兴趣的话大家可以一起交流下