Skip to content

Vue 笔记

vue3 响应式原理

通过Proxy代理劫持数据的getter和setter,在响应式数据发生变化时自动触发依赖的收集或重新运行effects。

通过虚拟dom的机制再次渲染页面,实现页面随数据变化更新

数据结构

  • 目标图 targetMap: WeakMap
    • 依赖图 depsMap: Map
      • 作用表 dep: Set

INFO

WeakMap

  • 键必须是对象:如果你尝试使用非对象作为键,将会抛出 TypeError。
  • 不可迭代:WeakMap 不可迭代,因此你不能使用 for...of 循环遍历它,也没有 keys()values()entries()方法。
  • 没有 clear 方法:WeakMap 没有提供清除所有元素的方法。
  • 弱引用:因为WeakMap对其键的引用是弱引用,所以它不会阻止垃圾回收器回收其键所指向的对象(Map持有对其中键值对的强引用)。

截图.png

实现

effect:订阅了响应式数据的作用域更新函数,比如这里的 effect 是一个更新total值的函数

截图.png

js
const targetMap = new WeakMap()
function track(target: Map, key: string) {
  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)
  }
}

function trigger(target, key) {
  const depsMap = targetMap.get(target)
  if(!depsMap) {return}
  let dep = depsMap.get(key)
  if (!dep) {
    dep.forEach(effect => {effect()})
  }
}
const product = {number: 3, quantity: 5}
let total = 0

let effect = () => {
  total = product.number * product.quantity
}

track(product, 'quantity')
effect()
// ===== Console Command =======
console.log(total)
// output: 15
product.quantity = 6
trigger(product, 'quantity')
console.log(total)
// output: 18

劫持对象的 get 和 set 方法:

image.png

vue3 虚拟DOM

vue将页面的状态,也就是页面的dom抽象为js中的对象,通过事务处理机制,将多次dom修改的结果一次性更新到页面上,从而有效的减少了页面渲染的次数,提高页面性能。

虚拟dom的解析过程

  • 将要插入到文档中的dom树映射为js对象
  • vue数据发生变化时,缓存原本的dom树并重新计算虚拟dom树,并与缓存的原本树对比,用diff算法找到一个操作dom代价最小的方式去更新发生了变化的节点
  • 渲染到页面dom树中

优点

避免频繁操作真实dom树,直接操作网页dom开销大

组件化开发:对每个组件生成单独的虚拟dom树,使得组件开发和维护更简单

独立于平台:虚拟dom与浏览器无关也不依赖特定库,可以在不同平台环境中使用

优化开发流程:通过比较新旧虚拟dom的差异,开发人员可以准确知道哪些部分需要更新,进而优化页面渲染流程

diff 算法

Vue3 DOM Diff 算法 - bilibili

vue3手写diff算法(图解+例子+详细注释)vue3 diff-CSDN博客

vue3 快速 diff 算法

注意:新 dom 树内的结点并不是真实有对应的 dom 结点,还只是纯的 js 对象,没有和它对应的真实 dom

  • 比较两个对象是否相同
  • 找到最小代价的更新方式

image.png

第一第二步:

  • 从头往下对比,相同直接 patch(对于相同的结点,pathch 直接把旧 dom 的真实结点值赋给新虚拟dom)
  • 不同的时候停止并记录 i 的值
  • 从后往前对比,同样记录不同的位置

image.png

第三第四步:

这是两种比较理想的情况

仅有新增结点时,直接从 i 的位置往下到 e2 遍历将新结点挂载即可(i < e2 + 1 遍历挂载新结点)

对称的情况是仅有删除结点

image.png

源码:

image.png

image.png

最后混合的情况:

  • 这里找最长递增子序列是为了找到一个最长的一个和原来一样的部分,这整个部分可以整体处理(移动),从而减少渲染的代价
  • 新结点位置映射表:头尾比较后剩下的结点都遍历记录其信息到映射表中
  • 新旧结点映射表:初始化为 0,后续通过比较赋予新值。
    • 比较结束后剩下还是 0 的说明这个位置的结点是新结点,直接挂载
    • 用来计算最大递增子序列
  • 当前最远位置记录旧结点通过映射表获得的新位置的最大值
    • 如果当前旧结点映射得到的新位置比这个值要小(不再呈现递增趋势),说明有结点移动了(前后交错)
  • 不在最长递增子序列位置的元素

image.png

image.png

vue3 路由

Route 和 Router

useRoute 是一个 Composition API Hook,它返回当前路由的路由对象 Route。这个路由对象包含了当前路由的许多信息,比如路径、参数、查询、hash等。

与useRoute不同,useRouter返回的是路由的实例 Router,而不是当前路由的路由对象。通过这个路由实例,我们可以进行诸如导航、编程式导航等操作。

点击切换组件的实现

  • 编程式导航:@click 触发 router.push()
  • 声明式导航:用RouterLink标签实现:<RouterLink to="/">Go to Home</RouterLink>

使用路由(vue3)

js
// routes.js
import { createRouter, createWebHashHistory } from 'vue-router'
export const router = createRouter({
  // 设置 history 模式目前是 hash 模式 url 含有 # 符号,开发使用
  // 要设置成历史模式,使用 createWebHistory(),需要后端配合
  history: createWebHashHistory(),
  // 路由表,是一个数组
  routes: constantRoute,
  // 当切换页面时,滚动条不变,这里 left 和 top 都是相对于 viewport 视口而不是页面
  scrollBehavior() {
    return {
      left: 0,
      top: 0,
    }
  },
})
const constantRoute = [
  {
    path: '/login',
    component: () => import('@/views/login/index.vue'),
    name: 'login',
    meta: {
      title: '登录',
      hidden: true,
      icon: '',
    },
    children: [
      // 更多路由
    ]
  },
  // ... 更多路由
]

路由守卫

路由守卫用来跳转或者取消导航。有全局守卫,独享守卫和组件内守卫。其中前置的守卫在实现前端鉴权时非常有用,可以根据用户请求是否携带了 cookie 等身份信息进行放行、拦截或者重定向。

当一个导航触发时,全局前置守卫按照创建顺序调用。守卫是异步解析执行,此时导航在所有守卫 resolve 完之前一直处于等待中。

以下是一个使用全局前置守卫的例子,有关于可选的第三个参数 next 已经被移出,但仍然被支持。

js
const router = new VueRouter({ ... });

// to: 即将要进入的目标路由对象
// from: 当前导航正要离开的路由对象
// return: true 表示放行,false 取消当前导航,重置到 from 路由地址,或者是一个路由地址
 router.beforeEach(async (to, from) => {
   if (
     // 检查用户是否已登录
     !isAuthenticated &&
     // ❗️ 避免无限重定向
     to.name !== 'Login'
   ) {
     // 将用户重定向到登录页面
     // 也可以是这样:return { name: 'Login' }
     return { path: '/login', query: { tolink: to.path } }
   }
 })

路由独享的守卫,可以直接在路由表中配置

js
const routes = [
  {
    path: '/users/:id',
    component: UserDetails,
    beforeEnter: (to, from) => {
      // reject the navigation
      return false
    },
  },
]

// 也可以直接传递定义好的函数给守卫
const routes = [
  {
    path: '/users/:id',
    component: UserDetails,
    // 这里 removeQueryParams 和 removeHash 都是预先定义好的函数
    beforeEnter: [removeQueryParams, removeHash],
  },
  {
    path: '/about',
    component: UserDetails,
    // 再次复用该函数
    beforeEnter: [removeQueryParams],
  },
]

组件内的守卫等其他信息可以参考文档 Vue-路由守卫

vue3 组件通信

父子通信

  1. props & emit

父传子:v-bind发送 + defineProps接收

子传父:$emit 或者 difineEmits 触发父组件的事件

子组件想要传递的数据作为 被触发事件的处理函数的参数 被传递.

TIP

注意 defineProps 宏接收的 Props 可以直接在模板中使用,但是不能在 js 内直接访问! 正确的方式是用一个变量接收defineProps返回的对象,然后通过这个对象进行访问。

vue
<script setup>
// 对象方式
const props = defineProps({
  name: String,
  age: Number
})
// 数组方式
const props = defineProps(['name', 'age'])

// 使用 Props
console.log(props.name, props.age)
</script>
  1. 依赖注入 provide & inject

provide(key, value)提供一个值,可以被后代组件注入

  • key - 字符串或者 symbol
  • value - 注入的值,可以是任意内容

inject(key, defaultValue?, treatDefaultAsFactory?) 注入一个由祖先组件或整个应用 (通过 app.provide()) 提供的值。

  • key - 注入值的 key
  • defaultValue - 可选参数,指定没有匹配值时的默认值
  • treatDefaultAsFactory - 可选参数,布尔值,当第二个参数是工厂函数时必须设置为 true

与注册生命周期钩子的 API 类似,provide()inject() 必须在组件的 setup() 阶段同步调用。

父组件:provide(key, value)

子组件:const p = inject(key)

其他通信

  1. 事件总线 eventBus ($emit & $on): 使用 mitt
  2. defineExpose() + $refs/ref() + $parent

TIP

  • $refs$parent 都只能在模板中使用,不能直接在组件内部使用(setup脚本中)
  • $refs 是所有模板引用的数集合

使用 <script setup> 的组件是默认关闭的——即通过模板引用(ref)或者 $parent 链获取到的组件的公开实例,不会暴露任何在 <script setup> 中声明的绑定。

可以通过 defineExpose 编译器宏来显式指定在组件中要暴露出去的属性:

js
// script setup
import { ref } from 'vue'

const a = 1
const b = ref(2)

defineExpose({
  a,
  b
})

当父组件通过模板引用的方式获取到当前组件的实例,获取到的实例会像这样 { a: number, b: number } (ref 会和在普通实例中一样被自动解包)

image.png

$attr: 这个属性是父组件通过在子组件标签上使用 v-bind 传过来的,除去被 Props 接收的剩下的属性,包括style和事件监听

小八股

emit

$emit(): 在当前组件或者直接父组件触发一个自定义事件。任何额外的参数都会传递给事件监听器的回调函数。

TIP

和原生 DOM 事件不一样,组件触发的事件没有冒泡机制。你只能监听直接子组件触发的事件。平级组件或是跨越多层嵌套的组件间通信,应使用一个外部的事件总线,或是使用一个全局状态管理方案

$emit 可以直接在模板中使用,但是不能在 js 中写像 vue2 那这样用 this.$emit(...)

defineEmits()

组件可以显式地通过 defineEmits() 宏来声明它要触发的事件:

vue
<script setup>
defineEmits(['inFocus', 'submit'])
</script>

我们在 <template> 中使用的 $emit 变量不能在组件的 <script setup> 部分中使用,但 defineEmits() 会返回一个相同作用的函数供我们使用:

vue
<script setup>
const emit = defineEmits(['inFocus', 'submit'])
function buttonClick() {
  emit('submit')
}
</script>

defineEmits()不能在子函数中使用。如上所示,它必须直接放置在 <script setup> 的顶级作用域下。

手写 v-model

Vue2

  • 视图变化 => 数据变化:通过监听input事件实现
  • 数据变化 => 视图变化:通过劫持数据的set方法实现
js
function RefData() {
  Object.defineProperties(this, {
    data: {
      get() {
        return data
      },
      set(newval) {
        console.log(newval)
        data = newval
        // 数据变化 => 视图变化(这里是对应input组件的 value 变化)
        document.querySelector(".ref").value = val
        // document.querySelector(".box").innerHTML = val
      }
    }
  })
}
const mydata = new RefData()
// 输入变化 => 数据变化
document.querySelector('.ref').addEventListener('input', (e) => {
  mydata.data = e.target.value
})
// button 回调
const change = () => {
  // 会触发 set 
  mydata.data = 99
}

Vue3

原理类似,但是用 ES6 的 Proxy 对象来代理数据的 setter 和 getter.

js
var p = {
  data: ''
}
// 视图输入框变化 -> 数据变化
document.querySelector('input').addEventListener('input', (e) => {
  pproxy.data = e.target.value
})
// 数据变化 -> 视图变化
const pproxy = new Proxy(p, {
  set(target, prop, value, receiver) {
    let result = Reflect.set(target, prop, value, receiver)
    document.querySelector('.ref').value = value
    return result
  }
})
const change = () => {
  pproxy.data = 99
}

手写 v-if, v-show

vue
<template>
  <div v-if="1+1=== 2" htmll="123">我要显示</div>
  <div v-if="1+1!==2" htmll="123">我不能显示</div>
  <div v-else-if="1+2===3">
    我也能显示
  </div>
  <div v-else-if="false">
    我也不能显示
  </div>
  <div v-show="true">我是show显示</div>
  <div v-show="false">我是show隐藏</div>
</template>

<script>
  // 获取根元素
  const el = document.querySelector('body')
  // 获取子节点
  const nodeList = el.childNodes

  // 遍历子节点
  for (let i = 0; i < nodeList.length; i++) {
    const childNode = nodeList[i]

    // 判断子元素是否含有v-if、v-else-if、v-show属性
    const has = hasIf(childNode)
    // 如果存在v-if、v-else-if、v-show属性,则执行响应操作
    if (has) {
      // 根据v-if、v-else-if、v-show的值操作相应元素
      operateNode(childNode, has)
    }
  }

  // 删除节点
  function operateNode(el, attr) {
    // 获取v-if、v-else-if、v-show的值
    const attrValue = el.attributes.getNamedItem(attr) ? el.attributes.getNamedItem(attr).value : null

    // 执行v-if、v-else-if、v-show表达式
    const fun = new Function('', 'return (' + attrValue + ')')
    const res = fun()
    // v-if、v-else-if、v-show的值为真时正常显示
    if (res) return
    // 非v-show指令时采用删除子节点的方式进行操作
    attr !== 'v-show' && el.parentNode && el.parentNode.removeChild(el)
    // v-show指令时通过设置display的值控制显隐
    attr === 'v-show' && (el.style.display = 'none')

  }

  // 判断是否含有v-if、v-else-if、v-show属性
  function hasIf(el) {
    // 子元素没有属性时直接返回
    if (!el.attributes) return
    const e = el.attributes

    for (let i = 0; i < e.length; i++) {
      // 通过正则匹配v-if、v-else-if、v-show指令
      if (/^v\-if|v\-else\-if|v\-show$/.test(e[i].name)) {
        return e[i].name
      }
    }
  }

</script>

为什么不用虚拟dom的也可以取得不错的性能

直接用 js “手动”正确地操作 dom 可以取得理论意义上的最好性能。

vue 使用虚拟 dom 在真正更新页面之前要进行对比 diff 算法找到开销最小的操作方法,这个过程也是耗时的,除此以外把事件映射到虚拟 dom 也耗时。

vue 或者 react 的设计理念或者 motivation 从来都不是更好的性能和速度,而是更方便维护代码,方便开发。

vite 构建原理

image.png

Released under the GNU General Public License v3.0.