前言:最近关注了vue,想对比一下和react的实现的区别,方便以后项目做选型。
打算了解对比一下基本实现,才能写出更符合 框架设计思想高效率的代码,出了问题才能更好的排错。
这个版本并没有实现四则运算,和生命周期以及数组的方法劫持,只是做了简单的响应式分析,后续会做一版虚拟dom的分析
vue响应式模拟
准备工作
- 数据驱动
- 响应式核心
- 发布订阅和观察者
数据驱动
- 数据响应式:数据模型仅仅是普通的js对象,而当我们修改数据时,视图会进行对应的更新,避免了繁琐的dom操作,提高开发效率
- 双向绑定:数据改变,视图改变;视图改变,数据也随之改变,使用v-model在表单元素上创建双向数据绑定
- 数据驱动是vue最独特特性之一:开发过程仅需要关注数据本身,不需要关心数据是如何渲染到视图的
数据响应式
vue2是用的Object.defineProperty来进行对对象的get,set进行劫持来实现的双向绑定类似:
let data ={msg:'hello'}
let vm={}
Object.defineProperty(vm,'msg',{
enumerable:true,
configurable:true,
get(){
return data.msg
}
set(newValue){
if(newValue === data.msg){
return
}
data.msg=newValue
document.querySelector('#app').textContent=data.msg
}
})
vm.msg='HelloWorld'
console.log(vm.msg)
vue3使用的是代理对象,直接监听对象,不监听属性,性能由浏览器优化,
let data={msg:'hello',count:0}
let vm = new Proxy(data,{
get(target,key){
return target[key]
}
set(target,key,newValue){
if(target[key] === newValue){
return
}
target[key] = newValue
document.querySelector('#app').textContent=target[key]
}
})
贴个图来展示下俩的区别
Object.defineProperty
只能劫持对象的属性,因此我们需要对每个对象的每个属性进行遍历。Object.defineProperty
不能监听数组。是通过重写数据的那7个可以改变数据的方法来对数组进行监听的。Object.defineProperty
也不能对es6
新产生的Map
,Set
这些数据结构做出监听。
Proxy不会直接侵入对象去做劫持,而是直接对对象整体进行包装一层。
不过我们目前实现的简易版vue是基于vue2去做的。
发布/订阅模式和观察者模式
发布/订阅模式
- 订阅者
- 发布者
- 信号中心
说明:我们假定,存在一个"信号中心",某个任务执行完成,就向信号中心"发布"(publish)一个信号,其他任务可以向信号中心"订阅"(subscribe)这个信号,从而知道什么时候自己可以开始执行。这就叫做"发布/订阅模式"(publish-subscribe pattern)
vue中的自定义事件就是一个发布订阅模式$emit,$on.
//eventBus.js
//事件中心
let eventHub=newVue()
//ComponentA.vue
//发布者
addTodo:function(){//发布消息(事件)
eventHub.$emit('add-todo',{text:this.newTodoText})
this.newTodoText=''
}
//ComponentB.vue//订阅者
created:function(){//订阅消息(事件)
eventHub.$on('add-todo',this.addTodo)
}
我们模拟一个vue的发布订阅,如下
class EventEmitter{
constructor(){
this.subs={}
}
$on(eventType,handler){//订阅
this.subs[eventType] = this.subs[eventType]||[]
this.subs[eventType].push(handler)
}
$emit(eventType){//发布
if(this.subs[eventType]){
this.subs[eventType].forEach(handler=>{
handler()
})
}
}
}
var bus= new EventEmitter()
//注册事件
bus.$on('click',function(){
console.log('click')
})
bus.$on('click',function(){
console.log('click1')
})
//触发事件
bus.$emit('click')
观察者模式
- 观察者watcher:update当事件发生时更新
目标(发布者)-Dep
- subs数组:储存所有观察者
- addsub添加观察者
- notify事件发生时通知观察者
- 没有事件中心
class Dep{
constructor(){
this.subs=[]
}
addsub(sub){
if(sub&&sub.update){
this.subs.push(sub)
}
}
notify(){
this.subs.forEach(sub=>{sub.update()})
}
}
class Watcher{
update(){
console.log('更新六')
}
}
let dep= new Dep()
let watcher = new Watcher()
dep.addSub(watcher)
dep.notify()
总结
观察者模式是由具体目标调度,比如当事件触发,Dep 就会去调用观察者的方法,所以观察者模式的订阅者与发布者之间是存在依赖的。
发布/订阅模式由统一调度中心调用,因此发布者和订阅者不需要知道对方的存在。
从使用层面上讲:
- 观察者模式,多用于单个应用内部
- 发布订阅模式,则更多的是一种跨应用的模式(cross-application pattern),比如我们常用的消息中间件
然后我们分析一下vue中的实例化及更新流程,如图
- vue:把传入的data注入vue实例,并且转换为set/get
- Observer:能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知 Dep,Dep再调用观察者更新
- Compiler:解析每个元素中的指令/插值表达式,并替换成相应的数据
- Dep:添加观察者(watcher),当数据变化通知所有观察者
- Watcher:数据变化更新视图
然后我们一个一个来实现对应的类
vue
功能:
- 负责接收初始化的参数(选项)
- 负责把data中的属性注入到vue实例中,转换为getter/setter
- 负责调用observer监听data中所有属性变化,进行劫持
- 分则调用compiler解析指令/插值表达式
结构:
代码实现如下:
class Vue {
constructor(options) {
// 1 通过实例属性来保存传进来得配置及数据
options = options || {}
this.$options = options
this.$data = options.data || {}
// $el存储真实dom对象方便后续更新元素使用
this.$el = typeof options.el ? document.querySelector(options.el) : options.el
// 2 把data中得数据转换为get和set注入到vue实例中
this._proxyData(this.$data)
// 3 调用observer进行数据劫持,把data转换为get,set,监听数据变化,
// 初始化dep发布者准备收集观察者watcher依赖
new Observer(this.$data)
//4 调用compiler对象,开始编译,解析指令和差值表达式,
// 首次解析渲染时,初始化watcher观察者,并通过获取data数据时得get方法,放到dep发布者数组中,
// 方便后续值变化时,发布者进行依次通知对应得观察者
new Compiler(this)
}
_proxyData (data) {
// 转换传入得data所有属性转换为get set放到vue实例上
// 通过keys获取当前对象上可枚举得属性
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, { // 把data中每个属性取出转换成get set属性放到vue实例中
enumerable: true,// 属性可枚举
configurable: true,// 可删除
get () { //后续把data对象逐级也转换为了get和set,
// 然后获取实际属性时包括层级较深得属性时,也会触发data对象上对应得get和set方法
return data[key]
},
set (value) {// 和上面得get一样
if (value === data[key]) {
return
}
data[key] = value
}
})
})
}
}
Observer
功能:
- 负责把data选项中的属性转换为响应式数据
- data中的某个属性也是对象,把该属性也转换为响应式数据
- 数据变化发送通知
结构:
代码:
class Observer {
constructor(data) {
this.walk(data) //遍历所有得属性
}
walk (data) {
if (!data || typeof data === 'string') {
//如果传入得data不是一个对象,或者它是一个字符串退出(要递归调用,所以判断下是字符串)
//因为这里做得是一个简易版得,所以不考虑数组,实际上当属性值是数组,数组变化的时候,跟踪不到变化。
//因为数组虽然是对象,但是Object.defineProperty不支持数组,所以vue改写了数组的所有方法,
//当调用数组方法的时候,就调动变动事件。但是不能通过属性或者索引控制数组,比如length,index。
//如果是数组,只能通过数组方法修改数组。如,控制台vm.arr--发现视图并不会变化,vm.arr.push(4)就能变化
return
}
Object.keys(data).forEach(key => {
this.defineReactive(data, key, data[key]) //进行数据劫持
})
}
defineReactive (obj, key, value) {
//这个方法在这里传入data[key]这个值是为了get中返回使用
//如果不传入,在get中通过data[key],获取使用得话,会循环调用get方法死循环了。
//这里通过闭包把value留住
let that = this
let dep = new Dep() //创建发布者,进行依赖watcher观察者收集,和发送通知watcher更新
//每个属性节点都有一个发布者,来进行监听
this.walk(value) // 判断当前data[key]得值是不是对象,是得话,继续进行它内部属性得劫持
Object.defineProperty(obj, key, {
enumerable: true,//可枚举
configurable: true,//可删除
get () {
//获取闭包内部得value
Dep.target && dep.subs.push(Dep.target)//收集依赖,watcher中初始化时会设置值并调用对应get
return value
},
set (newValue) {
if (newValue === value) {
return
}
//赋值给闭包内部得value,不通过data[key]赋值,防止死循环
value = newValue
//查看它newValue是不是对象,如果是的话,对新的这个对象进行数据劫持
that.walk(newValue)
dep.notify() //值变化,发布者通知储存得观察者watch进行dom更新
}
})
}
}
这里在使用defineReactive进行劫持的时候,我们把value传入这个方法中在get/set中使用,形成闭包,避免get或者set的时候通过obj[key]获取或修改数据造成死循环,所有的get/set中都是操作的闭包中的value这个变量。
在这个方法中针对每一个属性节点都有一个Dep发布者对象,因为每个节点属性都有可能展示在界面上,界面上多个地方都有可能使用这个属性(也就是说每个节点都有可能被观察者观察,观察者可能是多个),所以我们在这个属性劫持的方法里来对Dep对象添加观察者以及通知观察者改变dom
Compiler编译器
功能:
- 负责编译模板,解析指令/插值表达式
- 负责页面的首次渲染,初始化watcher观察者
- 当数据观察者变化后重新渲染视图
结构:
代码实现:
class Compiler {
constructor(vm) {
this.el = vm.$el //dom容器元素
this.vm = vm //vue得实例
this.compile(this.el)//开始进行指令和差值表达式编译
}
compile (el) {//编译模板,处理文本节点和元素节点
let childNodes = el.childNodes //获取当前dom元素得所有子节点(是节点,不是元素,节点包含了空格换行等)
Array.from(childNodes).forEach(node => { //类数组转换数组对象,然后依次对内部节点进行处理
if (this.isTextNode(node)) {//处理文本节点
this.compileText(node)
} else if (this.isElementNode(node)) {//处理元素节点
this.compileElement(node)
}
if (node.childNodes && node.childNodes.length) {//当前节点还有子集,继续递归调用来编译它的子集节点
this.compile(node)
}
})
}
compileText (node) {//文本节点得时候,处理差值表达式{{msg}}
// https://www.cnblogs.com/yalong/p/14101587.html
let reg = /\{\{(.+?)\}\}/ //使用()分组,通过正则匹配内部任意字符多次出现,然后懒惰匹配
let value = node.textContent;
if (reg.test(value)) { //匹配上了
//RegExp这个对象会在我们调用了正则表达式的方法后, 自动将最近一次的结果保存在里面,
//所以如果我们在使用正则表达式时, 有用到分组, 那么就可以直接在调用完以后直接使用RegExp.$xx来
//使用捕获到的分组内容
//获取分组0位置,也就是{{}}里面得字符串,然后除去空格(里面可能右空格)
let key = RegExp.$1.trim()
node.textContent = value.replace(reg, getVmDataValue.call(this, key))
//创建观察者watch,当数据改变时更新视图
new Watcher(this.vm, key, function (newValue) {
node.textContent = newValue
})
}
}
compileElement (node) {//编译元素节点上得指令 v- 开头
Array.from(node.attributes).forEach(attr => {
let attrName = attr.name
if (this.isDirective(attrName)) { //判断属性是否是指令
attrName = attrName.substr(2) //v-text 转换为 text
let key = attr.value //获取指令得值 ,后续通过它获取对应vue实例得数据
this.updata(node, key, attrName)
}
})
}
updata (node, key, attrName) {
//减少if判断所以通过动态取值方式,获取对应得更新方法。
let updateFn = this[attrName + 'Updater']
//进行更新,绑定this,后面要用,然后传入node节点,对应值和key,创建观察者要用
updateFn && updateFn.call(this, node, getVmDataValue.call(this, key), key)
}
textUpdater (node, value, key) {//v-text处理
node.textContent = value
new Watcher(this.vm, key, function (newValue) {
node.textContent = newValue
})
}
modelUpdater (node, value, key) {//v-model input类型赋值为node.value
node.value = value
new Watcher(this.vm, key, function (newValue) {
node.value = newValue
})
node.addEventListener('input', () => {//进行双向数据绑定
let keys = key.split('.')
if (keys.length === 1) {
this.vm[keys[0]] = node.value
} else {
let obj = this.vm
for (let i = 0, length = keys.length; i < length; i++) {
if (i < length - 1) {
obj = obj[keys[i]]
} else {
obj[keys[i]] = node.value
}
}
}
})
}
isDirective (attrName) {//通过es6方法查看属性是否是v-开头
return attrName.startsWith("v-")
}
isTextNode (node) { //nodeType 为3得是文本节点
return node.nodeType === 3
}
isElementNode (node) {
return node.nodeType === 1 //1得为元素节点
}
}
Dep(Dependency)
发布者:
- 收集依赖添加观察者watcher
- 通知所有观察者
结构:
这个类在observer中进行数据劫持时使用,在每个属性节点劫持时进行实例化,在get中收集观察者,在set中通知观察者更新dom
代码:
class Dep { //发布者对象,储存观察者,等待对应劫持数据更行了,通知储存得观察者,进行dom更新
constructor() {
//储存所有观察者
this.subs = []
}
addSub (sub) {//添加
if (sub && sub.update) { //观察者一定是有一个update方法得,用来更新dom
this.subs.push(sub)
}
}
notify(){ //对所有观察者进行通知更新
this.subs.forEach(sub=>{
sub.update()
})
}
}
Watcher观察者
功能:
- 当数据变化触发依赖,dep通知所有的watcher实例更新视图
- 自身实例化的时候往dep对象中添加自己
结构:
代码:
class Watcher {
constructor(vm, key, cb) {
this.vm = vm //vue实例
this.key = key //指令和文本节点中得值 也就是vue实例中data里得key
this.cb = cb //回调函数,更新视图dom用
//把watcher对象记录到Dep类得静态属性target
Dep.target = this //方便获取vue中data数据时调用get时让对应的Dep把当前得观察者储存到它内部subs数组里
this.oldValue = getVmDataValue.call(this, key) //获取vue data中得数据,这会触发get时,dep.target会为true
//就会添加到 对应dep发布者实例数组中了
Dep.target = null //只第一次渲染dom 初始化watcher得时候添加就够了,防止重复添加,再把它置为null
}
update () {
let newValue = getVmDataValue.call(this, this.key)
if (this.oldValue === newValue) {//相同值
return;
}
this.cb(newValue)
}
}
这个watcher对象的实例化是在compiler编译首次渲染进行差值或指令解析的时候进行的,实例化时会把dep的target设置为自身,然后获取data中的值方便更新时比对,触发data中的get添加到对应dep实例中,
等待data改变时dep调用对应的watcher来进行dom更新。
还有一个公共方法的操作:
function getVmDataValue (key) { //获取vue 实例data数据,查看是否取得是 深层级对象里得属性
let keys = key.split('.')
let vmValue = keys.length === 1 ? this.vm[keys[0]] : keys.reduce(function (obj, current) {
return obj[current];
}, this.vm)
return vmValue
}
最后是我的html界面代码,贴出来:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">
<h1> 差值表达式:</h1>
<h3>{{ person.name }}</h3>
<h3> {{ count}}</h3>
<h1>
v-text
</h1>
<div v-text="msg"></div>
<h1>v-model</h1>
<input type="text" v-model="person.name"></input>
<input type="text" v-model="count">
</div>
<script src="./js/baseVue.js"></script>
<script src="./js/dep.js"></script>
<script src="./js/watcher.js"></script>
<script src="./js/compiler.js"></script>
<script src="./js/observer.js"></script>
<script src="./js/vue.js"></script>
<!-- <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script> -->
<script>
var vm = new Vue({
el:'#app',
data:{
msg:'Hello word',
count:10,
person:{
name:'lp'
}
}
})
console.log(vm)
</script>
</body>
</html>
这样一个最简易版的mini-vue数据相应就完成了。
总结一下,来个图看着说明:
- 首先创建vue实例,实例中进行2,3操作
进行observer进行数据劫持
- 每个节点初始化Dep发布者
- 在get中判断是否添加watcher观察者对象
- 在set中dep通知watcher更新dom
最后解析指令Compiler
- 在第一次解析差值表达式或指令时,初始化watcher观察者
- 初始化观察者时会改变dep的target并触发data的get方法,从而添加 到对应属性节点的dep发布者对象中
- 初始化watcher观察者时会添加更改dom的回调,方便dep通知它时更新dom
- 对对应指令进行input双向数据绑定
这样到这里我们一个最简易版的mini-vue数据响应式就算完成了,后续会有一篇虚拟dom的分析。
该内容借鉴于拉钩大前端训练营
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。