Vue3 核心模块源码解析(上)

时间:2023-02-27 19:51:27

Vue3相比大家也都有所了解,即使暂时没有使用上,但肯定也学习过!Vue3是使用TS进行重写,采用了MonoRepo的管理方式进行管理,本篇文章我们一起来看看 Vue3的使用,与Vue2有什么区别,以及我们该如何优雅的去使用?【中】篇会从源码的角度去学习,【下】篇主要是讲解Vue3的高频面试题,开始正文吧!!!

一、Vue2 与 Vue3响应式对比

Vue2 与 Vue3 最显著的差别就是响应式的差别,那么是什么原因导致 Vue3 的双向绑定原理采用了Proxy?我们下面来由浅入深的去了解一下。

1. Vue2 的 Object.defineProperty

基础使用:

const initData = { value: 1 };
const data = {};
Object.keys(initData).forEach(key => {
	Object.defineProperty(data, key, {
		get() {
			console.log('访问了', key);
		},
		set(v) {
			console.log('修改了', key);
			data[key] = v;
		}
	})
})

data.value;
data.value = 2;
data;
initData.value2 = 2;
data.value2;

以上就是最基础的使用;
但是我们一起来看一下,下面几个问题会输出什么?

  1. 直接访问 data.value => 访问了 value
  2. 改变 data.value => 修改了 value
  3. 直接输出 data => 空对象: { }
  4. 给 initData 添加一个新值 => 输出新值结果:2
  5. data.value2 又会输出什么? => undefined

总结一下 Vue2 响应式弊端:给对象加属性和删除属性,响应式会检测不到。通常我们是使用 Vue.set( ) 来解决,那么面试官问 Vue.set( ) 为什么可以解决?他具体经历了那些步骤你知道吗?

2. Vue.set() 为什么可以解决上述问题?他具体经历了那些步骤你知道吗?

Vue.set(target, key, value)
// target 必须是一个响应式的数据源,在下面步骤会讲到

会经历一下三个步骤

对 target 进行数据校验
① 数据是 undefined、null或其他基本数据类型,会报错;
② 数据是 数组:则会取出当前数组的长度与当前 key 值的位置进行一个对比,取两者最大值,作为新数组的长度 -> max(target.length, key) ,然后使用 splice(key, 1, value); 当使用 splice 的时候,会自动遍历设置响应式。
③ 数据是 对象:key 是否在对象里,如果在则直接替换;如果不在则直接判断 target 是不是响应式对象;然后判断是不是 Vue 实例或者根的数据对象,如果是 throw error。如果不是直接给 target 的 key 赋值,如果 target 是响应式,使用 defineReactive 将新的属性添加到 target,进行依赖收集;

Vue.set( ) 源码

// example :
this.$set(data, a, 1);
function set(target: Array<any>  object, key: any, val: any): any {
  // isUndef 是判断 target 是不是等于 undefined 或者 nul1
  // isPrimitive 是判断 target 的数据类型是不是 string、number、symbol、boolean 中的一种
  if (process.env.NODE ENV !== 'production' &&(isUndef(target) isPrimitive(target))) {
  	warn(`Cannot set readtive property on undefined, null, or primitive value: $((target: any))`)
  }
  // 数组的处理
  if (Array.isArray(target) && isValidArrayIndex(key)) {
  	target.length = Math .max(target .length, key)
  	target.splice(key,1, val)
	return val
  }

  // 对象,并且该属性原来已存在于对象中,则直接更新
  if (key in target && !(key in object.prototype)) {
	target[key] = val
	return val
  }
  // vue给响应式对象(比如 data 里定义的对象)都加了一个  ob  属性,
  // 如果一个对象有这个 ob属性,那么就说明这个对象是响应式对象,修改对象已有属性的时候就会触发页面渲染
  // 非 data 里定义的就不是响应式对象。
  const ob = (target: any).__ob__

  if (target. isVue  (ob && ob.vmCount)) {
	process.env.NODE ENV !== 'production' && warn(
	'Avoid adding reactive properties to a Vue instance or its root $data' +
	'at runtime - declare it upfront in the data option.!'
	return val
  }

  // 不是响应式对象
  if (!ob) {
	target[key] = val
	return val
  }

  // 是响应式对象,进行依赖收集
  defineReactive(ob.value, key, val)
  // 触发更新视图
  ob.dep.notify()
  return val
}

3. 如何实现一个简单的 Vue2 响应式 ?

export function Vue(options) {
  this.__init(options);
}

// initMixin
Vue.prototype.__init = function (options) {
  this.$options = options;
  // 假如这里是一个字符串,就需要使用 document.querySelector 去获取
  this.$el = options.el;
  this.$data = options.data;
  this.$methods = options.methods;

  // beforeCreate -- initState -- initData
  proxy(this, this.$data);
  // Object.defineProperty
  observer(this.$data);
  new Compiler(this);
};

// this.$data.message ---> this.message
function proxy(target, data) {
  let that = this;
  Object.keys(data).forEach((key) => {
    Object.defineProperty(target, key, {
      enumerable: true,
      configurable: true,
      get() {
        return data[key];
      },
      set(newVal) {
        // 考虑 NaN 的情况
        // this 指向已经改变
        if (!isSameVal(data[key], newVal)) {
          data[key] = newVal;
        }
      },
    });
  });
}

function observer(data) {
  new Observer(data);
}

class Observer {
  constructor(data) {
    this.walk(data);
  }

  walk(data) {
    if (data && typeof data === "object") {
      Object.keys(data).forEach((key) =>
        this.defineReactive(data, key, data[key])
      );
    }
  }

  //要把 data 里面的数据,收集起来
  defineReactive(obj, key, value) {
    let that = this;
    this.walk(value);
    let dep = new Dep();
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get() {
        // get时 Dep 收集依赖
        // 4. 对于 num 来说,就要执行这一句
        // 5. num 中的 dep,就有了这个 watcher
        Dep.target && dep.add(Dep.target);
        return value;
      },
      set(newVal) {
        if (!isSameVal(value, newVal)) {
          //赋值进来的新值,是没有响应式的,所以我要在 walk 一次,添加响应式
          value = newVal;
          that.walk(newVal);
          // 重新 set时,notify 通知更新
          // 6.
          dep.notify();
        }
      },
    });
  }
}

// 视图怎么更新?
// 数据改变,视图才会更新。需要去观察
// 1. new Watcher(vm, 'num', ()=>{ 更新视图上的 num 显示 })
class Watcher {
  constructor(vm, key, callback) {
    this.vm = vm; // VUE 的一个实例
    this.key = key;
    this.callback = callback;

    // 2. 此时 Dep.target 作为一个全局变量理解,放的就是就是 watcher
    Dep.target = this;
    // 3. 一旦进行了这一句赋值,是不是就触发了这个值的 getter 函数
    this.__old = vm[key];
    Dep.target = null;
  }

  // 8. 执行所有的 callback 函数
  update() {
    let newVal = this.vm[this.key];
    if (!isSameVal(newVal, this.__old)) this.callback(newVal);
  }
}

// 每一个数据都要有一个 Dep 依赖
class Dep {
  constructor() {
    this.watchers = new Set();
  }
  add(watcher) {
    if (watcher && watcher.update) this.watchers.add(watcher);
  }

  // 7. 让所有的 watcher 执行 update 方法
  notify() {
    this.watchers.forEach((watch) => watch.update());
  }
}

class Compiler {
  constructor(vm) {
    this.vm = vm;
    this.el = vm.$el;
    this.methods = vm.$methods;

    this.compile(vm.$el);
  }
  // 这里是递归编译 #app 下面的所有的节点内容
  compile(el) {
    let childNodes = el.childNodes;
    // childNodes 为类数组
    Array.from(childNodes).forEach((node) => {
      // 判断如果是文本节点
      if (node.nodeType === 3) {
        this.compileText(node);
      }
      // 判断如果是元素节点
      else if (node.nodeType === 1) {
        this.compileElement(node);
      }
      // 判断如果还有子节点,就递归下去
      if (node.childNodes && node.childNodes.length) this.compile(node);
    });
  }

  compileText(node) {
    // 匹配出来 message
    let reg = /\{\{(.+?)\}\}/;
    let value = node.textContent;
    if (reg.test(value)) {
      let key = RegExp.$1.trim();
      // 开始时赋值
      node.textContent = value.replace(reg, this.vm[key]);
      // 给 message 添加观察者
      new Watcher(this.vm, key, (val) => {
        // 数据改变时更新
        node.textContent = val;
      });
    }
  }

  compileElement(node) {
    if (node.attributes.length) {
      Array.from(node.attributes).forEach((attr) => {
        let attrName = attr.name;
        if (attrName.startsWith("v-")) {
          // v- 指定匹配成功,可能是 v-on:click 或者 v-model
          // 假设我们这里就处理两个指令,Vue源码对这一块是有特殊处理的
          attrName =
            attrName.indexOf(":") > -1
              ? attrName.substr(5)
              : attrName.substr(2);
          let key = attr.value;
          this.update(node, key, attrName, this.vm[key]);
        }
      });
    }
  }

  update(node, key, attrName, value) {
    if (attrName === "model") {
      node.value = value;
      new Watcher(this.vm, key, (val) => (node.value = val));
      node.addEventListener("input", () => {
        this.vm[key] = node.value;
      });
    } else if (attrName === "click") {
      node.addEventListener(attrName, this.methods[key].bind(this.vm));
    }
  }
}

function isSameVal(a, b) {
  return a === b || (Number.isNaN(a) && Number.isNaN(b));
}

Vue2 的响应式我们简单介绍一下,下来一起来看 Vue3 的 Proxy!

2. Vue3 的 Proxy

Proxy:代理或拦截器,Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写;Proxy的思路和 React 的 HOC 很像,组件外面包裹一层,对外界的访问进行过滤和改写;

const initData = {value:1};
const proxy = new Proxy(initData, {
  get(target, key, receiver) {
    console.log('访问了', key);
	return Reflect.get(target, key, receiver);
  },
  set(target, key, value, receiver){
    console.log('修改了', key);
	return Reflect.set(target, key, value, receiver);
  }
})

proxy.value;
proxy.value = 2;
proxy;
proxy.value2 = 2;
proxy.value2;

具体这里就不详细说了,感兴趣的大家可以移步下面链接:
Vue3中的响应式原理,为什么使用Proxy(代理) 与 Reflect(反射)

二、Vue3 新特性

Composition API

composition api : 组合式 api,通过组合式 API,我们可以使用导入的 API 函数来描述组件逻辑。在单文件组件中,组合式 API 通常会与 < script setup> 搭配使用。这个 setup attribute 是一个标识,告诉 Vue 需要在编译时进行一些处理,让我们可以更简洁地使用组合式 API。比如, < script setup> 中的导入和顶层变量/函数都能够在模板中直接使用。

1. 如何理解 setup ?

通俗一点的讲,setup 可以把他理解为 Vue3 组件模块的入口文件,Vue3 中组件的新特性 ,作为组件统一的入口支持;

未使用 setup 语法糖的写法 (了解即可,实际开发还是使用语法糖写法更加便捷):

setup(props, context){
	context.attrs  -->  this.$attrs
	context.slot  -->  this.$slot
	context.emit  -->  this.$emit

	context.expose
}

使用 setup 语法糖写法

<script setup>
// 变量
const msg = 'Hello!'

// 函数
function log() {
  console.log(msg)
}
</script>

<template>
  <button @click="log">{{ msg }}</button>
</template>

为什么推荐使用 setup 语法糖?

  1. 更少的样板内容,更简洁的代码。
  2. 能够使用纯 TypeScript 声明 props 和自定义事件。
  3. 更好的运行时性能 (其模板会被编译成同一作用域内的渲染函数,避免了渲染上下文代理对象)。
  4. 更好的 IDE 类型推导性能 (减少了语言服务器从代码中抽取类型的工作)。

setup 是在 beforeCreate 和 created 之前去执行

2. 多根节点

什么是多根节点呢?看下图代码

单文件的多根节点

vue3 中之所以可以有多个节点,是因为引入了Fragment的概念,这是一个抽象的节点,如果发现组件有多个根,就创建一个Fragment节点,把多个根节点作为它的children,将来path的时候,如果发现是一个Fragement节点,则直接遍历children创建或更新。
Vue3 核心模块源码解析(上)
Vue3 核心模块源码解析(上)

项目的多根节点——多个应用实例

应用实例并不只限于一个。createApp API 允许你在同一个页面中创建多个共存的 Vue 应用,而且每个应用都拥有自己的用于配置和全局资源的作用域。

Vue3 核心模块源码解析(上)
Vue3 核心模块源码解析(上)

Vue3 核心模块源码解析(上)

如果你正在使用 Vue 来增强服务端渲染 HTML,并且只想要 Vue 去控制一个大型页面中特殊的一小部分,应避免将一个单独的 Vue 应用实例挂载到整个页面上,而是应该创建多个小的应用实例,将它们分别挂载到所需的元素上去。

3. reactive() 与 shallowReactive()

reactive:通过 proxy 声明一个深层的响应式对象,响应式是深层次的,会影响所有嵌套。 等同于 Vue2 的 Vue.observable()

const person = {
  name: 'Barry',
  age: 18,
  contacts: {
    phone: 1873770
  }
}

const personReactive = reactive(person);
console.log(personReactive); // proxy
const contacts = personReactive.contacts;
console.log(contacts); // proxy

shallowReactive:和 reactive() 不同,这里没有深层级的转换:一个浅层响应式对象里只有根级别的属性是响应式的。

const person = {
  name: 'Barry',
  age: 18,
  contacts: {
    phone: 1873770
  }
}

const personshallowReactive = shallowReactive(person);
console.log(personshallowReactive); // proxy
const contactsShallowReactive = personshallowReactive.contacts
console.log(contactsShallowReactive); // no proxy

4. ref()、isRef() 、toRef()、toRefs()

ref:返回一个响应式的、可更改的 ref 对象,此对象只有一个指向其内部值的属性 .value ,所以我们想要修改 ref 声明的响应式数据需要带上 .value ;
如果将一个对象赋值给 ref, 那么这个对象将通过 reactive() 转为具有深层次响应式的对象。这也意味着如果对象中包含了嵌套的 ref 它们将被深层地解包。

const count = ref(10);
const cObj = reactive({
  a: 100,
  count
})

console.log(cObj.count);
console.log(cObj.count === count.value);
count.value = 20;
console.log(count.value, cObj.count);
cObj.count = 30;
console.log(count.value, cObj.count);

isRef:检查某个值是否为 ref。

使用 ref 或者 reactive 声明的响应式数据,通过 结构会失去响应式

解决办法:
1. 使用 ref 声明的响应式可以通过 toRef() API
2. 使用 reactive 声明的响应式可以通过 toRefs API

toRef:基于响应式对象上的一个属性,创建一个对应的 ref。这样创建的 ref 与其源属性保持同步:改变源属性的值将更新 ref 的值

const state = reactive({
  foo: 1,
  bar: 2
})

const fooRef = toRef(state, 'foo')

// 更改该 ref 会更新源属性
fooRef.value++
console.log(state.foo) // 2

// 更改源属性也会更新该 ref
state.foo++
console.log(fooRef.value) // 3

toRefs:将一个响应式对象转换为一个普通对象,这个普通对象的每个属性都是指向源对象相应属性的 ref。每个单独的 ref 都是使用 toRef() 创建的。

const state = reactive({
  foo: 1,
  bar: 2
})

const stateAsRefs = toRefs(state)
/*
stateAsRefs 的类型:{
  foo: Ref<number>,
  bar: Ref<number>
}
*/

// 这个 ref 和源属性已经“链接上了”
state.foo++
console.log(stateAsRefs.foo.value) // 2

stateAsRefs.foo.value++
console.log(state.foo) // 3

5. readOnly()、isReadonly() 、shallowReadonly()

readOnly:类似于 Object.freeze() 的效果,把一个响应式对象变成一个只读的对象,只读代理是深层的:对任何嵌套属性的访问都将是只读的。会递归的去阻止 Proxy set 的触发,【中】篇会从源码的角度去学习 中间会讲到 readOnly 源码是如何处理的

const original = reactive({ count: 0 })

const copy = readonly(original)

watchEffect(() => {
  // 用来做响应性追踪
  console.log(copy.count)
})

// 更改源属性会触发其依赖的侦听器
original.count++

// 更改该只读副本将会失败,并会得到一个警告
copy.count++ // warning!

isReadonly:检查传入的值是否为只读对象。只读对象的属性可以更改,但他们不能通过传入的对象直接赋值。
通俗一点讲就是检查你传入的值,是否由 readonly 创建出来的

function isReadonly(value: unknown): boolean

shallowReadonly readonly() 的浅层作用形式
这里没有深层级的转换:只有根层级的属性变为了只读。属性的值都会被原样存储和暴露,这也意味着 值为 ref 的属性不会被自动解包了

const state = shallowReadonly({
  foo: 1,
  nested: {
    bar: 2
  }
})

// 更改状态自身的属性会失败
state.foo++

// ...但可以更改下层嵌套对象
isReadonly(state.nested) // false

// 这是可以通过的
state.nested.bar++

6. 生命周期

新版的生命周期函数,可以按需导入到组件中,且只能在 setup 函数中使用,但是也可以在 setup 外定义,在 setup 中使用;
setup 是围绕 beforeCreatecreated 生命周期钩子运行的,所以不需要显式的定义它们。换句话说在这些钩子中编写的任何代码都应该直接在 setup 函数中编写。

选项式 API 组合式 API
beforeCreate Not needed*
created Not needed*
beforeMount onBeforeMount
mounted onMounted
beforeUpdate onBeforeUpdate
updated onBeforeUpdate
beforeUnmount onBeforeUnmount
ummounted onUnmounted
errorCaptured onErrorCaptured
renderTracked onRenderTracked
renderTriggered onRenderTriggered
activated onActivated
deactivated onDeactived

注意,若要在 setup 中引入,需要 vue 中引入对应 hook
Vue3 核心模块源码解析(上)
官网生命周期钩子地址

7. 全局配置

Vue2 中我们通常是使用 Vue.property.xxx = xxx 的方式定义,
第一个弊端 :全局配置很容易意外的污染其他测试用例
第二个弊端 :全局配置使得同一个页面上的多个“应用”在全局配置不同时共享同一个 Vue 副本非常困难;

Vue3 我们是利用: app.config.globalProperties.xxx= xxx 的方式来实现的。通过 Vue 实例上 config 来配置,包含Vue应用程序全局配置的对象,您可以在挂载应用程序之前修改对应的属性;
具体点击右侧链接查看:Vue3中全局配置 axios 的两种方式

可以在应用程序内的任何组件实例中访问的全局属性,组件的属性将具有优先权。同时,可以在组件通过 getCurrentInstance() 来获取全局 globalProperties 中配置的信息, getCurrentInstance 用于获取当前的组件实例,然后通过 ctx 属性获得当前上下文,这样我们就可以在 setup 中使用。

const app = Vue.createApp({});
app.config = {......}
app.config.globalProperties.$htpp = xxx;

app.config.errorhandler = (err, vm, info) => {}

const { ctx } = getCurrentInstance();
ctx.$http

8. 异步组件

异步组件:在大型项目中,我们可能需要拆分应用为更小的块,并仅在需要时再从服务器加载相关组件。Vue 提供了 defineAsyncComponent 方法来实现此功能;

全局注册

// 可以利用返回值的实例去自定义异步组件,在那个应用里生效,在那个应用里注册
const AsyncComp = defineAsyncComponent(() => import('./components/AsyncComp.vue'));
app.component('async-comp', AsyncComp)

局部注册

// main.js
const AsyncComp = defineAsyncComponent(() => import('./components/AsyncComp.vue'));
// app.vue
import AsyncComp  from './components/AsyncComp.vue';
{
components: 'async-comp', AsyncComp
}

异步组件的作用:

  1. 打包后不会集成在 index.js 中,会单独进行打包,方便后续操作,可以进行缓存,如多个页面都使用一个相同的组件,可以将打包文件缓存下来;
  2. 如果组件包过大,可以使用 loading 代替显示;

Vue3 支持 suspense,< Suspense > 是一个内置组件,用来在组件树中协调对异步依赖的处理。它让我们可以在组件树上层等待下层的多个嵌套异步依赖项解析完成,并可以在等待时渲染一个加载状态。
< Suspense > 组件有两个插槽:#default 和 #fallback。两个插槽都只允许一个直接子节点。在可能的时候都将显示默认槽中的节点。否则将显示后备槽中的节点。
React V16.6.0 中,官方提出了lazy suspense 组件

<Suspense>
  <!-- 具有深层异步依赖的组件 -->
  <Dashboard />

  <!-- 在 #fallback 插槽中显示 “正在加载中” -->
  <template #fallback>
    Loading...
  </template>
</Suspense>

9. Teleport

< Teleport> 是一个内置组件,可以将子节点渲染到存在于父组件以外的 DOM 节点的方案

当处理某些类型的组件(如模式,通知或提示) 时,模板HTML的逻辑可能位于与我们希望染元素的位置不同的文件中
很多时候,与我们的 Vue 应用程序 DOM 完全分开处理时,这些元素的管理要容易得多。所有这些都是因为处理嵌套组件的位置,z-index 和样式可能由于处理其所有父对象的范围而变得棘手。这种情况就是 Teleport 派上用场的地方。我们可以在逻辑所在的组件中编写模板代码,这意味着我们可以使用组件的数据或 props。 但是,然后完全将其渲染到我们Vue应用程序的范围之外。

<button @click="open = true">Open Modal</button>

<Teleport to="body">
  <div v-if="open" class="modal">
    <p>Hello from the modal!</p>
    <button @click="open = false">Close</button>
  </div>
</Teleport>

9. 自定义 Hook

Vue3 的 hooks 其实可以参考 React 的自定义 hooks 的定义,在 React 中,在函数组件中保留 state 数据的同时,融入生命周期函数,将组件整体作为一个钩子函数。
当组件复杂时,多个组件中一些重复的逻辑可以被抽象出来。在 Hook 诞生之前,React 和 Vue 都拥有高阶组件的设计模式,在 React 使用到 HOC,在 Vue 2 中使用到 mixin。为什么要舍弃它们而使用 Hook,使用自定义 Hook又有哪些优点,我们先简单了解一下 HOC 和 mixin ,对比后便知。

Vue3 核心模块源码解析(上)
HOC 的原理是把组件作为参数传入一个函数,加入复用部分后将新的组件作为返回值,使用了装饰器模式mixin 像是把复用的部分拆解成一个个小零件,某个组件需要时就拼接进去
在实践中,mixin 有如下缺点:
1.引入了隐式依赖关系。
2不同 mixins之间可能会有先后顺序甚至代码冲突覆盖的问题
3.mixin 代码会导致滚雪球式的复杂性
4多个 mixin 导致合并项不明来源
为了避开这些问题,React 采用 HOC,但它依然存在缺陷
1.一个组件的state影响许多组件的props
2.造成地狱嵌套
不过使用全新的 Hook 组件结构,可以实现平铺式调用组件的复用部分,解决了 mixin 的来源不明和 HOC 的地狱嵌套问题。

举个栗子,自定义一个 Hook 来记录鼠标的位置

Tips: 一般我们的自定义 Hook 都需要使用 use 开头!

// src/hooks/useMousePosition. ts
import { ref, onMounted, onUnmounted, Ref } from 'vue
function useMousePosition() {
  const x = ref(0)
  const y = ref(0)
  const updateMouse = (e) => {
    x.value = e.pageX
    y.value = e.pageY
  }
  onMounted(() => {
    document.addEventListener('click', updateMouse)
  })
  onUnmounted(() => {
    document.removeEventListener('click', updateMouse)
  })
  return { x, y }
}
export default useMousePosition

<template>
  <div>
	<p>X: {{ x }}</p>
	<p>Y: {{ y }}</p> I
  </div>
</template>
<script lang="ts">
import { defineComponent} from 'vue'
//引入hooks
import useMousePosition from ' ../ ../hooks/useMousePosition'
export default defineComponent({
  setup () {
    //使用hooks功能
    const { x, y} = useMousePosition()
    return {
      X,
    }
  }
})
</script>



结语:【Vue3 核心模块源码解析(上)】到此结束,此篇还是以Vue2的部分回顾,加上Vue3的新特性的基础使用,以及部分见解,有不对的地方欢迎大家及时指出,本文到此结束!!!