vue原理

201 阅读8分钟

组件化 响应式 虚拟DOM和diff 模板编译 组件渲染和更新 前端路由

一、如何理解MVVM

MVC是什么

MVC(model view controller)即数据存储、用户界面、业务逻辑。此框架可以对服务器渲染后的数据进行操作,所有的通信是单向的。工作原理:view传送指令到controller,controller完成业务逻辑后要求model设置状态,model将新的数据发送到view。

其缺点为:

  1. 必须等待服务器端的指示。如果为异步模式,所有节点、数据、页面结构都是后端发送过来的,前端对页面的控制权将会下降
  2. MVC最大的诟病是model和view不分离,一旦需要修改后端的某类数据,前端便要将页面重新渲染一遍
  3. 因为前端需要渲染的页面结构大多是后端整理的一大堆数据组成的,前端处理起来不方便

MVVM是什么

MVVM(model view ViewModel)即数据、视图、业务逻辑。MVVM的核心在于ViewModel对于view和model的处理,ViewModel可以让model的变化自动同步到view上,同时也可以让view的操作同步到model,这就是双向数据绑定

MVVM中的ViewModel是对MVC中的controller的改进,它可以使数据和视图分离,由于它的机制是数据驱动视图,这可以让前端开发者的精力从操作DOM转到操作数据上。

二、vue的响应式

Object.defineProperty

通过Object.defineProperty对data中的数据做数据劫持,其实就是获取和改变数据时通过get和set方法,方法中可以做一些额外的事情。

Object.defineProperty的缺点

  1. 深度监听时,需要对对象结构的数据递归到底,导致一次性的计算量大,如果对象结构复杂,这种情况会加剧
  2. 无法监听新增/删除属性,需要增加对应的api:Vue.set() Vue.delete()
  3. 无法原生监听数组,需要重写数组的原型,在原型方法如push中调用触发视图更新的函数 image.png

三、虚拟DOM(Virtual DOM)和diff

  • vdom是vue和react的基石
  • diff算法是vdom中最核心的部分

虚拟DOM出现的背景

DOM操作非常耗时,在react、vue等框架出现之前使用jq,可以自行控制DOM操作的时机,简化DOM操作的方法,但是随着业务的发展,前端项目复杂度越来越高,这时仍然对网页中的DOM直接进行操作,会造成很大的性能问题,甚至网页卡死。

react和vue是数据驱动视图,如何有效的控制DOM操作是框架内为我们解决的问题,vdom应运而生。

框架为市面上各种行业各种业务提供统一的解决办法,想要减少计算的复杂度和次数,这不太现实。vdom的底层原理是用js模拟DOM结构,通过js计算出最小的变更,在数据驱动视图的模式下,有效控制DOM操作。

vnode结构

image.png

snabbdom官网给出的示例:

import {
  init,
  classModule,
  propsModule,
  styleModule,
  eventListenersModule,
  h,
} from "snabbdom";

const patch = init([
  // Init patch function with chosen modules
  classModule, // makes it easy to toggle classes
  propsModule, // for setting properties on DOM elements
  styleModule, // handles styling on elements with support for animations
  eventListenersModule, // attaches event listeners
]);

const container = document.getElementById("container");

const vnode = h("div#container.two.classes", { on: { click: someFn } }, [
  h("span", { style: { fontWeight: "bold" } }, "This is bold"),
  " and this is just normal text",
  h("a", { props: { href: "/foo" } }, "I'll take you places!"),
]);
// Patch into empty DOM element – this modifies the DOM as a side effect
patch(container, vnode);

const newVnode = h(
  "div#container.two.classes",
  { on: { click: anotherEventHandler } },
  [
    h(
      "span",
      { style: { fontWeight: "normal", fontStyle: "italic" } },
      "This is now italic type"
    ),
    " and this is still just normal text",
    h("a", { props: { href: "/bar" } }, "I'll take you places!"),
  ]
);
// Second `patch` invocation
patch(vnode, newVnode); // Snabbdom efficiently updates the old view to the new state

snabbdom重点:

  1. h函数,参数一:元素名称;参数二:一个对象放className、id、事件等;参数三:一个数组放子元素
  2. vnode数据结构
  3. patch函数

diff算法

js的运算速度是很快的,但是操作DOM的开销很大,虚拟DOM主要就是通过js模拟出真实DOM的结构,通过比较新旧vnode的区别,只操作发生了改变了的部分,这个比较的过程就是diff算法的过程。

diff算法的直接体现:key

diff即对比,是一个广泛的概念,diff算法不是vdom所独创的,如Linux diff、git diff等。组件化也不是vue、react所独创的,在后端写页面的时代,模板的概念就已经有了,前端框架只是在组件化思维上的提升。

树diff的时间复杂度O(n^3):

  1. 第一,遍历tree1;第二,遍历tree2
  2. 第三,排序
  3. 1000个节点,要计算100010001000次,算法不可用

优化时间复杂度到O(n):

  1. 只比较同一层级,不跨级比较
  2. tag不相同,则直接删掉重建,不再深度比较
  3. tag和key都相同,则认为是相同节点,不再深度比较

image.pngimage.png

image.png

四、模板编译

with语句

      const obj = {
        a: 10,
        b: 20
      }

      // with改变 {} 中的自由变量的指向,原来a是在全局中找的,现在a在obj中找
      // 自由变量:需要跨越当前作用域才能访问到的变量
      with (obj) {
        console.log(a)
        console.log(b)
        console.log(c) // Uncaught ReferenceError: c is not defined
      }

html和模板的区别

  1. 模板不是html,它有指令、插值、js表达式,能实现循环、判断
  2. html是标签语言,只有js才能实现循环、判断(图灵完备的:一个完整的语言应该满足这3项:顺序执行、判断执行、循环执行)
  3. 模板一定是转换为某种js代码,即编译模板

vue如何将template转换为vnode

在vue中,通过vue-template-compiler将模板编译成js代码,这段js就是render函数,render中返回vnode。具体转换操作如下:

  1. 新建Demo文件夹,执行npm init -y,安装vue-template-compiler@2.6.11
  2. Demo/index.js
const compiler = require('vue-template-compiler')

const template = `<p>{{message}}</p>`

const res = compiler.compile(template)
console.log(res.render) // with(this){return _c('p',[_v(_s(message))])}
  1. 执行node index.js,便可以得到js代码:with(this){return _c('p',[_v(_s(message))])}。这段代码返回的就是Vnode
  2. vue中常用的模板经过编译后返回的js代码如下:
const compiler = require('vue-template-compiler')

// 插值
var template = `<p>{{message}}</p>` // with(this){return _c('p',[_v(_s(message))])}

// 表达式
var template = `<p>{{flag?message:'no message found'}}</p>` // with(this){return _c('p',[_v(_s(flag?message:'no message found'))])}

// 属性和动态属性
var template = `
  <div id='div1' class='container'>
    <img :src="imgUrl" alt="" />
  </div>
` // with(this){return _c('div',{staticClass:"container",attrs:{"id":"div1"}},[_c('img',{attrs:{"src":imgUrl,"alt":""}})])}

// 条件
var template = `
  <div>
    <p v-if="flag === 'a'">A</p>
    <p v-else>B</p>
  </div>
` // with(this){return _c('div',[(flag === 'a')?_c('p',[_v("A")]):_c('p',[_v("B")])])}

// 循环
var template = `
  <ul>
    <li v-for='item in list' :key='item.id'>{{item.title}}</li>
  </ul>
` // with(this){return _c('ul',_l((list),function(item){return _c('li',{key:item.id},[_v(_s(item.title))])}),0)}

// 事件
var template = `<button @click='handleClick'>submit</button>` // with(this){return _c('button',{on:{"click":handleClick}},[_v("submit")])}

// v-model
var template = `<input type="text" v-model='name' />` // with(this){return _c('input',{directives:[{name:"model",rawName:"v-model",value:(name),expression:"name"}],attrs:{"type":"text"},domProps:{"value":(name)},on:{"input":function($event){if($event.target.composing)return;name=$event.target.value}}})}

const res = compiler.compile(template)
console.log(res.render)

缩写函数的含义:

  1. _c createElement
  2. _v createTextVNode
  3. _s toString
  4. _l renderList
image.png

模板渲染总结

  1. 从template到render函数,再到vnode,再到渲染和更新
  2. 通过vue-template-compiler将template转为render,使用with语法
  3. vue组件可以用render代替template

五、vue组件是如何渲染的

初次渲染过程

  1. 解析模板为render函数(这个过程也可能是在开发环境完成的,webpack中使用vue-loader)
  2. 触发响应式,监听data属性
  3. 执行render函数,生成vnode,patch(elem, vnode)。在执行render函数时,会触发getter

更新过程

  1. 修改data,触发setter(此前getter已被触发)
  2. 重新执行render函数,生成newVnode
  3. patch(vnode, newVnode)。在patch执行时,内部按diff算法计算出最小差异

异步渲染

为什么vue的组件是异步渲染的?

如果采用同步渲染,那么每次更新data中的数据,都会立即渲染DOM,这无疑对内存不够友好,所以vue采用的是异步渲染,在页面渲染前一刻对data数据做整合,再去触发视图更新。这样可以减少DOM操作的次数,提升性能

以下demo很好的展示了vue组件的异步渲染,点击按钮时,会立即打印出ul子元素的个数,然后再去触发视图的更新

<template>
  <div id="app">
    <el-button @click="push">点击</el-button>
    <ul ref="ul">
      <li v-for="(item,index) of list" :key="index">{{item}}</li>
    </ul>
  </div>
</template>
<script>
export default {
  name: 'app',
  data() {
    return { list: ['a', 'b', 'c'] }
  },
  methods: {
    push() {
      this.list.push('d')
      const ulElem = this.$refs.ul
      console.log(ulElem.childNodes.length) // 3
    }
  }
}
</script>

如果希望在点击时获取准确的ul子元素的个数?

      this.$nextTick(() => {
        const ulElem = this.$refs.ul
        console.log(ulElem.childNodes.length) // 4
      })

这就是nextTick出现的原因,nextTick中的回调会等待DOM渲染完成后执行,这里便可以获取到最新的DOM

六、前端路由原理

hash的原理

image.png

hash的特点:

  1. hash变化会触发网页跳转,即浏览器的前进、后退
  2. hash变化不会刷新页面,SPA必需的的特点
  3. hash永远不会提交到server端

原生监听hash变化的事件:hashchange

哪些操作会触发hashchange事件:

  1. js修改url
  2. 用户手动修改hash
  3. 浏览器的前进、后退
    <button id="btn">修改hash</button>
    <script>
      window.addEventListener('DOMContentLoaded', () => {
        console.log('hash初始值', location.hash)
      })

      window.addEventListener('hashchange', (e) => {
        console.log('old url', e.oldURL)
        console.log('new url', e.newURL)
        console.log('hash变化了', location.hash)
      })

      const btn = document.querySelector('#btn')
      btn.addEventListener('click', () => {
        location.href = '#/user' // 通过js修改hash
      })
    </script>

如图,分别演示了js修改url、手动修改hash、点击浏览器的前进后退都会触发onhashchange事件: 动图.gif

history的原理

history的特点: history是遵循url规范的路由,但跳转时不刷新页面,不刷新是SPA的硬性要求

原生监听hash变化的事件:popstate

哪些操作会触发popstate事件:

  1. history.pushState
  2. 点击浏览器的前进后退按钮
    <button id="btn">切换到user页面</button>
    <script>
      window.addEventListener('DOMContentLoaded', () => {
        console.log('初始化的path', location.pathname)
      })

      addEventListener('popstate', (e) => {
        console.log('监听路由变化', e.state, location.pathname)
      })

      const btn = document.querySelector('#btn')
      btn.addEventListener('click', () => {
        history.pushState({ name: 'user' }, '', 'user')
        console.log('路由切换到user')
      })
    </script>

如图,点击按钮(history.pushState)和浏览器的前进后退都可以触发popstate事件: 动图.gif 但如果切换到user页面时,一刷新,页面就没了,因为前端此时找不到user页面。

总结

  1. hash和history都是满足前端SPA的硬性要求,即路由切换时页面不刷新
  2. hash路由是通过监听hashchange事件,在事件回调中可以根据hash值去渲染对应的组件。可以通过js修改url或者用户手动修改hash,或者点击浏览器的前进后退按钮,这都可以触发hashchange事件
  3. history路由是通过监听popstate事件,在事件回调中可以获取到路由信息渲染对应的组件。通过history.pushState方法或者浏览器的前进后退,都可以触发popstate事件
  4. hash路由不会提交到服务端,在前端的使用中更广,但是它有一个#号。history的优点在于seo优化,但是它需要后端的支持,后端需要做一个处理:无论前端访问什么路由,都返回index.html