前言
Vue这个框架自己也用了两年多了,发现自己对它的了解不是很深刻,对vue也只是停留在会用的地方,对于一些细节的地方了解的不是很深,最近有时间就在b站看了路白大佬的公开课,结合vue官网的源码理解,打算自己照着模仿写一个mini版的vue框架,方便自己加深印象,有兴趣的也可以自己去b站看视频,或者看官网。
b站链接:https://www.bilibili.com/video/BV1Lo4y1277S
官网链接: https://vue-js.com/learn-vue/start
内容梳理
此次实现的mini版的vue主要包括了大家比较关心的以下几点:
1、数据的双向绑定
2、数据劫持,也就是实现依赖收集
3、模板编译,主要实现几个简单的命令,比如v-html,v-text,v-model以及click事件
大致内容是以上几点,除此之外我们还要理解几个文件的内容
上图是我们的项目目录,项目名是myvue,index.html是我们的入口文件,modules下面有一下几个文件
1、complier.js用于模板编译
2、dep.js用于依赖收集
3、index.js也是入口文件,在index .html内引入
4、observe.js用于实现双向数据绑定
5、vue.js包含了vue的类
6、watcher.js用于通知观察者,也就是数据的依赖
到这里大家应该有大致的了解了,接下来是编码环节
index.html
index.html就是一个普通的html页面,内容只有一个id为app的根结点
<!DOCTYPE html>
<html lang="cn">
<head>
</head>
<body>
<div id="app">
</div>
</body>
</html>
vue.js
vue.js里面是我们的一个类,它需要接受一个options参数,这个options参数就是我们new Vue()时候传进来的,具体有哪些内容呢,我们可以联想到一个Vue页面就是一个vue的实例,它包括了一下内容:
1、el 就是我们的根结点
2、data 就是我们常见的vue页面里面的data
3、methods 一些事件,触发的方法
当然了,一个完整的vue肯定还有很多,今天我们只实现一部分,因此,我们的options包含了以上的内容,
export default class Vue{
constructor(options = {}){
this.$options = options
this.$data = options.data
this.$methods = options.methods
this.isElement(options.el)
this._proxyData(this.$data)
}
isElement(el){
if(typeof el == 'string'){
this.$el = document.querySelector(el)
}else if(el instanceof HTMLElement){
this.$el = el
}
if(!this.$el){
throw new Error('请传入字符串节点')
}
}
_proxyData(data){
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get(){
return data[key]
},
set(newValue){
if(newValue == data[key]){
return
}
data[key] = newValue
}
})
})
}
}
拿到传入的options,我们先在当前的实例先保存一下,这里我们要判断一下传入的节点el是否是合法的,如果传的节点找不到,或者传的是其他类型的数据我们要提示一下,因此借助一个isElement方法来判断el的合法性,如果传入的不是字符串(id,类名之类的)也不是一个html节点元素就提示传入的节点不合法,否则我们就把拿到的el挂载到当前的实例上。
其次我们还要把data挂载在实例上,这里借助于Object.defineProperity,这里有小伙伴可能要问为什么要把data挂载到实例上了?回想我们在vue开发的过程,如果在data 里面定了一个变量,我们是不是直接this.xxx就能拿到了,这一步就是为了实现这个效果;其次有可能还会问,如果是对象不是需要递归去挂载么?这里其实不需要,我们在这里不是实现双向绑定,只是把data里面的东西挂载到实例上,因此不需要。
index.js
上面我们写了最基本的vue类的内容,先在我们把vue.js引入到我们的入口文件,并初始化看看初始化了什么。
import Vue from './vue.js'
const vm = new Vue({
el: '#app',
data: {
msg: 'hello world',
},
methods: {
handle(){
alert(111)
}
}
})
console.log(vm)
别忘了我们还要在index.html内引入index.js,在index.html内加上这句
<script src="./modules/index.js" type="module"></script>
在控制台我们可以看到vue实例已经初始化出来了,data的msg直接挂载到了实例上,上面还有msg的 get和set方法。想要试试传错误el的小伙伴可自行尝试,到这里初始化的工作就做的差不多了,接下来是我们比较麻烦的一部分。
捋清关系
这里我们先捋一捋数据流,看看他们之间的关系,逐个细说
1、dep.js 用来存储依赖,收集依赖,通知依赖,既然知道了作用我们先建一个dep的类
export default class Dep{
constructor(){
this.subs = [] //依赖的集合
}
// 添加依赖的方法 watcher是观察者,也就是数据的依赖者
addSubs(watcher){
}
// 用于通知watcher更新
notify(){
}
}
2、wtacher.js 实际上数据的更细最终是在这里执行,dep负责通知watcher,
import Dep from './dep.js'
export default class Watch{
/**
* @description:
* @param {*} vm 当前vue实例
* @param {*} key data的key
* @param {*} cb update回调函数
* @return {*}
*/
constructor(vm, key, cb){
this.vm = vm
this.key = key
this.cb = cb
Dep.target = this
this.oldValue = vm[key]
Dep.target = null
}
update(){
// 判断新值与旧值是否相等
if(this.vm[this.key] === this.oldValue){
return
}
// 执行回调更新
this.cb(this.vm[this.key])
}
}
到了这里大家和我一样可能都有些迷糊了,我来和大家细说一下,我们的watcher初始化的时候会接收三个参数,vm是当前的都vue实例;key是我们datā都key,比如你在data定义了一个a变量,那么这个key就是a;cb是一个回调函数,用于数据的更新;初始化的时候我们先把这三个保存到a变量的watcher中,每一个变量都有属于自己的watcher,互不干扰;这里还有一步更精妙的就是在Dep上挂载了一个target,因为我们在获取旧值的时候会触发我们的变量的get方法,我们需要把依赖收集到dep中,有小伙伴可能会疑惑,不设置target我一样可以添加依赖啊,是的,的确可以,但是这样有可能会造成多次添加依赖,我们的target是为了确保唯一性,只添加一次,不重复添加,并且添加完马上设置为null。
这时候回过头去看dep.js就很简单了,直接上代码
export default class Dep{
constructor(){
this.subs = [] //依赖的集合
}
// 添加依赖的方法 watcher是观察者,也就是数据的依赖者
addSubs(watcher){
if(watcher && watcher.update){
this.subs.push(watcher)
}
}
// 用于通知watcher更新
notify(){
this.subs.forEach(watcher => {
watcher.update()
})
}
}
依赖的收集和更新都写好了,接下来就到我们模板的编译了,首先我们得知道complier.js是用来做什么的,我们比如有下面一段这样的template:
<div id="app">
<h3 v-html='msg'></h3>
<h3 v-text='msg'></h3>
<input type="text" v-model="msg">
<button v-on:click="handle"></button>
</div>
首先我们怎么拿到这段模板呢,
1、首先我们需要先拿到根结点
2、拿根结点的集合去遍历,判断他是文本节点还是标签节点
3、如果是文本节点直接解析它的nodeContent,如果是表达式,类似{{ msg }}的,直接用正则表达式替换掉内容
如果是标签节点,拿到该节点的属性进行遍历,如果是v-开头的 指令就进行下一步的指令解析。
4、如果指令是v-html,v-text这种,我们就截取指令v-的后半部分,如果是v-on:click就截取v-on:后面的内容;并把截取到的字符串拼接一个对应的事件,比如html就定义一个htmlHandle专门去处理v-html指令
大致步骤如上,接下来我们开始编码,根据上面的内容,我们需要初始化complier的时候需要传入一个vm实例,然后把实例上的内容保存起来
import Watcher from './watcher.js'
export default class Complier{
constructor(vm){
this.vm = vm
this.data = vm.$data
this.methods = vm.$methods
this.initTemplate(vm.$el)
}
// 解析模板
initTemplate(el){
let childNodes = Array.from(el.childNodes)
childNodes.forEach(node => {
// 判断是否是文本节点
if(this.isTextNode(node.nodeType)){
this.initTextNode(node)
}else if(this.isTagNode(node.nodeType)){
// 标签节点
this.initTagNode(node)
}
// 如果节点下面还有子节点 递归遍历
if(node.childNodes && node.childNodes.length > 0){
this.initTemplate(node)
}
})
}
initTextNode(node){
let reg = /\{\{(.+)\}\}/ // 正则匹配{{}}
let value = node.textContent // 获取文本节点内容
if(reg.test(value)){
let key = RegExp.$1.trim() // 获取当前上下文第一个正则表达式的结果
node.textContent = value.replace(reg, this.vm[key]) // 替换掉节点的内容
}
}
initTagNode(node){
// v-html v-text v-on:click
let attrs = Array.from(node.attributes) // 获取标签节点属性
attrs.forEach(attr => {
if(this.isDirective(attr.name)){ //判断是否是vue的指令
// v-text v-on:click根据不同指令截取不同内容
let name = attr.name.indexOf(':') > -1 ? attr.name.slice(5) : attr.name.slice(2)
// 生成fn
let fn = this[ name + 'Handle']
fn && fn.call(this, node, name, attr.value)
}
})
}
clickHandle(node, name, key){
// click事件直接在节点上添加dom事件,回调方法就是methods中的方法,例如v-on:click=”handle“,key就是handle
node.addEventListener(name, this.methods[key])
}
textHandle(node, name, key){
// v-text直接替换掉内容
node.textContent = this.data[key]
// 这里需要添加一个watcher,因为{{ msg }}也是数据的依赖者
new Watcher(this.vm, key, (newValue) => {
node.textContent = newValue
})
}
htmlHandle(node, name, key){
// v-html与v-text大同小异
node.innerHTML = this.data[key]
new Watcher(this.vm, key, (newValue) => {
node.innerHTML = newValue
})
}
modelHandle(node, name, key){
node.value = this.data[key]
new Watcher(this.vm, key, (newValue) => {
node.value = newValue
})
// v-model还需要在输入的时候手动更新data的值,达到数据双向绑定
node.addEventListener('input', () => {
this.vm[key] = node.value
})
}
isDirective(name){
return name.startsWith('v-')
}
isTextNode(type){
return type === 3
}
isTagNode(type){
return type === 1
}
}
complier.js内容比较多,但不复杂,处理的细节比较多,需要多看看。
最后一个就是observe.js了,在这里实现我们的双向数据绑定
import Dep from './dep.js'
export default class Observer{
constructor(vm){
this.data = vm.$data
this.vm = vm
this.traverse(this.data)
}
// 遍历data
traverse(data){
if(!data || typeof data != 'object'){
return
}
Object.keys(data).forEach(key => {
this.defineReactive(data, key, data[key])
})
}
defineReactive(data, key, value){
let dep = new Dep()
let that = this
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get(){
// 这里结合watcher那一段看,只有初始化watcher的时候,Dep.target才会存在,所以get的时候需要添加到依赖
Dep.target && dep.addSubs(Dep.target)
return value
},
set(newValue){
if(newValue == value){
return
}
value = newValue
// 设置新值的时候如果newValue是obj需要在遍历一次
that.traverse(newValue)
// 数据更新通知依赖发生了改变
dep.notify()
}
})
}
}
到这里我们的mini版vue就写完了,大家可以运行一遍试试,其实不需要大家去死记硬背,最重要的是大家要去了解vue每一步到底干了什么,面试的时候可能会问你,什么时候收集依赖?什么时候通知订阅者数据发生了更新,在哪里通知?vue是如何解析模板的,等等这些问题,看完文章我也相信大家有了一定的了解,由于我的文章比较烂,还是希望大家多看看官网的源码系列以及b站教程的,链接在上面,我也是照着b站自己撸一遍,来这里写篇文章加深印象,希望大家和我一样有所收获,文章有错误的地方欢迎指出,多多相互学习交流。