Skip to content
当前页导航

vue2 篇

介绍一下 MVVM 模式,和 MVC 模式有什么区别

MVVM 即 Model-View-ViewModel 的简写,即模型-视图-视图模型。模型(Model)指的是后端传递的数据。视图(View)指的是所看到的页面。视图模型(ViewModel)是 MVVM 模式的核心,它是连接 View 和 Model 的桥梁。

视图模型有两个方向的作用:

  1. 将模型(Model)转化成视图(View),即将后端传递的数据转化成所看到的页面,实现的方式是:数据绑定。
  2. 将视图(View)转化成模型(Model),即将所看到的页面转化成后端的数据,实现的方式是:DOM 事件监听。当这两个方向的数据转换都实现时,我们称之为数据的双向绑定。

MVC 是 Model-View-Controller 的简写,即模型-视图-控制器。M 和 V 指的意思和 MVVM 中的 M 和 V 意思一样。C 即 Controller 指的是页面业务逻辑。使用 MVC 的目的就是将 M 和 V 的代码分离。MVC 是单向通信,也就是 View 跟 Model ,必须通过 Controller 来承上启下。

MVVM 与 MVC 最大的区别就是:MVVM 实现了 View 和 Model 的自动同步,也就是当 Model 的属性改变时,我们不用再自己手动操作 Dom 元素来改变 View 的显示,而是改变属性后该属性对应 View 层显示会自动改变(双向绑定)。

Vue 的数据为什么频繁变化但只会更新一次

vue 为了优化性能,它在同一个事件循环中,会将所有数据变化都放入一个异步更新队列中,在下一次的 tick 中批量处理这些变化。

vue-router 实现原理

hash 模式和 history 模式的区别

  • 表现形式的区别
    • hash 模式 http:localhost/#/about
    • history 模式 http:localhost/about
  • 原理的区别
    • hash 模式 Vue Router 默认使用的模式是 hash 模式,通过 onhashchange 事件监听 url 的变化,再通过 history 的 pushState 方法,往浏览器添加历史记录。不需要后台人员进行配置
    • history 模式 使用的是 H5 的 History API,通过 popstate 事件监听 url 的变化,再通过 history 的 pushState 方法,往浏览器添加历史记录。需要后台人员处理刷新页面的情况(找不到当前页面,在服务端应该除了静态资源外都返回单页应用的 index.html)
js
window.addEventListener('hashchange', () => {})
window.addEventListener('popstate', () => {})

history.pushState({}, '', 'bar.html') // 往浏览器添加历史记录
history.replaceState({}, '', 'bar.html') // 往浏览器修改当前的历史纪录
history.go() // 跳转指定页面

实现

  • 创建一个 VueRouter 类,有一个静态方法 install,判断此插件是否加载过,只需加载一次。当 Vue 加载时,把传入的 router 对象挂载到 Vue 实例上(只执行一次)。
  • 在构造函数中,初始化,把传入的参数 options 放到自己的 this 上;创建一个 routerMap 对象,用来记录路由和组件的映射关系;再利用 Vue 提供的 observable 方法创建一个响应式数据,记录当前的路由。
  • 创建 initRouterMap 事件,循环传入的 routes 路由配置信息,把路由和组件的映射关系记录到 routerMap 对象中。
  • 创建 initEvent 事件,利用 popstate 事件监听 history 模式下路由的变化,利用 hashchange 事件监听 hash 模式下的变化。
  • 创建 initComponent 事件,创建 router-link 和 router-view 两个组件。
    • router-link 组件里面渲染一个 a 标签,然后阻止默认事件,手动改变当前路由,再通过 history 的 pushState 方法,往浏览器添加历史记录。
    • router-view 组件则是直接调用 vue 的 render 方法中的 h 函数,渲染路由对应的组件。因为 当前路由是响应式的,所以路由变化的时候,会重新渲染对应的组件。
js
// my-Router.js

let _Vue = null

class VueRouter {
  static install(Vue) {
    // 判断此插件是否安装过
    if (VueRouter.prototype.installed) {
      return
    }
    VueRouter.prototype.installed = true

    _Vue = Vue

    // 把当前路由对象挂载到vue实例上
    _Vue.mixin({
      beforeCreate() {
        // 为什么在这里挂载,因为这里的this才是vue实例
        if (this.$options.router) {
          _Vue.prototype.$router = this.$options.router
        }
      },
    })
  }

  constructor(options) {
    // 传入的对象
    this.options = options
    // 所有路由
    this.routerMap = {}
    // 当前路由
    this.data = _Vue.observable({
      current: '/',
    })
    this.init()
  }

  init() {
    //监听浏览器地址变化
    this.initEvent()
    // 把所有的路由添加到routerMap中
    this.initRouterMap()
    // 初始化全局的路由组件
    this.initComponent(_Vue)
  }

  initRouterMap() {
    for (let item of this.options.routes) {
      this.routerMap[item.path] = item.component
    }
  }

  initEvent() {
    if (this.options.mode == 'history') {
      window.addEventListener('popstate', () => {
        this.data.current = window.location.pathname
      })
    } else {
      window.addEventListener('hashchange', () => {
        this.data.current = window.location.hash.substr(1)
      })
      window.addEventListener('load', () => {
        if (!window.location.hash) {
          window.location.hash = '#/'
        }
      })
    }
  }

  initComponent(Vue) {
    let that = this

    Vue.component('router-link', {
      props: {
        to: String,
      },
      render(h) {
        return h(
          'a',
          {
            attrs: {
              href: this.to,
            },
            on: {
              click: this.clickhander,
            },
          },
          [this.$slots.default]
        )
      },
      methods: {
        clickhander(e) {
          e.preventDefault()

          if (that.options.mode == 'history') {
            history.pushState({}, '', this.to)
          } else {
            history.pushState({}, '', '#' + this.to)
          }
          that.data.current = this.to
        },
      },
    })

    Vue.component('router-view', {
      render(h) {
        let app = that.routerMap[that.data.current]
        return h(app)
      },
    })
  }
}

export default VueRouter

vue-router 路由守卫

  • router.beforeEach 全局前置守卫
js
const router = createRouter({ ... })

router.beforeEach((to, from, next) => {
  // ...
  // 返回 false 以取消导航
  return false
})
  • router.afterEach 全局后置钩子
js
const router = createRouter({ ... })

router.afterEach((to, from, failure) => {

})
  • beforeEnter 路由独享的守卫,只在进入路由时触发
js
const routes = [
  {
    path: '/users/:id',
    component: UserDetails,
    beforeEnter: (to, from) => {
      // reject the navigation
      return false
    },
  },
]
  • beforeRouteEnter、beforeRouteUpdate、beforeRouteLeave 组件内的守卫
js
const UserDetails = {
  template: `...`,
  beforeRouteEnter(to, from) {
    // 在渲染该组件的对应路由被验证前调用
    // 不能获取组件实例 `this` !
    // 因为当守卫执行时,组件实例还没被创建!
  },
  beforeRouteUpdate(to, from) {
    // 在当前路由改变,但是该组件被复用时调用
    // 举例来说,对于一个带有动态参数的路径 `/users/:id`,在 `/users/1` 和 `/users/2` 之间跳转的时候,
    // 由于会渲染同样的 `UserDetails` 组件,因此组件实例会被复用。而这个钩子就会在这个情况下被调用。
    // 因为在这种情况发生的时候,组件已经挂载好了,导航守卫可以访问组件实例 `this`
  },
  beforeRouteLeave(to, from) {
    // 在导航离开渲染该组件的对应路由时调用
    // 与 `beforeRouteUpdate` 一样,它可以访问组件实例 `this`
  },
}

完整的导航解析流程

  1. 导航被触发。
  2. 在失活的组件里调用 beforeRouteLeave 守卫。
  3. 调用全局的 beforeEach 守卫。
  4. 在重用的组件里调用 beforeRouteUpdate 守卫(2.2+)。
  5. 在路由配置里调用 beforeEnter。
  6. 解析异步路由组件。
  7. 在被激活的组件里调用 beforeRouteEnter。
  8. 调用全局的 beforeResolve 守卫(2.5+)。
  9. 导航被确认。
  10. 调用全局的 afterEach 钩子。
  11. 触发 DOM 更新。
  12. 调用 beforeRouteEnter 守卫中传给 next 的回调函数,创建好的组件实例会作为回调函数的参数传入。

实现路由、按钮权限

路由权限

不同账号登录,获取后端返回的菜单

  • Vue2 通过 addRoute 把对应的路由添加进去,每次登录之前需要重置一下路由,不然会包含以前的路由权限。通过把旧路由实例的 matcher 属性换成新路由实例的matcher 属性
js
import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router)

const createRouter = () =>
  new Router({
    mode: 'history',
    routes: [],
  })

const router = createRouter()

export function resetRouter() {
  // 重置路由
  const newRouter = createRouter()
  router.matcher = newRouter.matcher
}

export default router
  • Vue3 通过 addRoute 把对应的路由添加进去,每次登录之前需要删除之前的路由,不然会包含以前的路由权限。通过 router.getRoutes()方法获取所有路由,循环所有路由,再通过 router.removeRoute() 方法删除
js
const list = router.getRoutes()
list.forEach((item) => {
  if (item.name) {
    router.removeRoute(item.name)
  }
})

按钮权限

  • 通过 v-if
  • 通过自定义指令
js
// router.js
{
  path: '/permission',
  component: Layout,
  name: '权限测试',
  meta: {
      btnPermissions: ['admin', 'supper', 'normal']
  },
  //页面需要的权限
  children: [{
      path: 'supper',
      component: _import('system/supper'),
      name: '权限测试页',
      meta: {
          btnPermissions: ['admin', 'supper']
      } //页面需要的权限
  },
  {
      path: 'normal',
      component: _import('system/normal'),
      name: '权限测试页',
      meta: {
          btnPermissions: ['admin']
      } //页面需要的权限
  }]
}

// 自定义指令
import Vue from 'vue'
/**权限指令**/
const has = Vue.directive('has', {
  bind: function (el, binding, vnode) {
    // 获取页面按钮权限
    let btnPermissionsArr = [];
    if(binding.value){
        // 如果指令传值,获取指令参数,根据指令参数和当前登录人按钮权限做比较。
        btnPermissionsArr = Array.of(binding.value);
    }else{
        // 否则获取路由中的参数,根据路由的btnPermissionsArr和当前登录人按钮权限做比较。
        btnPermissionsArr = vnode.context.$route.meta.btnPermissions;
    }
    if (!Vue.prototype.$_has(btnPermissionsArr)) {
        el.parentNode.removeChild(el);
    }
  }
});
// 权限检查方法
Vue.prototype.$_has = function (value) {
  let isExist = false;
  // 获取用户按钮权限
  let btnPermissionsStr = sessionStorage.getItem("btnPermissions");
  if (btnPermissionsStr == undefined || btnPermissionsStr == null) {
      return false;
  }
  if (value.indexOf(btnPermissionsStr) > -1) {
      isExist = true;
  }
  return isExist;
};
export {has}
html
<el-button @click="editClick" type="primary" v-has>编辑</el-button>

Vue 实例挂载的过程

vue 构建函数调用 _init 方法,根据传入的 option 配置对象,进行一些操作

  • 初始化一些数据,像 props/data/watch/methods ,初始化生命周期,定义 $on、$off、$emit、$off 等事件。
  • 初始化渲染函数,把模板生成虚拟 DOM,然后再调用 mount 函数,把虚拟 DOM 渲染成真实 DOM 并挂载到页面上。
  • 挂载完成后,Vue 会对数据进行响应式处理,然后再触发 mounted 生命周期

Vue 组件通信

  1. props 和 $emit,父子组件传值。
  2. $attrs 和 $listeners,父子组件传值。
  • $attrs 接收父组件传值, 它跟 props 的区别在于,它是不会包含子组件中在 props 中已接收的值,且它如果需要在传递给孙组件,是通过 v-bind="$attrs" 传递,它经常配合  inheritAttrs 选项一起使用,inheritAttrs 为 false 时,传递的值不会展示在子组件的根元素上。
  • $listeners 接收父组件 (不含 .native 修饰器的) v-on 事件。它可以通过 v-on="$listeners"  传给孙组件
html
<!-- Home.vue -->
<template>
  <div class="home">
    <Parent
      :msg="msg"
      :foo="foo"
      :bar="bar"
      :name="name"
      @click="handleClick"
      @aa="handleAa"
    />
  </div>
</template>

<script>
  import Parent from '@/components/Parent.vue'

  export default {
    name: 'Home',
    data() {
      return {
        foo: 'foo',
        bar: 'bar',
        name: 'sky',
        msg: 'message',
        age: 16,
      }
    },
    components: {
      Parent,
    },
    methods: {
      handleClick() {
        console.log(this.age)
      },
      handleAa() {
        console.log(2)
      },
    },
  }
</script>
html
<!-- Parent.vue -->
<template>
  <div>  Parent  <Child v-bind="$attrs" v-on="$listeners" /></div>
</template>

<script>
  import Child from '@/components/Child.vue'
  export default {
    data() {
      return {}
    },
    props: ['name'],
    components: {
      Child,
    },
    mounted() {
      console.log('parent', this.$attrs) // {msg: "message", foo: "foo", bar: "bar"}
      console.log('parent', this.$listeners) // {click: ƒ, aa: ƒ}
      this.$listeners.click() // 16
    },
  }
</script>
html
<!-- Child.vue -->
<template>
    
  <div>Child {{ msg }}</div>
</template>

<script>
  export default {
    inheritAttrs: false, // 默认值为 true, 父组件传递的值会展示在根元素上,false 则不展示
    props: ['msg'],
    mounted() {
      console.log('child', this.$attrs) // {foo: "foo", bar: "bar"}
      console.log('child', this.$listeners) // {click: ƒ, aa: ƒ}
    },
  }
</script>
  1. provide 和 inject ,通过依赖注入传值,它是跨父子,祖孙组件传值。但是它传递的值不是响应式的,如果要实现响应式的可以有两种方法:
  • 把父组件的实例 this 直接传给子孙组件,这样子孙组件都可以改父组件的数据,但是这样会传递很多不需要的数据
  • 使用 Vue.observable 对 provide 的数据进行响应式处理(推荐这种)
js
// 不处理响应式
// parent.vue
export default {
  data() {
    return {
      name: 'sky',
    }
  },
  provide() {
    return {
      name: this.name,
    }
  },
}

// child.vue
export default {
  inject: ['name'],
}
js
// 响应式

// 方法1
// parent.vue
export default {
  data() {
    return {
      name: 'sky',
    }
  },
  provide() {
    return {
      parent: this,
    }
  },
}

// child.vue
export default {
  inject: ['parent'],
}

// 方法2
// parent.vue
export default {
  provide() {
    this.theme = Vue.observable({
      name: 'sky',
    })
    return {
      theme: this.theme,
    }
  },
}

// child.vue
export default {
  inject: ['theme'],
}
  1. 使用 $parent 、 $children 和 ref 访问父子组件
  2. $on 和 $emit,它是通过一个空的 Vue 实例来作为事件中心去进行通信的。它可以跨组件,父子组件传值,传值的时候即使接收值的页面是未显示的页面也能触发,但是再切回接收页面,不会重新触发
js
// bus.js
import Vue from 'vue'

export const bus = new Vue()

// a.vue
import { bus } from '@/assets/bus'
export default {
  name: 'Home',
  mounted() {
    bus.$on('data', (val) => {
      console.log('ff')
      this.ok = val
    })
  },
}

// b.vue
import { bus } from '@/assets/bus'
export default {
  methods: {
    handleClick() {
      bus.$emit('data', 88)
    },
  },
}
  1. 使用 vuex

Vue 常用自定义指令

  • 自定义按钮权限指令
js
// <el-button @click="editClick" type="primary" v-has>编辑</el-button>

import Vue from 'vue'
/**权限指令**/
const has = Vue.directive('has', {
  bind: function (el, binding, vnode) {
    // 获取页面按钮权限
    let btnPermissionsArr = []
    if (binding.value) {
      // 如果指令传值,获取指令参数,根据指令参数和当前登录人按钮权限做比较。
      btnPermissionsArr = Array.of(binding.value)
    } else {
      // 否则获取路由中的参数,根据路由的btnPermissionsArr和当前登录人按钮权限做比较。
      btnPermissionsArr = vnode.context.$route.meta.btnPermissions
    }
    if (!Vue.prototype.$_has(btnPermissionsArr)) {
      el.parentNode.removeChild(el)
    }
  },
})
// 权限检查方法
Vue.prototype.$_has = function (value) {
  let isExist = false
  // 获取用户按钮权限
  let btnPermissionsStr = sessionStorage.getItem('btnPermissions')
  if (btnPermissionsStr == undefined || btnPermissionsStr == null) {
    return false
  }
  if (value.indexOf(btnPermissionsStr) > -1) {
    isExist = true
  }
  return isExist
}
export { has }
  • 按钮防抖,防止重复点击
js
// <button @click="sayHello" v-throttle>提交</button>

Vue.directive('throttle', {
  bind: (el, binding) => {
    let throttleTime = binding.value // 节流时间
    if (!throttleTime) {
      // 用户若不设置节流时间,则默认2s
      throttleTime = 2000
    }
    let cbFun
    el.addEventListener(
      'click',
      (event) => {
        if (!cbFun) {
          // 第一次执行
          cbFun = setTimeout(() => {
            cbFun = null
          }, throttleTime)
        } else {
          event && event.stopImmediatePropagation()
        }
      },
      true
    )
  },
})

Vue 常用的修饰符

vue 中修饰符分为以下五种

  • 表单修饰符
    • lazy 相当于把输入框的 input 事件变成了 change 事件。在我们填完信息,光标离开标签的时候,才会将值赋予给 value,也就是在 change 事件之后再进行信息同步
    html
    <input type="text" v-model.lazy="value" />
    <p>{{value}}</p>
    • trim 自动过滤用户输入的首空格字符,而中间的空格不会过滤
    • number 自动将用户的输入值转为数值类型,但如果这个值无法被 parseFloat 解析,则会返回原来的值
  • 事件修饰符
    • stop 阻止事件冒泡,相当于调用了 event.stopPropagation 方法
    • prevent 阻止了事件的默认行为,相当于调用了 event.preventDefault 方法
    • self 只当在 event.target 是当前元素自身时触发处理函数
    • once 事件只能触发一次
    • capture 使事件触发从包含这个元素的顶层开始往下触发
    • passive 在移动端,当我们在监听元素滚动事件的时候,会一直触发 onscroll 事件会让我们的网页变卡,因此我们使用这个修饰符的时候,相当于给 onscroll 事件整了一个.lazy 修饰符
    • native 让组件变成像 html 内置标签那样监听根元素的原生事件,否则组件上使用 v-on 只会监听自定义事件
  • 鼠标按键修饰符
    • left 左键点击
    • right 右键点击
    • middle 中键点击
  • 键值修饰符
    • 普通键(enter、tab、delete、space、esc、up...)
    • 系统修饰键(ctrl、alt、meta、shift...)
  • v-bind 修饰符
    • sync 能对 props 进行一个双向绑定

Vue 中的过滤器

过滤器(filter)可以理解为一个函数,对数据进行一定的加工,再返回,不会改变原始数据,它分为全局过滤器和组件过滤器。 处理一个数据的时候,可以有多个过滤器,过滤器也可以传参。 Vue3 中已废弃 filter

Vue.filter('capitalize', function (value) {
  if (!value) return ''
  value = value.toString()
  return value.charAt(0).toUpperCase() + value.slice(1)
})

<!-- 在双花括号中 -->

{{ message | capitalize }}

<!-- 在 `v-bind` 中 -->

<div v-bind:id="rawId | formatId"></div>

{{ message | filterA | filterB }}

其中 message 的值作为第一个参数,普通字符串 'arg1' 作为第二个参数,表达式 arg2 的值作为第三个参数

{{ message | filterA('arg1', arg2) }}

平时开发中,需要用到过滤器的地方有很多,比如单位转换、数字打点、文本格式化、时间格式化之类的等

js
// 比如我们要实现将30000 => 30,000,这时候我们就可以使用过滤器
Vue.filter('toThousandFilter', function (value) {
  if (!value) return ''
  value = value.toString()
  return .replace(str.indexOf('.') > -1 ? /(\d)(?=(\d{3})+\.)/g : /(\d)(?=(?:\d{3})+$)/g, '$1,')
})

原理

  • 在编译阶段通过 parseFilters 将过滤器编译成函数调用(串联过滤器则是一个嵌套的函数调用,前一个过滤器执行的结果是后一个过滤器函数的参数)
  • 编译后通过调用 resolveFilter 函数找到对应过滤器并返回结果
  • 执行结果作为参数传递给 toString 函数,而 toString 执行后,其结果会保存在 Vnode 的 text 属性中,渲染到视图

生命周期

  • beforeCreate 做了些参数初始化和合并,mergeOptions 中初始化 props 和 inject 等参数。在当前阶段 data、props、methods、computed 以及 watch 上的数据和方法都不能被访问

  • created 初始化了  Inject 、Provide 、 props 、methods 、data 、computed  和  watch。这里没有$el,如果非要想与 Dom 进行交互,可以通过 vm.$nextTick 来访问 Dom

  • beforeMount 检查是否存在 el 属性,存在的话进行渲染 dom 操作。

  • mounted 实例化 Watcher ,渲染 dom,数据完成双向绑定,可以访问到 Dom 节点

  • beforeUpdate 针对视图层的,只有页面中渲染的数据发生改变或者渲染,才会触发这个生命周期函数。数据更新时调用,发生在虚拟 DOM 重新渲染和打补丁(patch)之前。可以在这个钩子中进一步地更改状态,这不会触发附加的重渲染过程

  • updated 针对视图层的,只有页面中渲染的数据发生改变或者渲染,才会触发这个生命周期函数。发生在更新完成之后,检查当前的 watcher 列表中,是否存在当前要更新数据的 watcher ,如果存在就执行。当前阶段组件 Dom 已完成更新。要注意的是避免在此期间更改数据,因为这可能会导致无限循环的更新,该钩子在服务器端渲染期间不被调用。

  • beforeDestroy 实例销毁之前调用。在这一步,实例仍然完全可用。我们可以在这时进行善后收尾工作,比如清除计时器。

  • destroyed Vue 实例销毁后调用。调用后,Vue 实例指示的所有东西都会解绑定,所有的事件监听器会被移除,所有的子实例也会被销毁。 该钩子在服务器端渲染期间不被调用。

  • activated keep-alive 专属,组件被激活时调用

  • deactivated keep-alive 专属,组件被销毁时调用

keep-alive 原理

在 keep-alive 组件内,created 生命周期内声明了一个缓存对象和一个 key 数组;

key 数组记录目前缓存的组件 key 值,如果组件没有指定 key 值,会自动生成一个唯一的 key 值。

cache 对象会以 key 值为键,vnode 为值,用于缓存组件对应的虚拟 DOM

在 keep-alive 的渲染函数中,其基本逻辑是判断当前渲染的 vnode 是否有对应的缓存,如果有,会从缓存中读取到对应的组件实例,如果没有就会把它缓存。

当缓存的数量超过 max 设置的数值时,keep-alive 会移除 key 数组中的第一个元素,遵循的是  LRU 置换策略

如何更新数据

  • 利用 activated 生命周期
  • 利用路由的 组件中的 beforeRouteEnter 事件,因为每次切换路由的时候,都会执行这个事件

nextTick 原理

nextTick 方法是用来在 DOM 更新循环结束之后执行延迟回调函数的,多个 nextTick 会被合并。

利用 Event loop 事件线程去异步操作,控制 DOM 更新和 nextTick 回调先后执行,保证了能在 dom 更新后在执行回调。

具体实现原理是通过 Promise、MutationObserver、setImmediate 和 setTimeout 等技术手段进行兼容性处理,以确保在所有浏览器环境下都能正确执行回调函数。在 Vue2 中,优先使用 Promise 和 MutationObserver 这两种高效的异步操作方式,如果浏览器不支持,则使用 setImmediate 或 setTimeout 进行兼容处理。

SSR 服务端渲染

SSR 也就是服务端渲染,也就是将 Vue 在客户端把标签渲染成 HTML 的工作放在服务端完成,然后再把 html 直接返回给客户端。

传统的服务端渲染:都是在服务端完成的,即服务端将数据和页面模板渲染成 HTML ,返回给客户端,这就是早期的前后端不分离模式。

现代化的服务端渲染(同构渲染):它是结合服务端渲染 + 客户端渲染来实现的,在服务器端执行一次,用于实现服务器端渲染(首屏直出),在客户端再执行一次,用于接管页面交互,核心解决 SEO 和首屏渲染慢的问题。

  • 客户端发起请求
  • 服务端渲染首屏内容 + 生成客户端 SPA 相关资源
  • 服务端将生成的首屏资源发送给客户端
  • 客户端直接展示服务端渲染好的首屏内容
  • 首屏中的 SPA 相关资源执行之后会激活客户端
  • 之后客户端所有的交互都由客户端 SPA 处理

优点:SSR 有着更好的 SEO、并且首屏加载速度更快

缺点:服务端渲染只支持 beforeCreate 和 created 两个生命周期,项目需要处于 Node 的运行环境,服务器会有更大的负载需求

vue 中使用了哪些设计模式

  1. 工厂模式 - 传入参数即可创建实例 虚拟 DOM 根据参数的不同返回基础标签的 Vnode 和组件 Vnode

  2. 单例模式 - 整个程序有且仅有一个实例。vuex 和 vue-router 的插件注册方法 install 判断如果系统存在实例就直接返回掉

  3. 发布-订阅模式 (vue 事件机制)

  4. 观察者模式 (响应式数据原理)

  5. 装饰模式: (@装饰器的用法)

  6. 策略模式 策略模式指对象有某个行为,但是在不同的场景中,该行为有不同的实现方案-比如选项的合并策略

谈谈响应式

响应式就是当数据发生变化时可以被检测到。

Vue 使用响应式能连接数据层和视图层,任何一方发生变化都能去影响另一方。它可以让开发者只需要关注数据变化,不需要去处理视图。

Vue2 它是通过 Object.defineProperty 方法,去循环对象的所有属性,在 getter 方法中会进行依赖收集,在 setter 中则去触发更新,进行拦截实现响应式操作的。它只能拦截对象一开始就有的属性。

如果是数组,则是通过重写数组的 7 个方法(push、pop、shift、unshift、splice、sort、reserve),然后通过拦截这些方法去收集依赖和触发更新的。如果也通过 Object.defineProperty 方法去拦截的话,会浪费太多性能

Vue2 新增删除对象属性的无法监控变化,需要通过 $set、Sdelete 实现。Vue2 也不支持 ES6 中 Map、Set 的数据

Vue3 则是通过 proxy 去进行拦截的,它可以直接代理对象和数组,就不用循环对象所有属性,和重写数组的方法了。如果对象里面的属性是对象或者数组,直接再代理那个属性就行。

Vue2 初始化的时候,就会去循环所有属性进行拦截,而 Vue3 初始化的时候,只会代理最外层的对象和数组,对于属性,访问的时候才会再进行代理。

vm.$set 原理

vm.$set( target, propertyName/index, value )

  • 如果目标是数组,使用 vue 实现的变异方法 splice 实现响应式
  • 如果目标是对象,判断属性存在,即为响应式,直接赋值。如果属性不存在,则调用 defineReactive 方法进行响应式处理
  • 如果目标本身就不是响应式,直接赋值

简单实现观察者模式

vue 响应式,就是观察者模式实现的。

它就是有一个存储所有观察者的数组,当事件发生时,会循环这个数组,调用观察者中的 update 方法,从而进行通知。

观察者模式是由具体目标调度,比如当事件触发,Dep 就会去调用观察者的方法,所以观察者模式的订阅者与发布者之间是存在依赖的。

观察者 -- Watcher

  • update() 这里处理当事件发生时,所要做的事情

目标 -- Dep

  • subs 存储所有观察者的数组
  • addSub() 添加观察者的方法,参数是观察者对象
  • notify() 循环 subs,通知所有观察者,调用观察者的 update 方法
js
class Dep {
  constructor() {
    this.subs = []
  }

  addSub(sub) {
    // 收集依赖
    if (sub && sub.update) {
      this.subs.push(sub)
    }
  }

  notify() {
    // 通知
    if (!this.subs.length) return
    this.subs.forEach((sub) => {
      sub.update()
    })
  }
}

class Watcher {
  update() {
    console.log('update')
  }
}

let dep = new Dep()
let watch = new Watcher()
dep.addSub(watch)
dep.notify()

简单实现发布-订阅模式

发布/订阅模式由统一调度中心调用,因此发布者和订阅者不需要知道对方的存在。

思路:

  • 定义一个类,初始化的时候,定义一个对象 subs,用来存储各种事件,以及订阅这个事件的数组对象
  • 创建一个订阅方法,把订阅的事件和对象存储到 subs 中,如果没有这个事件,则创建一个,有的话,则添加到订阅的数组对象中
  • 创建一个发布方法,把发布事件,所订阅的所有对象,都通知一遍
js
class EventEmitter {
  constructor() {
    // 存储所有事件对象
    this.subs = {}
  }

  $on(eventType, fn) {
    // 订阅
    this.subs[eventType] = this.subs[eventType] || []
    this.subs[eventType].push(fn)
  }

  $emit(eventType, ...params) {
    // 发布
    if (this.subs[eventType]) {
      this.subs[eventType].forEach((fn) => {
        fn.call(fn, ...params)
      })
    }
  }
}

let eve = new EventEmitter()

eve.$on('click', function (data, two) {
  console.log('click1', data, two)
})

eve.$on('click', function (data) {
  console.log('click2', data)
})

eve.$on('change', function () {
  console.log('change')
})

eve.$emit('click', 1, 2)
eve.$emit('change')

1

虚拟 DOM

虚拟 DOM(Virtual DOM)是使用 js 对象来描述 DOM,它的本质就是 js 对象。

为什么使用虚拟 DOM

  • 具备跨平台的优势,因为虚拟 DOM 是 js 对象,所以它不依赖真实的平台环境,如 weex、小程序、React Native 等
  • 可以避免操作 DOM,只需关注业务代码实现过程,从而提高开发效率
  • 提升渲染性能,因为不用频繁的操作真实 DOM,不过虚拟 DOM 适用于大量、频繁的更新下,如果是很简单的页面,不使用虚拟 DOM 还更好点

Vue 中创建虚拟 DOM 的过程

  • 把模板使用 render 函数生成虚拟 DOM
  • render 函数中通过 createElement 函数创建 vnode
  • 创建完后调用 update 函数进行处理,内部调用 patch 方法把虚拟 DOM 转成真实 DOM

key 的作用

key 主要是用于 diff 算法,用来判断节点是否相同。

如果不使用 key, vue 会尽可能的尝试就地修改或复用相同类型的算法逻辑。

html
<input v-if="bol" />
<input type="password" v-else />

<!-- 这个时候没有设置 key ,当去修改 bol 的值时,输入框里面的内容不会清空,相当于vue只是修改了输入框的 type  -->
<!-- 如果给两个输入框分别设置 key,则会删除之前的 输入框,再创建新的输入框 -->

对于 for 循环来说,如果使用 index 作为 key 也会导致问题,vue 也会尽可能的尝试就地修改或复用相同类型的算法逻辑。它就会导致一些固定元素(写死的元素标签),直接复用了原来索引位置的,不会重新生成。

如下面的例子:for 循环不设置 key,或者设置 key 为 index,都会导致固定元素 input[checkbox] 直接复用,比如未添加水果之前,选中了第一个苹果,点击添加之后,还是选中的第一个,但是第一个水果变成了荔枝。

html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <script src="https://unpkg.com/vue@2.7.16/dist/vue.js"></script>
  </head>
  <body>
    <div id="app">
      <button @click="list.unshift('荔枝')">添加水果</button>
      <li v-for="(item, index) in list" :key="index">
        <input type="checkbox" />
        {{item}}
      </li>
    </div>
    <script>
      var app = new Vue({
        el: '#app',
        data: {
          list: ['苹果', '香蕉', '西瓜'],
        },
      })
    </script>
  </body>
</html>

diff 算法

遵循 同级比较,再比较子节点 的规则

  • 新节点是文本节点,且跟旧节点不相等,则直接把  DOM  的内容设置为新节点的  text  内容。
  • 新节点不是文本节点:
    • 如果新旧节点都有子节点,并且都不相等的情况下,则调用  updateChildren  方法,通过从首尾节点对比的方式,把差异更新到 DOM 上。
    • 如果新节点有子节点,旧节点没有子节点,则调用  addVnodes  方法,把新节点的子节点转成  DOM  元素,并添加到  DOM  树上。
    • 如果新节点没有子节点,旧节点有子节点,则调用  removeVnodes  方法,删除旧节点中的子节点。
    • 如果旧节点是文本节点,新节点没有文本节点和子节点的情况下,清空对应的  DOM  元素的文本内容

谈谈双向绑定,以及它的原理

双向绑定靠的是指令 v-model ,它是修改数据可以改变视图,修改视图也能改变数据。

  • 输入框的 v-model 是 value + input 事件,其实 vue 还做了当输入中文的时候,在打字的过程中,不会把拼音显示在视图上,它在 input 事件中判断了 event.target.composing 为 true 的时候,直接 return 打断

  • 复选框的 v-model 是 checked + change 事件

  • 自定义组件的 v-model

    • vue2 是利用 props 的 value + emit 的 input 事件, 可以通过设置 model 来修改对于的值,不支持多个 v-model,如果需要支持多个,可以利用 .sync 修饰符
    js
    {
      model: {
        prop: 'title',
        event: 'change'
      }
      props: ['title']
    }
    <!-- Parent.vue -->
    <UserName :title.sync="title" />
    
    <!-- Child.vue -->
    {
      props: ['title']
    }
    
    <button @click="$emit('update:title', '你好')">修改</button>
    • vue3 则是利用 props 的 modelValue + emit 的 update:modelValue 事件,vue3 支持多个 v-model
      • 3.4 版本之前
    <!-- Parent.vue -->
    <UserName v-model="first" v-model:title="title" />
    
    <!-- Child.vue -->
    const props = defineProps(['modelValue', 'title'])
    const emit = defineEmits(['update:modelValue', 'update:title'])
    
    <input
      :value="props.modelValue"
      @input="emit('update:modelValue', $event.target.value)"
    />
    
     <input
      type="text"
      :value="title"
      @input="$emit('update:title', $event.target.value)"
    />
    • 3.4 版本之后,利用 defineModel 方法,如果需要额外的 prop 选项,应该在 defineModel 名称之后传递:
    <!-- Parent.vue -->
    <UserName v-model="first" v-model:title="title" />
    
    <!-- Child.vue -->
    const model = defineModel({ required: true })
    const title = defineModel('title', { required: true, default: 0  })
    
    <input v-model="model" />
    <input type="text" v-model="title" />