解读 VUE 3 新特性

385 阅读7分钟
从去年国庆发布的Beta版本,到前几天9.18,Vue 3 终于发布了正式版本。这距离Vue 2的出现也已经有4年时间了。虽然整个市场使用占比来看React 远远 > Vue,但Vue也因其简单易学、数据响应等特点而拥有自身优势,以及作者是国人等原因吧,我们就算日常不使用,大都也还是会对Vue保持一份关注和了解。
Vue这一次可以说绝大部分都重写的版本迭代,提供了:
  • 更优的数据双向绑定设计
  • 能更好的实现Vue组件代码组织&逻辑复用的 ​Composition API​
  • TS真香(源码全部用TS重写)
  • 一些性能优化
  • ... ...
从本篇文章中,我们可以集中详细了解一下以上比较受到关注的新特性。

🛠 Composition API

1、书写变化

Vue 3对于使用者来说,最大的变化可能就是全新的​Composition API​,因为它改变了开发者书写Vue组件的方式,从某种程度上来说它与React Hooks的代码组织方式是很像的。
使用​Composition API​,就是在单个vue组件中,通过 ​`setup()​`方法来统一组织组件内部状态&逻辑,代替之前的​Options API​。

Vue 2.x 中Options API所提供的选项功能:(在Composition API​都能找到对应实现)

  • Components
  • Props
  • Data
  • Methods
  • Computed Properties
  • Lifecycle methods
举个🌰,实现一个包含search功能和sorting功能的SearchBar组件:
// Options API 写法
export default {  
  name: 'SearchBar',  
  props: {},  
  data() {    
    return {      
      searchData: {},      
      sortingData1: {},      
      sortingData2: '',    
    }  
  },  
  methods: {    
    searchMethod1: () => {},       
    searchMethod2: () => {},    
    sortingMethod: () => {}, 
  }
}
// Composition API 写法
const useSearch = () => {  
  const state = reactive({    name: '',    value: ''  });  
  const search = () => { console.log(state.value) };  
  return { state, search };
}
const useSorting = () => {  
  return { ... }
}
export default {  
  setup() {    
    return { ...useSearch(),   ...useSorting() }  
  }
}

2、写法清单

  • 入参&返回值:
import { h, ref, reactive } from 'vue'
export default {  
  setup(props, context) {       
    // 组件参数props + 全局上下文context(没有this,使用参数context代替)    
    console.log(context.attrs);    
    console.log(context.slots);    
    console.log(context.emit);

    const count = ref(0)
    const object = reactive({ foo: 'bar' })
    // 返回值:
    // A. 使用模板:直接将模板的数据和方法返回
    // B. 使用Render Function:返回render方法
    return () => h('div', [      count.value,      object.foo    ]) 
  }
}
  • 使用 ​ref​ 或 ​reactive​ 创建响应式的数据:
// reactive 创建可响应的对象类型数据
// ref 创建可响应的基本类型数据,得到的ref对象存在一个value属性,保存的就是ref对象的值
function useMousePosition() {
  const pos = reactive({    x: 0,    y: 0,  })
  return pos
  // return toRefs(pos)
}
export default {
  setup() {
    const { x, y } = useMousePosition();
    // 解构或展开返回一个reactive对象,会丢失响应性!
    return { x, y }
    //使用toRefs方法包裹后,解构不会丢失,因为它将对象的每个property都转成了相应的ref  
  }
}
  • ​watch​/​computed​方法:
export default {
  setup(props) {
    const state = reactive({
      count: 0,
      double: computed(() => state.count * 2)
    });
    // watchEffect是lazy默认false,立即执行的(用于依赖收集)
    const stop = watchEffect(() => console.log(state.double));
    stop();    // 可手动停止

    // watch定义了依赖项就是默认懒执行的,可以拿到 preValue,依赖项可以是数组/多个
    watch(() => state.count, (val, preValue) => console.log(val,preValue));
    ...
  }
};
watchEffect(() => {
      const apiCall = someAsyncMethod(props.userID)
      // watch、watchEffect可以主动清除副作用:
      onInvalidate(() => {
    // 调用时机:watchEffect将被重新执行、停止时
        apiCall.cancel()
      })
})
  • 使用组件生命周期钩子:(没有beforeCreate和created的原因,setup()方法本身执行时机就是在组件​beforeCreate​之后、​created​之前)
import {
  onBeforeMount,
  onMounted,
  onBeforeUpdate,
  onUpdated,
  onBeforeUnmount,  // 不再使用beforeDestory和destoryed
  onUnmounted,
  onActivated,    // 当子组件被打开
  onDeactivated,   // 当子组件被关闭
  onErrorCaptured,   // 当子组件发生审核错误时被捕获到 
 // Vue 3 新增的生命周期钩子
  onRenderTracked,
  onRenderTriggered   // 当一次新的渲染被triggered之后
} from "vue";
export default {
  setup() {
    onMounted(() => { window.addEventListener('mousemove', update) })
    onUnmounted(() => { window.removeEventListener('mousemove', update)})
    ...  
  }
};

3、解决的问题

总体说来,Composition API的出现,是Vue把目标从简单易学构建中小型项目扩展到能更好支持大型项目的转变。它的动机与目的,就是为了适应解决复杂组件问题,提供更好的代码组织、逻辑复用、类型推导。

**1)复杂组件难以阅读和维护**
当Vue组件内部逻辑变多、代码变长,​Options API​的组织方式将会变得低效,各个逻辑的代码交叉错乱,可读性变差、代码维护成本也随之增加,同时也不利于公共逻辑的复用。而​Composition API​的设计就是为更方便的实现每个逻辑的聚合而生。如图就是两种代码组织方式的变现区别,同一个色块代表同一个逻辑可能包含的数据、方法、计算属性等等:

**2)Vue2现有逻辑复用方式存在问题:包括Mixin、高阶组件等**
  • ​mixin​是侵入式的,它改变了原组件,并且随着逻辑增加很难判断某个property的具体来源
  • 各个​mixin​之间的property可能会有命名冲突的问题
  • 性能方面,高阶组件需要额外的有状态的组件实例,从而使得性能有所损耗
相比而言,Composition API:
  • 暴露给模板的 property 来源十分清晰,因为它们都是被组合逻辑函数返回的值。
  • 不存在命名空间冲突,可以通过解构任意命名
  • 不再需要仅为逻辑复用而创建的组件实例,也避免嵌套地狱
**3)TS类型支持不友好**
Vue 2.x本身使用​flow​来做类型限定,并且难以使用​TS​的根本原因是它依赖单个this上下文来暴露property,而在3.0中或者说​Composition API​中,在setup中进行编程,setup不依赖this,大部分API大多使用普通的变量和函数,它们天然类型友好,用Composition API编写的代码也可以享受完整的类型推断。

4、与React hooks的对比

尤雨溪本人也都说过​Composition API​是受React Hooks启发的,这种灵活有效的代码组织方式,确实为是为组件模式定义了新方向的,所以能得到广大使用者和同行竞品的认可。尽管大体思路和想法一致,结合Vue 自身的数据响应特性,​Composition API​ 还是会有一些优势:
  • setup()​ 函数只会被调用一次,不会像react ​render()​函数在每次渲染时重复执行,可以降低垃圾回收的压力
  • 不需要手动处理更新依赖,避免像react中​useEffectuseMemo​等的使用
  • 不需要考虑内联函数导致子组件永远更新的问题,​避免useCallback​使用
  • 不需要顾虑调用顺序,可以用在条件语句中,react hooks需要在​render​函数顶部调用
  • 一般来说更符合惯用的 JavaScript 代码的直觉

📣 依赖收集&数据响应

除了使用层面的升级,Vue在框架实现底层,也做了较大的改动。在实现数据双向绑定这一卖点功能上,也拿出了突破性的更新。

1、Vue 3 响应式实现原理

要理解Vue 3 Reactivity的实现原理,我们可以通过 [Vue Mastery](https://www.vuemastery.com/courses/vue-3-reactivity/vue3-reactivity) 提供的一个模拟实现得到简单清晰的思路。看看这个🌰:
let product = {
   price: 5,
   quantity: 2
}
let total = product.price * product.quantity  // 10
product.price = 20
console.log(`total is ${total}`)  //我们需要在price变成20后,拿到重新计算的total的值
Vue 3的做法,把变化/计算操作存放在​`effect`​中:
const effect = () => { total = product.price * product.quantity }
通过​`track`​把数据和相应的​effect​关联起来——**_依赖收集_**,存储在层级map中;
通过​`trigger`​在数据变化后找到并触发相关​effect​执行——**_数据响应_**。
存储容器的定义:
// 使用set来存放一系列的effects
let dep = new Set()
// 使用map来存放data属性和dep之间的关系
const depsMap = new Map()
// 使用weakmap来存放各个reactive对象与depsMap之间的关系
const targetMap = new WeakMap()
3个层级的存储结构,维护了整个组件的数据响应流式:

track方法的实现:
function track(target, key) {
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = new Set()))
  }
  dep.add(effect)
}
trigger方法的实现:
function trigger(target, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) {    return  }
  let dep = depsMap.get(key)
  if (dep) {
    dep.forEach(effect => {
      effect()
    })
  }
}
与Vue 3比,现在的reactivity流程里,还差「**数据劫持**」的部分,就是去调用track和trigger方法的时机。这个可以从源码中对创建响应式对象的​reactive​方法的实现找到答案:核心原理就是使用​`Proxy`​( [MDN](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy)) 对每个目标对象添加一层代理,在`proxy`对象的`​get/has/ownKeys`​方法中调用​track​方法收集依赖,对象任一属性发生变化,触发`​set/deleteProperty`​方法时,调用​`trigger`​方法响应各个关联的更新操作​`effect`​。
function reactive(obj) {
  return new Proxy(obj, {
     get(target, key, receiver) {
       track(target, key);
       return Reflect.get(target, key, receiver);
     },
     set(target, key, value, receiver) {
       const result = Reflect.set(target, key, value, receiver);
       trigger(target, key, value);
       return result;
     }
  });
}

_*手写实现一个简单的reactivity_:[https://codesandbox.io/s/suspicious-dew-m0lmt?file=/index.html](https://codesandbox.io/s/suspicious-dew-m0lmt?file=/index.html)

2、与Vue 2 响应式的对比

**1)Vue 2 响应式实现原理**
在Vue 2中的响应系统与3.0中的是完全不同的,虽然都是属于观察者模式,2.x中的响应式系统是基于Observer、Dep、Watcher几个大类实现的:
  • Observer:Observer类实现响应式的核心原理是使用​Object.defineProperty​实现数据劫持,其作用就是遍历对象并重写属性的 ​getter​ 、 ​setter​。在​Object.defineProperty​的get方法中向dep.subs添加Watcher订阅者——收集依赖,在set方法中循环遍历​dep.subs​数组,通知每个Watcher进行update——派发更新。
  • Dep 依赖管理:(Dependency)是用于管理当前响应式对象的依赖关系, 每个响应式对象和子对象都有一个Dep实例,Dep通过数组subs收集Watcher订阅者,当数据更新时再去通Watcher。
  • Watcher 观察者:每个组件实例都对应一个单独的Watcher实例,Watcher实例专门负责订阅Dep,当Dep派发更新时触发自己的​update​。
*手写简单实现_Vue 2 响应式_:[https://codesandbox.io/s/amazing-waterfall-0y10q?file=/index.html](https://codesandbox.io/s/amazing-waterfall-0y10q?file=/index.html)
**2)Vue 3 响应式优点**
  • 更小的内存占用:​Object.defineProperty​是对整个数据递归劫持,​Proxy​是通过统一代理来监控属性多种变动
  • 更多数据类型的劫持:Vue 2只支持​Object、Array​两种,Vue 3支持​Object、Array、Map、WeakMap、Set、WeakSet ​6种
  • 更多的操作类型的劫持:Vue 2只在目标对象的属性进行get操作时进行依赖收集,在对目标对象的属性进行set操作时触发通知依赖(并且无法自动监测属性添加、数组索引变化);而Vue3在目标对象进行​get、has、iterate​三种操作时进行依赖收集,目标对象进行​set、add、delete、clear​四种操作时触发通知依赖
  • 更快的性能:JS引擎对相关API未来优化方向是​Proxy
  • 更易维护的依赖关系收集器:Vue 2则是通过被闭包引用的dep和通过observer实例引用的dep来作为依赖收集器,Vue 3通过一个WeakMap作为全局的依赖收集器,可以方便的找到或者移除目标对象的依赖。

⏱ Compiler 优化

1、动态节点标记 PatchFlag

Vue 3 在编译模板的过程中,将页面节点区分成静态节点、动态节点(包含动态文字、动态属性的可变节点)。在​`createVnode`​的时候,动态节点会带上相应的`patchFlag`。在​patchElement​节点更新时,通过`​patchFlag > 1​`条件可直接跳过静态节点的更新。
(patchFlag没有向外暴露,所以调用​h函数​生成的节点,默认不使用标记)
export const enum PatchFlags {
   TEXT = 1,           // 动态文字内容
  CLASS = 1 << 1,     // 动态 class
  STYLE = 1 << 2,     // 动态样式
  PROPS = 1 << 3,     // 动态 props
  FULL_PROPS = 1 << 4,            // 有动态的key,也就是说props对象的key不是确定的
  HYDRATE_EVENTS = 1 << 5,        // 合并事件
  STABLE_FRAGMENT = 1 << 6,       // children 顺序确定的 fragment
  KEYED_FRAGMENT = 1 << 7,       // children中有带有key的节点的fragment
  UNKEYED_FRAGMENT = 1 << 8,    // 没有key的children的fragment
  NEED_PATCH = 1 << 9,          // 只有非props需要patch的,比如`ref`
  DYNAMIC_SLOTS = 1 << 10,     // 动态的插槽
}

2、靶向更新 Block​

Block​特指带有一个 ​dynamicChildren​ 属性的一类特殊的VNode(​createBlock​实际调用​createVnode​)。​dynamicChildren​ 就是用来存储一个节点下,所有带有上述patchFlag的子代动态节点的数组。有了它在后续 ​diff​ 过程中就可以避免按照Vdom树一层一层的遍历,而是直接找到Block的dynamicChildren进行一一更新,并且根据动态节点打上的patchFlag,可以准确的知道为每个节点进行哪些更新动作,从而实现了整个视图模板的靶向更新。

会生成block节点的情况:
  • v-if/v-else-if/v-else
  • v-for
  • 根节点
而父级 ​Block​ 除了会收集子代动态节点之外,也会收集子 ​Block​,当子 ​Block​ 将作为父 ​Block​ 的 ​`dynamicChildren`​时,就构成了​`Block Tree`​。​Block Tree​ 主要是能避免一些Dom结构不稳定时引起的渲染更新问题。

3、缓存策略

**1)静态节点提升**
Vue 3 的 ​Compiler​ 如果开启了 ​`hoistStatic`​ 选项,则会提升静态节点的创建到render外层,这可以减少创建 ​VNode​ 的消耗 。
未开启静态提升:
function render() {
    return (openBlock(), createBlock('div', null, [
        createVNode('p', null, 'text')
    ]))
}
已开启静态提升:
const hoist1 = createVNode('p', null, 'text')
function render() {
    return (openBlock(), createBlock('div', null, [
        hoist1
    ]))
}
静态提升是以树为单位的,如果子代元素包含:有key值、ref值绑定、自定义指令,或是动态数据、属性,将不会被提升。
**2)预字符串化**
20个以上的无属性静态节点,或5个以上的有属性静态节点,可以是兄弟关系或父子关系,可以进一步使用预字符串化来减少开销:
const hoistStatic = createStaticVNode('<p></p><p></p><p></p>...20个...<p></p>')
render() {
    return (openBlock(), createBlock('div', null, [       hoistStatic    ]))
}
**3)事件缓存**
当 Vue3 Compiler 开启 ​`prefixIdentifiers`​ 以及 ​`cacheHandlers`​ 时,可以对组件绑定事件进行缓存,这个事件在运行时阶段会被当作静态处理:
render(ctx, cache) {
    return h(Comp, {
        onChange: cache[0] || (cache[0] = ($event) => (ctx.a + ctx.b))
    })
}
**4)Vnode缓存**
手动调用v-once指令,只渲染一次后,不参与后面的diff过程:
<div v-once>{{ foo }}</div>
render(ctx, cache) {
    return (openBlock(), createBlock('div', null, [
        cache[1] || (
            setBlockTracking(-1),    // 阻止这段 VNode 被 Block 收集
            cache[1] = h("div", null, ctx.foo, 1 /* TEXT */),
            setBlockTracking(1),        // 恢复
            cache[1]
       )
    ]))
}
🎤 总结
从以上几个主要更新中,可以看到Vue 3确实像官方报告的做到了这5个关键优化点:
  • 速度:数据跟踪、编译、渲染
  • 体积:全局API tree shaking,按需引用
  • 可维护性
  • 面向原生
  • 易用性
如今3.0正式版一旦发布,接下来也还会引起一波Vue重构、实践大潮。后续将继续关注新版实践体验,可能也会帮助到我们日常对Vue、React的项目开发。
**参考&延伸**:
Composition API RFC:[https://vue-composition-api-rfc.netlify.app/zh/#%E6%A6%82%E8%BF%B0](https://vue-composition-api-rfc.netlify.app/zh/#%E6%A6%82%E8%BF%B0)
Vue 响应式原理:[https://www.yuque.com/wuhaosky/vue3/vue-reactivity](https://www.yuque.com/wuhaosky/vue3/vue-reactivity)
Vue 提供的模板编译查看工具:[https://vue-next-template-explorer.netlify.app/](https://vue-next-template-explorer.netlify.app/)
Vue 3 migration guide: [https://v3.vuejs.org/guide/migration/introduction.html#overview](https://v3.vuejs.org/guide/migration/introduction.html#overview)
Vue 3 所有 RFC / 更新细节:[https://github.com/vuejs/rfcs](https://github.com/vuejs/rfcs)