前言
准备写个关于Vue的问题解答合集100问,开玩笑啦,具体也不知道会有多少个,看到的时候再加上去,持续更新中...
1 Vue如何监听数组的变化?
解答:还是从一个简单的例子开始
<template>
<div id="app">
<ul>
<li v-for="(item, i) in list" :key="i">{{ item }}</li>
</ul>
<h1 @click="add">add</h1>
</div>
</template>
<script>
export default {
name: 'App',
data: function () {
return {
list: [1, 2, 3, 4, 5],
list1: [{name: ''}, 2 ,3 ,4 ,5]
}
},
methods: {
add () {
this.num[1] = 2 // 这样修改是无效的,只能通过methodsToPatch里面定义的方法才能触发。
// var num = [1, 2, 3, 4, 5]
// num.push(6) 这样也是不能触发的,具体看protoAugment函数
// function protoAugment (target, src) { target = [1, 2, 3, 4, 5]这个target是从data属
// 性中访问到的数组,所以并不是给所有的数组添加方
// 法,而是data中定义的数组。
//
// this.list1[0].name = 'Wayag' // 这是可以触发的,因为数组的每一项都是会被observe()调用的
// 调用observe({name: 'Wayag'}),就会像前面一样去将它变成
// 响应式数据
// target.__proto__ = src;
// }
}
}
}
</script>
var methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
];
methodsToPatch.forEach(function (method) {
// cache original method
var original = arrayProto[method];
def(arrayMethods, method, function mutator () {
var args = [], len = arguments.length;
while ( len-- ) args[ len ] = arguments[ len ];
var result = original.apply(this, args);
// this就是data里面当前监听的数组list
var ob = this.__ob__;
var inserted;
switch (method) {
case 'push':
case 'unshift':
inserted = args;
break
case 'splice':
inserted = args.slice(2);
break
}
if (inserted) { ob.observeArray(inserted); }
ob.dep.notify();
return result
});
});
var Observer = function Observer (value) {
this.value = value;
this.dep = new Dep();
this.vmCount = 0;
// 给当前的数组元素添加__ob__属性,用于后续使用数组方法的时候获取到dep(this.__ob__.dep.notify())
def(value, '__ob__', this);
// value是数组的时候进入此条件
if (Array.isArray(value)) {
if (hasProto) {
protoAugment(value, arrayMethods);
} else {
copyAugment(value, arrayMethods, arrayKeys);
}
this.observeArray(value);
} else {
this.walk(value);
}
};
执行步骤:首先在initData的时候能够获取到data = {list: [1, 2, 3, 4, 5]},之后通过observe里面new Observer(data)执行defineReactive$$1实现数组list的响应式,var childOb = !shallow && observe(val)的时候将数组[1, 2, 3, 4, 5]赋值给observe进行回调,变成new Observer([1, 2, 3, 4, 5]),给数组对象添加___ob__属性,[1, 2, 3, 4, 5].__ob__ = new Observer([1, 2, 3, 4, 5]), 然后给数组添加修改的methodsToPatch中的数组方法,对自定义方法进行拦截,后续如果执行了这里拦截的数组方法就会触发[1, 2, 3, 4, 5].__ob__.dep.notify(),那么[1, 2, 3, 4, 5].__ob__.dep是在什么时候赋值的呢?其实是在访问vm.list时会触发Object.defineProperty的get函数,执行childOb.dep.depend()会访问到当前访问vm.list的watcher,就把watcher添加进childOb.dep里面了。
现在知道为什么new Observer()实例为什么要收集dep了,实际上就是在数组或者其他无法触发Object.defineProperty的set函数的地方,可以选择手动去调起这个属性收集的订阅各个watcher。
function defineReactive$$1 (
obj,
key,
val,
customSetter,
shallow
) {
var dep = new Dep();
var property = Object.getOwnPropertyDescriptor(obj, key);
if (property && property.configurable === false) {
return
}
// cater for pre-defined getter/setters
var getter = property && property.get;
var setter = property && property.set;
if ((!getter || setter) && arguments.length === 2) {
val = obj[key];
}
var childOb = !shallow && observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
var value = getter ? getter.call(obj) : val;
if (Dep.target) {
dep.depend();
if (childOb) {
childOb.dep.depend();
if (Array.isArray(value)) {
dependArray(value);
}
}
}
return value
},
set: function reactiveSetter (newVal) {
debugger
var value = getter ? getter.call(obj) : val;
/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
/* eslint-enable no-self-compare */
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter();
}
// #7981: for accessor properties without setter
if (getter && !setter) { return }
if (setter) {
setter.call(obj, newVal);
} else {
val = newVal;
}
childOb = !shallow && observe(newVal);
dep.notify();
}
});
}
2 vue组件中data为什么必须是一个函数?
提前说明:因为子组件的构造函数是依赖子组件export出来的options通过Vue.extend生成,生成之后还会保存Vuecomponent构造函数在options._Ctor中,所以如果重复使用该组件,无论是不是在同一个页面(前提是同一个Vue构造函数下,Vue.extend的时候根据cachedCtors[SuperId=this.cid]返回,假如是Sub.extend子组件下的扩展,那就不是同一个了,因为cid = 1, Sub.cid = cid++),都会返回options._Ctor保存的子组件构造函数。
解答:直接来看源码
// 初始化data的时候
function initData (vm) {
var data = vm.$options.data;
data = vm._data = typeof data === 'function'
? getData(data, vm)
: data || {};
...省略
}
data如果是函数的时候,vm._data = data.call(vm, vm),假如vm组件被一个页面内(不需要在同一个页面内,其实无论在哪里只要重复调用同一组件)调用多次,每次返回的其实都是一个新的对象。但是如果data就是一个对象的话,就如下代码所示,我们知道关于原型和原型链的知识中,当两个对象同时指向构造函数的原型,修改原型的属性,这两个对象访问的是同一个原型对象的属性。
function A() {
}
A.$options={
data: {
num: 1
}
}
let a1 = new A()
let a2 = new A()
a1 = Object.create(A.$options)
a2 = Object.create(A.$options)
a1.data.num++
console.log(a1.data.num) // 2
console.log(a2.data.num) // 2
vue源码中是如何做的?我们都知道在执行子组件的初始化的时候会调用Vue.prototype._init函数,然后判断当前如果是子组件的初始化过程,就会调用initInternalComponent函数。
function initInternalComponent (vm, options) {
`第一句话就可以看出来,子组件实例的vm.$options都是继承自vm.constructor.options(也就是Sub.options),
无论当前这个子组件在哪个页面调用,都会有vm.$options.__proto__ = vm.constructor.options,
指向同一个构造函数的原型`
var opts = vm.$options = Object.create(vm.constructor.options);
// doing this because it's faster than dynamic enumeration.
var parentVnode = options._parentVnode;
opts.parent = options.parent;
opts._parentVnode = parentVnode;
var vnodeComponentOptions = parentVnode.componentOptions;
opts.propsData = vnodeComponentOptions.propsData;
opts._parentListeners = vnodeComponentOptions.listeners;
opts._renderChildren = vnodeComponentOptions.children;
opts._componentTag = vnodeComponentOptions.tag;
if (options.render) {
opts.render = options.render;
opts.staticRenderFns = options.staticRenderFns;
}
}
3 为什么 Vue 中不要用 index 作为 key?
解答:在解答问题之前我们得先来说一下组件更新的过程,从一个简单的例子开始:
<ul>
<li>1</li>
<li>2</li>
</ul>
那么这个template会被转化成的vnode如下
{
tag: 'ul',
children: [
{ tag: 'li', children: [ { vnode: { text: '1' }}] },
{ tag: 'li', children: [ { vnode: { text: '2' }}] },
]
}
得到vnode之后就是update过程(patch)过程了,将vnode转为真实的dom并挂载到节点上,但是在转化之前我们得对vnode进行diff算法判断,看看是否有节点可以复用,解释完之后就明白为什么最好不要用index作为key值使用,哪些场景会导致错误。
话不多说,先来看下patch函数
function patch (oldVnode, vnode, hydrating, removeOnly) {
var isInitialPatch = false;
var insertedVnodeQueue = [];
if (isUndef(oldVnode)) { // oldVnode不存在是子组件初始化的时候,所以更新过程应该进入else
isInitialPatch = true;
createElm(vnode, insertedVnodeQueue);
} else {
// 如果是最外层的Vue初始化oldVnode =`<div id="app"></div>`(oldVnode是index.html里面的
// div#app)或者组件更新过程会进入这里
var isRealElement = isDef(oldVnode.nodeType);
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// 这里是该vm实例(子组件)的根vnode才会进入这里
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly);
} else {
// 这里是最外层Vue实例的patch过程
// ...略
}
return vnode.elm
}
接下来进入子组件的patchVnode过程
function patchVnode (
oldVnode,
vnode,
insertedVnodeQueue,
ownerArray,
index,
removeOnly
) {
if (oldVnode === vnode) {
return
}
// vnode.elm赋值oldVnode.elm
var elm = vnode.elm = oldVnode.elm;
var i;
var data = vnode.data;
// data.hook是子组件vnode才有的,普通节点只有attrs等。
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
i(oldVnode, vnode);
}
// 其实patch就是看oldVnode与vnode的子节点对比,父节点的话外面已经判断过了,sameVnode才进来的
var oldCh = oldVnode.children; // 获取oldVnode的子节点vnode
var ch = vnode.children; // 获取vnode的子节点vnode
// 插播一下isPatchable函数
```js
function isPatchable (vnode) {
// vnode.componentInstance属性也是组件vnode才会有的,所以意思就是说如果当前的vnode是组
// 件vnode,那么就不断地找子组件,直到vm._vnode的root vnode是普通节点,而不是组件节点。
while (vnode.componentInstance) {
vnode = vnode.componentInstance._vnode;
}
// 文本节点没有tag属性
return isDef(vnode.tag)
}
```
// 就是说进入这里要么vnode是子组件要么是普通节点(除了文本节点)
if (isDef(data) && isPatchable(vnode)) {
// 如下图,cbs.update是一个数组,如果一个节点设置了class或者其他属性,更新时会去更新这些属性。
for (i = 0; i < cbs.update.length; ++i) { cbs.update[i](oldVnode, vnode); }
// 这里就是更新组件了,因为有.hook属性,这里是i.hook.update,用于更新attrs属性
if (isDef(i = data.hook) && isDef(i = i.update)) {i(oldVnode, vnode); }
}
// 如果新节点vnode不是文本节点,那就需要对新老节点的vnode进行对比,updateChildren里面的diff算法。
if (isUndef(vnode.text)) {
// 新旧节点都存在子节点
if (isDef(oldCh) && isDef(ch)) {
// diff
if (oldCh !== ch) { updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly); }
} else if (isDef(ch)) {
// 如果只有新的vnode存在子节点
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(ch);
}
// 旧的vnode是文本节点,先将文本节点置为空,然后在里面插入新的vnode的子节点ch。
if (isDef(oldVnode.text)) { nodeOps.setTextContent(elm, ''); }
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
// 如果只存在旧节点的子节点,直接将旧节点的子节点删除即可。
removeVnodes(oldCh, 0, oldCh.length - 1);
} else if (isDef(oldVnode.text)) {
// 都不存在节点并且旧节点是文本节点,直接将文本置为空。
nodeOps.setTextContent(elm, '');
}
} else if (oldVnode.text !== vnode.text) {
// 新旧节点都是文本节点,而且text不相等,直接新节点内容替换旧节点的内容。
nodeOps.setTextContent(elm, vnode.text);
}
}
其实上面的情况比较复杂的就是新旧节点的子节点都存在并且不相等的时候会调用updateChildren函数,也就 是diff算法。
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
var oldStartIdx = 0; // 旧子节点开始下标
var newStartIdx = 0; // 新子节点开始下标
var oldEndIdx = oldCh.length - 1; // 旧子节点结束下标
var oldStartVnode = oldCh[0]; // 旧子节点首节点
var oldEndVnode = oldCh[oldEndIdx]; // 旧子节点尾节点
var newEndIdx = newCh.length - 1; // 新子节点结束下标
var newStartVnode = newCh[0]; // 新子节点首节点
var newEndVnode = newCh[newEndIdx]; // 新子节点尾节点
var oldKeyToIdx, idxInOld, vnodeToMove, refElm;
var canMove = true;
if (process.env.NODE_ENV !== 'production') {
// 记得这个循环中重复的key报的错吗?
`("Duplicate keys detected: '" + key + "'. This may cause an update error.")`
checkDuplicateKeys(newCh);
}
// 不断判断开始下标不能大于结束下标,无论是新旧节点的子节点不满足条件直接跳出,执行后续的
// 多余的节点插入操作。
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx];
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx];
//如果旧首索引节点和新首索引节点相同
} else if (sameVnode(oldStartVnode, newStartVnode)) {
//对旧头索引节点和新头索引节点进行diff更新, 从而达到复用节点效果
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
//旧头索引向后
oldStartVnode = oldCh[++oldStartIdx];
// 新头索引向后
newStartVnode = newCh[++newStartIdx];
//如果旧尾索引节点和新尾索引节点相似,可以复用
} else if (sameVnode(oldEndVnode, newEndVnode)) {
//旧尾索引节点和新尾索引节点进行更新
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);
//旧尾索引向前
oldEndVnode = oldCh[--oldEndIdx];
//新尾索引向前
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
/// 对旧首索引和新尾索引进行patch
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);
// 旧vnode开始插入到真实DOM中,旧首向右移,新尾向左移
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
// 将旧节点的首子节点往后一位,新节点的尾子节点往前一位
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// 同上中可能,旧尾索引和新首也存在相似可能
// 对旧首索引和新尾索引进行patch
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
// 旧vnode开始插入到真实DOM中,新首向左移,旧尾向右移
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
//如果上面的判断都不通过,我们就需要key-index表来达到最大程度复用了
//如果不存在旧节点的key-index表,则创建
if (isUndef(oldKeyToIdx)) { oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx); }
//找到新节点在旧节点组中对应节点的位置
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
// 如果新节点在旧节点中不存在,就创建一个新元素,我们将它插入到旧首索引节点前(createElm第4个参数
if (isUndef(idxInOld)) { // New element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx);
} else {
// 如果旧节点有这个新节点
vnodeToMove = oldCh[idxInOld];
// 将新节点和新首索引进行比对,如果类型相同就进行patch
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
// 然后将旧节点组中对应节点设置为undefined,代表已经遍历过了,不在遍历,否则可能存在重复插入的问题
oldCh[idxInOld] = undefined;
// 如果不存在group群体偏移,就将其插入到旧首节点前
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm);
} else {
// 类型不同就创建节点,并将其插入到旧首索引前(createElm第4个参数)
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx);
}
}
//将新首往后移一位
newStartVnode = newCh[++newStartIdx];
}
}
//当旧首索引大于旧尾索引时,代表旧节点组已经遍历完,将剩余的新Vnode添加到最后一个新节点的位置后
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
} else if (newStartIdx > newEndIdx) { // 如果新节点组先遍历完,那么代表旧节点组中剩余节点都不需要,所以直接删除
removeVnodes(oldCh, oldStartIdx, oldEndIdx);
}
}
// sameVnode
function sameVnode (a, b) {
// 前提就是需要比较a,b的key,undefined === undefined也是true。
return (
a.key === b.key && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
a.asyncFactory === b.asyncFactory &&
isUndef(b.asyncFactory.error)
)
)
)
}
借用网图看一下diff过程
说完了diff过程,现在我们知道为什么最好不要用index作为节点的key了:
1.场景反序中,其实是有很简便的重复利用旧子节点的操作,但是用index作为key值时,即使数组反序之后,index还是保持顺序,等于没有利用到旧的节点,diff无效。
2.场景删除数组中,使用index会导致出错,如下代码:
当我们想去删除第一个节点的时候,却会变成删除最后一个,为什么会这样呢?
是因为Vue更新子组件的机制导致的,如果页面中引入component组件时,它只会看你在模板上声明的传递给子组件的一些属性是否有更新,现在这种情况下,我们删除数组里的第一个数据,但是渲染vnode的时候,key还是从0开始,patchVnode的时候sameVnode函数会判断新旧节点key值为0的两个节点是sameVnode,本来是要对其子节点再进行patchVnode的,但是由于子节点是组件vnode,patchVnode并不会对组件vnode进行比较,如果是组件vnode会进入prepatch函数之后调用updateChildComponent对props[key]进行赋值,进而更新子组件,由于上面并未将父组件传递变化的参数给子组件,所以不会触发子组件更新,之后不断地对比节点之后发现newCh对比oldCh不存在最后一个节点,所以就把旧节点的尾子节点删掉了。下面插入updateChildComponent函数
function updateChildComponent() {
// 更新props
if (propsData && vm.$options.props) {
toggleObserving(false);
var props = vm._props;
var propKeys = vm.$options._propKeys || [];
for (var i = 0; i < propKeys.length; i++) {
var key = propKeys[i];
var propOptions = vm.$options.props; // wtf flow?
// 这里去赋值props[key],会调用Object.defineProperty的set函数,之后子组件
// dep.nofity(),对子组件进行更新。
props[key] = validateProp(key, propOptions, propsData, vm);
}
toggleObserving(true);
// keep a copy of raw propsData
vm.$options.propsData = propsData;
}
}
<body>
<div id="app">
<ul>
<li v-for="(value, index) in arr" :key="index">
<test />
</li>
</ul>
<button @click="handleDelete">delete</button>
</div>
</div>
</body>
<script>
new Vue({
name: "App",
el: '#app',
data() {
return {
arr: [1, 2, 3]
};
},
methods: {
handleDelete() {
this.arr.splice(0, 1);
}
},
components: {
test: {
template: "<li>{{Math.random()}}</li>"
}
}
})
</script>
如果对test组件设置props参数的话,之后新旧节点对比时发现传递的参数变化了,就会触发之前props定义的响应式数据,进而更新子组件,所以视图就变化了。
// 修改
<body>
<div id="app">
<ul>
<li v-for="(value, index) in arr" :key="index">
<test :i="value" />
</li>
</ul>
<button @click="handleDelete">delete</button>
</div>
</div>
</body>
<script>
new Vue({
name: "App",
el: '#app',
data() {
return {
arr: [1, 2, 3]
};
},
methods: {
handleDelete() {
this.arr.splice(0, 1);
}
},
components: {
test: {
template: "<li>{{Math.random()}}</li>",
props: {
i: {
type: Number,
default: 0
}
}
}
}
})
</script>
4 v-if 和 v-show的区别
v-if 在编译(template -> ast(parse、parseHTML) -> render(generate、genElement))过程中会被编译成render中一个三元表达式,条件不满足时不渲染此节点。
v-show会被编译成指令保存在vnode.data中,在createElm生成真实dom的过程中会把vnode.data中的属性挂到真实dom上(操作dom:el.style.display = 'none'),条件不满足时控制样式将对应节点隐藏 (display:none)
使用场景: v-if 适用于在运行时很少改变条件,不需要频繁切换条件的场景
v-show适用于需要非常频繁地切换条件的场景
5 说说 vue 内置指令
-
v-once:
机制:首先是在template转为Ast树之后,通过processOnce在element(也就是Ast树)上添加属性
isOnce: true,然后再Ast-> render过程中,通过genOnce -> genStatic转为_m()-> renderStatic说明:定义它的元素或组件只渲染一次,包括元素或组件的所有子节点,首次渲染后将不再随数据的变化重新渲染,将被视为静态内容
function renderStatic (
index,
isInFor
) {
var cached = this._staticTrees || (this._staticTrees = []);
var tree = cached[index];
// 读取缓存(不能在v-for里面,否则失效)
if (tree && !isInFor) {
return tree
}
// 缓存到cached对象中,以便后续重复使用
tree = cached[index] = this.$options.staticRenderFns[index].call(
this._renderProxy,
null,
this // for render fns generated for functional component templates
);
markStatic(tree, ("__static__" + index), false);
return tree
}
栗子:
new Vue({
template: `<div>
<p v-once="true">{{ flag }}</p>
<button @click="flag = !flag">{{ flag ? '显示' : '隐藏'}}</button>
</div>`,
data() {
return {
flag: true
}
}
})
2.v-cloak