Vue3 初探

152 阅读5分钟

引言

Vue3 发布有一段时间了,近期有空一探究竟,特此总结。本文将从以下角度加以阐述:

  • 全面支持 TypeScript
  • 全新的数据响应式
  • 性能优化(大家最关心的)
  • Tree Shaking (全局 API 分块)
  • Composition API

全面支持 TypeScript

众所周知 Vue2TypeScript 的支持不太友好。

我们通常需要单独安装组件本身的装饰器 vue-class-componentvue-property-decorator,如项目中引入了 Vuex, 还需要加入 vuex-class等第三方插件,再加上 webpack 文件的配置,使得在 Vue2 中引入 TypeScript 成本太高。

这一点在 Vue3 中得到了很大的改善。

  • 首先从安装上,Vue Cli 直接内置了 TypeScript 工具支持,不需要单独安装引入。

  • npm 包中的官方声明

    随着应用的增长,静态类型系统可以帮助防止许多潜在的运行时错误,这就是为什么 Vue3 是用 TypeScript 编写的。这意味着在 Vue 中使用 TypeScript 不需要任何额外的工具——它作为头等公民被支持。

  • 定义组件

    import { defineComponent } from 'vue'
    const Component = defineComponent({
      // 已启用类型推断
    })
    

全新的数据响应式

相信面试过 Vue 的同学,都会被面试官问道这个问题,Vue2 是怎么完成数据响应式的? 答案大家也都司空见惯了,通过 Object.defineProperty 完成数据劫持,通过递归的方式把一个对象的所有属性都转化成 getset方法,从而拦截到对象属性的访问和变更。

是否有考虑过这么做的缺点是什么?

  • 数据庞大所带来的速率性能问题:Observer方法上,由于需要对对象的每一个属性进行拦截,那么所有的key都要进行循环和递归。

  • Object.defineProperty 方法的瓶颈:该 API 不支持数组,Vue2 是通过数组方法重写完成的支持(链接

  • 动态添加或删除对象属性无法被侦测:definePropertysetter 方法做不到,所以我们经常会用指令 $set,为属性赋值。

再来看看 Vue3 的处理方法

ES6 Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。

该过程首先不涉及循环和递归的算法,针对当前对象的所有属性,无论原始的还是新增的,只要是外界对该对象的访问,都必须先通过这层拦截。

const obj = {
  name: 'Madrid',
  age: '27',
  cars:[
    {carName: '奥迪'},
    {carName: '奔驰'}
  ],
}
const p = new Proxy(obj, {
  get(target, propKey, receiver) {
    console.log('你访问了' + propKey);
    return Reflect.get(target, propKey, receiver);
  },
  set(target, propKey, value, receiver) {
    console.log('你设置了' + propKey);
    console.log('新的' + propKey + '=' + value);
    Reflect.set(target, propKey, value, receiver);
  }
});
p.age = '20';
console.log(p.age);                 // 20
p.newPropKey = '新属性';          
console.log(p.newPropKey);          // 新属性

//其中,尽管有新增属性 newPropKey, 也并不需要重新添加响应式处理

Vue3 的操作是通过 Proxy 是对对象进行拦截操作,与 Vue2 中的 defineProperty 是针对对象的属性进行操作。

性能优化

  • 结论:对比 Vue2,总体性能提升翻倍。
  • 优化点如下:

静态标记 Patchflag

diff 算法的优化 --- 静态标记

Vue2 的 diff 算法采用的是 全量比较 的策略。

原始策略:

当 Virtual DOM 节点发生改变时,会生成新的 VNode, 该VNode 和 oldVNode 节点作对
比,如果发现有差异,则直接在真实的 DOM 上操作修改成新值,同时替换 oldVNode 的值为 VNode。
<div id="app">
  <h1>Vue2的diff</h1>       // 静态节点
  <p>今天基金又绿啦</p>       // 静态节点
  <div>{{name}}</div>       // 动态节点
</div>
function render() {
  with(this) {
    return _c('div', {
      attrs: {
        "id": "app"
      }
    }, [_c('h1', [_v("Vue2的diff")]), _c('p', [_v("今天基金又绿啦")]), _c('div', [_v(
      _s(name))])])
  }
}

这种 全量比较 的策略毫无疑问可以遍布所有 VNode 节点,但是所谓的全量对比,意味着全部节点的对比。

在我们真实的业务场景下,其实存在一部分静态的标签名、 类名, 甚至标签内容都不会变的元素,如果这部分静态元素也参与了全量对比的替换过程中,毫无疑问就产生了一笔时间消耗。

让我们看看 Vue3 做了什么:

<div id="app">
  <h1>Vue3的diff</h1>       // 静态节点
  <p>今天基金又绿啦</p>       // 静态节点
  <div>{{name}}</div>       // 动态节点
</div>
render(_ctx, _cache) {
  return (_openBlock(), _createBlock("div", { id: "app" }, [
    _createVNode("h1", null, "Vue3的diff"),
    _createVNode("p", null, "今天基金又绿啦"),
    _createVNode("div", null, _toDisplayString(_ctx.name), 1 /* TEXT */)
  ]))
}

以上,我们发现 静态标记 实际上是在第一步创建 Virtual DOM 时,根据每个 VNode 节点变化与否,添加了相应的 标记 ,这样一来,之后 VNodeoldVNode 对比的过程中,就只需要对比有标记的节点。

其中,节点变化类型的参考系,是一个枚举 Patchflags

export const enum PatchFlags {
  TEXT = 1,
  CLASS = 1 << 1,
  STYLE = 1 << 2,
  PROPS = 1 << 3,
  FULL_PROPS = 1 << 4,
  HYDRATE_EVENTS = 1 << 5,
  STABLE_FRAGMENT = 1 << 6,
  KEYED_FRAGMENT = 1 << 7,
  UNKEYED_FRAGMENT = 1 << 8,
  NEED_PATCH = 1 << 9,
  DYNAMIC_SLOTS = 1 << 10,
  HOISTED = -1,
  BAIL = -2
 }

显而易见的是,如果只是文本动态变化,取值为1,如果是样式动态变化,取值为1 << 2,依次类推。当存在组合变化时,通过位运算组合,生成相应的标记节点代码。

事件绑定的优化

Vue2 中的事件绑定被视为动态绑定(这显然符合正常理解),但实际上每次点击事件执行的内容是一样的,于是在 Vue3 中,就把这个事件想办法直接缓存起来,通过复用就会提升性能。

上文中提到的 PatchFLags 里,动态属性的值是 1 << 3 ,结果是8,照理来说点击事件会按这个值进行编译和静态标记,这时候加入事件监听缓存 cacheHandlers, 原本的静态标记就不存在了。

export function render(_ctx, _cache) {
  return (_openBlock(), _createBlock("div", _hoisted_1, [
    _createVNode("button", {
      onClick: _cache[1] || (_cache[1] = $event => (_ctx.confirmHandler($event)))
    }, "确认"),
    _createVNode("span", null, _toDisplayString(_ctx.vue3), 1 /* TEXT */)
  ]))
}
// 首次渲染时, 将事件缓存在_cache[1]中
// 再次调用时, 判断是否有缓存,如果有直接从缓存中获取事件,无需再次创建更新,减少消耗 

静态提升 hoistStatic

静态标记 的部分我们了解到,有一些静态元素是不需要参与更新的,但是他们仍然需要每一次的创建过程,在这之后进行渲染。这个时候,通过静态提升( _hostied 标记的元素),就可以让指定元素只创建一次,在渲染时直接复用第一次也是唯一一次的创建结果,从而省去开销。

const  _hoisted_1 = /*#__PURE__*/_createVNode("div", null, "静态提升", -1 /* HOISTED */)

Tree Shaking

什么是 Tree-Shaking ?从字面意义出发,一棵树,通过晃动,甩掉多余的残叶。

回到代码世界,就是在我们前端的 Webpack 打包阶段,移除 JavaScript 上下文中的未引用代码。

我们在 Vue2 中应该都写过如下代码:

mport Vue from 'vue';
Vue.nextTick(() => {
  // 和 DOM 有关的一些操作
});

tips : 单文件中的 $nextTick 其实本质和 Vue.nextTick一样。

思考一个问题:

假如你没有用到 Vue.nextTick 这个方法,或者你更喜欢用 setTimeout 来代替实现,这样的话 VuenextTick 的部分将会变成 dead code —— 徒增代码体积但从来不会被用到,这对于客户端渲染的 web app 来说是拖累性能的一大因素。

于是,在 Vue3 中,官方团队重构了所有全局 API 的组织方式,让所有的 API 都支持了 TreeShaking,固上例我们可以改写成:

import { nextTick } from 'vue';
 
nextTick(() => {
  // 和 DOM 有关的一些操作
});

Composition API 与实战

起因

Vue2 风靡的时期,组件复用是团队开发中很重要的一环,同时,基于 slotprop 的组件内容传递,也帮助抽象了逻辑。

但是,在数据传递层数高的时候,组件内容会显得混乱。我们时常需要把负责增减的 methoddata 分在代码块的不同位置,在小组件中如果还可以一眼看到,但是在一些有着几十行几百行的组件中,分布在 datacomputemethod 的中的函数交互将会变得难以理解,增加了阅读、心智、维护、修改的负担。

体验

Composition Api 给予了用户灵活的组织组件代码块的能力。

我将在下文中通过一个 todoList,来比较 Vue2 option APIVue3 Composition API的区别

目标

image.png

  • 功能需求划分

    1. todo 项目的增加、删除、标记为完成、修改
    2. 遗留项
    3. 清除完成项

实现

先来看看如果用 Vue2 option API,我们会怎么处理该需求:

<template>
  <section class="todo-app">
    <header class="header"></header>
    <section class="main"></section>
    <footer class="footer"></footer>
  </section>
</template>
<script>
export default {
  data() {
    return {
      todos: [],             // 存储 todo 的数组
      newTodo: '',           // 当前新增的 todo 项
      editTodo: ''           // 当前修改的 todo 项
    }
  },
  computed: {
    // 当前遗留项
    remaining: function () {
      return this.todos.filter((todo) => !todo.completed).length;
    },
    // 全选逻辑
    allDone: {
      get: function () {
        return this.remaining === 0;
      },
      set: function (value) {
        this.todos.forEach(function (todo) {
          todo.completed = value;
        });
      },
    },
  },
  methods: {
    addTodo () {},
    deleteTodo () {},
    editTodo () {},
    doneEdit () {},
    cancelEdit () {},
    removeCompleted () {},
  },
};
</script>

以上,Vue2 todoList 基本已经完成。 直接来看 Composition API 该怎么写:

// 保持 template 不变
<script>
import { ref,reactive, computed, toRefs } from "vue"
// ref 用来追踪普通数据
// reactive 用来追踪对象或数组
// toRefs 把 reactive 的值处理为ref
export default {
  setup() {
    // 数据层
    const test = ref('this is test')
    const state = reactive({
      todos: [],             // 存储 todo 的数组
      newTodo: '',           // 当前新增的 todo 项
      editTodo: ''           // 当前修改的 todo 项
    })
    
    // computed 层
    const remaining = computed(
      () => state.todos.filter(todo => !todo.completed).length
    );
    const allDone = computed({});
    
    // methods 层
    function addTodo () {},
    function removeTodo () {},
    function editTodo () {},
    ...
    
    return {
      ...toRefs(state),
      remaining,
      allDone,
      addTodo,
      removeTodo,
      editTodo,
      ... // 省略了其他方法
    };
  }
</script>

对比总结

发现没有,Composition API 其实是一种更倾向于 hooks 的写法。

  1. 新函数 setup
  • 它会在 created 生命周期之前执行,并且可以接受 props(用来内部访问) , context 两个参数(上下文对象,可以通过 context 来访问实例 this).
  1. ref , reactive, toRefs
  • ref 可以接受一个传入值作为参数,返回一个基于该值的 响应式 Ref 对象,该对象中的值一旦被改变和访问,都会被跟踪到,通过修改 test 的值,可以触发模板的重新渲染,显示最新的值。

  • reactiveref 的区别仅仅是,通过 reactive 来修饰对象或数组。

  • toRefs 可以将 reactive 创建出来的响应式对象转换成内容为ref 普通对象

  1. 有关 computed, watch, watchEffect
  • computed 用来创建计算属性,返回值是一个 ref 对象,需要单独引入。
  • watchVue2 中的方法一致,需要侦听数据,并执行它的侦听回调。
  • watchEffect 会立即执行传入的函数,并响应式侦听其依赖,并在其依赖变更时重新运行该函数。
  1. 生命周期相关 Vue3生命周期 钩子有了小幅度的改变,直接上图:

image.png

可以看到的是,原有的生命周期基本都是存在的,只不过加上了 on 前缀。其中,setup 相当于融合了 beforeCreate created 两个钩子,剩下的钩子,直跟在 setup 内部书写即可:

<script>
import {
  ref
  onBeforeMount,
  onMounted,
  onBeforeUpdate,
  onUpdated,
  onBeforeUnmount,
  onUnmounted} from "vue"
export default {
  setup() {
    const count = ref(0)
    // 其他的生命周期都写在这里
    onBeforeMount (() => {
      count.value++
      console.log('onBeforeMount', count.value)
    })
    onMounted (() => {
      count.value++
      console.log('onMounted', count.value)
    })
    // 注意,onBeforeUpdate 和 onUpdated 里面不要修改值,会死循环的哦!
    onBeforeUpdate (() => {
      console.log('onBeforeUpdate', count.value)
    })
    onUpdated (() => {
      console.log('onUpdated', count.value)
    })
    onBeforeUnmount (() => {
      count.value++
      console.log('onBeforeUnmount', count.value)
    })
    onUnmounted (() => {
      count.value++
      console.log('onUnmounted', count.value)
    })
    return {
      count
    };
  },
};
</script>

注意这里所有的生命周期,都是单独引入的,日常开发当中我们或许用不到这么多钩子,按需引入削减了体积大小,这就是 Tree-shaking 的好处。

至此,Composition API 的基本用法我们已经掌握了。有关组件调用的部分,其实与 Vue2 的做法别无二致,只是我们可能会提取如上例提到的 todoList 的代码,作为一个函数组件,之后在父组件中引用便可。

  • 提取逻辑 useTodos

    通过 setup 方法来返回所有数据,于是可以定义组件 useTodos :

    const useTodos = () => {
      // 数据层
      const state = reactive({
        todos: [],             // 存储 todo 的数组
        newTodo: '',           // 当前新增的 todo 项
        editTodo: ''           // 当前修改的 todo 项
      })
      
      // computed 层
      const remaining = computed(
        () => state.todos.filter(todo => !todo.completed).length
      );
      const allDone = computed({});
      
      // methods 层
      function addTodo () {}, 
      function removeTodo () {},
      function editTodo () {},
      ...
      
      return {
        ...toRefs(state),
        remaining,
        allDone,
        addTodo, 
        removeTodo,
        editTodo,
        ...
      };
    }
    

    现在,我们如果需要使用 todos 组件,就可以:

    <script>
    import useTodos from './useTodos'
    export default {
      setup () {
        const { remaining, allDone, state ... } = useTodos()
    
        return {
          remaining,
          allDone,
          state,
          ...
        }
      }
    }
    </script> 
    

final

  • 还在为 mixin 的命名空间烦恼吗?
  • 还在为 option API 的维护性差感到烦恼吗?
  • 还在为 Vue2 性能评价担忧吗?

快来体验 Vue3 Composition API 吧!