本篇讲述手写一个简单版的Vue,后面会有双向数据绑定的原理
结 尾 部 分 放 上 源 码 \color{skyblue}{结尾部分放上源码} 结尾部分放上源码
(本篇用到较多ES6语法,对ES6不熟悉可以参考阮一峰ES6讲解)
准备工作
创建一个空白页面index.html,做基本初始化工作
<body>
<div id="app">
</div>
<script src="./src/compile.js"></script>
<script src="./src/observer.js"></script>
<script src="./src/watcher.js"></script>
<script src="./src/myVue.js"></script>
<script>
const vm = new myVue({
el: "#app",
data: {
},
methods: {
}
})
</script>
</body>
在index.html同级目录创建一个src文件夹存放js文件,在src下面创建四个js文件
compile.js、observer.js、watcher.js、myVue.js(每个文件用来做什么后面会有说明)
m y V u e . j s 用 于 定 义 响 应 式 框 架 的 构 造 函 数 ( 创 建 m y V u e 的 实 例 ) \color{#ff0000}{myVue.js用于定义响应式框架的构造函数(创建myVue的实例)} myVue.js用于定义响应式框架的构造函数(创建myVue的实例)
// 定义一个类 用于创建vue实例
class myVue {
constructor(options = {
}) {
//options是在页面new myVue({})时传入的这个对象,options = {}表示不传默认为空对象
//给vue实例增加属性,将传过来的值挂载到myVue上,(在vue中习惯给属性名前面加上$符号)
this.$el = options.el
this.$data = options.data
this.$methods = options.methods
//如果指定了el参数,对el进行解析
if (this.$el) {
//compile负责解析模板的内容
//需要:模板和数据,把整个vue实例传过去
new Compile(this.$el, this)
}
}
}
在页面中new myVue()的时候调用这个类
c o m p i l e . j s 负 责 解 析 模 板 里 的 内 容 ( 解 析 e l 里 面 的 指 令 和 事 件 绑 定 ) \color{#ff0000}{compile.js负责解析模板里的内容(解析el里面的指令和事件绑定)} compile.js负责解析模板里的内容(解析el里面的指令和事件绑定)
编译解析过程在内存中进行 用到了 文档片段 fragment
// 方便后面打印数据在控制台 直接log()
let {
log,
dir
} = console
//负责解析模板内容
class Compile {
//参数1:模板容器 myVue中传过来的this.$el
//参数2:vue实例 myVue中传过来的this
constructor(el, vm) {
//el:new vue传递的选择器 #app
//下面写法表示 el 的参数是一个选择器的字符串,或者也可以是一个DOM对象
this.el = typeof el === 'string' ? document.querySelector(el) : el
//vm:new的myVue实例
this.vm = vm
//编译模板
if (this.el) {
//1.把el中所有子节点都放入到内存中,fragment
let fragment = this.node2fragment(this.el)
// log(fragment)
//2.在内存中编译fragment
this.compile(fragment)
//3.把fragment一次性的添加到页面
this.el.appendChild(fragment)
}
}
//核心方法
node2fragment(node) {
let fragment = document.createDocumentFragment()
//把el中所有的子节点添加到文档碎片中
let childNodes = node.childNodes
this.toArray(childNodes).forEach(node => {
//把所有的子节点添加到fragment中
fragment.appendChild(node)
})
return fragment
}
/**
* 编译文档碎片(内存中进行)
*
* @param {*} fragment
*/
compile(fragment) {
let childNodes = fragment.childNodes
this.toArray(childNodes).forEach(node => {
//编译子节点
// log(node)
//如果是元素,需要解析指令
if (this.isElementNode(node)) {
//如果是元素,需要解析指令
this.compileElement(node)
}
//如果是文本节点,需要解析插值表达式
if (this.isTextNode(node)) {
//如果是文本节点,需要解析插值表达式
this.compileText(node)
}
//如果当前节点还有子节点,需要递归的解析
if (node.childNodes && node.childNodes.length > 0) {
this.compile(node)
}
})
}
//解析html标签
compileElement(node) {
// log('需要解析html')
//1.获取当前节点下所有的属性
let attributes = node.attributes
// log(attributes)
this.toArray(attributes).forEach(attr => {
//2.解析vue的指令(所有以v-开头的属性)
// log(attr)
let attrName = attr.name
if (this.isDirective(attrName)) {
let type = attrName.slice(2)
let expr = attr.value
// log(type)
if (this.isEventDirective(type)) {
compileUtil['eventHandler'](node, this.vm, type, expr)
} else {
compileUtil[type] && compileUtil[type](node, this.vm, expr)
}
}
})
}
//解析文本节点
compileText(node) {
compileUtil.mustache(node, this.vm)
}
//工具方法
//转数组
toArray(likeArray) {
return [].slice.call(likeArray)
}
//是元素节点
isElementNode(node) {
//nodeType:节点的类型 如果是1:元素节点 3:文本节点
return node.nodeType === 1
}
//是文本节点
isTextNode(node) {
return node.nodeType === 3
}
//解析v-开头的指令
isDirective(attrName) {
return attrName.startsWith('v-')
}
//是否为v-on: 判断是否为事件绑定的指令
isEventDirective(attrName) {
return attrName.split(':')[0] === 'on'
}
}
let compileUtil = {
//解析插值表达式复杂数据类型
mustache(node, vm) {
// log('需要解析文本')
let txt = node.textContent
//用()给正则表达式做分组,方便下面获取到插值表达式里面的变量
let reg = /\{\{(.+)\}\}/
if (reg.test(txt)) {
// log(txt) //需要解析的文本
let expr = RegExp.$1.trim()
// log(expr) //获取到插值表达式里面的变量
node.textContent = txt.replace(reg, compileUtil.getVMValue(vm, expr))
}
},
//解析v-text指令
text(node, vm, expr) {
node.textContent = this.getVMValue(vm, expr)
// log(node)
},
// 解析v-html指令
html(node, vm, expr) {
node.innerHTML = this.getVMValue(vm, expr)
},
//解析v-on指令
eventHandler(node, vm, type, expr) {
let eventType = type.split(':')[1]
// log(eventType)
let fn = vm.$methods && vm.$methods[expr]
if (eventType && fn) {
//使用bind改变methods里的this指向vm实例
node.addEventListener(eventType, fn.bind(vm))
}
},
//这个方法用于获取vm中的数据
getVMValue(vm, expr) {
let data = vm.$data
expr.split('.').forEach(item => {
// log(item)
data = data[item]
})
return data
}
}
至此已经实现了指令 v-text、v-html、事件绑定 v-on以及插值表达式{ {}}的解析,其他指令可举一反三
o b s e r v e r . j s 用 于 给 d a t a 中 所 有 的 数 据 添 加 g e t t e r 和 s e t t e r ( 监 听 d a t a 中 的 所 有 数 据 ) \color{#ff0000}{observer.js用于给data中所有的数据添加getter和setter(监听data中的所有数据)} observer.js用于给data中所有的数据添加getter和setter(监听data中的所有数据)
/**
1. observer用于给data中所有的数据添加getter和setter
*/
class Observer {
constructor(data) {
this.data = data
this.walk(data)
}
/**核心方法
* 遍历data中所有的数据,都添加上getter和setter
*/
walk(data) {
if (!data || typeof data != "object") {
return
}
Object.keys(data).forEach(key => {
// log(key) //给data对象的key设置getter和setter
this.defineReactive(data, key, data[key])
//如果data[key]是复杂数据类型,递归walk,直到简单数据类型后return
this.walk(data[key])
})
}
defineReactive(obj, key, value) {
//Vue2.x版本中使用Object.defineProperty()进行数据劫持,官方公布3.x版本改写为proxy()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
// log('你获取了值', value)
return value
},
set(newValue) {
if (value === newValue) {
return
}
// log('你设置了newValue', newValue)
value = newValue
}
})
}
}
在 m y V u e . j s 中 初 始 化 O b s e r v e r \color{skyblue}{在myVue.js中初始化Observer} 在myVue.js中初始化Observer
constructor(options = {
}) {
//给vue实例增加属性
this.$el = options.el
this.$data = options.data
this.$methods = options.methods
//------在此处初始化Observer------
new Observer(this.$data)
//如果指定了el参数,对el进行解析
if (this.$el) {
//compile负责解析模板的内容
//需要:模板和数据,把整个vue实例传过去
new Compile(this.$el, this)
}
}
最后实现双向数据绑定,即响应式框架
讲双向数据绑定要先讲一种设计模式,发布订阅模式(也叫观察者模式)的设计思想
观察者模式:定义了对象间一种一对多的依赖关系,当目标对象 Subject 的状态发生改变时,所有依赖它的对象 Observer 都会得到通知。
模式特征:
一个目标者对象 Subject,拥有方法:添加 / 删除 / 通知 Observer;
多个观察者对象 Observer,拥有方法:接收 Subject 状态变更通知并处理;
目标对象 Subject 状态变更时,通知所有 Observer。
Subject 添加一系列 Observer, Subject 负责维护与这些 Observer 之间的联系,“你对我有兴趣,我更新就会通知你”。
简 单 理 解 发 布 订 阅 模 式 : 发 布 者 可 以 理 解 为 微 信 公 众 号 作 者 , 订 阅 者 可 以 理 解 为 关 注 公 众 号 的 人 。 \color{skyblue}{简单理解发布订阅模式:发布者可以理解为微信公众号作者,订阅者可以理解为关注公众号的人。} 简单理解发布订阅模式:发布者可以理解为微信公众号作者,订阅者可以理解为关注公众号的人。
当 作 者 发 布 新 的 文 章 时 所 有 关 注 的 人 都 会 收 到 新 的 消 息 , 即 发 布 者 发 布 消 息 , 所 有 订 阅 者 收 到 通 知 来 及 时 更 新 内 容 \color{skyblue}{当作者发布新的文章时所有关注的人都会收到新的消息,即发布者发布消息,所有订阅者收到通知来及时更新内容} 当作者发布新的文章时所有关注的人都会收到新的消息,即发布者发布消息,所有订阅者收到通知来及时更新内容
w a t c h e r . j s 模 块 负 责 把 c o m p i l e 模 块 和 o b s e r v e r 模 块 关 联 起 来 ( 用 于 管 理 订 阅 者 ) \color{#ff0000}{watcher.js模块负责把compile模块和observer模块关联起来(用于管理订阅者)} watcher.js模块负责把compile模块和observer模块关联起来(用于管理订阅者)
双 向 数 据 绑 定 核 心 功 能 在 这 里 实 现 \color{#ff0000}{双向数据绑定核心功能在这里实现} 双向数据绑定核心功能在这里实现
/**
* watcher模块负责把compile模块和observer模块关联起来
*/
class Watcher {
/**
* vm:当前的vue实例
* expr:data中数据的名字
* cb:一旦数据发生了改变,则需要调用cb
*/
constructor(vm, expr, cb) {
this.vm = vm
this.expr = expr
this.cb = cb
/**
* this表示的就是新创建的watcher对象
* 存储到Dep.target属性上
* */
Dep.target = this
//把expr的旧值给存起来
this.oldValue = this.getVMValue(vm, expr)
//清空Dep.target
Dep.target = null
}
//对外暴露的一个方法,这个方法用于更新页面
update() {
let oldValue = this.oldValue
let newValue = this.getVMValue(this.vm, this.expr)
if (oldValue != newValue) {
this.cb(newValue)
}
}
//这个方法用于获取vm中的数据
getVMValue(vm, expr) {
let data = vm.$data
expr.split('.').forEach(item => {
// log(item)
data = data[item]
})
return data
}
}
/**
* dep对象管理订阅者和通知所有订阅者
* */
class Dep {
constructor() {
//用于管理订阅者
this.subs = []
}
//添加订阅者
addSub(watcher) {
this.subs.push(watcher)
}
//通知,发布
notify() {
//遍历所有的订阅者,调用watcher的update()方法
this.subs.forEach(sub => {
sub.update()
})
}
}
在compile.js的指令解析方法中加入Watcher,通过watcher对象,监听expr(data中数据的名字)数据的变化,一旦变化了,执行下面的回调函数
m u s t a c h e 方 法 增 加 W a t c h e r 监 听 \color{skyblue}{mustache方法增加Watcher监听} mustache方法增加Watcher监听
mustache(node, vm) {
// log('需要解析文本')
let txt = node.textContent
//用()给正则表达式做分组,方便下面获取到插值表达式里面的变量
let reg = /\{\{(.+)\}\}/
if (reg.test(txt)) {
// log(txt) //需要解析的文本
let expr = RegExp.$1.trim()
// log(expr) //获取到插值表达式里面的变量
node.textContent = txt.replace(reg, compileUtil.getVMValue(vm, expr))
//-------------此处为新增Watcher监听------------
new Watcher(vm, expr, newValue => {
node.textContent = txt.replace(reg, newValue)
})
}
}
t e x t 方 法 增 加 W a t c h e r 监 听 \color{skyblue}{text方法增加Watcher监听} text方法增加Watcher监听
text(node, vm, expr) {
node.textContent = this.getVMValue(vm, expr)
// log(node)
//通过watcher对象,监听expr的数据的变化,一旦变化了,执行下面的回调函数
//-------------此处为新增Watcher监听------------
new Watcher(vm, expr, newValue => {
node.textContent = newValue
})
}
h t m l 方 法 增 加 W a t c h e r 监 听 \color{skyblue}{html方法增加Watcher监听} html方法增加Watcher监听
html(node, vm, expr) {
node.innerHTML = this.getVMValue(vm, expr)
//-------------此处为新增Watcher监听------------
new Watcher(vm, expr, newValue => {
node.innerHTML = newValue
})
}
增 加 m o d e l 方 法 和 W a t c h e r 监 听 \color{skyblue}{增加model方法和Watcher监听} 增加model方法和Watcher监听
//解析v-model指令
model(node, vm, expr) {
let self = this
node.value = this.getVMValue(vm, expr)
//实现双向数据绑定,给node注册input事件,当当前value值发生改变时,修改对应的数据
node.addEventListener('input', function () {
self.setVMValue(vm, expr, this.value)
})
//-------------此处为新增Watcher监听------------
new Watcher(vm, expr, newValue => {
node.value = newValue
})
}
增 加 s e t V M V a l u e 方 法 处 理 数 据 双 向 绑 定 的 复 杂 数 据 类 型 \color{skyblue}{增加setVMValue方法处理数据双向绑定的复杂数据类型} 增加setVMValue方法处理数据双向绑定的复杂数据类型
//处理数据双向绑定的复杂数据类型
setVMValue(vm, expr, value) {
let data = vm.$data
let arr = expr.split('.')
arr.forEach((key, index) => {
// log(key)
if (index < arr.length - 1) {
data = data[key]
} else {
data[key] = value
}
})
}
在observer.js的defineReactive方法中给data中的每一个数据添加订阅
//data中的每一个数据都应该维护一个Dep对象
//Dep保存了所有的订阅了该数据的订阅者
defineReactive(obj, key, value) {
let that = this
let dep = new Dep()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
//如果Dep.target中有watcher对象,存储到订阅者数组中
Dep.target && dep.addSub(Dep.target)
// log('你获取了值', value)
return value
},
set(newValue) {
if (value === newValue) {
return
}
// log('你设置了newValue', newValue)
value = newValue
//如果newValue也是一个新对象,也要对其进行数据劫持
that.walk(newValue)
//发布通知,让所有订阅者更新内容
dep.notify()
}
})
}
到此已经实现了双向数据绑定
最后再将data、methods中所有的数据都代理到vm上,实现在内部可以直接用this访问数据和方法
在 m y V u e . j s 中 增 加 p r o x y 方 法 把 d a t a 和 m e t h o d s 中 所 有 的 数 据 代 理 到 v m 实 例 上 \color{skyblue}{在myVue.js中增加proxy方法把data和methods中所有的数据代理到vm实例上} 在myVue.js中增加proxy方法把data和methods中所有的数据代理到vm实例上
//把data和methods中所有的数据代理到vm上
proxy(data) {
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get() {
return data[key]
},
set(newValue) {
if (data[key] == newValue) {
return
}
data[key] = newValue
}
})
})
}
在 m y V u e . j s 中 的 c o n s t r u c t o r 函 数 里 面 执 行 p r o x y 方 法 \color{skyblue}{在myVue.js中的constructor函数里面执行proxy方法} 在myVue.js中的constructor函数里面执行proxy方法
constructor(options = {
}) {
//给vue实例增加属性
this.$el = options.el
this.$data = options.data
this.$methods = options.methods
new Observer(this.$data)
//--------------在此处执行proxy----------------
//把data中所有的数据都代理到vm上
this.proxy(this.$data)
//把methods中所有的数据都代理到vm上
this.proxy(this.$methods)
//如果指定了el参数,对el进行解析
if (this.$el) {
//compile负责解析模板的内容
//需要:模板和数据,把整个vue实例传过去
new Compile(this.$el, this)
}
}
至此已经实现了指令、插值表达式、事件绑定、双向数据绑定的功能
分享一个vsCode批量添加带src
属性img标签的方法
<!-- img[src='/images/pic_$$.png']*n -->