手写vue - 数据响应式、数据的双向绑定、事件监听
相关面试题:
1. MVVM的理解
MVVM:Model-View-ViewModel,也就是把MVC的Controller演变成ViewModel。
Model:数据模型,View:UI组件,ViewModel:View和Model的桥梁,数据绑定到ViewModel层并自动将数据渲染到页面中,视图变化会通知ViewModel层更新数据。
2. Vue实现数据双向绑定的原理: Object.defineProperty()
-
vue实现数据双向绑定主要是:采用 数据劫持 结合 发布者-订阅者模式的方式,通过Object.defineProperty() 来劫持data中各个属性的访问器
setter
,getter
。当数据发生变动时,发布者dep消息(notice)给订阅者wathcer通知更新,触发相应的监听回调。 -
vue数据双向绑定,整合 Observer,Compile和Watcher三者,通过Observe来监听自己model的数据变化,通过Compile来解析编译模板({{}}/v-/@),最终Watcher调用update来进行视图更新。
3. 组件中的data为什么是一个函数
一个组件被复用多次的话,就会创建多个实例。
本质上,这些实例用的是同一个构造函数。
如果data是对象的话,对象是引用类型,会影响到所有的实例。
所以,为了保证不同实例之间的data不冲突,data是个函数,返回一个对象。
手写VUE
数据响应式:数据劫持:依赖收集,通知更新
Vue对象
class Vue {
constructor(options) {
this.$options = options
this.$el = document.body
this.$data = options.data()
this.$methods = options.methods
this.$mounted = options.mounted
// 数据劫持
this.observe(this.$data)
// 编译数据
this.compile(this.$el)
}
// 遍历劫持数据
observe(obj) {
if (typeof obj !== 'object') {
return;
}
Object.keys(obj).forEach(key => {
// 递归遍历所有层次
this.observe(obj[key])
// 创建观察者
new Oberver(obj, key)
// 数据代理: this.$data.title ---> this.title
this.proxyData(key)
})
}
// 数据代理
proxyData(key) {
Object.defineProperty(this, key, {
get() {
return this.$data[key]
},
set(v) {
this.$data[key] = v
}
})
}
// 编译
compile(el) {
new Compile(this, el)
}
$mount(sel) {
this.$el = document.querySelector(sel)
const update = () => {
if (!this.mounted) {
// 首次执行, 实现挂载
this.mounted = true
if (this.$mounted) {
this.$mounted()
}
}
}
update()
}
}
Oberver对象
// 观察者
class Oberver {
constructor(obj, key) {
this.defineReactive(obj, key, obj[key])
}
// 定义响应式: 遍历data中的每个数据,都要生成一个Dep容器,这个Dep容器用来收集该数据产生的依赖(即:每使用一次该数据,就会产生一个Watcher,用来update更新)
defineReactive(obj, key, val) {
const dep = new Dep() // 遍历data中的每一个数据,并生成相应的Dep容器
Object.defineProperty(obj, key, {
get() {
// 每一次访问数据 this.title , 都会往Dep容器的实例里面deps推入一个watcher
if (Dep.target) { // 每次使用该数据的时候,都要创建一个依赖(即Watcher,方便未来进行数据更新)
dep.addDep(Dep.target)
}
return val
},
set(v) {
if (val !== v) { // 数据值改变时,给该数据赋新值,并通知(notity)该数据的所有依赖进行更新
val = v
// 数据重新赋值,[通知]该数据所有的依赖更新数据
dep.notice()
}
}
})
}
}
Dep对象: 依赖收集器
// 依赖收集容器Dep:即,管理wathcer的管理者。data中的每一个数据,都要生成一个dep容器,用来收集
// Dep容器,data中的每个数据会对应一个,用来收集并存储依赖(依赖: 就是 template中的 插值表达式,v-,@等等的数据)
// Dep对象有一个静态属性target,用来存放Watcher实例---即依赖deps数组中的元素---即Dep.target
class Dep {
constructor() {
// 每一项数据的依赖收集在这个数组中, 每一个依赖,就是一个Watcher
this.deps = []
}
addDep(dep) {
this.deps.push(dep)
}
notice() {
this.deps.forEach(dep => { // 这里的dep是一个watcher
dep.update()
})
}
}
Watcher对象
// Watcher: 编译时{{}},v-等,每访问一次数据,就要创建一个watcher实例
// 三个参数,vm:vue实例,方便是用
// 什么时候进行wathcer实例化呢?
// 在编译的时候,每次遇到{{}},v-,@ 等时,就要创建一个实例
class Watcher {
constructor(vm, key, callback) {
this.$cb = callback // 回调函数用来更新数据
Dep.target = this // 将Watcher实例存储一个全局变量中,存到Dep.target中,方便get方法,收集依赖
vm[key] // 触发get方法,在get方法中收集依赖(defineReactive中定义了)
Dep.target = null // Dep.target置空,方便下一次使用数据时,存储Watcher实例化时
}
update() {
// 执行回调函数,来更新数据
this.$cb()
}
}
Compile对象:编译
知识储备
1. nodeType: 1:元素节点 3:文本节点
2. fragment节点: 存在内存中的文档片段,并不在DOM树中--> 将子元素插入fragment文档片段中,不会引起页面回流(对元素位置和几何上的计算)。所以,更好的性能
3. const reg = /{{.*}}/
可以匹配{{name}}。
但要提取出name,还需要在 .*
外加一层()
。这一对小括号就是一个捕获组,可以帮助我们在匹配字符串的同时并捕获字符串中更精细的信息。
RegExp.$1
是RegExp的一个属性,指的是与正则表达式匹配的第一个 子匹配(以括号为标志)字符串
以此类推,RegExp.$2
,RegExp.$3
,……RegExp.$99
总共可以有99个匹配
// 编译: 1. document-> fragment 2. fragment中将 {{}}、v-、@等 提取出并进行相应操作 3. 将fragment转为dom
class Compile {
constructor(vm, el) {
this.$vm = vm
this.$el = el
if (this.$el && this.isElementNode(this.$el)) {
this.$fragment = this.node2Fragment(this.$el)
this.compileFragment(this.$fragment)
this.$el.appendChild(this.$fragment)
}
}
// 将节点转化为fragment文档片段,在内存中操作不直接操作dom,不会引起页面回流
node2Fragment(el) {
const fragment = document.createDocumentFragment()
let child
while (child = el.firstChild) { // el.firstChil:返回文档的首个子节点,将el.firstChild赋值给child,并且当child===undefined就跳出循环
fragment.appendChild(child) // appendChild会把原来的firstChild给移动到新的文档中, el中firstChild随之就会递进一个元素。
}
return fragment
}
// 编译fragment,提取出{{}}/v-/@,并创建相应的wathcer(依赖)
compileFragment(fragment) {
const nodes = fragment.childNodes // 伪数组
Array.from(nodes).forEach(node => {
if (this.isInterpolation(node)) { // 是插值表达式,提取出变量
this.compileText(node)
} else if (this.isElementNode(node)) {// 是v-model、v-html、v-text、@change
this.compileElement(node)
}
node.childNodes.length > 0 && this.compileFragment(node)
})
}
compileText(node) {
const key = this.getInterKey(node)
this.text(node, key)
}
compileElement(node) {
const attrs = node.attributes // {0: {name:'class', value: 'active'}, length: 1}
Array.from(attrs).forEach(attr => {
if (attr.name.startsWith('v-')) { // v-model = "inputValue"
this[attr.name.substring(2)](node, attr.value) // model、html/text
} else if (attr.name.startsWith('@')) {
this.eventHandler(node, attr.name.substring(1), attr.value)
}
})
}
// 事件处理
eventHandler(node, eventName, methodName) {
node.addEventListener(eventName, (e) => {
this.$vm.$methods[methodName].call(this.$vm, e.target.value, e)
})
}
// 通过data数据的key值,更新数据,需要用到watcher
text(node, key) {
new Watcher(this.$vm, key, () => {
node.textContent = this.$vm.$data[key]
})
node.textContent = this.$vm.$data[key]
}
// 通过data数据的key值,更新数据,需要用到watcher
html(node, key) {
new Watcher(this.$vm, key, () => {
node.innerHtml = this.$vm[key]
})
node.innerHtml = this.$vm[key]
}
// 通过data数据的key值,更新数据,需要用到watcher
model(node, key) {
new Watcher(this.$vm, key, () => {
node.value = this.$vm[key]
})
node.value = this.$vm[key]
node.addEventListener('input', (e) => {
this.$vm[key] = e.target.value
})
}
// 提取出插值表达式中的变量:data数据对应的key {{title}}
getInterKey(node) {
const reg = /{{(.*)}}/ // 正则一对小括号就是一个捕获组,可以帮助我们在匹配字符串的同事捕获字符串中更精细的信息
node.textContent.match(reg)
return RegExp.$1.trim()
}
// 判断是否是差值表达式 {{ title }}: 1. 文本节点; 2. 有花括号
isInterpolation(node) {
return node.nodeType === 3 && /{{.*}}/.test(node.textContent)
}
// 判断节点是否为元素节点
isElementNode(el) {
return el && el.nodeType === 1
}
}
引用Vue
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>手写mini-vue</title>
</head>
<body>
<div id="app">
<h3>{{title}}</h3>
<h3>{{input}}</h3>
<div>
<input v-model="input" type="text" @change="changeInput">
</div>
</div>
<script src="./vue.js"></script>
<script>
const mVue = new Vue({
data() {
return {
title: '这里是mini-vue的标题初始值!',
input: '111'
}
},
created() {
this.input = "input 在created阶段赋的值"
},
mounted() {
setTimeout(() => {
this.title = '标题已经改变了!'
}, 1500)
},
methods: {
changeInput(v, e) {
console.log(v, e);
}
}
})
mVue.$mount('#app')
</script>
</body>
</html>