工欲善其事必先利其器,Vue作为前端TOP2技术栈之一,是我们的老朋友,可以说无数个加班都有Vue的锅,很有必要搞懂Vue,解脱一部分无休止的加班。
为什么要看V2.7而不是V3.0,万变不离其宗,最初的形态更容易帮助理解最基本的原理,而且公司部分老代码是跑在V2.0+版本的。
为什么要写这篇文章?
这是一个测试自己的机会,到底能到哪个水平,写出来!
本文不是单纯学习源码和原理,而是问题驱动,最后要回答如何学以致用?
我的问题是:
- 响应式是怎么做到的?
data中属性值、methods、computed和watch的区别?v-model怎么做的?组件通信又是怎么一回事?- 响应式什么时候不生效以及如何快速排查?
- 数组响应式是怎么处理的?有哪些坑?
key作用以及日常中的问题?- 响应式数据修改会导致全部
DOM Diff么?
在深入问题前,先介绍一下我的方法:使用最简洁的html导入本地Vue源码方式,这样方便快速验证示例并且能直接修改源码打印关键日志。
响应式是怎么做到的?
先写个最简单的点击+1的示例,再深入源码看看怎么做到的。
示例很简单,按钮显示0,点击后显示1。问题在于点击只是将this.count++,怎么做到刷新页面?
即响应式,界面响应数据变化而刷新。
我们写的Vue模版代码并不是最终运行代码,首先我们需要简单了解一下上述模版代码编译后是啥样?
with(
this
){
return createElement(
'div',
{
attrs:{
"id":"app"
}
},
[
createElement(
'button',
{
on:{
"click":add
}
},
[
createTextVNode(
toString(
count
)
)
]
)
]
)
}
好像也没有什么特殊的,渲染时直接获取count,和响应式也没啥关联。
这时引入源码就派上用场了,Vue官网深入响应式原理很清晰地说明了实现原理,如果没有理论基础直接看代码(共11932行)可能会更艰难更耗时些。文档提及了依赖收集,大致是通过对data中的数据做了访问监听来实现,即定义了count的getter/setter并做了拦截处理。
从示例代码的add方法加日志,然后在控制台直接点击日志右侧代码链接,进入源码打断点,简单跟进调试运行就会发现流程是这样:
add() -(vue.count=1)-> proxySetter(1) -(vue._data.count=1)-> reactiveSetter(1) -(通知依赖)-> dep.notify() -(触发更新组件依赖)-> updateComponent()。
注:this.count++实际上会先访问getter,+1后再调用setter修改,此处为了便于描述,跳过了getter部分调用,等同于this.count=1。
更新组件方法updateComponent()是怎么被收集到依赖里面的?
找到触发通知依赖的属性设置器reactiveSetter(),再找对应的获取器reactiveGetter(),在该方法中的dep.depend()打断点即可,运行断点再简单回溯一下调用栈,逻辑就很明了,4张断点图片说明一下。
一句话,特定方法(如组件更新方法updateComponent())在调用前先封装到观察者对象Watcher中主动登记为依赖,data中属性数据访问器被触发时将当前登记依赖存储到属性数据对应的依赖变量dep里面,即依赖收集。
既然不知道谁调用我,那么在调用前主动登记一下,调用时已登记的即是调用我的方法。
“妙”就妙在这。
猜测这也是之前为什么没有诞生依赖收集框架的原因。
画张图总结一下:
小结:响应式是在获取数据触发data属性访问器时收集依赖,修改数据触发data属性设置器时通知依赖,其中挂载时渲染界面因获取数据被标记为依赖,在数据改变时会通知重新渲染。
依赖收集是约定的几个函数(如组件更新updateComponent())调用会封装到观察者Watcher,并主动登记到全局变量Dep.target,在data属性访问器reactiveGetter()调用时会将当前登记在Dep.target的观察者Watcher收集的被观察者dep中。
Watcher和Dep对象数据如下:
学以致用:响应式数据需要初始化时定义在data里面,没有定义则在初始化时就不会增加getter/setter拦截注入依赖收集,自然也就响应不生效。
data中属性值、methods、computed和watch的区别?
data中属性值是响应式数据,修改后界面会触发刷新。
methods是普通函数,每次调用都会执行一次。
computed是计算属性,也是响应式,会有缓存,只有在依赖的数据变化后才会刷新缓存,是模版中有数据处理的推荐写法,相比使用methods,减少了重复计算带来的性能开销。
watch是监听数据变化,数据变化时触发调用。
乍一看好像回答完了,但computed的计算属性是怎么做到的?watch侦听又是如何实现的?
还是从简单的示例深入。
为了看清楚computed怎么做的,先把示例里面watch侦听删了,且模版中count也写死+1。
在doubleCount()中打断点,看下如何依次调用。
add() -> proxySetter(1) -> reactiveSetter(1) -> dep.notify() -> doubleCount()和updateComponent(),其中doubleCount()并没有执行,只是设置脏数据dirty标记,updateComponent()则会放到微任务队列执行。执行updateComponent()组件更新时会调用到doubleCount,进一步调用计算属性的computedGetter(),该方法内部判断设置了脏数据dirty标记,则会重新执行doubleCount(),缓存最新值,再重置脏数据dirty标记。
流程没问题,问题在于只有doubleCount在模版布局里面,count不在,为什么updateComponent()会依赖count?充其量直接依赖doubleCount间接依赖count,继续追。
既然vm.data里面的属性是数据变化的唯一因素,监听变了直接重新执行更新组件不就行了,为啥还要依赖收集?
我认为要点有二:
vm.data如果有不影响界面的其他冗余属性,该属性数据修改不会引起界面刷新,更新就会更高效;- 直接更新组件,此时计算属性不知道自己缓存数据有没有变脏,如果直接计算则失去了避免重复计算的缓存优势。
watch又是如何实现的呢?通过日志以及调试如下:
其实watch的侦听属性就是一个Watcher,初始化获取数据时关联到依赖。
小结:data中属性关联依赖Dep,属于被观察者。computed和watch中属性关联Watcher,属于观察者。method中属性只是一个普通函数。
学以致用:数据转换处理建议使用带缓存的computed,watch要慎重使用,别造成了无限循环。
v-model怎么做的?组件通信又是怎么一回事?
还是从简单示例开始:
通过解析后的字符串很容易看懂。
with(
this
){
return createElement(
'div',
{
attrs:{
"id":"app"
}
},
[
createElement(
'input',
{
directives:[
{
name:"model",
rawName:"v-model",
value:(
message
),
expression:"message"
}
],
domProps:{
"value":(
message
)
},
on:{
"input":function(
$event
){
if(
$event.target.composing
)return;message=$event.target.value
}
}
}
),
createTextVNode(
" "
),
createElement(
'input',
{
domProps:{
"value":message2
},
on:{
"input":$event => message2=$event.target.value
}
}
),
createTextVNode(
" "
),
createElement(
'p',
[
createTextVNode(
"Message is: "+toString(
`${
message
}-${
message2
}`
)
)
]
)
]
)
}
小结:v-model就是个语法糖,<input v-model="message">等同于<input :value="message" @input="$event => message=$event.target.value">。
组件通信原理,还是从简单示例出发。
也就是通过$emit传入的方法名(如change)和参数(如$event.target.value),在自定义组件(如my-input)找到设置的属性函数(如'@change="$event => name=$event.target.value"')进行调用即完成了发送事件调用对应设置函数。
学以致用:事件不生效大概率因为方法名不对,加个断点追一下。
响应式什么时候不生效以及如何快速排查?
比较场景的场景是定义时没在data中声明(如data: {name: ''}),在操作时直接给data加上了属性数据(如this.name2='SSU')导致不生效。
还是从简单示例出发:
预期展示NN-NN1,实际展示NN-N1,也就是临时加的this.name1 = 'N1'并没有生效,
不过细心的朋友会发现,实际展示是N-undefined到NN-N1,细节点是为啥设置this.name1 = 'N1'没有立即生效,而是this.name1 = undefined?为啥this.name = 'NN'生效后this.name1 = 'N1'也跟着生效?
学以致用:通过Vue对象对应属性是否有getter/setter即可判断是否是响应式,有才是。
上面问题就比较好回答了,非响应式数据修改不会触发界面刷新,故this.name1 = 'N1'虽然执行了,但是界面没有刷新,还是this.name1 = undefined。
响应式数据修改可以触发界面刷新,即this.name = 'NN'执行后,界面this.name = 'NN',界面刷新会读取最新this.name1数据,故界面this.name1 = 'N1'。
接下来this.name1 = 'NN1',不会触发界面刷新,只能等到下次界面刷新时更新。
数组响应式是怎么处理的?有哪些坑?
还是构造一个只有数组和对象数据的页面:
通过Vue对象中数据就能一目了然:
坑就清楚了,因为数组的数据项不是响应式,所以修改不生效。
学以致用:解决方案要么每次换成新数组(因为数组变量是响应式的),如this.arr = [-1, -2],要么使用Array操作api,如this.arr.splice(0, 2, -1, -2)。
可能有同学比较好奇,为啥 Array.splice()能做到响应式?
又有同学好奇了,Vue3如何避免数组修改问题的?
写个简单示例切入看看:
因为使用了Proxy,,直接设置对象代理后const proxy = new Proxy(target, baseHandlers),就能对对象属性操作直接拦截,而不需要再主动去设置每个属性拦截,自然也没有新增属性不生效问题。
key作用以及日常中的问题?
还是举个例子,将[<input placeholder='aaa'/>,<input placeholder='bbb'/>,<input placeholder='ccc'/>]变换成[<input placeholder='ccc'/>,<input placeholder='aaa'/>,<input placeholder='bbb'/>]
小结:有唯一key则会找到相同key的新旧节点,在更新时就能最大程度减少操作,相同key节点一般比随便选一个同层类型节点改动少,但是更新属性和子节点操作是一样的。
那么不加key好像也没什么坑?
注意看,倘若我在aaa输入框填写1时,等调整数组后惊奇发现,调整后时ccc输入框输入1,aaa是空,这就是坑。
为什么会这样?
核心在复用,复用能更新的是Vue模版中显示绑定的属性,组件其他没绑定的属性就直接保持复用前的不变了...
学以致用:能确定复用的一定要设置唯一key,特别是在数组删除和调整顺序时,否则你会遇见一些稀奇古怪的问题。
响应式数据修改会导致全部DOM Diff么?
还是举个例子,两个输入框,只修改第一个输入框数据,第二个输入框会不会重新走一遍DOM Diff。
简而言之,新旧虚拟节点VNode是重新创建,如果对应真实element节点相关属性有变化,则需要调用对应Element的Web API更新。
上述是在一个Vue组件内部不同Web 组件变化,有好奇的同学认为Vue Dom Diff只限制在Vue组件内部,即如果Vue组件A内部变化,只有组件A进行Dom Diff,组件B不用Dom Diff。
是这样吗?让我们来看看。
看到这,可能有朋友会抛出终极问题“选Vue还是React?”。
我的理解是Vue基于依赖收集,即对每个数据变量设置了观察者,能精确知道改动影响,这对于中小规模或者局部变化的页面场景有优势,相当于相机自动挡(傻瓜式操作),但成本就是在于智能计算性能开销以及用户调整空间小。
React是自上而下直接比较更新,对于大规模或全局变化的页面场景有优势,相当于相机手动挡(专家级操作),只是调整起来麻烦,但是可调节细节多(重写shouldComponentUpdate进行剪枝),能获得更好的效果。
回到起点,看看官网介绍,[Vue](https://cn.vuejs.org/)是渐进式JavaScript框架,是易学易用,性能出色,适用场景丰富的Web前端框架。[React](https://zh-hans.react.dev/)是用于构建Web和原生交互界面的库。Vue侧重易用且保障性能,React侧重ReactNative。
可以看看React和Vue框架的区别。
学以致用:一般前端项目推荐使用Vue,上手快,源码和文档写的非常清晰。当然,也要考虑公司和团队主要技术栈,多能不如一专,如果有ReactNative,或者涉及低代码搭建,React优势就比较明显,因为React生态更大,能找到更多的直接可用的开源仓库。
授人以渔
最后,还是想和大家说说分析方法,问题千千万,如果知道可操作的方法,剩下只是时间问题。
从上述解析可以看出,首先需要问题点作为突破口,然后将问题点最小化成一个示例,接下来通过在源码加日志和打断点分析验证,得到关键代码运行时截图和推导结论。
上述运行代码示例详见sheng-vue-playground/vue2.7-demo