Vue.js 3.0 一些优化总结

2,022 阅读5分钟

目的

  1. 了解Vue.js 3.0 的升级给我们开发带来的收益
  2. 学习到一些设计思想和理念,并在自己的开发工作中应用以获得提升

Vue3会带来些什么?

  • 更快:重构虚拟dom(编译优化),Proxy响应式对象
  • 更小:Tree shaking
  • 更易于维护:TS、代码管理和Composition API

源码优化

源码的优化主要体现在使用 monorepo 和 TypeScript 管理和开发源码,提升了代码可维护性。具体变化如下:

更好的代码管理方式:monorep

Vue.js 2.x 的源码托管在 src 目录,然后依据功能拆分出了:

  • compiler(模板编译的相关代码)
  • core(与平台无关的通用运行时代码)
  • platforms(平台专有代码)
  • server(服务端渲染的相关代码)
  • sfc(.vue 单文件解析相关代码)
  • shared(共享工具代码)

image.png

Vue.js 3.0整个源码是通过 monorepo 的方式维护的,根据功能将不同的模块拆分到 packages 目录下面不同的子目录中:

image.png

模块被拆分到不同的 package 中,每个 package 有各自的 API、类型定义和测试。这样做的好处有以下几点:

  • 模块拆分更细化,职责划分更明确
  • 模块之间的依赖关系也更加明确
  • 开发人员也更容易阅读、理解和更改所有模块源码,提高代码的可维护性
  • 一些 package(reactivity响应式库等)是可以独立于 Vue.js 使用的。可单独依赖无需引用整个Vue.js

TypeScript

JavaScript 是动态类型语言,它的灵活性有目共睹,但是过于灵活的副作用是很容易就写出非常隐蔽的隐患代码,在编译期甚至看上去都不会报错,但在运行阶段就可能出现各种奇怪的 bug。

对于复杂的框架项目开发,使用类型语言非常有利于代码的维护,因为它可以在编码期间帮你做类型检查,避免一些因类型问题导致的错误。也可以去定义类和接口,便于IDE进行类型推断。

Vue.js 3.0 自身采用了 TypeScript 进行开发。Vue2.x采用FaceBook出品的 JavaScript 静态类型检查工具Flow进行管理。但是 Flow 对于一些复杂场景类型的检查,支持得并不好,例如如下代码:

const propOptions: any = vm.$options.props 

Flow 无法正确推导出 vm.$options.props 的类型,就不得不使用any进行强制声明。另外,社区里有人吐槽过Flow团队存在烂尾的可能。而TS有两个明显的优势:

  • 有更好的类型检查,能支持复杂的类型推导
  • TypeScript 的生态更加成熟和活跃,生命力更强

性能优化

源码体积优化

Vue.js 3.0 为了减少源码体积:

  • 移除一些冷门的 feature
  • 引入 tree-shaking 的技术,减少打包体积。

一些不常用的feature,例如filter

<template>
    <div>{{millisecond | format}}</div>
</template>
<script>
export default {
    props : {
	millisecond : 1516101106
    },
  filter: {
    format(value) {
	if (!value) return ''
	const date = new Date(value)
        return date.toLocaleDateString()
    }
  }
}

// 3.x 中,过滤器已删除,不再支持。可以使用计算属性或调用方法替换他们
<template>
  <h1>{{ format }}</h1>
</template>
<script>
export default {
	props : {
            millisecond : 1516101106
	},
        computed: {
            format(value) {
		if (!value) return ''
		const date = new Date(value)
		return date.toLocaleDateString()
        }
  }
}
</script>

再来看看 tree-shaking。它的原理很简单:tree-shaking 依赖静态的 ES2015 模块化语法(import  和 export 导入导出)。通过编译阶段的静态分析,找到没有引入的模块并打上标记,之后在压缩阶段删除掉那些没用的代码。例如,有如下工具类util.js:

export function funcA() {
	//do something
}

export function funB() {
	//do something
}

我们在main.js里引用它:

import {funcA} from './util.js';
funcA();

使用了tree-shaking之后最终打包后的util.js会变成下面的模样:

export function funcA() {
}

数据劫持优化

Vue3.0之前是通过Object.defineProperty 劫持数据的 getter 和 sette:

Object.defineProperty(data, 'a',{
  get(){
    // track
  },
  set(){
    // trigger
  }
})

通过Object.defineProperty使普通对象变为响应式对象,核心原理就是在getter中收集依赖,在sette中触发事件。但是,该方法有个缺点:在进行劫持之前必须知道所要拦截的属性key是什么,因此,它不无法拦截对象的属性的增加和删除。用如下代码举例:

<div id="app">
    <p>{{message.a}}</p>
    <p>{{message.b}}</p>
</div>
<script>
    const app = new Vue({
        el: "#app",
        data: {
            message: {
                a:""
            }
        },
        mounted() {
            //this.message.a = "A"
            this.message.b = "B"
            //this.$set(this.message,"b", "B")
        }
    })
</script>

如果单独使用 this.message.b = "B" 是无法触发页面更新的,Vue.js 为了解决这个问题提供了 setset 和 delete 实例方法。但是这个明显给开发者带来了代码维护上的负担。

另外在面对嵌套比较深的对象时,Object.defineProperty需要通过递归遍历这个对象,把每一层的对象数据都变成响应式的,删减完整源码后大致如下

function initData(vm: Component) {
	const ob = observe(data)
}

export function observe(
  value: any,
  shallow?: boolean,
  ssrMockReactivity?: boolean
): Observer | void {
  if (
    shouldObserve 
  ) {
    return new Observer(value, shallow, ssrMockReactivity)
  }
}

const NO_INITIAL_VALUE = {}

export class Observer {
  dep: Dep
  vmCount: number // number of vms that have this object as root $data

  constructor(public value: any, public shallow = false, public mock = false) {
    // this.value = value
    this.dep = mock ? mockDep : new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)
    if (isArray(value)) {
      //...
    } else {
      const keys = Object.keys(value)
      for (let i = 0; i < keys.length; i++) {
        const key = keys[i]
        defineReactive(value, key, NO_INITIAL_VALUE, undefined, shallow, mock)
      }
    }
}

export function defineReactive(
  obj: object,
  key: string,
  val?: any,
  customSetter?: Function | null,
  shallow?: boolean,
  mock?: boolean
) {
  const dep = new Dep()
	val = obj[key]
  let childOb = !shallow && observe(val, false, mock)
  Object.defineProperty(obj, key, {
    get: function reactiveGetter() {
      //...
      return isRef(value) && !shallow ? value.value : value
    },
    set: function reactiveSetter(newVal) {
      //...
    }
  })
  return dep
}

defineReactive函数拿到 obj的属性描述符,然后对子对象递归调用 observe方法,这样就保证了无论 obj的结构有多深,它的所有子属性也能变成响应式的对象,这样无论我们修改和使用任何属性,都能触发 getter 和 setter。最后利用 Object.defineProperty去给 obj 的属性 key 添加 getter 和 setter。毫无疑问——如果定义的响应式数据过于复杂,这就会有相当大的性能负担。

Vue3.0 使用了 Proxy API 做数据劫持,其官方定义如下:

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

它劫持的是整个对象,实现结构结构如下:

observed = new Proxy(data, {
  get() {
    // track
  },
  set() {
    // trigger
  }
})

然而,虽然对于对象的属性的增加和删除Proxy都能检测到,但是并不能监听到内部深层次的对象变化,VUE3采用的方式依然是递归。但是,是在 getter 中去递归响应式,这样只有真正被访问到的内部对象才会变成响应式,而不是无差别全部递归。完整源码删减后如下:


//初始化入口
export function reactive(target: object) {
  // if trying to observe a readonly proxy, return the readonly version.
  if (isReadonly(target)) {
    return target
  }
  return createReactiveObject(
    target,
    false,
    mutableHandlers,
    mutableCollectionHandlers,
    reactiveMap
  )
}

function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>,
  proxyMap: WeakMap<Target, any>
) {
  const proxy = new Proxy(
    target,
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
  )
  proxyMap.set(target, proxy)
  return proxy
}

const get = createGetter()
export const mutableHandlers: ProxyHandler<object> = {
  get,
  set,
  deleteProperty,
  has,
  ownKeys
}

function createGetter(isReadonly = false, shallow = false) {
  return function get(target: Target, key: string | symbol, receiver: object) {
    const targetIsArray = isArray(target)
    const res = Reflect.get(target, key, receiver)

    if (isObject(res)) {
      //延迟遍历进行响应式转换 
      //最初只将最外层对象进行进行响应式转换 
      //当对象被调用时,触发get函数,将对象直接子对象进行相应式转换 
      //以此类推,按需处理
      return isReadonly ? readonly(res) : reactive(res)
    }

    return res
  }
}

另外,Vue3使用了ref/reactive 替换了data中的变量。这样不仅使得变量定义更加灵活多变,而且避免了不必要的数据被转换为响应式数据。

语法优化:Composition API

Vue3之前,一直使用的是Options API,可以认为我们编写组件的工作就是对组件的配置项进行描述。 通过按照 methods、computed、data、props 等这些不同的选项进行分类,当组件小的时候,这种分类方式一目了然这样编写的代码简单直观,易于上手。

<script>
export default {
    data() {
        return {
            balance: 0,
            amount: 0
        }
    },
    computed: {
        balanceString() {
            return `Account Balance: ${this.balance}`;
        }
    },
    methods: {
        addBalance() {
            this.balance += this.amount
            this.amount = 0;
        },
        subtractBalance() {
            this.balance -= this.amount
            this.amount = 0;
        }
    },
    mounted() {
        console.log('Application mounted');
    },
}
</script>

但是在较大的组件中,可能包含有多个逻点,每一个关注点的相关代码分散在不同的配置项中,当想要需要修改一个逻辑时,就需要在单个文件中不断上下切换和寻找。Composition API的优化项之一就是解决这个问题。

Compositions API 提供Options API 欠缺的两种能力:

  • 将相关代码片段组合在一起
  • 可组合型使得复用代码更加的轻松方便

下面是使用了Compositions API的一段代码

<script setup>
import { onMounted, ref } from 'vue';

const amount = ref(0);
const balance = ref(0);

const balanceString = computed(() => `Account Balance: ${balance.value}`);

const addBalance = () => {
    balance.value += amount.value;
    amount.value = 0;
}

const subtractBalance = () => {
    balance.value -= amount.value;
    amount.value = 0;
}

onMounted(() => {
    console.log('Application mounted');
});
</script>

在业务量较小时,无法直观的体现出组合式Api的优势。我们用蓝黄橙三色分别表示三个业务点,下图可以直观的反映出二者在编码上的不同:

image.png

其次,是优化逻辑复用。假如我们需要在组件内引入一个人员管理数据模块,在Vue2中可以通过mixin实现,首先编写如下代码:

const userList = {
  data() {
    return {
      users: ['HU',"Liao","SI","ZHOU","DENG","WEI"]
    }
  },
  methods: {
    list() {
      return this.users
    },
    addUser(user) {
      this.users.add(user)
    },
    removeUser(user) {
      this.users.remove(user)
    }
  }
}
export default userList

然后在组件中使用:

<template>
  <div>
    All user:{{ list }}
  </div>
</template>

<script>

import userListMixin from './userList'

export default {
  mixins: [userListMixin]
}

</script>

虽然使用起来非常方便,但是这种方法有两个问题:

  • 命名冲突 :每个mixin 都可以定义自己的 props、data,它们之间是没有关联的,很容易定义相同的变量,导致命名冲突
  • 数据来源不清晰:在模板中使用不在当前组件中定义的变量,不太容易知道这些变量在哪里定义

使用Compositions API 来实现上述功能:

export const useUserList = () => {
    const list = ref('HU',"Liao","SI","ZHOU","DENG","WEI"]);
    const addUser = (user) => {
        list.value.push(user);
    };
    const remove = (user) => {
        list.value.remove(user);
    };

    return {
        list,
        addUser,
        removeUser,
    };
};
<template>
  <div>
    All user : {{ users }}
  </div>
</template>
<script>
  import useMousePosition from './useUserList'
  export default {
    setup() {
      const { list:users } = useUserList()
      return { users }
    }
  }
</script>
</script>

整个数据来源清晰了,还可以通过解构任意命名,可以有效的避免命名冲突和数据来源不明确的问题。

最后,总结一下Composition API相对与Options API的优势:

  • 有助于编写干净的代码,使得代码的可读性更高
  • 可以将同一逻辑的代码片段组合在一起
  • 让代码复用变的更轻松方便