1. 请说说vue的双向绑定
表象上,双向绑定指的是JS 变量到DOM内容之间的数据映射,也就是变量值更新后会自动反映到DOM节点;而用户在DOM上做的变更操作也会即时反馈到对应变量。
实现上,基于响应式系统实现了从变量到DOM树的绑定,在Vue 2.x 版本是基于 Object.defineProperty
接口实现了对变量属性值get、set过程的拦截,每当 render
函数所依赖的属性被修改时触发 set
逻辑, set
底层的观察者(Watch 对象)进一步触发组件更新回调 —— updateComponent
函数(定义在 src/core/instance/lifeCycle.js),进而重新执行组件 render
、 __patch__
以及 diff
过程,最终将数值更新到DOM树上。
从DOM到变量的绑定则主要通过 v-model
指令实现,内在逻辑简单很多,可以近似地理解为 v-bind:value="x"
以及 @change="x=value"
逻辑的复合,比如对于 input[type=text]
,绑定 input
的值为组件变量 x
,同时监听 change
事件,在事件回调中修改对应的绑定变量。当然了,考虑到浏览器兼容性、复杂类型(checkbox、radio、select) 取值等问题,源码会比上述过程复杂一些。
2. 计算属性如何收集依赖
表象上,计算属性具备 「缓存」 功能,仅当它所依赖的其他属性数值变更时才会重新执行计算。
依赖收集的实现集中在 Watcher
与 Dep
两个类中。首先要理解,在Vue中有两种形态的跟踪机制,一是data、props这种「简单对象」,特点是它们本身就存储了原子值,不会因为其它变量的变化而变化;第二种类型包括computed、render这类「复合对象」则复杂很多,特点是它们自身并不维护数值,而是执行过程中通过组件其他 data、props、computed、render 以及运行上下文中的其他数值推算而得。
❝❞
props
比较特殊,虽然在父组件中可能是通过computed计算得出,但在子组件的initProps
函数中只是调用了observe
进行观察,处理上与data
相似。
实现上,对于 「简单对象」 ,Vue 2.x 会遍历其属性,通过 Object.defineProperty
接口包装get、set行为,同时为该属性绑定一个 Dep
对象;对于 computed、render 这类复合求值的属性,则会绑定一个 Watcher
对象。那么「重点来了」,计算属性在求值的时候 ( Watcher.run
函数) 会调用 pushTarget
将其绑定的 Watcher
对象设置为全局变量(源码),此后,函数体内一旦调用经过封装的响应式属性,就将触发属性对应的 Dep.prototype.depend
函数(源码)将依赖对象记录到 Watcher
对象的 newDeps
数组,后续这些响应式属性被修改时(源码)再遍历通知依赖于它的 Watcher
对象。
❝为了更好地帮助理解,建议简单地阅读一下相关实现:
❞
❝另外,这个问题不要乱猜,我就遇到过有候选人答复是通过正则匹配实现。正则解决这种场景的问题首先非常复杂,其次本质上只是一种词法程度的静态分析手段,要扩展推断语义某种程度上相当于实现一个简化的编译器,性能、复杂度都不可控。 我还遇到过有人答复说是通过AST实现,在执行阶段做AST分析显然对性能会造成不小的负担,也并不是好的方案。
❞
扩展开来,Vue3中依赖收集过程的设计与Vue2相似,细节上有如下区别:
3. patch
过程
patch
模块继承自 snabbdom 框架,在vue中负责从vnode到DOM节点的转换。首次创建节点逻辑:
- 调用
createComponent
函数根据vnode创建组件 (patch.js#L144,这个步骤递归处理子组件)createComponent
检测 vnode 是否具有init
钩子(patch.js#L214),没有则跳过下面步骤,返回undefined- 调用
init
钩子(定义于 create-component.js#L37),创建子组件实例 - 调用
initComponent
,触发create
钩子 - 将子组件生成的vnode挂载到父节点 (patch.js#L222)
- 返回true
- 若
createComponent
返回结果为true,则跳过下面步骤,否则:- 当
vnode.tag
不为空时,调用createChildren
,并将结果插入当前节点 - 当
vnode.tag
为空,且vnode.isComment === true
时,调用nodeOps.createComment
创建 comment 节点 - 上述判断均为false时,调用
nodeOps.createTextNode
创建文本节点
- 当
数据变更触发重新渲染时,会在上述逻辑之前执行diff比对,确定那些节点需要重新渲染。
4. key
属性的作用
这个问题出现的频率跟 「响应式原理」 有的一拼,简单来说是 Vue 做 diff 比对时核心是根据vnode的tag、key两个属性判断两个节点是否相同 (isSameNode 函数),适当的key配置能够有效降低DOM节点重建频率,提高运行性能。
更深入的答案可以扩展开来聊聊diff过程:每当组件 render
函数的依赖项发生变化时,会重新执行 render
生成一颗新的 vnode 树,新树被传入 __patch__
函数进行diff更新。diff 是一个广度优先的比对过程(patch.js#L404),针对新旧两颗vnode树的同一层级,Vue 首先进行首尾比对,若比对不通过则调用 createKeyToOldIdx
函数收集旧vnode树节点的key值,若新树节点的key值在旧树key列表中则复用旧DOM节点;否则创建新的DOM节点。若旧vnode树节点未正确提供key,则上述过程只能到 「首尾比对」 时结束。
5. Vue 对数组做了那些特殊处理
大部分特殊处理的代码集中在 src/core/observer/array.js 文件,主要是对数组的突变方法例如 push
、 splice
、 sort
进行响应式封装,当变更发生时调用 dep.notify
触发响应式流程。
这是面上的答案,其实底子里还有不少针对数组的特殊处理,比如说 src/core/observer/index.js 文件中, Observer
构造函数遇到数组时需要展开每个数组项调用 observe
函数:
export class Observer {
constructor (value: any) {
this.value = value
...
if (Array.isArray(value)) {
...
this.observeArray(value)
} else {
this.walk(value)
}
}
/**
* 使用于对象场景
*/
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i])
}
}
/**
* 适用于数组场景
*/
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i])
}
}
}
响应式属性的 get
过程遇到数组时,也需要展开数组项逐个定义依赖:
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const 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) {
...
}
})
function dependArray (value: Array<any>) {
for (let e, i = 0, l = value.length; i < l; i++) {
e = value[i]
e && e.__ob__ && e.__ob__.dep.depend()
if (Array.isArray(e)) {
dependArray(e)
}
}
}
6. Vue 组件性能优化手段
常用的性能优化手段有(仅适用于 2.x版本):
- 使用 key 提高组件复用率
- 正确处理组件
render
函数复杂度,因为render
依赖的响应属性越多,越容易造成重新渲染,必要时可拆分子组件将复杂度分发到更细粒度上进行管理 - 通常地,可以在
created
回调中执行异步数据处理,以尽快启动异步任务 - 必要时可以使用
inject/provide
替代props
实现父子间传值,因为props
会对属性再进行一次响应式封装,而inject/provide
接口只是简单地传值 - 对于大数据场景,可以使用
Object.freeze
接口禁止属性变更,避免过度频繁的渲染 - 使用
keep-alive
缓存组件实例 - 使用
functional
降低单个组件生命流程与状态管理的复杂度
7. 与Vue2.x相比,vue3响应式实现的优缺点
优点:
- 普适性更强,不需要针对数组做特殊处理;也能应用于值类型变量
- 启动速度更快,因为启动时不需要再遍历对象的所有属性,而是在运行过程中增量执行依赖管理
- 实现上重构
Watcher-Dep
模式,改用单个变量(reactivity/src/effect.ts#L10) 记录依赖关系,架构关系更简单,性能也稍有增强 - 响应式能力通过
Composition API
开放,不再依赖于 Vue 实例,更容易复用
缺点:
- 响应式底层依赖的
Proxy
相比于Object.defineProperty
在许多场景中性能是相对较差的,这种差距放在SSR场景可能会造成性能问题
8. 各发布版本的区别
Vue 2.x 的发布策略相信比较耳熟能详了,内容上包含两种形态,一个是 「Full」 包,包含 「runtime」 与 ** compiler** 两种类型的源码;一个是 「runtime」 包,只包含运行时需要的最小依赖,不具备编译功能。在 「Full」 / 「runtime」 基础上再根据 UMD/CMD/ESM
,普通版本、DEV版本、prod 版本组合分发,那么结果就会生成一堆类似这样的发布文件:
vue.js
/vue.min.js
: UMD 模块,适用于通过script
直接引入vue.common.js
/vue.common.dev.js
/vue.common.prod.js
: commonJS 模块,适用于node、browserify、webpack 1 等环境vue.esm.js
/vue.esm.browser.js
/vue.esm.browser.min.js
: ESM 模块,ESM的特点是可以配合 wepack、rollup实现 treeshake,但Vue2.x实际上只export default Vue
vue.runtime.js
/vue.runtime.min.js
: UMD 模块,只包含runtime
vue.runtime.common.js
/vue.runtime.common.dev.js
/vue.runtime.common.prod.js
:commonJS模块vue.runtime.esm.js
:ESM 模块
9. 组件通讯方式
** to be continue **
10. Vue 中如何实现HOC
** to be continue **
本文使用 mdnice 排版