问题描述
面试中,面试官除了问基础知识以外,还喜欢问一些框架原理。比如:你对vue的数据双向绑定mvvm是如何理解的?
网上的部分贴子可能写的有点抽象,不便于快速阅读理解。本篇文章就使用通俗易懂的简单方式,来讲解并实现一个简单的vue数据双向绑定原理demo,希望对大家有一定的帮助
先复习基本知识
为了便于大家更好的理解下文数据双向绑定的代码,我们最好先复习一下旧知识,如果基础知识扎实的道友,可以直接跳过这一段。
DOM.children属性返回DOM元素有哪些元素子节点
代码:
<body>
<div class="divClass">
<span>孙悟空</span>
<h4>猪八戒</h4>
<input type="text" value="沙和尚">
</div>
<script>
let divBox = document.querySelector('.divClass')
console.log('元素节点', divBox);
console.log('元素节点的子节点伪数组', divBox.children);
</script>
</body>
示例图:
注意区分:DOM.childNodes得到所有的节点
,比如元素节点、文本节点、注释节点;而,DOM.children只得到所有的元素节点
。二者返回的都是一个伪数组,但伪数组有length长度,代表有多少个节点,且可以循环遍历,遍历的每一项都是一个dom元素标签!
不过伪数组不能使用数组的方法
DOM.hasAttribute(key)/getAttribute(key)判断元素标签是否有key属性以及访问对应value值
代码:
<body>
<h3 class="styleCss" like="coding" v-bind="fire in the hole">穿越火线</h3>
<script>
let h3 = document.querySelector('h3')
console.log(h3.hasAttribute('v-hello')); // 看看此标签有没有加上v-hello这个属性,没的,故打印:false
console.log(h3.hasAttribute('like')); // 看看此标签有没有加上like这个属性,有,故打印:true
console.log(h3.getAttribute('like')); // 访问此标签上加上的这个v-bind属性值是啥,打印:coding
console.log(h3.hasAttribute('v-bind')); // 看看此标签有没有加上v-bind这个属性,,有的,故打印:true
console.log(h3.getAttribute('v-bind')); // 访问此标签上加上的这个v-bind属性值是啥,打印:fire in the hole
console.log(h3.attributes); // 可以看到所有的在标签上绑定的属性名和属性值(key="value"),是一个伪数组
</script>
</body>
示例图:
这两个api可以用来看标签上是否绑定了vue的指令,以及看看vue指令值是啥,以便于我们去与data中的相应数据做对应
DOM.innerHTML与DOM.innerText的区别
二者均可以修改dom的文本内容。innerHTML是符合W3C标准的属性,所以是主流使用的dom的api。而innerText虽然兼容性要好一些,不过主流还是innerHTML
代码:
<body>
<h3>西游记</h3>
<button>更改dom内容</button>
<script>
let h3 = document.querySelector('h3')
let btn = document.querySelector('button')
btn.onclick = () => {
h3.innerHTML = h3.innerHTML + '6'
}
</script>
</body>
示例图:
DOM.innerHtml这个api可用于更改vue中的差值表达式{{key}}对应的内容值
数据双向绑定成品效果图
我们先看一下,我们所要实现的成品的效果图
需求分析
- 输入框输入值内容发生变化,页面也发生对应变化
- 点击按钮,输入框和页面都发生对应变化
即:
- 页面变化(输入框引起)触发数据data变化,最终触发页面变化;
- 数据data变化(按钮引起),触发页面变化
关于MVVM的理解
简单理解
mvvm即为m v vm分别对应的是:
- m是model数据层(就是vue中的data、computed、watch啊之类的数据配置项)
- v是view视图层(视图层效果是dom堆叠出来的,所以视图层可以理解为dom元素)
- vm是model数据层和view视图层的中间层view_model(vm)层,是vue中的核心,功能强大
vm可以监听视图层dom的变化
,比如监听input标签dom的value值变化,去更改model数据层中的data对应值,vm也可以监听model数据层中的data对应key的value的值的变化,
去更改input标签dom的value值。即:vm相当于一个摆渡人,可把此岸人渡到彼岸、彼岸人渡到此岸
核心理解
所以,MVVM的核心是,所以,MVVM的核心是,所以,MVVM的核心是(重要的事情说三遍:)
监听页面的DOM的内容值变化,从而通知到data中做对应数据变化(主要是监听表单标签)
监听表单标签的变化,是使用dom.addEventListener()这个方法
当data中数据变化以后,再去及时更新页面DOM的内容变化
监听data中数据的变化,是使用Object.defineProperty()的set方法,自动帮我们监听变化,至于更新dom,就是首先找到要更新哪个dom,如果是普通标签就更新其innerHTML值、如果是表单标签,就更改其value即可
关于Object.defineProperty的理解
关于Object.defineProperty这个方法,一言以蔽之,给对象定义响应式。论坛有很多资料帖子,在此不赘述。推荐看官方文档:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty
关于这个方法,我们先理解下面案例就差不多了:
案例需求
有一个对象obj,里面有name和age属性,要让这个obj的每一个属性,都是响应式的,访问和修改的时候,都要对应打印信息。
案例代码
复制粘贴跑一下,大致就明白了
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<button id="nameId">修改名字</button>
<button id="ageId">修改年龄</button>
<script>
let obj = {
name: '孙悟空',
age: 500,
}
for (const key in obj) { // 因为是给对象中每一个属性都添加响应式,所以要遍历对象
let value = obj[key] // 存一份对应value值,用于访问返回,以及新值修改赋值
Object.defineProperty(obj, key, { // 给这个obj对象的每一个属性名key都定义响应式
get() {
console.log('访问之(自动触发),访问值为:', value);
return value
},
set(newVal) {
console.log('修改之(自动触发),修改的属性名为:', key, '属性值为:', newVal);
value = newVal
}
})
}
let nameBtn = document.querySelector('#nameId')
let ageBtn = document.querySelector('#ageId')
nameBtn.onclick = () => {
obj.name = obj.name + '^_^ | '
}
ageBtn.onclick = () => {
obj.age = obj.age + 1
}
// 这样的话,访问和修改的时候都会触发啦(修改的时候是要先访问找到,再去修改,故打印两次)
</script>
</body>
</html>
案例效果图
完整代码
代码中写了不少注释,大家跟着注释步骤阅读应该就可以了。演示的话直接复制粘贴即可。注意代码中的subArr,搜集依赖,目的是看看有哪些dom元素需要做后续的响应式更新内容
打印new出来的Vue实例
如果下方的完整代码,有助于各位道友更好的理解mvvm的话,那就给咱点个赞鼓励一下创作呗
^_^
完整MVVM代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
#app {
width: 600px;
height: 216px;
background-color: #ccc;
padding: 24px;
}
button {
cursor: pointer;
}
</style>
</head>
<body>
<!-- view视图层dom,为了便于理解,这里以#app的根元素内部只有一层dom为例(多层需要递归) -->
<div id="app">
<input v-model="name" placeholder="请填写名字">
<span>名字是:</span><span v-bind="name"></span>
<br>
<br>
<input v-model="age" placeholder="请填写年龄">
<span>年龄是:</span><span v-bind="age"></span>
<br>
<br>
<h3>{{name}}</h3>
<h3>{{age}}</h3>
<button id="nameId">更改名字</button>
<button id="ageId">更改年龄</button>
<button id="resetId">恢复默认</button>
<button id="removeId">全部清空</button>
</div>
<script>
// 简单函数封装 之 判断标签内是否包含双差值表达式
function isIncludesFourKuoHao(str) {
// 不过这里不是特别严谨。严谨需要使用正则限制,大家明白思路即可
if (str.length <= 4) { // 得大于4个字符
return false
}
if ( // 且要有双差值表达式,
str[0] == '{' &
str[1] == '{' &
str[str.length - 1] == '}' &
str[str.length - 2] == '}'
) {
return true
} else {
return false
}
}
// 简单函数封装 之 获取双差值表达式之间的变量名
function getKuoHaoBetweenValue(params) {
// 这里也不是特别严谨,严谨也需要使用正则,大家明白思路即可
return params.slice(2, params.length - 2) // {{name}} --> name
}
// 这里使用构造函数,使之拥有new的功能。当然也可以使用class类编程
function Vue(options) {
/**
* 第一步,获取根节点dom元素,这一步的作用是有了根节点dom以后,可以通过dom.children获取其所有子节点的dom元素,
* 便于我们对子节点的dom进行操作,比如给子节点的input标签绑定input事件监听,这样就可以通过dom.value
* 实时拿到用户在输入框输入的值了
* */
this.$el = document.querySelector(options.el);
/**
* 第二步,把data中的数据{name:'jack',age:'500'}存一份,因为我们除了修改this.name要是响应式的,同样:
* this.$data.name也要是响应式的
*/
this.$data = options.data;
/**
* 第三步,定义一个数组搜集要变化的dom元素,当我们修改data中数据的时候,触发Object.defineProperty()的set方法执行
* 然后去subArr数组中去寻找,看看是要修改那个dom元素的数据值即可,大家打印一下,就会发现subArr存放的是一个又
* 一个对象,对象中记录的是 哪一个dom,什么属性名key,以及对应更改innerHTML或value
* */
this.subArr = []
/**
* 第四步,执行模板编译操作,把data中的数据做页面呈现。这里又分为两部分
* 4.1 给相应的交互输入类标签绑定事件监听,比如input标签绑定input事件,select标签绑定change事件等。为便于理解
* 本案例中只以input标签为例说明(当然前提是:加了v-model指令做数据双向绑定才会去操作这一步)
* 4.2 把v-bind和插值表达式{{}}做内容呈现,即:把model中的对应数据值,并找到对应dom,更改其innerHTML的值为对应数据值
* */
this.useDataToCompileRenderPage(); // 使用data中的数据做模板编译并渲染到页面上
/**
* 第五步,给m中的数据使用Object.defineProperty做数据劫持,这样的话,访问或者修改对象的属性值时,都可以得知。即:
* 访问时,不用额外操作。不过修改时,model中的data的值变化了,于此同时,还需同时更新dom,因为m变v也要跟着变
* 即:dataChangeUpdatePage方法的执行,只要一set更新,我就让dataChangeUpdatePage方法去更新对应的dom值
* (因为第四步以后,data中数据是渲染到页面上了,但还需让data中的数据变化,页面也跟着变化,故要做数据劫持)
* */
this.definePropertyAllDataKey(); // 数据劫持data中的所有key使之成为响应式的
}
// 先把data中的数据,去编译渲染到页面上
Vue.prototype.useDataToCompileRenderPage = function () {
let _this = this; // 存一份this实例对象
let nodes = this.$el.children; // 获取根元素下的所有的子节点dom;值为伪数组,打印结果:[input, span, span, br, br, input, span, span, br, br, button]
for (let i = 0; i < nodes.length; i++) { // 循环这个子节点dom伪数组,
let node = nodes[i]; // 所有的标签,一个一个去判断,判断这个标签有没有加上v-model,有没有加上v-bind,有没有差值表达式{{}} ,以这三种情况为例
// 若dom标签节点上加上了v-model指令
if (node.hasAttribute('v-model')) {
let dataKey = node.getAttribute('v-model');// 去获取v-model绑定的那个属性值,本例中为dataKey的值分别为:name、age
node.addEventListener('input', function () { // 以input输入框为例:给标签绑定input输入事件监听,即:<input/>.addEventListener('input',function(){})
/** 注意,这里是页面到数据的处理,即v --> vm --> m的流程 */
_this.$data[dataKey] = node.value; // 如果是input标签,可以直接通过inputDom.value获取到input标签中用户输入的值
_this[dataKey] = node.value; // 上一行是$data更改,即:this.$data.name或age获取dom最新的值、这一行是this.name或age获取最新的值
});
/** 把model中的数据更新赋值(编译)到页面上 */
node['value'] = _this.$data[dataKey]; // inputDom.value = this.$data.name或age 赋值
/** 所以,经过这一波操作,成功的把输入框(变化)的值,更改到数据层中了 即:v --> vm --> m */
/** 注意这里,就是搜集依赖,可以提取一个方法的,为了便于理解,就不提取了 */
_this.subArr.push({
nodeLabelDom: node, // 哪个dom标签元素
whichAttribute: dataKey, // 哪一个属性name或age
valueOrInnerHtml: 'value', // 更改value还是innerHTML
})
}
// 若dom标签节点上加上了v-bind指令
if (node.hasAttribute('v-bind')) {
/** 如果是v-bind指令,只需要添加watcher即可 * */
let dataKey = node.getAttribute('v-bind'); // 去获取v-bind绑定的那个属性值,本例中为dataKey的值分别为:name、age
node['innerHTML'] = _this.$data[dataKey]; // normalDom.innerHtml = this.$data.name或age 普通dom显示赋值操作
/** 注意这里,就是搜集依赖,可以提取一个方法的,为了便于理解,就不提取了 */
_this.subArr.push({
nodeLabelDom: node, // 哪个dom标签元素
whichAttribute: dataKey, // 哪一个属性name或age
valueOrInnerHtml: 'innerHTML', // 更改value还是innerHTML
})
}
// 如果包含双差值表达式{{}}
if (isIncludesFourKuoHao(node.textContent)) {
let dataKey = getKuoHaoBetweenValue(node.textContent) // 就拿到双差值表达式中间的key,属性名,这里的dataKey分别为:name、age
node['innerHTML'] = _this.$data[dataKey]; // 把双差值表达式中的key做一个替换对应值
/** 注意这里,就是搜集依赖,可以提取一个方法的,为了便于理解,就不提取了 */
_this.subArr.push({
nodeLabelDom: node, // 哪个dom标签元素
whichAttribute: dataKey, // 哪一个属性name或age
valueOrInnerHtml: 'innerHTML', // 更改value还是innerHTML
})
}
}
}
// 再做数据劫持,遍历给data中的每一个数据都劫持,使之,都用于set和get方法
Vue.prototype.definePropertyAllDataKey = function () {
let _this = this; // 存一份this以便使用
for (let key in _this.$data) { // 遍历对象{name:'孙悟空',age: 500}
let value = _this.$data[key]; // value值为孙悟空、500 key的值自然是name和age
Object.defineProperty(_this.$data, key, { // 使用defineProperty去添加拦截、劫持(劫持到$data身上)
get: function () { //
return value; // 访问key,访问name或者age,就返回对应的值
},
set: function (newVal) {
value = newVal; // 修改key的属性值,修改name或者age的属性值,在做正常操作value = newVal赋值的同时
// 每当更新this.$data数据时,如:this.$data.name = 'newVal'就去做对应dom的更新即可
_this.dataChangeUpdatePage(key, newVal)
}
})
Object.defineProperty(_this, key, { // 劫持到自己身上
get: function () {
return value;
},
set: function (newVal) {
value = newVal;
// 每当更新this数据时,如:this.name = 'newVal'就去做对应dom的更新即可
_this.dataChangeUpdatePage(key, newVal)
}
})
}
}
// 公共方法,当更新触发的时候,去根据数据做页面渲染
Vue.prototype.dataChangeUpdatePage = function (key, newVal) {
let _this = this; // 存一份this实例对象
// 也要去更新对应dom的内容
_this.subArr.forEach((item) => {
if (key == item.whichAttribute) {
// 哪个dom的 // innerText或者value // 赋新值
item.nodeLabelDom[item.valueOrInnerHtml] = newVal;
}
})
}
let vm = new Vue({
el: '#app', // 指定vue的根元素
/**
* model数据层,为了便于理解,这里也是举例data中数据只有一层,多层需要递归
* */
data: {
name: '孙悟空',
age: 500,
}
});
console.log('vmvm', vm);
// 更改名字
let nameBtn = document.querySelector('#nameId')
nameBtn.onclick = () => {
vm.name = vm.name + '^' // 直接访问
}
// 更改年龄
let ageBtn = document.querySelector('#ageId')
ageBtn.onclick = () => {
vm.$data.age = vm.$data.age * 1 + 1 // 通过$data间接访问
}
// 恢复默认的名字和年龄
let resetBtn = document.querySelector('#resetId')
resetBtn.onclick = () => {
vm.$data.name = '孙悟空'
vm.age = 500
}
// 清空名字和年龄
let removeBtn = document.querySelector('#removeId')
removeBtn.onclick = () => {
vm.name = ''
vm.$data.age = null
}
</script>
</body>
</html>
好记性不如烂笔头,记录一下呗