vue3跟vue2对比
vue3支持composition API,并且代码可以通过声明全局变量__VUE_OPTIONS_API__的方式决定最后项目的编译代码中有没有对options API的支持代码同时new webpack.DefinePlugin({ __VUE_OPTIONS_API__: JSON.stringify(false) // 去掉vue中对options API的支持代码 })composition API本身就支持更好的tree-shaking,因为现在必须显示的具名引用import { nextTick } from "vue"才会将对象打入最终包,vue2所有API挂载在vue对象上,无论你引不引用,都会打入最终包vue3增加了对typescript的支持,使编辑器能智能提示vue中的变量和类型,自动引用,并做静态类型检查vue3使用proxy代替defineProperty,从而大大加快项目初始化速度并减少内存占用。vue3中可以使用createApp方法创建多个app实例,并单独在每个实例上设置全局属性,而vue2只能在全局的vue对象上设置属性,因此每个实例上都是统一的设置无法区分vue3把模版分析生成render函数的步骤放入了编译时,使得运行时初始化更快vue3支持ts,能用hooks代替vue2的mixins
vue跟react对比
vue的composition API已经能实现灵活的代码组合了,但跟react比起来感觉还是不够彻底- 一个明显的是,灵活程度接近react那种了,但依然需要写script、template标签,导致这种灵活性不够彻底,模版依然无法非常灵活的拆分
- 属性解构后就无法响应式了,这感觉像是个bug,vue专门为此提供一个api:toRefs来解决
- setup中声明的reactive数据,重新赋值不会触发响应机制
- 新增的effect接口,传入的cb会在依赖变更后同步执行,而不是异步,watch和模板render是异步,这个不一致
- 对象和简单属性的响应式不一样,有ref、reactive两个api,react只需要使用统一的useState就行了
以下hook对比引用自zhuanlan.zhihu.com/p/133819602 但 React Hook 的限制非常多:
- 不能在循环,条件或嵌套函数中调用 Hook
- 确保总是在你的 React 函数的最顶层调用他们。
- 遵守这条规则,你就能确保 Hook 在每一次渲染中都按照同样的顺序被调用。这让 React 能够在多次的 useState 和 useEffect 调- 用之间保持 hook 状态的正确。
而 Vue 的优势在于:
- 与 React Hooks 相同级别的逻辑组合功能,但有一些重要的区别。 与 React Hook 不同,setup 函数/ script type="setup" 仅被调用一次,这在性能上比较占优。
- 对调用顺序没什么要求,每次渲染中不会反复调用 Hook 函数,产生的的 GC 压力较小。
- 不必考虑几乎总是需要 useCallback 的问题,以防止传递函数prop给子组件的引用变化,导致无必要的重新渲染。
- React Hook 有臭名昭著的闭包陷阱问题(甚至成了一道热门面试题,omg),如果用户忘记传递正确的依赖项数组,useEffect 和 useMemo 可能会捕获过时的变量,这不受此问题的影响。 Vue 的自动依赖关系跟踪确保观察者和计算值始终正确无误。
- 不得不提一句,React Hook 里的「依赖」是需要你去手动声明的,而且官方提供了一个 eslint 插件,这个插件虽然大部分时候挺有用的,但是有时候也特别烦人,需要你手动加一行丑陋的注释去关闭它。
vue3双向绑定基本原理
基本原理是使用Proxy代理数据,获取数据时在get里收集依赖改数据的执行体,设置数据时在set里执行收集的执行体
function render (vNodes) {
function innerRender(VNode, container) {
const elm = document.createElement(VNode.tag)
for (const key in VNode.props) {
if (/^on/.test(key)) {
elm.addEventListener(key.replace('on', '').toLowerCase(), VNode.props[key])
} else {
elm.setAttribute(key, VNode.props[key])
}
}
if (typeof VNode.children === 'string') {
elm.innerText = VNode.children
} else if (VNode.children.length) {
VNode.children.forEach(node => render(node, elm))
}
container.appendChild(elm)
}
// 这里不写diff逻辑,复杂了点,因此直接简单的强行清空
// 本来多次渲染应该是要diff然后局部更新的
document.body.innerHTML = ''
innerRender(vNodes, document.body)
}
const Bucket = new Set()
let effectFn
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
Bucket.add(effectFn)
return Reflect.get(target, key, receiver)
},
set(target, key, newVal, receiver) {
const rs = Reflect.set(target, key, newVal, receiver)
Bucket.forEach(fn => fn && fn())
return rs
}
})
}
function effect(fn) {
effectFn = fn
fn()
}
let data = reactive({
count: 0
})
effect(() => {
const vNodes = {
tag: 'div',
props: {
onClick() { console.log('click count:', ++data.count) }
},
children: 'click me: ' + data.count
}
render(vNodes)
})
effect(function test() {
console.log(data.count)
})
模版语法和虚拟dom语法
模版语法:
<h1 v-if="level === 1" :id="1"><h1/>
<h2 v-if="level === 2" @click="handleClick"><h2/>
...
虚拟dom:
const level = 1
const title = {
tag: `h${level}`
}
由此可以看出虚拟dom比模版语法更灵活,声明式ui中很多东西都可以自定义,但标签名是没法自定义的,但js对象就不同了
竞态问题
不知何为竞态问题,可以看:zhuanlan.zhihu.com/p/130278711 vue在watch中提供了一种解决方案:
watch(value, function async callback(newV, oldV, onInvalidate) {
let isValid = true
// 通过onInvalidate注册的回调,会在每次value发生变化时,在整个callback执行之前执行
onInvalidate(() => {
isValid = false
})
const data = await fetchData(props.id)
if (isValid) {
commit('setData', data)
}
})
但也只有这样了,没有一个统一的解决途径 比如,一个按钮多次点击,每次点击请求一次,这种不通过watch的情况就无法处理了,因此如果要解决竞态问题还是要像文中去另外封装
小知识点 NaN
NaN是唯一一个不等于自身的值
var a = NaN
a === a // false
可以利用这点判断是否是NaN
const isNaN = n => n !== n
isNaN('str') // false
isNaN(NaN) // true
为什么使用Reflect
const obj = {
foo: 1,
get bar() {
return this.foo
}
}
const proxy = new Proxy(obj, {
get(target, key) {
... // 省略依赖收集的代码
return target[key]
}
...
})
effect(function test(){
proxy.bar
})
此处test执行会依赖bar,并最终取到obj.foo。但这里无法收集到test对obj.foo的依赖,原因是:
proxy.bar-- 会经过proxy的get回调,因此可以收集到依赖return target[key]-- 这句代码里的target就不是proxy而是obj原始对象return this.foo-- 因为取bar属性的对象的obj,因此这里等于直接读取obj.foo,因此不会经过proxy的get回调,因此就无法收集依赖
Reflect第三个参数官方说明如下
receiver
如果
target对象中指定了getter,receiver则为getter调用时的this值
因此proxy写成如下形式可以解决上述问题:
const proxy = new Proxy(obj, {
get(target, key, receiver) {
... // 省略依赖收集的代码
// 这里receiver代表读取属性的对象,当通过proxy读取,这里就是proxy
return Reflect.get(target, key, receiver)
}
...
})
或许有人说那怎么不直接return proxy[key],这样的问题是无法代理深度取值了,比如proxy.a.b这种取值
vue2不能代理数组,vue3可以代理的原因
Object.defineProperty不能代理length和索引属性,因此无法做到完美代理
Proxy可以代理length,而对索引的引用也可以走到set/get里,因此可以做到完美代理数组
vue2数组代理机制
总体代理机制如下图:
其中重设的_proto_对象为如下methodsToPatch变量:
const arrayProto = Array.prototype;
const arrayMethods = Object.create(arrayProto);
const methodsToPatch = [
'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'
];
methodsToPatch.forEach(function (method) {
// cache original method
const original = arrayProto[method];
def(arrayMethods, method, function mutator (...args) {
// 此处省略XX字
// 这里会去通知依赖更新
ob.dep.notify();
return result
});
});
也就是说vue2对数组没有去代理索引和length,因此arr[2] = 1/arr.length = 0这种代码是不会触发更新的,而通过调用push等方法去改变数组是可以触发更新的
Proxy数组代理的问题
数组存在以下应收集依赖的场景:
arr[0]arr.lengthslice,concat,filter,every,find等不会改变数组的原型方法for in/ for of循环
存在以下应触发副作用重新执行的场景:
arr[0] = 1arr.length = 1push,pop,shift等会改变数据的原型方法
情况相比普通对象复杂,体现在以下几种情况:
-
比如一个特殊的场景:如果数组长度为2,
arr.length = 1的执行会导致effect(function test() { console.log(arr[1]) })这个副作用函数需要被重新执行,原因是arr[1]被清空了而如果数组长度为1,
arr[1] = 1会导致effect(function test() { console.log(arr.length) })这个副作用函数需要被重新执行,原因arr.length变大了也就是说索引和
length属性有关联关系 -
const arr = reactive([{}]);arr.includes(arr[0]);由于
reactive对值为对象的属性,取值会返回新的reactive对象,代码如下:// reactive函数的部分代码 if (typeof res === 'object' && res !== null) { return reactive(res) }因此
arr.includes(arr[0]);这句代码中arr[0]不是原始值而是一个新的代理对象,includes在js中的实现是去遍历数组,挨个比较,那遍历到0的时候就跟执行arr[0]是一样的,因此也是一个新的代理对象,那显然这句代码结果是false。因此vue的处理方式是建立了原对象到代理对象的映射缓存,创建代理对象时,判断如果该对象已经存在代理对象,那么直接返回该代理对象,就可以解决此问题 -
const obj = {};const arr = reactive([obj]);arr.includes(obj);如第二个问题所说,
includes遍历到0的时候返回的是一个代理对象,这个代理对象跟obj进行相等比较,那当然是false。vue对这种情况的处理是:重写了includes,在proxy get中判断如果key是includes,会返回重写的方法。重写的方法中如果arr.includes(obj);返回false,会再取arr对应的原始值来执行,也就等于是:[obj].includes(obj),此时就返回true了。同样的方法还有
indexOf、lastIndexOf -
const arr = reactive([]); effect(() => arr.push(1))push的原理,会调用length属性并判断其push后是否会超过最大长度,如果没有,会重设length。因此上面的代码会造成无限循环:先读取length触发了依赖收集,然后修改length导致effect中函数重新开始执行,而此时第一次执行还没结束。vue的处理是,也重写push,进入函数后,先设置全局变量shouldTrack为false,然后才执行原始的push方法。而依赖收集函数中加个判断:如果shouldTrack为false,直接return同样的方法还有:
pop\shift\unshift\splice。
ref
Proxy无法代理非对象值,因此vue3封了个ref,原理如下
const ref = (v) => {
const obj = { value: v }
return reactive(obj)
}
小知识点迭代器
为了实现可迭代,一个对象必须实现 @@iterator 方法,这意味着这个对象(或其原型链中的任意一个对象)必须具有一个带
Symbol.iterator键(key)的属性。
而数组默认的存在迭代器方法Symbol.iterator,因此数组是可迭代对象,所有可迭代对象都可以用for of循环遍历
如果我们给一个对象实现Symbol.iterator方法,那么他也可以用for of循环遍历
var obj1 = {
val: 0,
[Symbol.iterator]() {
return {
next() {
return {
value: obj1.val++, // 迭代器方法的next必须返回value和done
done: obj1.val > 10 // 如果done为true了,就不会被for of访问到
}
}
}
}
}
for (const it of obj1) {
console.log(it) // 1 2 3 4 5 6 7 8 9
}
解构
const obj = reactive({
a: 1, b: 2
})
const { a, b } = obj
effect(() => {
console.log(a, b)
})
上面这段代码,effect回调执行并不会触发依赖收集,当修改a或b时,该函数不会重新执行
原因就是解构,解构的时候会触发两次proxy set,并分别返回值1,2,因此,变量a和b就只是两个普通的变量了,值分别为1,2,而不再是proxy对象,因此在effect中使用他,并不会触发依赖收集。
其实解构过程本身是可以触发依赖收集的,但因为没有被effect包裹,因此不会收集到什么依赖。
vue3为此提供了两个函数toRef、toRefs
function toRef(obj, key) {
return {
get value() {
return obj[key]
}
}
}
function toRefs(obj) {
const rs = {}
for (const key in obj) {
rs[key] = toRef(obj, key)
}
return rs
}
const obj = reactive({
a: 1, b: 2
})
const { a, b } = toRefs(obj)
effect(() => {
console.log(a.value, b.value)
})
本质上就是弄了个新对象,这个对象具有解构对象的所有属性,每个属性的值是个对象,这个对象有个叫value的getter属性,这个getter直接返回被解构对象的相同属性
因此,如果被解构对象是个响应式对象,那么解构后依然是响应式的
调度机制
调度机制的意思是:当响应式对象发生变化,依赖此对象的函数要执行时,把这个函数的执行交由外部控制,不再自动执行,比如:
const obj = reactive({a: 1})
const cb = () => obj.a
effect(cb, {
// 调度器函数,参数fn在这里可以当作就是cb
// 当obj.a发生变化时,cb不会自动执行,而是传递到这里,让用户自己去控制他怎么执行
scheduler(fn) {
// 比如我把他异步执行,或者放到队列中去重执行之类的,有没有想到什么?没错!vue2的队列异步机制
setTimeout(fn)
// 基于该函数特性,还可以把他当作依赖的数据变化了的回调
console.log('函数cb依赖的数据发生了变化')
}
})
effect还有个参数是lazy,作用是调用时不直接执行回调函数,而是把回调函数返回,简单代码如下:
function effect(cb, { lazy }) {
const _effectFn = () => {
effectFn = fn
return fn()
}
if (lazy) {
return _effectFn
}
_effectFn()
}
调度机制是vue3内部很重要的一个机制,computed/watch就是借这个机制实现的
function computed(fn) {
let obj, value
let dirty = true
const _effectFn = effect(fn, {
lazy: true,
scheduler() {
dirty = true
trigger(obj, 'value') // 触发依赖此计算属性的函数执行
}
})
obj = {
get value() {
if (dirty) {
dirty = false
value = _effectFn()
}
track(obj, 'value') // 依赖收集
return value
}
}
return obj
}
function watch(exp, cb) {
const fn = () => {
if (isFunction(exp)) {
fn()
} else {
// 深度遍历对象,从而使得此函数依赖exp的所有属性
traverse(exp)
}
}
effect(fn, {
scheduler() {
cb()
}
})
}
fragment
vue2中不支持一个template中存在多个根元素,vue3支持。原因是vue3中新增了一种节点类型:fragment
对于fragment节点,渲染时不渲染本身,只渲染所有子元素即可
function patch(oldVNode, newVNode, container) {
...
if (newVNode.type === 'fragment') {
newVNode.children.forEach(child => patch(null, child, container))
}
...
}
卸载时直接卸载所有子元素
function unmount(vNode) {
...
if (vNode.type === 'fragment') {
vNode.children.forEach(unmount)
}
...
}
diff算法
react diff算法是简单diff算法,这种算法dom移动数较多
vue2采用的diff算法是双端对比算法,或者交叉对比,算法逻辑:
循环以下步骤,直到
newStartIdx > newEndIdx || oldStartIdx > oldEndIdx
- 首首对比,如果相同直接更新老并递增
newStartIdx、oldStartIdx;如果不相同,下一步 - 尾尾对比,如果相同直接更新并递减
newEndIdx、oldEndIdx;如果不相同,下一步 - 尾首对比,如果相同,更新
oldStartIdx指向的老节点,并把该节点移动到尾部,然后递减newEndIdx,递增oldStartIdx;如果不相同,下一步 - 首尾对比,如果相同,更新
oldEndIdx指向的老节点,并把该节点移动到首部,然后递增newStartIdx,递减oldEndIdx;如果还是不相同,下一步 - 先遍历旧子节点列表,生成一个
key值到节点的映射,如果已经生成过就不再生成。根据newStartIdx对应的新节点的key值从映射中取对应的老节点,取到了就直接更新该节点,并移动到oldStartIdx指向的老节点的前面,然后newStartIdx++并把oldCh数组中该老节点原本索引位置置为undefined;如果压根没找到,就createElement新节点并放在oldStartIdx指向的老节点的前面
上面的步骤基本完整了,由这里可以看出:
- 每次循环处理一个节点
- 该循环一定会遍历一次所有节点,而且不存在对节点的重复遍历
- 除了主循环外,存在一次额外的循环:遍历旧节点列表生成
key值到节点的映射
因此时间复杂度是5n + n,是线性的,因此是O(n)
vue3采用的diff算法是快速Diff算法,总体思想是做最少的Dom移动:
这里先介绍一个概念:最长递增自序列。比如数组[9,3,4,12],他的最长递增自序列是[3,4,12]。相信大家已经懂了这个概念~
假设新旧节点列表分别为newCh,oldCh
- 从
newCh,oldCh两个数组的0位开始比较,如果是相同的节点,就直接更新 - 从
newCh,oldCh两个数组的末位开始比较,如果是相同的节点,就直接更新 - 如果经过
1,2步newCh就已经遍历完了,而oldCh还没遍历完,说明有多余的节点,直接全移除 - 反之,如果经过
1,2步oldCh已经遍历完了,而newCh还没遍历完,说明newCh中剩余的节点是新增的,全部创建
上述这几步相比双端对比算法在性能上并无区别, 接着才是性能上真正提升的地方,上述3、4情况较为理想,实际上很可能首尾对比后两者都还存在一些节点,就走到以下步骤,先假设处理完首尾后,新旧列表分别为newCh1,oldCh1,newCh1的长度为count:
const source = new Array(count);source.fill(-1);这个source的目的是为了存新节点在oldCh1中的索引,如果新节点在oldCh1中不存在,就会保持-1- 遍历
newCh1生成key到索引的映射keyIndex for node, oldIndex of oldCh1;if keyIndex[node.key] source[keyIndex[node.key]] = oldIndex- 得到
source的最长递增自序列seq,有了这个,凡是这个序列中的对应的老节点,都是不需要移动的,因为这些节点已经是按newCh1中的顺序排列的了,只有这个序列之外的节点才是可能需要移动的 - 接着从
newCh1的末尾开始往前遍历,遍历索引值为i,如果source[i] === -1,说明该新节点不存在对应的老节点创建 - 如果
i不在seq中,说明此节点是需要移动的,把他移动到newCh中i对应的下一个元素之前
最重要的是第8步,这一步先得出了最长的不需要移动的DOM列表,先排除了一些元素移动的可能,接着处理剩下可能需要移动的元素,因此只做出了最少的dom移动,这一点就是相比于vue2的优化。vue2双端对比过程中,凡是发现新旧列表位置不匹配的就会移动。其实整个算法的时间复杂度本身并没有提升,还是O(n + nlogn),是本身循环的复杂度加上最长递增自序列算法的复杂度
比如a,b,c和d,a,b,c,d,理论上只需要删除两个节点即可,但如果走vue2的双端对比,会有3次移动
-----------------待续---------------------------
weakMap使用场景
用于存储一些只有key值存在才有意义的数据,比如vue3里的存储所有proxy对象依赖的就是用weakMap