vue3学习笔记

170 阅读16分钟

vue3vue2对比

  • 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代替vue2mixins

vuereact对比

  • vuecomposition 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。但这里无法收集到testobj.foo的依赖,原因是:

  • proxy.bar -- 会经过proxyget回调,因此可以收集到依赖
  • return target[key] -- 这句代码里的target就不是proxy而是obj原始对象
  • return this.foo -- 因为取bar属性的对象的obj,因此这里等于直接读取obj.foo,因此不会经过proxyget回调,因此就无法收集依赖

Reflect第三个参数官方说明如下

receiver

如果target对象中指定了getterreceiver则为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数组代理机制

总体代理机制如下图:

image.png

其中重设的_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.length
  • slice,concat,filter,every,find等不会改变数组的原型方法
  • for in/ for of 循环

存在以下应触发副作用重新执行的场景:

  • arr[0] = 1
  • arr.length = 1
  • push,pop,shift等会改变数据的原型方法

情况相比普通对象复杂,体现在以下几种情况:

  1. 比如一个特殊的场景:如果数组长度为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属性有关联关系

  2. 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]不是原始值而是一个新的代理对象,includesjs中的实现是去遍历数组,挨个比较,那遍历到0的时候就跟执行arr[0]是一样的,因此也是一个新的代理对象,那显然这句代码结果是false。因此vue的处理方式是建立了原对象到代理对象的映射缓存,创建代理对象时,判断如果该对象已经存在代理对象,那么直接返回该代理对象,就可以解决此问题

  3. const obj = {};const arr = reactive([obj]);arr.includes(obj);

    如第二个问题所说,includes遍历到0的时候返回的是一个代理对象,这个代理对象跟obj进行相等比较,那当然是falsevue对这种情况的处理是:重写了includes,在proxy get中判断如果keyincludes,会返回重写的方法。重写的方法中如果arr.includes(obj);返回false,会再取arr对应的原始值来执行,也就等于是:[obj].includes(obj),此时就返回true了。

    同样的方法还有indexOf、lastIndexOf

  4. const arr = reactive([]); effect(() => arr.push(1))

    push的原理,会调用length属性并判断其push后是否会超过最大长度,如果没有,会重设length。因此上面的代码会造成无限循环:先读取length触发了依赖收集,然后修改length导致effect中函数重新开始执行,而此时第一次执行还没结束。

    vue的处理是,也重写push,进入函数后,先设置全局变量shouldTrackfalse,然后才执行原始的push方法。而依赖收集函数中加个判断:如果shouldTrackfalse,直接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回调执行并不会触发依赖收集,当修改ab时,该函数不会重新执行

原因就是解构,解构的时候会触发两次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)
})

本质上就是弄了个新对象,这个对象具有解构对象的所有属性,每个属性的值是个对象,这个对象有个叫valuegetter属性,这个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算法是双端对比算法,或者交叉对比,算法逻辑:

WechatIMG43的副本.jpeg 循环以下步骤,直到newStartIdx > newEndIdx || oldStartIdx > oldEndIdx

  1. 首首对比,如果相同直接更新老并递增newStartIdx、oldStartIdx;如果不相同,下一步
  2. 尾尾对比,如果相同直接更新并递减newEndIdx、oldEndIdx;如果不相同,下一步
  3. 尾首对比,如果相同,更新oldStartIdx指向的老节点,并把该节点移动到尾部,然后递减newEndIdx,递增oldStartIdx;如果不相同,下一步
  4. 首尾对比,如果相同,更新oldEndIdx指向的老节点,并把该节点移动到首部,然后递增newStartIdx,递减oldEndIdx;如果还是不相同,下一步
  5. 先遍历旧子节点列表,生成一个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

  1. newCh,oldCh两个数组的0位开始比较,如果是相同的节点,就直接更新
  2. newCh,oldCh两个数组的末位开始比较,如果是相同的节点,就直接更新
  3. 如果经过1,2newCh就已经遍历完了,而oldCh还没遍历完,说明有多余的节点,直接全移除
  4. 反之,如果经过1,2oldCh已经遍历完了,而newCh还没遍历完,说明newCh中剩余的节点是新增的,全部创建

上述这几步相比双端对比算法在性能上并无区别, 接着才是性能上真正提升的地方,上述3、4情况较为理想,实际上很可能首尾对比后两者都还存在一些节点,就走到以下步骤,先假设处理完首尾后,新旧列表分别为newCh1,oldCh1newCh1的长度为count

  1. const source = new Array(count);source.fill(-1);这个source的目的是为了存新节点在oldCh1中的索引,如果新节点在oldCh1中不存在,就会保持-1
  2. 遍历newCh1生成key到索引的映射keyIndex
  3. for node, oldIndex of oldCh1;if keyIndex[node.key] source[keyIndex[node.key]] = oldIndex
  4. 得到source的最长递增自序列seq,有了这个,凡是这个序列中的对应的老节点,都是不需要移动的,因为这些节点已经是按newCh1中的顺序排列的了,只有这个序列之外的节点才是可能需要移动的
  5. 接着从newCh1的末尾开始往前遍历,遍历索引值为i,如果source[i] === -1,说明该新节点不存在对应的老节点创建
  6. 如果i不在seq中,说明此节点是需要移动的,把他移动到newChi对应的下一个元素之前

最重要的是第8步,这一步先得出了最长的不需要移动的DOM列表,先排除了一些元素移动的可能,接着处理剩下可能需要移动的元素,因此只做出了最少的dom移动,这一点就是相比于vue2的优化。vue2双端对比过程中,凡是发现新旧列表位置不匹配的就会移动。其实整个算法的时间复杂度本身并没有提升,还是O(n + nlogn),是本身循环的复杂度加上最长递增自序列算法的复杂度

比如a,b,cd,a,b,c,d,理论上只需要删除两个节点即可,但如果走vue2的双端对比,会有3次移动

-----------------待续---------------------------

weakMap使用场景

用于存储一些只有key值存在才有意义的数据,比如vue3里的存储所有proxy对象依赖的就是用weakMap