Skip to content

Vue3和Vue2的主要区别,Vue3带来了哪些新特性?

响应式系统优化

Vue 3 使用 Proxy 对象替代了 Vue 2 的 Object.defineProperty。这让 Vue 3 能够监听到数组下标的变化和对象属性的添加/删除等

性能提升

  • 重写的 Virtual DOM:Vue 3 采用了一个更快的 Virtual DOM 算法,减少了内存的消耗,并提高了渲染速度。
  • 编译时优化:静态提升,patchFlags、block等
  • Tree Shaking 支持:Vue 3 使用Typescript重构,依靠ESM,能更好的被TreeShaking。当然,打包体积也更小。

组合式API(Composition API)

Vue3中新增一系列组合式API, 相比Vue2的Option API,逻辑代码分散在文件各个部分,Vue3提供了一种更加灵活的方式来组织和复用逻辑。

  • setup 函数:引入了 setup 函数作为组件的新入口点,在这里可以使用新的 Composition API,使得逻辑复用和代码组织更灵活。
  • ref() / reactive() 响应式引用和响应式状态
  • toRef() / toRefs()
  • watch() / watchEffect()
  • computed()
  • provide() / inject()
  • Composition API中的生命周期钩子

响应式系统:重写的响应式系统使用 Proxy 替代了 Object.defineProperty,允许 Vue 3 处理嵌套对象和数组的变化检测更加高效和自然。

更好的TS支持

TypeScript 重构:Vue 3 的代码库是用 TypeScript 完全重写的,提供了更好的 TypeScript 类型支持和推导,让用TypeScript 开发 Vue 应用更加顺畅。

新的组件功能

Fragment、Teleport 与 Suspense:

  • Fragment:组件可以返回多个根节点,不再强制要求单个根节点。
vue
<template>
  <!-- 可以多个根节点 -->
  <span></span>
  <span></span>
</template>

  • Teleport:允许开发者将组件的子节点“传送”到页面上的其他位置。
typescript
// index.html 提供一个挂载元素
<body>
  <div id="app"></div>
  <div id="dialog"></div>
</body>

定义一个Dialog组件Dialog.vue, 留意 to 属性, 与上面的id选择器一致:

typescript
<template>
  <teleport to="#dialog">
    <div class="dialog">
      <div class="dialog_wrapper">
        <div class="dialog_header" v-if="title">
          <slot name="header">
            <span>{{ title }}</span>
          </slot>
        </div>
      </div>
      <div class="dialog_content">
        <slot></slot>
      </div>
      <div class="dialog_footer">
        <slot name="footer"></slot>
      </div>
    </div>
  </teleport>
</template>

希望继续在组件内部使用Dialog, 又希望渲染的 DOM 结构不嵌套在组件的 DOM 中


  • **Suspense:**提供了一种处理异步组件加载状态的新方法。
vue
<Suspense>
  <template #default>
    <async-component></async-component>
  </template>
  <template #fallback>
    <div>
      Loading...
    </div>
  </template>
</Suspense>

Suspense 只是一个带插槽的组件,只是它的插槽指定了default 和 fallback 两种状态

其余特点

  • Vue3 不兼容 IE11,因为IE11不兼容Proxy
  • v-if的优先级高于v-for,不会再出现vue2的v-for,v-if混用问题;
  • vue3中v-model可以以v-model:xxx的形式使用多次,而vue2中只能使用一次;多次绑定需要使用sync

Vue3响应式的特点

Vue2 数据响应式是通过 Object.defineProperty() 劫持各个属性 getter 和 setter,在数据变化时触发Setter中的Dep.notify, 发布消息给订阅者Watcher,从而触发watcher.update(),最终执行render函数和更新dom。

  • 缺陷:不能监听数组下标的改变和对象属性的新增删除。
typescript
// watcher.ts 中
update() {
  /* istanbul ignore else */
  if (this.lazy) {
    // 表明是惰性Watcher(主要用于计算属性)
    // 只需将dirty置为true,延迟计算到实际需要时
    this.dirty = true
  } else if (this.sync) {
    // 标记为同步时执行
    this.run()
  } else {
    // 一般都是异步更新,=> 最终还是会调用到run方法
    queueWatcher(this)
  }
}

Vue3为了解决这些问题,使用Proxy结合Reflect来代替了Object.defineProperty

  • 支持监听 对象 和 数组 的变化
  • 并且能拦截对象13种方法,动态属性增删都可以拦截,新增数据结构全部支持
  • Vue3还提供了refreactive两个API来实现响应式

为什么需要 Reflect

  • 将Object对象的一些明显属于语言内部的方法(比如Object.defineProperty),放到Reflect对象上,从Reflect对象上可以拿到语言内部方法
  • 修改Object方法返回结果,Reflect方法可以返回true或者false
  • 让Object的操作变为函数行为。
  • 使用 Reflect 可以修正 proxy 的 this指向问题;
typescript
// Reflect第三参数指定接收者 receiver
// 可以把它理解为函数调用过程中的 this,
const obj = { foo: 1 } 
console.log(Reflect.get(obj, 'foo', { foo: 2 })) 
// 我们指定第三个参数 receiver 为一个对象 { foo: 2 },
// 这时读取到的值是 receiver 对象的 foo 属性值
typescript
const person = {
  name: "John",
  get nameWithSalutation() {
    return `Mr. ${this.name}`;
  }
};

const proxyPerson = new Proxy(person, {
  get: function(target, propKey, receiver) {
    console.log(`getting ${propKey}!`);
    // 修正 proxy 的 this 指向问题
    return Reflect.get(target, propKey, receiver);
  }
});

console.log(proxyPerson.nameWithSalutation);  // "Mr. John"

defineProperty 和 Proxy 的区别

Object.defineProperty

  • 优点: 兼容性好,IE9以上支持。Proxy不兼容IE11.
  • 缺点:
    1. 不能监测到数组长度的变化
    2. 不能监测到对象属性的添加和删除
    3. 当数据结构较为复杂时,需要手动递归对数据每一层进行监测
    4. defineProperty 不支持 Map、Set 等数据结构

Proxy

  • 优点:
  1. 能够监测到数组长度的变化
  2. 可以直接监控对象而非属性
  3. 可以拦截多大13种方法,define只能拦截属性的get,set

Object.defineProperty 是劫持对象属性,Proxy 是代理整个对象

Ref 和 Reactive

  • ref 可以定义 基本数据类型 引用类型(对象),reactive的参数必须是引用类型
  • ref 在 JS中读值需要加.value, reactive不能改变对象本身,但可以改变内部的值
  • 从模板中访问,setup中返回ref时,会自动解包,不需要添加 .value

Ref

  • Ref 可以定义任何类型数据,包括深层嵌套的对象,数组,Map等。
  • Ref 具有深层响应性,嵌套的对象数组也会变为响应式的。
typescript
import { ref } from 'vue'

const obj = ref({
  nested: { count: 0 },
  arr: ['foo', 'bar']
  // 非原始值将通过 reactive() 转换为响应式代理, 更深层不需要加.value
})

function mutateDeeply() {
  // 以下都会按照期望工作
  obj.value.nested.count++
  obj.value.arr.push('baz')
}
typescript
const foo = ref({
  count: ref(0)
});
foo.value.count++; // 无需写成 foo.value.count.value++
// 内部对象会被reactive()转为具有深度响应性的对象
// 这个对象和嵌套的所有子对象都会变为响应式
// 如果内部有嵌套ref,会被递归的展开,解包,使用时不需要加.value
  • 可以通过 shallow ref 来放弃深层响应性。对于浅层 ref,只有.value的访问会被追踪
typescript
const foo = shallowRef({
  count: ref(0)
});
foo.value.count.value++; // 需要写成 foo.value.count.value++

ref 底层实现 - Vue3设计

typescript
// ref内部其实就是reactive
//  用__v_isRef 标记是ref数据
function ref(val) { 
   const wrapper = { 
     value: val 
   } 
   // 使用 Object.defineProperty 在 wrapper 对象上定义一个不可枚举的属性 __v_isRef,并且值为 true 
   Object.defineProperty(wrapper, '__v_isRef', { 
     value: true 
   }) 
   return reactive(wrapper) 
 }

ShallowRef

  • 性能优化:当你需要处理大型的数据集,或者包含深度嵌套对象的时候,使用 shallowRef 或 shallowReactive 可以提高性能,避免不必要的深度响应式转换和更新
  • 仅关心顶层属性的变化:如果你有一个深度嵌套的对象,但只关心其顶层属性的变化,shallowRef 可以帮助你避免对整个对象进行深度响应式转化。

Reactive

返回一个对象的响应式代理。

若要避免深层响应式转换,只想保留对这个对象顶层次访问的响应性,请使用 shallowReactive()

typescript
function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      if (key === 'raw') {
        return target;
      }
      track(target, key);
      // 得到原始值结果
      const res = Reflect.get(target, key, receiver);
      if (typeof res === 'object' && res !== null) {
        // 调用 reactive 将结果包装成响应式数据并返回
        return reactive(res);
      }
      // 返回 res
      return res;
    }
    // 省略其他拦截函数
  });
}

toRef

typescript
// obj 是响应式数据
const obj = reactive({ foo: 1, bar: 2 });
// 将响应式数据展开到一个新的对象 newObj
const newObj = {
  ...obj
};
// newObj不是响应式的,obj响应式断开了

// 我们封装一个toRef函数
function toRef(obj, key){
  const wrapper = {
    get Value(){
      return obj[key]
    }
  }
}

// ==> 改
const newObj = {
  foo: toRef(obj, 'foo'),
  bar: toRef(obj, 'bar')
}

toRef 底层实现

typescript
function toRef(obj, key) {
  const wrapper = {
    // get
    get value() {
      return obj[key];
    },
    // 允许设置值
    set value(val) {
      obj[key] = val;
    }
  };
  // 定义 __v_isRef 属性
  Object.defineProperty(wrapper, '__v_isRef', { value: true });
  return wrapper;
}

toRefs

toRefs底层实现

typescript
function toRefs(obj) {
  const ret = {}; // 使用 for...in 循环遍历对象
  for (const key in obj) {
    // 逐个调用 toRef 完成转换
    ret[key] = toRef(obj, key);
  }
  return ret;
}

const obj = reactive({foo:1, bar:2})

const newObj = {...toRefs(obj)}
console.log(newObj.foo.value) // 1
cosnole.log(newObj.bar.value)  // 2
// toRefs 解决了响应丢失问题,但会把响应式数据第一层属性值转为ref
// 必须通过.value属性访问,我们需要自动脱ref的能力
console.log(newObj.foo) // 1 
// 怎么实现呢  proxyRefs

proxyRefs

自动脱ref,自动拆包 自动脱 ref,指的是属性的访问行为,即如果读取的属性是一个 ref,则直接将该 ref 对应的 value 属性值返回

typescript
function proxyRefs(target) { 
   return new Proxy(target, { 
     get(target, key, receiver) { 
       const value = Reflect.get(target, key, receiver)
       return value.__v_isRef ? value.value : value 
     }, 
     set(target, key, newValue, receiver) { 
       // 通过 target 读取真实值 
       const value = target[key] 
       // 如果值是 Ref,则设置其对应的 value 属性值     
        if (value.__v_isRef) { 
         value.value = newValue 
         return true 
       } 
     return Reflect.set(target, key, newValue, receiver)
     } 
 }) 
}
typescript
 const newObj = proxyRefs({ ...toRefs(obj) })
console.log(newObj.foo) // 1

我们定义了 proxyRefs 函数,该函数接收一个对象作为参数,并返回该对象的代理对象。代理对象的作用是拦截 get 操作,当读取的属性是一个 ref 时,则直接返回该 ref 的value 属性值,这样就实现了自动脱 ref。

Vue组件之间通信方式有哪些

javascript
1. props / $emit // 父子组件
2. $children(vue3抛弃了) / $parent
3. provide / inject 
4. ref 
5. $attrs / $listeners(Vue3弃)
5. eventBus 
6. Vuex

根据组件之间关系讨论组件通信最为清晰有效

  • 父子组件
    • props/$emit/$parent/ref/$attrs
  • 兄弟组件
    • $parent/$root/eventbus/vuex
  • 跨层级关系
    • eventbus/vuex/provide+inject

Vue3的8种和Vue2的12种组件通信,值得收藏 - 掘金

v-if和v-for哪个优先级更高?

风格指南 — Vue.js永远不要把 v-if 和 v-for 同时用在同一个元素上。 针对Vue2, v-for 优先级 高于 v-if 针对Vue3, v-if 优先级 高于 v-for 在vue2中v-for的优先级是高于v-if,把它们放在一起,输出的渲染函数中可以看出会先执行循环再判断条件,哪怕我们只渲染列表中一小部分元素,也得在每次重渲染的时候遍历整个列表,这会比较浪费;另外需要注意的是在vue3中则完全相反,v-if的优先级高于v-for,所以v-if执行时,它调用的变量还不存在,就会导致异常 通常有两种情况下导致我们这样做:

  • 为了过滤列表中的项目 (比如)。
typescript
v-for="user in users" v-if="user.isActive"
// 定义计算属性 activeUsers 渲染过滤后的这个列表
users.filter(u=>u.isActive)
  • 为了避免渲染本应该被隐藏的列表
typescript
// 用template包裹
v-for="user in users" v-if="shouldShowUsers"
// 把 v-if 移动至容器元素上 (比如 ul、ol)或者外面包一层template即可

文档中明确指出永远不要把 v-if 和 v-for 同时用在同一个元素上,显然这是一个重要的注意事项。

简述 Vue 的生命周期以及每个阶段做的事

思路

  1. 给出概念
  2. 列举生命周期各阶段
  3. 阐述整体流程
  4. 结合实践
  5. 扩展:vue3变化

回答

Vue生命周期总共可以分为8个阶段:创建前后, 挂载前后, 更新前后, 销毁前后,以及一些特殊场景的生命周期。vue3中新增了三个用于调试和服务端渲染场景。

8个常用的

生命周期v2生命周期v3 选项式API描述
beforeCreatebeforeCreate组件实例被创建之初
createdcreated组件实例已经完全创建
beforeMountbeforeMount组件挂载之前
mountedmounted组件挂载到实例上去之后
beforeUpdatebeforeUpdate组件数据发生变化,更新之前
updatedupdated数据数据更新之后
beforeDestroybeforeUnmount组件实例销毁之前
destroyedunmounted组件实例销毁之后

其他

虽然在注释里我们自己清楚的知道,第一个 vnode 被删除了,但是对于 Vue 来说,它是感知不到子组件里面到底是什么样的实现(它不会深入子组件去对比文本内容),那么这时候 Vue 会怎么 patch 呢? 由于对应的 key使用了 index导致的错乱,它会把

  1. 原来的第一个节点text: 1直接复用。
  2. 原来的第二个节点text: 2直接复用。
  3. 然后发现新节点里少了一个,直接把多出来的第三个节点text: 3 丢掉。

至此为止,我们本应该把 text: 1节点删掉,然后text: 2、text: 3 节点复用,就变成了错误的把 text: 3 节点给删掉了。

生命周期v2生命周期v3 Option API描述
activatedactivatedkeep-alive 缓存的组件激活时
deactivateddeactivatedkeep-alive 缓存的组件停用时调用
errorCapturederrorCaptured捕获一个来自子孙组件的错误时被调用
-renderTracked调试钩子,响应式依赖被收集时调用
-renderTriggered调试钩子,响应式依赖被触发时调用
-serverPrefetchssr only,组件实例在服务器上被渲染前调用

结合实践:

  • beforeCreate:通常用于插件开发中执行一些初始化任务
  • created:组件初始化完毕,可以访问各种数据,获取接口数据等
  • mounted:dom已创建,可用于获取访问数据和dom元素;访问子组件等。
  • beforeUpdate:此时view层还未更新,可用于获取更新前各种状态
  • updated:完成view层的更新,更新后,所有状态已是最新
  • beforeunmount:实例被销毁前调用,可用于一些定时器或订阅的取消
  • unmounted:销毁一个实例。可清理它与其它实例的连接,解绑它的全部指令及事件监听器

可能的追问

setup和created谁先执行?

在 Vue 3 组件的生命周期中,setup 钩子函数会比 created 生命周期钩子更早执行。具体的执行顺序是:

  1. setup()
  2. beforeCreate()
  3. created()

其中 setup 是 Vue 3 组合式 API 新增的一个入口点,它会在创建组件实例时被执行,是组件初始化阶段的第一个生命周期函数。 beforeCreate 和 created 则是选项式 API 中的生命周期钩子函数,分别在组件实例创建之前和之后执行。 需要注意的是,因为 setup 执行的非常早,所以在 setup 钩子内是没有办法访问 data 和 methods 选项的,因为它们还没被解析和初始化。但可以在 setup 内定义数据和方法,并使用响应式 API 如 reactive、ref 来创建响应式数据。


setup中为什么没有beforeCreate和created?

  1. **组合式 API 设计理念不同 **

组合式 API 的设计理念是将实例的生命周期和其他逻辑解耦,使得代码更加可维护和可组合。setup 钩子函数是组合式 API 的入口,它在组件创建之前执行,可以在其中完成大部分逻辑。因此,beforeCreate 和 created 钩子的作用在组合式 API 中已经不那么重要。

  1. 避免生命周期钩子过度使用

Vue 3 的设计理念之一是鼓励开发者将逻辑代码和副作用代码分离,减少生命周期钩子的使用。鼓励使用组合式 API 中的新钩子函数,如 watchEffect、watch 等,通过响应式数据的变化来触发相应的副作用操作,从而减少对生命周期钩子的依赖。

能说一说双向绑定使用和原理吗?

思路分析:

  1. 给出双绑定义
  2. 双绑带来的好处
  3. 在哪使用双绑
  4. 使用方式、使用细节、vue3变化
  5. 原理实现描述

回答范例:

  1. vue中双向绑定是一个指令v-model,可以绑定一个响应式数据到视图,同时视图中变化能改变该值。
  2. v-model是语法糖,默认情况下相当于:value和@input。使用v-model可以减少大量繁琐的事件处理代码,提高开发效率。
  3. 通常在表单项上使用v-model,还可以在自定义组件上使用,表示某个值的输入和输出控制。
  4. 通过<input v-model="xxx">的方式将xxx的值绑定到表单元素value上;
    1. 对于checkbox,可以使用true-value和false-value指定特殊的值,
    2. 对于radio可以使用value指定特殊的值;
    3. 对于select可以通过options元素的value设置特殊的值;
    4. 还可以结合.lazy,.number,.trim对v-mode的行为做进一步限定;
    5. v-model用在自定义组件上时又会有很大不同
    6. vue3中它类似于sync修饰符,最终展开的结果是modelValue属性和update:modelValue事件;vue3中我们甚至可以用参数形式指定多个不同的绑定,例如v-model:foo和v-model:bar,非常强大!
  5. v-model是一个指令,本质上是vue的编译器根据表单元素的不同做了不同的处理。对于一个包含了v-model的template的模板,在最后转为render渲染函数后,实际上还是对value属性的绑定以及input事件的监听。
  6. 编译器会根据表单元素的不同会展开不同的DOM属性和事件对,
    1. 比如text类型的input和textarea会展开为value和input事件;
    2. checkbox和radio类型的input会展开为checked和change事件;
    3. select用value作为属性,用change作为事件。

Vue中如何扩展一个组件

答题思路:

  1. 按照逻辑扩展和内容扩展来列举,
    • 逻辑扩展有:mixins、extends、composition api;
    • 内容扩展有 slots;
  2. 分别说出他们使用方法、场景差异和问题。
  3. 作为扩展,还可以说说vue3中新引入的composition api带来的变化

回答

  1. 常见的组件扩展方法有:mixins,slots,extends等
  2. 混入mixins是分发 Vue 组件中可复用功能的非常灵活的方式。混入对象可以包含任意组件选项。当组件使用混入对象时,所有混入对象的选项将被混入该组件本身的选项。
javascript
// 复用代码:它是一个配置对象,选项和组件里面一样
const mymixin = {
  methods: {
    dosomething(){}
  }
}
// 全局混入:将混入对象传入
Vue.mixin(mymixin)

// 局部混入:做数组项设置到mixins选项,仅作用于当前组件
const Comp = {
  mixins: [mymixin]
}
  1. 插槽主要用于vue组件中的内容分发,也可以用于组件扩展。
    1. 子组件Child
vue
<div>
  <slot>这个内容会被父组件传递的内容替换</slot>
</div>

b. 父组件Parent

html
<div>
  <Child>来自老爹的内容</Child>
</div>

如果要精确分发到不同位置可以使用具名插槽,如果要使用子组件中的数据可以使用作用域插槽。

  1. 组件选项中还有一个不太常用的选项extends,也可以起到扩展组件的目的
javascript
// 扩展对象
const myextends = {
  methods: {
    dosomething(){}
  }
}
// 组件扩展:做数组项设置到extends选项,仅作用于当前组件
// 跟混入的不同是它只能扩展单个对象
// 另外如果和混入发生冲突,该选项优先级较高,优先起作用
const Comp = {
  extends: myextends
}

  1. 混入的数据和方法不能明确判断来源且可能和当前组件内变量产生命名冲突,vue3中引入的composition api,可以很好解决这些问题,利用独立出来的响应式模块可以很方便的编写独立逻辑并提供响应式的数据,然后在setup选项中组合使用,增强代码的可读性和维护性。例如:
javascript
// 复用逻辑1
function useXX() {}
// 复用逻辑2
function useYY() {}
// 逻辑组合
const Comp = {
  setup() {
    const {xx} = useXX()
    const {yy} = useYY()
    return {xx, yy}
  }
}

子组件可以直接改变父组件的数据么,说明原因

组件化开发过程中有个单项数据流原则,不在子组件中修改父组件是个常识问题

防止从子组件意外变更父级组件的状态,从而导致应用的数据流向难以维护。

实际开发过程中有两个场景会想要修改一个属性:

  1. 这个 prop 用来传递一个初始值;这个子组件接下来希望将其作为一个本地的 prop 数据来使用。
javascript
const props = defineProps(['initialCounter'])
const counter = ref(props.initialCounter)
  1. 这个 prop 以一种原始的值传入且需要进行转换。
javascript
const props = defineProps(['size'])
// prop变化,计算属性自动更新
const normalizedSize = computed(() => props.size.trim().toLowerCase())
  1. 实践中如果确实想要改变父组件属性应该emit一个事件让父组件去做这个变更

谈一谈你对MVVM的理解

MVVM(Model-View-viewModel)是一种设计模式/思想,分为 Model、View、ViewModel

  • M: Model 模型;
    • 代表数据模型,数据和业务逻辑都在 Model 层中定义。(对应data中的数据和业务逻辑)
  • V: View 视图;
    • 代表UI视图,也就是用户界面,负责数据的展示。 (对应着template模板,负责数据的展示)
  • VM: ViewModel 视图模型
    • 也叫做视图数据连接层, 负责监听 Model 中数据的改变并且控制视图的更新,处理用户交互操, (对应着我们的Vue实例)

这里model数据,是我们自己定义的,view模板也是我们自己定义的,Model和View并没有直接联系而是通过ViewModel来进行双向的通讯联系的,也就是Vue实例作为视图数据连接层,将我们需要展示的视图和模型数据做了一个连接绑定,它(ViewModel)包括 DOM Listenters 和 Data bindings,


1. DOM Listenters => 当View层中由于用户交互操作而改变的数据会在 Model 中同步。

  • 换种说法:当页面有事件触发时(比如监听Input的输入), viewModel也能够监听到事件,并通知model进行响应

2. Data bindings => 当 Model 中的数据改变时会触发 View 层的视图自动更新

  • 换种说法:当数据变化的时候,viewModel能够监听到这种变化,并及时的通知view做出修改。

数据变化更新视图,视图变化更新数据,这种模式实现了 Model 和 View 的数据自动同步,因此我们只需要专注于数据的逻辑处理,而不需要自己操作 DOM。

Vue要做权限管理该怎么做?控制到按钮级别的权限怎么做?


  • 把所有页面路由信息存在数据库中,用户登录的时候根据其角色查询得到其能访问的所有页面路由信息返回给前端,前端再通过addRoutes动态添加路由信息

  • 按钮权限的控制通常会实现一个指令,例如v-permission,将按钮要求角色通过值传给v-permission指令,在指令的moutned钩子中可以判断当前用户角色和按钮是否存在交集,有则保留按钮,无则移除按钮。

登录权限

登录权限控制要做的事情,是实现哪些页面能被游客访问,哪些页面只有登录后才能被访问.在一些没有引入角色的软件系统中,通过是否登录来评定页面能否被访问在实际工作中非常常见.

typescript
// 1. 定义路由 meta
export const routes = [
    {
       path: '/login', //登录页面
       name: 'Login',
       component: Login,
    },
    {
       path:"/list", // 列表页
       name:"List",
       component: List, 
    },
    {
       path:"/myCenter", // 个人中心
       name:"MyCenter",
       component: MyCenter, 
       meta:{
          need_login:true //需要登录
       }
    }
  ]

通过router.beforeEach做拦截 to是要即将访问的路由信息,从其中拿到need_login的值可以判断是否需要登录.再从vuex中拿到用户的登录信息.

如果用户没有登录并且要访问的页面又需要登录时就使用next跳转到登录页面,并将需要访问的页面路由名称通过redirect_page传递过去,在登录页面就可以拿到redirect_page等登录成功后直接跳转.

typescript
//vue-router4 创建路由实例
const router = createRouter({  
  history: createWebHashHistory(),
  routes,
});

router.beforeEach((to, from, next) => {
  const { need_login = false } = to.meta;
  const { user_info } = store.state; //从vuex中获取用户的登录信息
  if (need_login && !user_info) {
    // 如果页面需要登录但用户没有登录跳到登录页面
    const next_page = to.name; // 配置路由时,每一条路由都要给name赋值
    next({
      name: 'Login',
      params: {
        redirect_page: next_page,
        ...from.params, //如果跳转需要携带参数就把参数也传递过去
      },
    });
  } else {
    //不需要登录直接放行
    next();
  }
});

页面权限控制

引入了角色这样一个概念后,假设后端不处理,前端需要有一张角色权限列表, 表明每个角色能访问的页面。 当用户登录成功之后,通过接口返回值得知用户数据和所属角色.拿到角色值后就去配置文件里取出该角色能访问的页面列表数组,随后将这部分权限数据加载到应用中从而达到权限控制的目的. 缺点:不灵活,新增角色和页面列表都不需要前端重新修改

typescript
 export const permission_list = {
   member:["List","Detail"], //普通会员
   admin:["List","Detail","Manage"],  // 管理员
   super_admin:["List","Detail","Manage","Admin"]  // 超级管理员
 }

最优方案:交给后端配置角色权限列表。 用户一旦登录后,后端接口直接返回该账号拥有的权限列表就行了,至于该账户属于什么角色以及角色拥有的页面权限全部丢给后端去处理. 假设用户登录后,后端接口如下

typescript
{
  user_id:1,
  user_name:"张三",
  permission_list:["List","Detail","Manage"]
}

前端现在不需要理会张三属于什么角色,只需要按照张三的权限列表给他相应的访问权限就行了。 通过接口的返回值permission_list可知,张三能访问列表页、详情页以及内容管理页.我们先回到路由配置页面,看看如何配置.

typescript
    //静态路由
    export const routes = [
      {
         path: '/login', //登录页面
         name: 'Login',
         component: Login,
      },
      {
         path:"/myCenter", // 个人中心
         name:"MyCenter",
         component: MyCenter, 
         meta:{
            need_login:true //需要登录
         }
      },
      {
         path:"/", // 首页
         name:"Home",
         component: Home, 
      }
    ]
    
    //动态路由
    export const dynamic_routes = [
       {
           path:"/list", // 列表页
           name:"List",
           component: List
       },
       {
           path:"/detail", // 详情页
           name:"Detail",
           component: Detail
       },
       {
           path:"/manage", // 内容管理页
           name:"Manage",
           component: Manage
       },
       {
           path:"/admin", // 人员管理页
           name:"Admin",
           component: Admin
       }
    ]

现在将所有路由分成两部分,静态路由routes动态路由dynamic_routes。静态路由里面的页面是所有角色都能访问的,它里面主要区分登录访问和非登录访问,处理的逻辑与上面介绍的登录权限控制一致。 动态路由dynamic_routes里面存放的是与角色定制化相关的的页面。


用户登录成功后,一般会将上述接口信息存到vuex和localStorage里面,此时跳转新页面前,我们需要动态添加路由信息。

  • 利用router.addRoute动态添加路由信息
typescript
import store from "@/store";

export const routes = [...]; //静态路由

export const dynamic_routes = [...]; //动态路由

const router = createRouter({ //创建路由对象
  history: createWebHashHistory(),
  routes,
});

//动态添加路由
if(store.state.user != null){ //从vuex中拿到用户信息
    //用户已经登录
    const { permission_list } = store.state.user; // 从用户信息中获取权限列表
    const allow_routes = dynamic_routes.filter((route)=>{ //过滤允许访问的路由
      return permission_list.includes(route.name); 
    })
    allow_routes.forEach((route)=>{ // 将允许访问的路由动态添加到路由栈中
      router.addRoute(route);
    })
}

export default router;

切换用户

当用户选择登出后,清除掉路由实例里面处存放的路由栈信息。不然路由信息可能是前一个的权限

typescript
  const router = useRouter(); // 获取路由实例
  const logOut = () => { //登出函数
      //将整个路由栈清空
      const old_routes = router.getRoutes();//获取所有路由信息
      old_routes.forEach((item) => {
        const name = item.name;//获取路由名词
        router.removeRoute(name); //移除路由
      });
      //生成新的路由栈
      routes.forEach((route) => {
        router.addRoute(route);
      });
      router.push({ name: "Login" }); //跳转到登录页面
    };

内容权限控制(按钮)

给内容单独设置一套CURD权限标志,现在用户登录完成后,假设后端接口返回的数据如下(将这条数据存到vuex):

typescript
{
  user_id:1,
  user_name:"张三",
  permission_list:{
    "List":"CR", //权限编码
    "Detail":"CURD"  //权限编码
  }
}

张三除了静态路由设置的页面外,他只能额外访问List列表页以及Detail详情页.其中列表页他只具备创建和新增权限,详情页他具备增删查改所有权限.那么当张三访问上图中的页面时,页面中应该只显示列表和发布需求按钮.


我们现在要做的就是设计一个方案尽可能让页面内容方便被权限编码控制.首先创建一个全局的自定义指令permission,

typescript
import router from './router';
import store from './store';

const app = createApp(App); //创建vue的根实例

app.directive('permission', {
  mounted(el, binding, vnode) {
    const permission = binding.value; // 获取权限值
    const page_name = router.currentRoute.value.name; // 获取当前路由名称
    const have_permissions = store.state.permission_list[page_name] || ''; // 当前用户拥有的权限
    if (!have_permissions.includes(permission)) {
      el.parentElement.removeChild(el); //不拥有该权限移除dom元素
    }
  },
});

当元素挂载完毕后,通过binding.value获取该元素要求的权限编码.然后拿到当前路由名称,通过路由名称可以在vuex中获取到该用户在该页面所拥有的权限编码.如果该用户不具备访问该元素的权限,就把元素dom移除.

html
<template>
  <div>
    <button v-permission="'U'">修改</button>  <button v-permission="'D'">删除</button>
  </div>
  <p>
    <button v-permission="'C'">发布需求</button>
  </p>

  <!--列表页-->
  <div v-permission="'R'">
    ...
  </div>
</template>

首先前端开发页面时要将页面分析一遍,把每一块内容按照权限编码分类。比如修改按钮就属于U,删除按钮属于D。并用v-permission将分析结果填写上去。

当页面加载后,页面上定义的所有v-permission指令就会运行起来。在自定义指令内部,它会从vuex中取出该用户所拥有的权限编码,再与该元素所设定的编码结合起来判端是否拥有显示权限,权限不具备就移除元素。

虽然分析过程有点复杂,但是以后每个新页面想接入权限控制非常方便。只需要将新页面的各个dom元素添加一个v-permission和权限编码就完成了,剩下的工作都交给自定义指令内部去做。

说说你对虚拟 DOM 的理解?

思路:

  1. vdom是什么
  2. 引入vdom的好处
  3. vdom如何生成,又如何成为dom
  4. 在后续的diff中的作用

  • Virtual DOM 本质就是用一个原生的JS对象去描述一个DOM节点。是对真实DOM的一层抽象
  • 由于在浏览器中操作DOM是很昂贵的。频繁的操作DOM,会产生一定的性能问题。使用虚拟DOM可以减少直接操作DOM的次数,减少浏览器的重绘及回流
  • Virtual DOM 映射到真实DOM要经历VNode的create、diff、patch等阶段

  1. 引入虚拟DOM的好处
    1. 将真实元素节点抽象成 VNode,有效减少直接操作 dom 次数,从而提高程序性能
      1. 操作 dom 是比较昂贵的操作,频繁的真实dom操作容易引起页面的重绘和回流
    2. **方便实现跨平台:**可以使用虚拟DOM去针对不同平台进行渲染
  2. vdom如何生成?在vue中我们常常会为组件编写模板 - template, 这个模板会被编译器 - compiler编译为渲染函数,在接下来的挂载(mount)过程中会调用render函数,返回的对象就是虚拟dom

image.png

  1. 挂载过程结束后,vue程序进入更新流程。如果某些响应式数据发生变化,将会引起组件重新render,此时就会生成新的vdom,和上一次的渲染结果diff就能得到变化的地方,从而转换为最小量的dom操作,高效更新视图。

你了解Diff算法吗

  1. diff算法是干什么的
  2. 它的必要性
  3. 它何时执行
  4. 具体执行方式
  5. 拔高:说一下vue3中的优化

Vue中的diff算法由Snabbdom修改而来,虚拟DOM要想转化为真实DOM就需要通过patch方法转换

diff算法遵循深度优先、同层比较的策略vue3中引入的更新策略:编译期优化patchFlags、block等

执行时机

当数据改变时,会触发setter,并且通过Dep.notify去通知所有订阅者Watcher,

  • Watcher会在收到通知后调用自身的update方法,但是更新操作一般会被推迟到异步队列中等待执行(Vue异步更新策略)
  • 对于组件的更新,涉及到的是渲染Watcher,会进一步调用vm._update,
  • vm._update内部再调用vm.__patch__进行diff对比。从而更新相应的视图

Vue 中 Diff算法 执行的时刻是组件更新的时候,更新函数会再次执行 render 函数获得最新的虚拟DOM,然后执行patch函数,并传入新旧两次虚拟DOM,通过比对两者找到变化的地方,最后将其转化为对应的DOM操作。

Diff算法实现原理

image.png

patch方法

这个方法作用就是,对比当前同层的虚拟节点是否为同一种类型的标签(同一类型的标准):

  • 是:继续执行patchVnode方法进行深层比对
  • 否:没必要比对了,直接整个节点替换成新虚拟节点
typescript
function patch(oldVnode, newVnode) {
  // 比较是否为一个类型的节点
  if (sameVnode(oldVnode, newVnode)) {
    // 是:继续进行深层比较
    patchVnode(oldVnode, newVnode)
  } else {
    // 否
    const oldEl = oldVnode.el // 旧虚拟节点的真实DOM节点
    const parentEle = api.parentNode(oldEl) // 获取父节点
    createEle(newVnode) // 创建新虚拟节点对应的真实DOM节点
    if (parentEle !== null) {
      api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
      api.removeChild(parentEle, oldVnode.el)  // 移除以前的旧元素节点
      // 设置null,释放内存
      oldVnode = null
    }
  }

  return newVnode
}

sameVnode方法

patch关键的一步就是sameVnode方法判断是否为同一类型节点(同一节点),那问题来了,怎么才算是同一类型节点呢?这个类型的标准是什么呢?

  • 主要是判断key和tagName
typescript
function sameVnode(oldVnode, newVnode) {
  return (
    oldVnode.key === newVnode.key && // key值是否一样
    oldVnode.tagName === newVnode.tagName && // 标签名是否一样
    oldVnode.isComment === newVnode.isComment && // 是否都为注释节点
    isDef(oldVnode.data) === isDef(newVnode.data) && // 是否都定义了data
    sameInputType(oldVnode, newVnode) // 当标签为input时,type必须是否相同
  )
}

// sameVnode 函数用于判断两个节点是否是可视为相同的虚拟节点
// 当进入 patchVnode 函数时,if (oldVnode === newVnode)
// 这个判断确保了,如果新旧 VNode 完全一致(指向同一个对象)
// 那么就没有必要进行任何更新操作,因为它们实际上是同一个对象

判断 sameNode 的时候,只会判断key、 tag、是否有data的存在(不关心内部具体的值)、是否是注释节点、是否是相同的input type,来判断是否可以复用这个节点

patchVnode方法

首先,对比新旧节点(VNode)本身,判断是否可以视为相同的虚拟节点,如果不相同,则删除真实的旧元素节点,重新创建新节点进行替换;


如果为相同节点,就要判断如何对该节点的子节点进行处理。


这个函数做了以下事情:

  • 找到对应的真实DOM,称为el
  • 判断newVnode和oldVnode是否指向同一个对象,如果是,那么直接return

    1. 如果他们都有文本节点并且不相等,那么将el的文本节点设置为newVnode的文本节点。
    1. 如果oldVnode有子节点而newVnode没有,则删除el(真实dom)的子节点
    1. 如果oldVnode没有子节点而newVnode有,则将newVnode的子节点真实化之后添加到el
    1. 新旧节点都有子节点,就进行双端比较;(updateChildren
typescript
function patchVnode(oldVnode, newVnode) {
  const el = newVnode.el = oldVnode.el // 获取真实DOM对象
  // 获取新旧虚拟节点的子节点数组
  const oldCh = oldVnode.children, newCh = newVnode.children
  // 如果新旧虚拟节点是同一个对象,则终止
  if (oldVnode === newVnode) return
  // 如果新旧虚拟节点是文本节点,且文本不一样
  if (oldVnode.text !== null && newVnode.text !== null && oldVnode.text !== newVnode.text) {
    // 则直接将真实DOM中文本更新为新虚拟节点的文本
    api.setTextContent(el, newVnode.text)
  } else {
    // 否则

    if (oldCh && newCh && oldCh !== newCh) {
      // 新旧虚拟节点都有子节点,且子节点不一样

      // 对比子节点,并更新
      updateChildren(el, oldCh, newCh)
    } else if (newCh) {
      // 新虚拟节点有子节点,旧虚拟节点没有

      // 创建新虚拟节点的子节点,并更新到真实DOM上去
      createEle(newVnode)
    } else if (oldCh) {
      // 旧虚拟节点有子节点,新虚拟节点没有

      //直接删除真实DOM里对应的子节点
      api.removeChild(el)
    }
  }
}

updateChildren方法

这是patchVnode里最重要的一个方法,新旧虚拟节点的子节点对比,就是发生在updateChildren方法中 是怎么样一个对比方法呢?就是首尾指针法双端比较。新的子节点集合和旧的子节点集合,各有首尾两个指针。

html
<ul>
  <li>a</li>
  <li>b</li>
  <li>c</li>
</ul>

<!-- 修改数据后 -->

<ul>
  <li>b</li>
  <li>c</li>
  <li>e</li>
  <li>a</li>
</ul>

那么新旧两个子节点集合以及其首尾指针为: image.png 然后会进行互相进行比较,总共有五种比较情况:

  • 1、oldS 和 newS 使用sameVnode方法进行比较,sameVnode(oldS, newS)
  • 2、oldS 和 newE 使用sameVnode方法进行比较,sameVnode(oldS, newE)
  • 3、oldE 和 newS 使用sameVnode方法进行比较,sameVnode(oldE, newS)
  • 4、oldE 和 newE 使用sameVnode方法进行比较,sameVnode(oldE, newE)
  • 5、如果以上逻辑都匹配不到,再把所有旧子节点的 key 做一个映射到旧节点下标的 key -> index 表,然后用新 vnode 的 key 去找出在旧节点中可以复用的位置。

image.png

双端比较原理

使用了四个指针,分别指向新旧两个 VNode 的头尾,它们不断的往中间移动,当处理完所有 VNode 时停止,每次移动都要比较 头头、头尾 排列组合共4次对比,来去寻找key相同的可复用的节点来进行移动复用;

Vue3的diff => 最长递增子序列

vue3 为了尽可能的减少移动,采用 贪心 + 二分查找 去找最长递增子序列

DIFF算法为什么是 O(n) 复杂度而不是 O(n^3)

正常Diff两个树的时间复杂度是O(n^3),但实际情况下我们很少会进行跨层级的移动DOM,所以Vue将Diff进行了优化,只对同层的子节点进行比较,放弃跨级的节点比较,使得时间复杂从O(n^3)降低至O(n)。

为什么不建议使用 index 作为 key 值

index 并不能保证元素的唯一性和稳定性。

当列表元素发生变化(如元素被添加、删除或排序)时,每个元素的 index 都会改变。而key值改变,意味着 Vue 在进行 DOM diff 的时候会认为整个列表都发生了变化,diff算法就无法得知在更改前后它们是同一个DOM节点。无法正确的复用 DOM 元素,出现渲染问题。

用 index 作为 key 值带来问题的例子

v-for渲染三个输入框,用index作为key值,删除第二项,发现在视图上显示被删除的实际上是第三项,因为原本的key是1,2,3,删除后key为1,2,所以3被认为删除了

typescript
// 再比如,删除第一个元素
[
  // 第一个被删了
  {
    tag: "li",
    key: 0,
    // 这里其实上一轮子组件对应的是第二个 假设子组件的text是2
  },
  {
    tag: "li",
    key: 1,
    // 这里其实子组件对应的是第三个 假设子组件的text是3
  },
];

虽然在注释里我们自己清楚的知道,第一个 vnode 被删除了,但是对于 Vue 来说,它是感知不到子组件里面到底是什么样的实现(它不会深入子组件去对比文本内容),那么这时候 Vue 会怎么 patch 呢?


由于对应的 key使用了 index导致的错乱,它会把

  1. 原来的第一个节点text: 1直接复用。
  2. 原来的第二个节点text: 2直接复用。
  3. 然后发现新节点里少了一个,直接把多出来的第三个节点text: 3 丢掉。

至此为止,我们本应该把 text: 1节点删掉,然后text: 2、text: 3 节点复用,就变成了错误的把 text: 3 节点给删掉了。

Vue2生命周期

总共分为 8 个阶段:创建前/后,挂载前/后,更新前/后,销毁前/后。

各阶段使用场景


  • beforeCreate:执行一些初始化任务,此时获取不到 props 或者 data 中的数据
  • created:组件初始化完毕,可以访问各种数据,获取接口数据等
  • beforeMount:此时开始创建 VDOM
  • mounted:dom已创建渲染,可用于获取访问数据和dom元素;访问子组件等。
  • beforeUpdate:此时view层还未更新,可用于获取更新前各种状态
  • updated:完成view层的更新,更新后,所有状态已是最新
  • beforeDestroy:实例被销毁前调用,可用于一些定时器或订阅的取消
  • destroyed:销毁一个实例。可清理它与其它实例的连接,解绑它的全部指令及事件监听器

keep-alive 独有的生命周期,分别为 activated 和 deactivated。用 keep-alive 包裹的组件在切换时不会进行销毁,而是缓存到内存中并执行 deactivated 钩子函数,命中缓存渲染后会执行 actived 钩子函数。

父子组件生命周期顺序

  • 创建过程从外到内,挂载过程从内到外

  • 加载渲染过程
typescript
//  父 beforeCreate->父 created->父 beforeMount
// ->子 beforeCreate->子 created->子 beforeMount
// ->子 mounted->父 mounted
  • 组件更新过程
typescript
// 父 beforeUpdate-> 子 beforeUpdate-> 子updated -> 父 updated
  • 销毁过程
typescript
// 父 beforeDestroy->子 beforeDestroy->子 destroyed->父 destroyed

Vue模板编译原理

  • 或者说Vue的渲染过程

模板编译分为三个阶段:解析parse、优化optimize、生成generate

typescript
// =>  compiler/index.ts
export const createCompiler = createCompilerCreator(function baseCompile(
  template: string,
  options: CompilerOptions
): CompiledResult {
  // 1. parse解析
  const ast = parse(template.trim(), options)
  if (options.optimize !== false) {
    // 2. 优化标记静态节点
    optimize(ast, options)
  }
  // 3. 生成generate render函数
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
})

parse

解析template 转换为 AST语法树

编译器使用大量正则对template模板进行解析,需要处理指令(v-if,v-for等)插值语法属性等,将这些内容转换为抽象语法树 AST

optimize

遍历AST,找到其中的静态节点并标记。

这一步,主要是标记模板中的静态节点。因为Vue组件在渲染过程中,不论数据怎么更新,静态的部分节点都不会改变,通过对静态节点做标记,后续在做虚拟 DOM 的 diff 操作的时候,会忽略掉这些静态节点,从而提高性能。

generate

将最终的AST转为render函数字符串

typescript
// generate方法的返回
return {
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }

视图渲染更新流程

  • 监听数据的变化,当数据发生变化时,Render 函数执行生成 vnode 对象
  • 对比新旧 VNode 对象,通过Diff算法(双端比较)生成真实DOM;

computed 和 watch 的区别

computed,计算属性。依赖于某个或者某些属性值,当依赖的数据发生变化的时候才会更新 watch,侦听属性,使用于观测某个值的变化去完成一段复杂的业务场景。

=> computed具有缓存性。


  • 如果一个数据依赖于其他数据,那么把这个数据设计为computed
  • 如果需要在某个数据变化时做一些事情,使用watch来观察这个数据的变化

本质区别,源码层

  1. 本质上,computed和 watch都是 watcher类的实例, 计算属性是一个computed watcher(lazy watcher)侦听属性本质上是user watcher;
typescript
// core/observer/watcher.ts
class Watcher implements DepTarget{
  constructor(){
    if (options) {
      this.deep = !!options.deep
      this.user = !!options.user // user watcher
      this.lazy = !!options.lazy // computed watcher
      this.sync = !!options.sync
      this.before = options.before
      if (__DEV__) {
        this.onTrack = options.onTrack
        this.onTrigger = options.onTrigger
      }
    } else {
      this.deep = this.user = this.lazy = this.sync = false
    }
  }
}
  1. computed是有缓存的, 由this.dirty 控制。
    1. 当计算属性的依赖发生改变时,this.dirty 会被设为 true,表示当前计算属性的值是脏的,它的依赖性有变化,值不再是最新的,所以要重新计算**。那么就会执行计算属性相应的 getter 函数进行计算.**
    2. 当计算完成后,就会将 this.dirty 设置为 false,表示已经完成计算,下次获取该计算属性的值时,只需直接返回结果即可,无需重新计算
typescript
// watcher类中的update方法 
  update() {
    /* istanbul ignore else */
    if (this.lazy) {
      // 惰性watcher,computed watcher
      this.dirty = true
    } else if (this.sync) {
      // 标记为同步时执行
      this.run()
    } else {
      // 开启异步队列,批量更新Watcher
      queueWatcher(this)
    }
  }
typescript
// computed.ts
if (watcher.dirty) {
  // dirty为true,触发 evalute() 
  watcher.evaluate()
}

// ==> 走到 watcher.ts
/**
 * Evaluate the value of the watcher.
 * This only gets called for lazy watchers.
 */
evaluate() {
  // 执行计算,并且将dirty设为false
  this.value = this.get()
  this.dirty = false
}

Vue set()方法

  1. 在实例创建之后,如果向实例添加新的属性,新添加的属性不会是响应式的。
  2. 如果通过数组索引设置值,或者修改数组的长度,Vue 无法捕获这种变化。

Vue.set() 可以用来解决上面两个问题:

typescript
const vm = new Vue({
  data: {
    usermessage: {
      firstName: 'Foo'
    }
  }
})

// `vm.usermessage.lastName` 是非响应性的
vm.usermessage.lastName = 'Bar'
// 在更新 `lastName` 时,视图不会更新

// 使用 Vue.set() 添加一个新属性,使其成为响应式
this.$set(this.usermessage, 'lastName', 'Bar')
// 现在,当 `lastName` 改变时,视图将会响应这个变化
typescript
let array = this.items  // this.items 是一个响应式数组
this.$set(array, indexOfItem, newValue)

set底层源码

  • 如果是数组,通过数组的splice触发响应式(splice原型被重写了)
  • 如果是对象,通过defineReactive给新增属性添加getter和setter
typescript
export function set(
  target: any[] | Record<string, any>,
  key: any,
  val: any
): any {


  const ob = (target as any).__ob__

  // 如果是数组
  if (isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    // 通过splice触发响应式
    target.splice(key, 1, val)
    // when mocking for SSR, array methods are not hijacked
    if (ob && !ob.shallow && ob.mock) {
      observe(val, false, true)
    }
    return val
  }

  // 如果目标对象已经存在该属性
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }

   // 如果目标对象没有Observer实例,则直接设置属性
  if (!ob) {
    target[key] = val
    return val
  }

   // 为新属性创建getter/setter,并触发响应式
  defineReactive(ob.value, key, val, undefined, ob.shallow, ob.mock)
  if (__DEV__) {
    ob.dep.notify({
      type: TriggerOpTypes.ADD,
      target: target,
      key,
      newValue: val,
      oldValue: undefined
    })
  } else {
    ob.dep.notify()
  }
  return val
}