【一文读懂】对 Vue 原理的理解-简易版(如何处理模版和指令、虚拟DOM、双向数据绑定+监听、怎么触发生命周期、nextTick简述)

👉 个人博客主页 👈
📝 一个努力学习的程序猿


更多文章/专栏推荐:
HTML和CSS
JavaScript
Vue
Vue3
React
TypeScript
个人经历+面经+学习路线【内含免费下载初级前端面试题】
前端面试分享
前端学习+方案分享(VitePress、html2canvas+jspdf、vuedraggable、videojs)
前端踩坑日记(ElementUI)
【一文读懂】对闭包的理解及其衍生问题
【一文读懂】对原型链、事件循环、异步操作(Promise、async/await)、递归栈溢出的理解及其衍生问题


前言

在正式进入 Vue 系列内容之前,先简单描述下与 Vue 相关的基础原理。在面试过程中,鲜有面试官让我们聊 Vue 的底层原理。太复杂的源码逻辑,博主确实也不甚了解(下文中出现的代码,仅为展示,不代表 100% 为官方源码)。但是为了"应试",或许本文的内容,对各位有所帮助。


1、vue

1.1 vue 前言-简述

我们先回顾一下,在 JS 中使用 Vue:

<div id="app"> 
  <input type="text" v-model="msg">
  <p>hello {{msg}}</p>
</div>
<script type="text/javascript">
  // 创建Vue示例
  const vm = new Vue({
    el: '#app',
    data: {
      msg: 'hello'
    }
  })
</script>

看到这个示例,如果看过我上一篇文章,就能发现,这不是妥妥的构造函数嘛!
https://blog.youkuaiyun.com/qq_45613931/article/details/145586439(构造函数 + 原型链)

那我们就可以先给一个简单到不能再简单的构造函数,用于下文的说明:

// 简化版的 Vue 构造函数
function Vue(options) {
    // 数据初始化
    this._init(options)
}

// Vue 在原型上定义的特殊属性(以 $ 开头,避免用户定义的数据属性与 Vue 内部属性命名冲突)
// 可以在 constructor 内初始化相关数据
Vue.prototype.$options = {
  el: '#app',
  data: function() {},
  methods: {},
  computed: {},
  watch: {},
  components: {},
  directives: {},
  filters: {},
  // 生命周期钩子
  created: function() {},
  mounted: function() {},
  // ... 其他选项
}
Vue.prototype.$data = {} // 数据
Vue.prototype.$props = {} // 属性
Vue.prototype.$el = {} // DOM 元素
// ... 其他属性

Vue.prototype._init = function(options) {
  const vm = this
  // mergeOptions 内,将用户选项覆盖到 Vue.prototype.$options中
  vm.$options = mergeOptions(
    vm.$options,  // 默认选项
    options || {},  // 用户传入的选项
    vm          // 实例自身
  )
}
// ….

1.2 Vue 处理模版和指令

有了上述的概念后,接下来说下 Vue 如何处理模版和指令(内部太复杂,以下为简述)。

(1)首先,在 init 内拿到 el,获取到对应 DOM 元素后,内部要做的事情,就是把这些 Vue 自己定义的语法,转成浏览器可以理解的方式

所以第一步就是解析 => 将模版字符串转换为 AST(抽象语法树)。主要就是识别 Vue 的语法(v-if、v-for 等),并确保模版语法的正确性。

比如我有一个这样的结构:<input type="text" v-model="msg">,简易转化结果为:

// AST 结构示例(简易版)
{
  type: 1,
  tag: 'input',
  attrs: [
    {
      name: 'type',
      value: 'text'
    },
    {
      name: 'v-model',
      value: 'msg'
    }
  ],
  parent: null,
  children: []
}

(2)第二步:通过 AST 生成 render 渲染函数调用它之后,生成虚拟DOM。比如(简易版):

// 假设模板为:
// <div class="container">
//   <p>{{ msg }}</p>
//   <input type="text" v-model="msg">
//   <button @click="handleClick">Click</button>
// </div>

// Vue 2.x 生成的 render 函数(简易版)
function render() {
  const h = this._createVNode;
  const toString = this._toString;
  const model = this.msg;

  return h('div', 
    {
      staticClass: 'container' 
    },
    [
      // p 元素
      h('p', [toString(model)]),
      // input 元素
      h('input', {
	    // 指令信息
	    directives: [{
	      name: "model",
	      rawName: "v-model",
	      value: model,
	      expression: "msg"
	    }],
        attrs: { type: 'text' },
        domProps: { value: model },
        on: {
          input: ($event) => {
            if ($event.target.composing) return;
            this.msg = $event.target.value;
          }
        }
      }),
      // button 元素
      h('button', {
        on: {
          click: this.handleClick
        }
      }, ['Click'])
    ]
  );
}

// Vue 3.x 生成的 render 函数(简易版)
export function render(_ctx, _cache) {
  return _createVNode("div", {
    class: "container"
  }, [
    // p 元素
    _createVNode("p", null, [
      _ctx.msg
    ], 1 /* TEXT */),

    // input 元素
    _createVNode("input", {
      type: "text",
      "onUpdate:modelValue": $event => ((_ctx.msg) = $event),
      modelValue: _ctx.msg
    }, null, 8 /* PROPS */, ["modelValue", "onUpdate:modelValue"]),

    // button 元素
    _createVNode("button", {
      onClick: _cache[0] || (_cache[0] = (...args) => (_ctx.handleClick(...args)))
    }, "Click")
  ])
}

createVNode 用来创建虚拟节点(内部逻辑较复杂)

toString 用来转化为文本

至此,就可以通过抽象的 AST 转化为可以用来生成虚拟 DOM 的 render 函数
(为什么需要虚拟 DOM,详见 1.3 节)


(3)第三步:Vue 内部根据虚拟 DOM 去生成真实 DOM。最后的效果类似于:

<!-- 原始模板 -->
<input type="text" v-model="msg">
<!-- 相当于被转换为 -->
<input type="text"
       :value="msg"
       @input="msg = $event.target.value">

在 vue 项目中,vue-loader 就会做这样的事情,将 .vue 文件的每个部分(template、script、style)提取出来,并最后转化为浏览器能理解的方式。

(msg 双向绑定请看第 1.4 节)

Vue3.0 相对 Vue2.0 的优化点,可以参考我的文章(需要从文章内至少了解到,数据更新时,内部用到了diff算法):
https://blog.youkuaiyun.com/qq_45613931/article/details/109470718


1.3 虚拟 DOM

回答上文的问题:为什么需要虚拟 DOM?直接生成真实 DOM,挂载到页面不可以吗?

首先,浏览器渲染引擎的大致工作流程为:

第一步,用HTML分析器,分析HTML元素,构建一颗DOM树。
第二步,用CSS分析器,分析CSS文件和元素上的样式,生成页面的样式表。
第三步,将DOM树和样式表,关联起来,构建一颗Render树。
第四步,有了Render树,浏览器开始布局,为每个Render树上的节点确定一个在显示屏上出现的精确坐标。
第五步,Render树和节点显示坐标都有了,就调用每个节点paint方法,把它们绘制出来。
最后通过浏览器复杂的渲染过程得到最终效果。

其中,当我们改变一个元素的尺寸位置属性时,会重新进行样式计算、布局、绘制以及后面的所有流程,这种行为被称为重排

当我们改变某个元素的颜色属性时,不会重新触发布局,但还是会触发样式计算和绘制,这种行为被称为重绘

因为我们拿到 AST 之后,最终还是要把它们转成正常的标签如果我们直接根据 AST 生成真实 DOM,则浏览器会从构建 DOM 树开始,从头到尾执行一遍流程。假设在一次操作中,需要更新 10 个 DOM 节点,浏览器收到第一个 DOM 请求后并不知道还有 9 次更新操作,因此会马上执行流程,最终执行 10 次。极端情况下,第一次计算完,紧接着下一个更新请求还是操作这个 DOM 节点,就会浪费性能。

因此,虚拟 DOM 的好处就是:若一次操作中有多次生成 / 更新 DOM 的动作,虚拟 DOM 不会立即操作DOM。此时会将多次更新的内容保存起来(批量处理),最终将处理结果一次性挂到 DOM 树上,再进行后续操作,避免大量无谓的计算量。


1.4 Vue 数据的双向绑定

我们在 1.2 节通过解析 el 模版,得到了真实 DOM。从表现来看,input 已经实现了自给自足,似乎不需要双向绑定?还是看这个例子:

<!-- 原始模板 -->
<input type="text" v-model="msg">
<!-- 相当于被转换为 -->
<input type="text"
       :value="msg"
       @input="msg = $event.target.value">

<!-- 原始模板 -->
<p>hello {{msg}}</p>
<!-- 相当于被转换为 -->
<p>hello <span>msg的值</span></p>

在原始模版的情况下,{{ msg }} 转化为真实 DOM,是上面的结果。此时如果想在 input 的 msg 变化的情况下,调整 {{ msg }},能想到的方法只能是这样做:

input.addEventListener('input', e => {
  msg = e.target.value
  // ❌ 问题:其他使用 msg 的地方不会自动更新
  // 需要手动更新所有相关的 DOM
  updateAllRelatedElements()
// document.querySelector('.xxx').textContent = msg
})

所以双向数据绑定不仅仅是处理输入和更新,更重要的是:自动依赖追踪


首先,在 Vue.init (参考第 1.1 节构造函数)的时候,给每个 data 增加监听 Object.defineProperty(Vue 2.x):

// 简化的响应式系统
class Dep {
  static target = null  // 静态属性,全局唯一

  constructor() {
    this.subs = []  // 存储依赖的 Watcher
  }
  
  depend() {
    if (Dep.target) {
      this.subs.push(Dep.target)
    }
  }
  
  notify() {
    this.subs.forEach(watcher => watcher.update())
  }
}

// 渲染 Watcher
class Watcher {
  constructor(vm, updateFn) {
    this.vm = vm
    this.getter = updateFn
    this.get() // 首次渲染
  }
  
  get() {
    // 设置当前 Watcher 为全局的活动 Watcher
    Dep.target = this
    // 执行更新函数,这会触发数据的 getter
    this.getter()
    // 清除当前 Watcher
    Dep.target = null
  }
  
  update() {
    // 数据变化时更新视图
    this.get()
  }
}

// 简化版的响应式实现
function defineReactive(obj, key, val) {
  // 依赖收集容器
  const dep = new Dep()
  
  Object.defineProperty(obj, key, {
    get() {
      // 收集依赖
      dep.depend()
      return val
    },
    set(newVal) {
      if (val === newVal) return
      val = newVal
      // 通知所有依赖更新
      dep.notify()
    }
  })
}

// 组件渲染时
function mountComponent(vm) {
  // 创建渲染 Watcher
  new Watcher(vm, () => {
    // 渲染函数执行,这里会访问响应式数据,触发 getter
    vm._update(vm._render())
  })
}

从上述简例,总结就是:

(1)在 vue.init 初始化执行到 $mount 阶段(详见第 1.6 节),在 render 每个组件时,调用 mountComponent,去创建 watcher。此时全局 Dep.target (指针)指向当前组件。在 Watcher 执行 this.getter() 期间,render 函数会访问对应响应式数据,从而触发 Object.defineProperty,收集到这个依赖。执行结束后 Dep.target = null

另外:defineReactive 内的 const dep = new Dep() 形成了闭包,这就让后续每次数据更新,都能拿到初始化时的依赖结果。

闭包的内容可以参考我之前的文章:https://blog.youkuaiyun.com/qq_45613931/article/details/144483787

(2)随后,在 input 值发生变化时,触发 Object.defineProperty – set,通过 dep.notify 再次 render 所有依赖的组件,重新生成虚拟 DOM

(3)接下来就是所谓的 vue-diff 算法出场的时间了。因为我们初始化已经生成了虚拟 DOM 树(并由此生成真实 DOM 树),我们需要只让页面更新有变化的 DOM 部分,所以需要找出有更新的节点,这就是 diff 算法做的事情。

综上,双向数据绑定流程结束。这就是 MVVM 模式:
在这里插入图片描述
数据绑定的流向:View -> Model 、Model -> View

在这里插入图片描述


1.5 Vue3.x 为什么改用 proxy

延续第 1.4 节,其实在使用 Object.defineProperty 时,会有以下局限性:

(1)无法监听新增或删除的属性。例如:

data() {
  return {
    a: '1'
  }
},
methods: {
  addProperty() {
    // ❌ 直接赋值不会触发响应式
    this.b = 2

    // ✅ 使用 Vue.set 手动添加响应式属性
    this.$set(this, 'b', 2)
  }
}

虽然 Vue 确实提供了一个可以更新的方法,为新增的属性定义 getter 和 setter,从而让其变成响应式。但个人理解不建议这么做。


(2)无法监听数组的索引和长度变化。例如:

const arr = [1, 2, 3]
arr[1] = 99 // 无法触发响应式更新
arr.length = 1 // 无法触发响应式更新

这也就是为什么之前经常会遇到:明明数组的数据更新,但页面不渲染的问题。从而转为使用 this.$set(this.arr, 1, 99) 或者 this.arr.splice(1) 等方式去触发数组更新。


(3)性能问题。对象的每个属性都需要通过 Object.defineProperty 单独定义才能使用。对于嵌套深层的对象,Vue2 会递归遍历每一层属性,初始化响应式,这在大数据量场景下性能较差。

(4)代码复杂性。上述也已经提到,为了解决上述问题,Vue2 引入了 Vue.set 等其他用法(Vue.delete)。在特定情况下的页面不渲染,让使用者也陷入了困惑


综上,Vue3.x 改用 ES6 的 Proxy 后,问题迎刃而解:Proxy 会代理整个对象,不用单独劫持每个属性(性能提升),几乎能拦截所有操作(新增、删除、数组索引修改等)。

const obj = reactive({})
obj.newKey = 'value' // 自动响应式
delete obj.newKey // 自动响应式

const arr = reactive([1, 2, 3])
arr[1] = 99 // 自动响应式
arr.length = 1 // 自动响应式
function defineReactive(obj) {
  // 依赖收集容器
  const dep = new Dep()

  // 创建 Proxy
  return new Proxy(obj, {
    get(target, key) {
      // 收集依赖
      dep.depend()
      return target[key]
    },
    set(target, key, newVal) {
      if (target[key] === newVal) return true
      target[key] = newVal
      // 通知所有依赖更新
      dep.notify()
      return true
    }
  })
}

1.6 Vue 如何得知生命周期

在第 1.5 节,提到了 mountComponent。那么 Vue 是如何判断什么时间,去触发不同的生命周期函数呢?我们来补全初始的构造函数。

function Vue(options) {
  this._init(options)
}

class Dep {
  static target = null
 
  constructor() {
    this.subs = []  // 存储依赖的 Watcher
  }
  
  depend() {
    if (Dep.target) {
      this.subs.push(Dep.target)
    }
  }
  
  notify() {
    this.subs.forEach(watcher => watcher.update())
  }
}

function defineReactive(obj, key, val) {
  // 依赖收集容器
  const dep = new Dep()
  
  Object.defineProperty(obj, key, {
    get() {
      // 收集依赖
      dep.depend()
      return val
    },
    set(newVal) {
      if (val === newVal) return
      val = newVal
      // 通知所有依赖更新
      dep.notify()
    }
  })
}

class Watcher {
  constructor(vm, updateFn) {
    this.vm = vm
    this.getter = updateFn
    this.get() // 初次渲染
  }

  get() {
    Dep.target = this // 当前 Watcher
    this.getter() // 执行渲染函数
    Dep.target = null
  }

  update() {
    // 数据变化时触发
    queueWatcher(this) // 异步更新队列
  }

  run() {
    this.get() // 重新渲染
  }
}

function queueWatcher(watcher) {
  // 将 Watcher 放入队列
  nextTick(() => {
    callHook(vm, 'beforeUpdate ')
    watcher.run() // 执行更新
    callHook(vm, 'updated ')
  })
}

Vue.prototype._init = function(options) {
  const vm = this
  vm.$options = options
  
  // beforeCreate: 实例初始化之后,数据观测之前
  callHook(vm, 'beforeCreate')
  
  // 初始化 data、props、methods 等 => vue2.x 内部遍历 data,触发defineReactive
  initState(vm)
  
  // created: 数据观测完成,还未挂载
  callHook(vm, 'created')
  
  if (vm.$options.el) {
    vm.$mount(vm.$options.el)
  }
}

Vue.prototype.$mount = function(el) {
  const vm = this
  
  // beforeMount: 挂载之前
  callHook(vm, 'beforeMount')
  
  mountComponent(vm)
  
  // 注意:实际实现比这复杂,需要考虑子组件
  // mounted: DOM 挂载完成
  nextTick(() => {
    callHook(vm, 'mounted')
  })
}

// 组件渲染
function mountComponent(vm) {
  // 创建渲染 Watcher
  new Watcher(vm, () => {
    // 渲染函数执行,这里会访问响应式数据,触发 getter
    vm._update(vm._render())
  })
}

Vue.prototype.$destroy = function() {
  const vm = this
  
  // beforeDestroy: 实例销毁之前
  callHook(vm, 'beforeDestroy')
  
  // 清理依赖、事件监听器等
  vm._cleanup()
  
  // destroyed: 实例销毁完成
  callHook(vm, 'destroyed')
}

// 钩子函数调用器
function callHook(vm, hook) {
  const handlers = vm.$options[hook]
  if (handlers) {
    for (let i = 0; i < handlers.length; i++) {
      handlers[i].call(vm)
    }
  }
}

通过该简例,相信各位可以有个大概的理解,实际的逻辑处理比这些复杂的多。其实就是在特定的时机调用对应的钩子函数并不是依赖 JS 提供的功能(实际上,也没有这样的 API):

  • created: 数据初始化后
  • mounted: DOM 挂载后(通过 nextTick)
  • updated: 数据更新后(通过 Watcher)
  • destroyed: 手动调用 $destroy 时

通过简例,这里还有两个问题没有得到答案。

(1)nextTick 是如何实现的,为何此时调用 mounted 一定是 DOM 挂载完成。这将在第 1.7 节表述。

(2)调用 $destroy 的时机。


关于(2)调用 $destroy 的时机,举个例子(我们不去考虑显示调用 vm.$destroy() 的情况):

new Vue({
  el: "#app",
  template: `
    <div>
      <child-component v-if="show"></child-component>
      <button @click="show = false">Destroy Child</button>
    </div>
  `,
  data: {
    show: true,
  },
});

在第 1.2 节我们知道,Vue 内部一定会把模版转化成 key-value 的形式,所以当 show 变为 false 时虚拟 DOM 重新生成通过 diff 算法检测到组件被销毁时触发 vm.$destroy() 即可。Vue 项目内的 Vue Router 也是同理,都是能检测到的。


1.7 nextTick 简述

第 1.6 节遗留的问题:nextTick 是如何实现的,为何此时调用 mounted 一定是 DOM 挂载完成。

nextTick 相信各位并不陌生,我们经常在 Vue2.x 里用它来处理一些要在 DOM 更新完成后执行的回调任务。在 Vue 内部,道理也一样。

首先我们已经得知 render 函数内,做的是从虚拟 DOM 到真实 DOM 的过程。因为内部处理是同步的,且通过我之前的文章提到的事件循环(https://blog.youkuaiyun.com/qq_45613931/article/details/145586439),我们知道:

JS 是单线程执行的,执行时一定是:主线程代码先执行,执行过后,到事件队列中执行。在事件队列中,先去微任务队列中执行,没有微任务再去执行宏任务。

因此 nextTick 要做的事情,就是把回调任务放到宏任务或微任务队列中,此时前序已经同步的从虚拟 DOM 生成真实 DOM。所以在调用 mounted 的时候,DOM 已经挂载完成。

那 nextTick 内部的实现原理简单来看就是:

function nextTick(cb) {
	if (typeof Promise !== 'undefined') {
		// 优先使用 Promise
		return Promise.resolve().then(cb)
	} else if (typeof MutationObserver !== 'undefined') {
		// 次优先使用 MutationObserver
		let counter = 1
		const observer = new MutationObserver(cb)
		const textNode = document.createTextNode(counter)
		observer.observe(textNode, { characterData: true })
		textNode.data = counter = 2
	} else {
		setTimeout(cb, 0)
	}
}

1.8 Vue 项目 data 为什么是函数?

最后,还有这样一个常见问题。我们已知,在 JS 里,我们可以这样用:

new Vue({
  el: '#app',
  data: {
    count: 0
  }
});

为什么这里的 data 可以直接是一个对象:因为根实例通常只有一个,不存在复用的问题。在项目中,我们看一下:

<template>
  <MyComponent />
  <MyComponent />
</template>

当我们定义一个 Vue 组件时,Vue 会将组件的选项(如 data、methods 等)存储起来,作为组件“模版”。当在使用 <MyComponent /> 时,Vue 会根据这个模板创建组件实例。也就是说,如果 data 是一个对象:

const MyComponent = {
  data: {
    count: 0
  }
};

// Vue 内部创建组件实例时:
const instance1 = Object.create(MyComponent);
const instance2 = Object.create(MyComponent);

// 两个实例的 `data` 都指向同一个对象:
instance1.data === instance2.data; // true

但如果 data 是一个函数:

const MyComponent = {
  data() {
    return {
      count: 0
    };
  }
};

// Vue 内部创建组件实例时:
const instance1 = Object.create(MyComponent);
instance1.data = MyComponent.data(); // 调用 data 函数,生成新的对象

const instance2 = Object.create(MyComponent);
instance2.data = MyComponent.data(); // 再次调用 data 函数,生成新的对象

// 两个实例的 `data` 是独立的:
instance1.data === instance2.data; // false

此时,每次调用函数都会返回一个新的对象,从而避免了引用共享的问题

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

前端Jerry_Zheng

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值