学习笔记-vue相关总结

169 阅读10分钟

Hash 和 History 模式的区别

不管哪种方式,都是客户端路由的方式,不会像服务器发送请求。

表现形式的区别

image.png

history 模式需要服务端配置的支持。

原理的区别

image.png

history.push() 方法 和 pushState 方法的区别是 push 方法路径会发生变化,这时候要向服务端发送请求;而 pushState 方法不会像服务端发送请求,只改变地址栏中的地址,并且把这个地址记录到历史记录中。

History 模式的使用

image.png

node.js 服务器端配置

image.png

nginx 服务器配置:

image.png

将打包好的前端项目放入 nginx 的 html 文件夹

image.png

启动 nginx:

image.png

想要在nginx 配置 history 模式需要修改配置文件

image.png

在文件中的 server 中的 location 添加一行代码:

image.png

VueRouter 实现原理

image.png

image.png

首先回顾一下核心代码:

image.png

VueRouter 的类图:

image.png

let _Vue = null

export default class VueRouter {
  static install(Vue) {
    // 1. 判断当前插件是否已经被安装
    if (VueRouter.install.installed) {
      return
    }
    VueRouter.install.installed = true
    // 2. 把 Vue 构造函数记录到全局变量
    _Vue = Vue
    // 3. 把创建 Vue 实例时候传入的 router 对象注入到 Vue 实例上
    // 混入
    _Vue.mixin({
      // 混入中的 this 指向的是 Vue 实例
      beforeCreate() {
        // 混入的选项,所有实例中都会有,比如组件中也会执行 beforeCreate
        // 而我们给 Vue 挂载 $router 只需要让他执行一次,所以需要判断组件就不执行,Vue 实例才执行
        // 只有 Vue 的 $options 中才有 router 属性,所以判断 this.$options.router 是否存在就行了
        if (this.$options.router) {
          _Vue.prototype.$router = this.$options.router
          this.$options.router.init()
        }
      }
    })
  }


  constructor(options) {
    this.options = options
    // 把 options 中的 routes 解析出来存储到 routeMap
    // 键值对的形式,键就是路由地址,值就是路由组件
    // router-view 这个组件中,会根据当前的路由地址,来 routeMap 里找到对应的路由组件,把它渲染到浏览器中。
    this.routeMap = {}
    // data 是一个响应式的对象,里边有一个属性 current 用于记录当前的路由地址
    // 默认情况下路由地址是 /,也就是根目录
    // Vue 的静态方法 observable 用于创建一个响应式的对象
    this.data = _Vue.observable({
      current: '/'
    })
  }

  init() {
    this.createRouteMap()
    this.initComponents(_Vue)
    this.initEvent()
  }
  // createRouteMap 方法是将构造函数中传入的参数 options 中的 routes 处理成键值对的形式存储到 routeMap 中
  createRouteMap() {
    // 遍历所有的路由规则,把路由规则解析成键值对的形式,存储到 routeMap
    this.options.routes.forEach(route => {
      this.routeMap[route.path] = route.component
    })
  }


  // initComponents 方法创建两个组件: router-link, router-view

  /**
   * 注意:Vue 的构建版本分为 运行时版 和 完整版
   * 运行时版:不支持 template 模板,需要打包的时候提前编译。使用 render 函数创建虚拟 dom,然后把它渲染到视图。
   * 完整版:包含运行时和编译器,体积比运行时版大 10k 左右,程序运行的时候把模板转换成 render 函数,性能不如运行时版本。
   * vue-cli 创建的项目默认使用的是运行时版本
   * 使用两种方法:
   * 1. 使用完整版
   *  1)在根目录下创建文件 vue.config.js 
   *  2)vue.config.js 文件中配置:
   *      module.exports = {
   *        runtimeCompiler: true
   *      }
   *  3)以下代码
   */
  // initComponents(Vue) {
  //   Vue.component('router-link', {
  //     props: {
  //       to: String
  //     },
  //     template: '<a :href="to"><slot></slot></a>'
  //   })
  // }
  /**
   * 2. 使用运行时版本
   *  1) 删除 vue.config.js 文件
   *  2)如下代码
   */
  initComponents(Vue) {
    const self = this
    Vue.component('router-link', {
      props: {
        to: String
      },
      render(h) {
        // h 函数是 Vue 传过来的,它用来创建虚拟dom,render 函数中调用 h 函数并把它的结果返回
        return h('a', {
          attrs: {
            href: self.options.mode === 'history' ? this.to : `/#${this.to}`
          },
          on: {
            // 注册点击事件
            click: this.clickHandler
          }
        }, [this.$slots.default])
      },
      methods: {
        clickHandler(e) {
          const to = self.options.mode === 'history' ? this.to : `/#${this.to}`
          if (self.routeMap[this.to]) {
            // 改变浏览器路径且不向服务端发送请求
            history.pushState({}, '', to)
            // 加载当前路由组件:将当前路由存储到 data 的current 中
            // this.data 是响应式对象,当 current 值改变,会将视图更新到浏览器
            this.$router.data.current = this.to
          } else {
            history.pushState({}, '', '/#/*')
            this.$router.data.current = '*'
          }

          e.preventDefault()
        }
      }
      // template: '<a :href="to"><slot></slot></a>'
    })

    // router-view 相当于一个占位符
    // 我们要根据当前路由地址,获取到路由组件,渲染到 router-view 的位置
    Vue.component('router-view', {
      render(h) {
        const component = self.routeMap[self.data.current]
        // h 函数将组件转换为虚拟 dom 并返回
        return h(component)
      }
    })
  }

  // 点击浏览器的前进后退按钮时,渲染当前地址栏中的地址对应的组件
  initEvent() {
    if (this.options.mode === 'history') {
      window.addEventListener('popstate', () => {
        this.data.current = this.routeMap[window.location.pathname] ? window.location.pathname : '*'
      })
    } else {
      window.addEventListener('hashchange', () => {
        const hash = window.location.hash
        this.data.current = hash ? (this.routeMap[hash.substr(1)] ? hash.substr(1) : '*') : '/'
      })
    }

  }
}

数据响应式

数据响应式的核心原理 - Vue 2.x

在 Vue 2.x 中,当把一个普通的 JavaScript 对象传入 Vue 实例作为 data 选项,Vue 将遍历此对象所有的属性,并使用 Object.defineProperty 把这些属性全部转为 getter/setter。Object.defineProperty 是 ES5 中一个无法降级处理(shim)的特性,所以不支持 IE8和更低版本浏览器。

image.png

数据响应式的核心原理 - Vue 3.x

  • 使用 Proxy
  • 直接监听对象,而非属性
  • ES6 中新增的,IE不支持,性能由浏览器优化

image.png

模拟 Vue 响应式原理

image.png

Vue - 负责把 Vue 中的 data 注入到 Vue 实例,并且调用 Observer 和 Compiler。

Observer - 负责数据劫持,也就是监听数据的变化,把 data 中的属性转换成 getter 和 setter。

Compiler - 负责解析差值表达式和指令。

在 Vue 的响应式机制中,需要使用到观察者模式来监听数据的变化,所以可以看到 Dep 和 Watcher。

Dep - 观察者模式中的发布者(目标),Dependency 的缩写,在 getter 方法中收集依赖。每一个响应式的属性,都会创建一个 Dep 对象,负责收集所有依赖于该属性的地方,所有依赖该属性的位置都会创建一个 Watcher 对象,所以依赖收集的就是依赖于该属性的 Watcher 对象。在 setter 方法中会去通知依赖,当属性中的值发生变化的时候,调用 Dep 的 notify 发送通知,调用 Watcher 对象的 update 方法

Watcher - 负责更新对应的视图

Vue

  • 负责接收初始化的参数(选项)
  • 负责把 data 中的属性注入到 Vue 实例,转换成 getter/setter
  • 负责调用 observer 监听 data 中所有属性的变化
  • 负责调用 compiler 解析指令/差值表达式

类图:

image.png

class Vue {
  constructor (options) {
    // 1. 通过属性保存选项的数据
    this.$options = options || {}
    this.$data = options.data || {}
    // options.el 可以是 dom 对象也可以是字符串,也就是一个选择器,所以要判断一下
    this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el
    // 2. 把 data 中的成员转换成 getter 和 setter,注入到 Vue 实例中
    this._proxyData(this.$data)
    // 3. 调用 observer 对象,监听数据的变化
    new Observer(this.$data)
    // 4. 调用 compiler 对象,解析指令和差值表达式
    new Compiler(this)
  }
  _proxyData(data) {
    // 遍历 data 中的所有属性
    Object.keys(data).forEach(key => {
      // 把 data 的属性注入到 Vue 实例中
      Object.defineProperty(this, key, {
        enumerable: true,
        configurable: true,
        get() {
          return data[key]
        },
        set(newValue) {
          if (newValue === data[key]) return
          data[key] = newValue
        }
      })
    })
  }
}

Observer

  • 负责把 data 选项中的属性转换成响应式数据
  • data 中的某个属性也是对象,把该属性转换成响应式数据
  • 数据变化发送通知

类图:

image.png


class Observer {
  constructor(data) {
    this.walk(data)
  }
  walk(data) {
    // 1. 判断 data 是否是对象
    if (!data || typeof data !== 'object') return
    // 2. 遍历 data 对象的所有属性
    Object.keys(data).forEach(key => {
      this.defineReactive(data, key, data[key])
    })
  }
  defineReactive(obj, key, val) {
    let that = this
    // 负责收集依赖,并发送通知
    let dep = new Dep()
    // 如果 val 是对象,把 val 内部的属性转换成响应式数据
    this.walk(val)
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get() {
        // 收集依赖
        Dep.target && dep.addSub(Dep.target)
        // 不可以 return obj[key],
        // 因为在获取 obj[key] 的过程中又会调
        // 用当前的 get 方法,形成死递归,造成内存溢出
        return val
      },
      set(newValue) {
        if (newValue === val) return
        val = newValue
        that.walk(newValue)
        // 发送通知
        dep.notify()
      }
    })

  }
}

Compiler

  • 负责编译模板,解析指令/差值表达式
  • 负责页面的首次渲染
  • 当数据变化后重新渲染视图

类图:

image.png

// Compiler:
// 负责编译模板,解析指令、差值表达式
// 负责页面的首次渲染
// 当数据变化后重新渲染视图
class Compiler {
  constructor(vm) {
    this.el = vm.$el
    this.vm = vm
    this.compile(this.el)
  }
  // 编译模板,处理文本节点和元素节点
  compile(el) {
    let childNodes = el.childNodes
    Array.from(childNodes).forEach(node => {
      // 处理文本节点
      if(this.isTextNode(node)) {
        this.compileText(node)
      } else if(this.isElementNode(node)) {
        this.compileElement(node)
      }

      // 判断 node 节点是否有子节点,如果有子节点,要递归调用 compile
      if(node.childNodes && node.childNodes.length) {
        this.compile(node)
      }
    })
  }
  // 编译元素节点,处理指令
  compileElement(node) {
    // console.log(node.attributes)
    // 遍历所有的属性节点
    Array.from(node.attributes).forEach(attr => {
      // 判断是否是指令
      let attrName = attr.name
      if(this.isDirective(attrName)) {
        // v-text --> text
        attrName = attrName.substr(2)
        let key = attr.value
        this.update(node,key,attrName)
      }
    })
  }

  update(node,key,attrName) {
    let updateFn = this[attrName + 'Updater']
    updateFn && updateFn.call(this,node,this.vm[key],key)
  }

  // 处理 v-text 指令
  textUpdater(node,value,key) {
    node.textContent = value
    new Watcher(this.vm,key,(newValue) => {
      node.textContent = newValue
    })
  }
  // v-model
  modelUpdater(node,value,key) {
    node.value = value
    new Watcher(this.vm,key,(newValue) => {
      node.value = newValue
    })
    // 双向绑定
    node.addEventListener('input',() => {
      this.vm[key] = node.value
    })
  }


  // 编译文本节点,处理差值表达式
  compileText(node) {
    // console.dir(node)
    // {{ msg }}
    let reg = /\{\{(.+?)\}\}/
    let value = node.textContent
    if(reg.test(value)) {
      let key = RegExp.$1.trim()
      node.textContent = value.replace(reg,this.vm[key])

      // 创建 watcher 对象,当数据改变更新视图
      new Watcher(this.vm,key,(newValue) => {
        node.textContent = newValue
      })
    }
  }
  // 判断元素属性是否是指令 - 是否 v- 开头
  isDirective(attrName) {
    return attrName.startsWith('v-')
  }
  // 判断节点是否是文本节点
  isTextNode(node) {
    return node.nodeType === 3
  }
  // 判断节点是否是元素节点
  isElementNode(node) {
    return node.nodeType === 1
  }
}

Dep

  • 收集依赖,添加观察者(watcher)
  • 通知所有观察者

类图:

image.png

class Dep {
  constructor () {
    // 存储所有的观察者
    this.subs = []
  }
  // 添加观察者
  addSub(sub) {
    if (sub && sub.update) {
      this.subs.push(sub)
    }
  }
  // 发送通知
  notify() {
    this.subs.forEach(sub => {
      sub.update()
    })
  }
}

Watcher

  • 当数据变化触发依赖,dep 通知所有的 Watcher 实例更新视图
  • 自身实例化的时候往 dep 对象中添加自己

image.png

类图:

image.png

class Watcher {
  constructor (vm, key, cb) {
    this.vm = vm
    // data 中的属性名称
    this.key = key
    // 回调函数负责更新视图
    this.cb = cb

    // 把 watcher 对象记录到 Dep 类的静态属性 target
    Dep.target = this
    // 触发 get 方法, 在 get 方法中会调用 addSub
    this.oldValue = vm[key]
    Dep.target = null
  }
  // 当数据发生变化的时候更新视图
  update() {
    let newValue = this.vm[this.key]
    if (newValue === this.oldValue) return
    this.cb(newValue)
  }
}

虚拟 dom(Virtual DOM)

什么是 Virtual DOM

  • Virtual DOM(虚拟DOM),是由普通的JS对象来描述DOM对象

image.png

如上图,我们将真实DOM成员遍历并打印,打印的结果如下图。可以看到打印的成员是非常多的,也就是要创建一个dom的成本是非常高的。

image.png

再看虚拟dom(下图),它就是一个js对象,创建一个虚拟dom,它的成员非常少,也就是要创建一个虚拟dom 的成本要比创建真实dom小很多。

image.png

为什么要使用虚拟dom

前端发展迅速,如今需要大量的操作dom。虚拟 DOM 可以跟踪状态变化,当状态改变的时候不需要立即更新 dom,只需要创建一个虚拟 dom 树来描述真实的 dom 树,它内部会使用 diff 算法来保存状态的差异,只更新变化的部分。

虚拟 DOM 的作用

  • 维护视图和状态的关系
  • 复杂视图情况下提升渲染性能
  • 跨平台
    • 浏览器平台渲染DOM

    • 服务端渲染 SSR (Nuxt.js/Next.js)

    • 原生应用(weex/React Native)

    • 小程序(mpvue/uni-app)等

虚拟 DOM 库

  • Snabbdom
    • Vue.js 2.x 内部使用的虚拟 DOM 就是改造的 Snabbdom
    • 大约 200 SLOC (single line of code)
    • 通过模块可扩展
    • 源码使用 TypeScript 开发
    • 最快的 Virtual DOM 之一
  • virtual-dom

Snabbdom 基本使用

创建项目

image.png

image.png

安装 Snabbdom,我的版本为:

image.png

目录结构:

image.png

index.html:

image.png

在 src 下创建 01-basicusage.js 文件,用来将 index.html 中的 div#app 替换为我们想要展示的内容。在 01-basicusage.js 中我们将展示替换成一个内容为字符串的div。

基本使用

Snabbdom 的核心:

  • init() 设置模块,创建 patch() 函数
  • 使用 h() 函数创建 JavaScript 对象(VNode) 描述真实 DOM
  • patch() 比较新旧两个 VNode
  • 把变化的内容更新到真实 DOM 树

01-basicusage.js:

image.png

import { init } from 'snabbdom/build/init'
import { h } from 'snabbdom/build/h'

const patch = init([])

// h 函数用来创建虚拟dom,这里创建的是 VNode 的虚拟节点,VNode 的作用是用来描述真实dom
// 第一个参数:标签+选择器
// 第二个参数:如果是字符串就是标签中的文本内容
let vnode = h('div#container.cls', 'hello world')
let app = document.querySelector('#app')

// patch 将虚拟dom 转换成真实dom 挂载到 dom 树上
// 第一个参数:旧的 VNode,可以是DOM元素,如果是DOM元素会将这个DOM元素转换成VNode
// 第二个参数:新的VNode
// 返回新的 VNode
// patch 会对比两个VNode,将差异更新到真实dom,并且把第二个参数返回
// 以下这行代码是将html中的 ‘div#app’ 修改为 vnode
let oldVnode = patch(app, vnode)

vnode = h('div#container.xxx', 'hello snabbdom')
patch(oldVnode, vnode)

运行 npm run dev,查看结果,可以看到页面中展示的 hello snabbdom。

将 index.html 中引入的 js 改为 02-basicusage.js,用来将 div#app 更新为 一个内部为其他元素的 div。

02-basicusage.js:

import { init } from 'snabbdom/build/init'
import { h } from 'snabbdom/build/h'

const patch = init([])

// 如果想要内容是标签,第二个元素就是数组,每一项都是元素,可以再用h函数
let vnode = h('div#container', [
  h('h1', 'hello snabbdom'),
  h('p', '这是一个p')
])

let app = document.querySelector('#app')
let oldVnode = patch(app, vnode)

setTimeout(() => {
  // vnode = h('div#container', [
  //   h('h1', 'hello world'),
  //   h('p', 'hello p')
  // ])
  // patch(oldVnode, vnode)

  // 清除div和div中的内容
  patch(oldVnode, h('!'))
}, 2000)

模块的使用

image.png

将 index.html 中引入的 js 文件改为 03-modules.js。

03-modules.js:

import { init } from 'snabbdom/build/init'
import { h } from 'snabbdom/build/h'

// 1. 导入模块
import { styleModule } from 'snabbdom/build/modules/style'
import { eventListenersModule } from 'snabbdom/build/modules/eventlisteners'


// 2. 注册模块
const patch = init([
  styleModule,
  eventListenersModule
])

// 3. 使用h() 函数的第二个参数传入模块中使用的数据(对象)
let vnode = h('div', [
  h('h1', { style: { backgroundColor: 'red' } }, 'hello world'),
  h('p', { on: { click: eventHandler } }, 'hello p')
])

function eventHandler() {
  console.log('别点我,疼')
}

let app = document.querySelector('#app')
patch(app, vnode)

Snabbdom 的核心

由以上可以看出,Snabbdom 的核心就是:

image.png image.png

请简述 Diff 算法的执行过程

diff算法是一种通过同层的树节点进行比较的高效算法,避免了对树进行逐层搜索遍历,所以时间复杂度只有 O(n)。

diff算法有两个比较显著的特点:

1、比较只会在同层级进行, 不会跨层级比较。

2、在diff比较的过程中,循环从两边向中间收拢。

diff流程:

1 、首先定义 oldStartIdx、newStartIdx、oldEndIdx 以及 newEndIdx 分别是新老两个 VNode 的两边的索引。

2、接下来是一个 while 循环,在这过程中,oldStartIdx、newStartIdx、oldEndIdx 以及 newEndIdx 会逐渐向中间靠拢。while 循环的退出条件是直到老节点或者新节点的开始位置大于结束位置。

while 循环中会遇到四种情况:

情形一:当新老 VNode 节点的 start 是同一节点时,直接 patchVnode 即可,同时新老 VNode 节点的开始索引都加 1。

情形二:当新老 VNode 节点的 end 是同一节点时,直接 patchVnode 即可,同时新老 VNode 节点的结束索引都减 1。

情形三:当老 VNode 节点的 start 和新 VNode 节点的 end 是同一节点时,这说明这次数据更新后 oldStartVnode 已经跑到了 oldEndVnode 后面去了。这时候在 patchVnode 后,还需要将当前真实 dom 节点移动到 oldEndVnode 的后面,同时老 VNode 节点开始索引加 1,新 VNode 节点的结束索引减 1。

情形四:当老 VNode 节点的 end 和新 VNode 节点的 start 是同一节点时,这说明这次数据更新后 oldEndVnode 跑到了 oldStartVnode 的前面去了。这时候在 patchVnode 后,还需要将当前真实 dom 节点移动到 oldStartVnode 的前面,同时老 VNode 节点结束索引减 1,新 VNode 节点的开始索引加 1。

3、while 循环的退出条件是直到老节点或者新节点的开始位置大于结束位置。

情形一:如果在循环中,oldStartIdx大于oldEndIdx了,那就表示oldChildren比newChildren先循环完毕,那么newChildren里面剩余的节点都是需要新增的节点,把[newStartIdx, newEndIdx]之间的所有节点都插入到DOM中

情形二:如果在循环中,newStartIdx大于newEndIdx了,那就表示newChildren比oldChildren先循环完毕,那么oldChildren里面剩余的节点都是需要删除的节点,把[oldStartIdx, oldEndIdx]之间的所有节点都删除

请简述 Vue 首次渲染的过程。

image.png