一、Vue.js 概述

本章概要

  • Vue 2.x 响应式系统的实现原理
  • Vue 3.0 响应式系统的实现原理
  • 体验 Vue 3.0 响应式系统
  • Vue 3.0 带来的新变化

1.1 Web 前端技术的发展

略,自行百度。

1.2 MV* 模式

MVC 是 Web 开发中应用非常广泛的一种框架模式,之后又演变出 MVP 模式和 MVVM 模式

1.2.1 MVC

在 MVC 模式中,一个应用被分成三个部分,即模型(Model)、视图(View)和控制器(Controller)。

1.2.2 MVP

MVP(Model-View-Presenter)是由经典的 MVC 模式演变而来,他们的基本思想有相同的地方:模型(Model)提供数据,视图(View)负责显示,表示器(Presenter)负责逻辑处理。
区别:在 MVP 中 View 并不直接使用 Model ,他们之间的通信是通过 Presenter 进行的,所有的交互都发生在 Presenter 内部,而在 MVC 中 View 会直接从 Model 中读取数据而不是通过 Controller。

1.2.3 MVVM

MVVM(Model-View-ViewModel)是一种软件框架模式,也是一种简化用户界面的事件驱动编程方式。

1.3 初识 Vue.js

Vue 是一套基于 MVVM 模式的用于构建用户界面的 JavaScript 框架,他是以数据和组件化的思想构建的。

1.3.1 渐进式框架

Vue 是渐进式的 JavaScript 框架。所谓渐进式,就是把框架进行分层设计,每层都是可选的,不同层可以灵活地替换为其它的方案。

1.3.2 响应式系统

MVVM模式最核心的特性就是数据双向绑定,Vue构建了一套响应式系统,可以实现用声明的方式绑定数据,从而在数据变化时自动渲染视图。

1. Vue 2.x 响应式系统的实现原理

Vue 2.x 是利用 Object.defineProperty() 方法为对象添加 get() 和 set() 方法来侦测对象的变化,当获取对象属性值时会调用 get() 方法,当修改对象属性值时会调用 set() 方法,于是可以在 get() 和 set() 方法中添加代码,实现数据与视图的双向绑定。

例1-1

// 对Object.defineProperty方法进行封装
function defineReactive(obj, key, value) {
    Object.defineProperty(obj, key, {
        get() {
            return value;
        },
        set(newValue) {
            if (newValue !== value) {
                updateView(); //在set方法中触发更新
                value = newValue;
            }
        }
    });
}
// 对一个对象中所有属性的变化进行侦测
function observer(target) {
    // 如果不是对象数据类型直接返回
    if (typeof target !== 'object') {
        return target;
    }
    // 循环遍历对象的所有属性,并将它们转换为getter和setter形式
    for (let key in target) {
        defineReactive(target, key, target[key]);
    }
}
// 模拟更新视图的方法
function updateView() {
    console.log("更新视图");
}
let user = {name:'张三'};
// 对user对象的所有属性变化进行侦测
observer(user);
user.name = '李四';

运行上诉代码,输出结果如下:

更新视图

例1-1 的代码只是简单地侦测了一个对象的属性变化,并没有考虑到对象属性本身又是一个对象的情形。假设 user 对象中有一个属性 address ,该属性本身也是一个对象。代码如下

let user = {name: '张三', address: {city: '北京'}};
// 对user对象的所有属性变化进行侦测
observer(user);
user.address.city = '天津';

运行上述代码,将看不到任何输出,说明对 address 对象的 city 属性的修改并没有被侦测到。
因此,需要修改例1-1 的代码,当对象的属性也是对象类型时,继续为该属性对象的所有属性添加 get() 和 set() 方法。实现起来也很简单,就是在 defineReactive() 函数中添加一个 observer() 函数的递归调用。代码如下

// 对Object.defineProperty方法进行封装
function defineReactive(obj, key, value) {
    // 通过递归调用,解决多层对象嵌套的属性侦测问题
    observer(value);
    Object.defineProperty(obj, key, {
      ...
    }
    ...
}

再次运行代码,可以看到“更新视图”的输出。
考虑一下下面的代码:

let user = {name: '张三', address: {city: '北京'}};
// 对user对象的所有属性变化进行侦测
observer(user);
user.address = {city: '天津'};
user.address.city = '成都';

上述代码有两次属性变化,一次是为 address 属性设置了一个对象字面常量,一次是修改 address 对象的 city 属性,但如果运行代码,就会发现只能看到一次“更新视图”。这是因为在修改 address 属性时是为它赋值了一个新的对象,而这个新对象的属性并没有被侦测,因此后面对这个新对象属性值的更改就没有被侦测到。
针对这种情况,可以在 set() 方法中为新的值添加 observer() 调用。如下:

  Object.defineProperty(obj, key, {
      get() {
          return value;
      },
      set(newValue) {
          if (newValue !== value) {
              // 如果newValue是对象类型,则继续侦测该对象的所有属性变化
              // observer函数中已经有对参数是否是对象类型的判断代码,此处可以省略
              observer(newValue);
              updateView(); //在set方法中触发更新
              value = newValue;
          }
      }
  });

至此,已经解决了对象侦测的问题,但还需要考虑数字侦测的问题。如下

let user = {name: '张三', address: {city: '北京'}, emails: ['zhang@163.com']};
// 对user对象的所有属性变化进行侦测
observer(user);
user.emails.push('zhang@sina.com');

emails 属性是数组类型,当通过push() 方法改变数组内容时,并不会触发对象的 set() 方法的调用。如果想在调用数组方法修改数字内容时得到通知,就需要替换数组原型对象,代码如下:

const arrayPrototype = Array.prototype;
// 使用数组的原型对象创建一个新对象
const proto = Object.create(arrayPrototype);

// 改变数组自身内容的方法只有如下7个,对它们进行拦截
['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
    .forEach(method => {
        Object.defineProperty(proto, method, {
            get() {
                updateView();
                // 返回数组原有的方法
                return arrayPrototype[method];
            }
        });
    });
// 对一个对象中所有属性的变化进行侦测
function observer(target) {
    // 如果不是对象数据类型直接返回
    if (typeof target !== 'object') {
        return target;
    }
    if (Array.isArray(target)) {
        // 如果target是数组,则将数组的原型对象设置为proto
        Object.setPrototypeOf(target, proto);
        // 对数组中的元素进行侦测
        for (let i = 0; i < target.length; i++) {
            observer(target[i])
        }
        return;
    }

    // 循环遍历对象的所有属性,并将它们转换为getter和setter形式
    for (let key in target) {
        defineReactive(target, key, target[key]);
    }
}

2. Vue 3.0 响应式系统的实现原理

Vue 2.x 是利用 Object.defineProperty() 方法侦测对象的属性变化,但该方法有一些固有的缺陷:性能较差、在对象上新增属性是无法被侦测的、改变数组的 length 属性是无法被侦测的。
以下是 Vue 3.0 一个创建代理的简单示例。

例1-2

// 处理器对象
const baseHandler = {
  // 陷阱函数,读取属性值时触发
  // 参数target是目标对象
  // 参数property是要获取的属性名
  // 参数receiver是Proxy对象或者继承Proxy的对象
  get(target, property, receiver){
    console.log("获取值");
  },
  // 陷阱函数,写入属性值时触发
  // 参数value是新的属性值
  set(target, property, value, receiver){
    console.log("设置值");
  },
  // 陷阱函数,删除属性时触发
  deleteProperty(target, property){
    console.log("删除属性");
  }
}
// 目标对象
const target = {name: '张三'};
// 为目标对象创建代理对象
const proxy = new Proxy(target, baseHandler);
// 读取属性值
proxy.name;
// 设置属性值
proxy.name = '李四';
// 删除属性
delete proxy.name;

之后,针对代理对象的相关操作就会触发处理器对象中的对应陷阱函数,在陷阱函数中就可以为目标对象的属性访问添加自定义的业务逻辑。
运行结果如下:

获取值
设置值
删除属性

下面使用 Proxy 模拟实现 Vue 3.0 的响应式系统。

例1-3

// 判断某个值是否是对象的辅助方法
function isObject(val){
  return val !== null && typeof val === 'object';
}
// 响应式核心方法
function reactive(target){
  return createReactiveObject(target);
}
// 创建响应式对象的方法
function createReactiveObject(target){
  // 如果target不是对象则直接返回
  if(!isObject(target)){
    return target;
  }
  const baseHandler = {
    get(target, property, receiver){
      console.log('获取值');
      const result = Reflect.get(target, property, receiver);
      return result;
    },
    set(target, property, value, receiver){
      console.log('设置值');
      const result = Reflect.set(target, property, value, receiver);
      return result;
    },
    deleteProperty(target, property){
      return Reflect.deleteProperty(target, property);
    }
  }
  const observed = new Proxy(target, baseHandler);
  return observed;
}
const proxy = reactive({name: '张三'});
proxy.name = '李四';
console.log(proxy.name);

Reflect 是一个内置对象,它提供了可拦截 JavaScript 操作的方法。每个代理陷阱对应一个命名和参数都相同的 Reflect 方法。
上述代码的运行结果为:

设置值
获取值
李四

同样,为了解决多层对象侦测的问题,需要在get 陷阱函数中对返回值做一个判断,如果返回值是一个对象,则为返回值也创建代理对象,这也是一个递归调用。
修改 get 陷阱函数,对返回值进行判断

...
	const baseHandler = {
    get(target, property, receiver){
      console.log('获取值');
      const result = Reflect.get(target, property, receiver);
      return isObject(result) ? reactive(result) : result;
    },
    set(target, property, value, receiver){
      console.log('设置值');
      const result = Reflect.set(target, property, value, receiver);
      return result;
    },
    deleteProperty(target, property){
      return Reflect.deleteProperty(target, property);
    }
  }
  const observed = new Proxy(target, baseHandler);
  return observed;
}
...
const proxy = reactive({name: 'vue.js', address: {city: '北京'}});
proxy.address.city = '天津';

访问proxy.address时,会引起 get 陷阱函数,由于 address 属性本身也是一个对象,因此该属性也创建代码。
运行结果如下:

获取值
设置值

考虑下面两种情况,第一种情况的代码如下:

const target = {name: '张三'};
const proxy1 = reactive(target);
const proxy2 = reactive(target);

上述代码对同一个目标对象进行了多次代理,如果每次返回一个不同的代理对象,是没有意义的,要解决这个问题,可以在为目标对象初次创建代理后,以目标对象为 key ,代理为 value ,保存到一个 Map 中,然后在每次创建代理前,对目标进行判断,如果已经存在代理对象,则直接返回代理对象,而不再新建代理对象。
第二种情况的代码如下:

const target = {name: '张三'};
const proxy1 = reactive(target);
const proxy2 = reactive(proxy1);

上述代码对一个目标对象进行了代理,然后又对该代理对象进行了代理,这也是无意义的,也需要进行区分,解决方法与第一种情况类似,不过是以代理对象为 key ,目标对象为 value ,保存到一个 Map 中,然后在每次创建代理前,判断传入的目标对象是否本身也是代理对象,如果是,则直接返回该目标对象(原目标对象的代理对象)。
继续完善 例1-3 ,定义两个 WeakMap 对象,分别保存目标对象到代理的映射,以及代理对象到目标对象的映射,并添加判断逻辑。如下:

...
const toProxy = new WeakMap(); // 存放目标对象=>代理对象
const toRaw = new WeakMap();   // 存放代理对象=>目标对象
...
// 创建响应式对象的方法
function createReactiveObject(target){
  // 如果target不是对象则直接返回
  if(!isObject(target)){
    return target;
  }
  const proxy = toProxy.get(target);
  // 如果目标对象是代理对象,并且有对应的真实对象,那么直接返回
  if(proxy){
    return proxy;
  }
  // 如果目标对象是代理对象,并且有对应的真实对象,那么直接返回
  if(toRaw.has(target)){  
    return target;  // 这里的target是代理对象
  }
  const baseHandler = {
    ...
  }
  const observed = new Proxy(target, baseHandler);
  toProxy.set(target, observed);
  toRaw.set(observed, target);
  return observed;
}

接下来解决向数组中添加元素而导致 set 陷阱相应两次的问题,如下

const proxy = reactive([1, 2, 3]);
proxy.push(4);

上述代码会导致 set 陷阱触发两次,因为push() 方法向数组中添加元素的同时还会修改数组的长度,因此有两次 set 陷阱的触发:一次是将数组索引为3的位置设置为4而触发;一次是修改数字的length属性为4而触发。假如在 set 陷阱函数中更新视图,那么就会出现更新两次的情况。
为了避免上述情况,需要在 set 陷阱函数中区分是新增属性还是修改属性,同时对属性值的修改做一个判断,如果要修改的属性的新值与旧值相同,则无需进行任何操作。
继续完善例1-3的代码。

...
// 判断当前对象上是否有指定属性
function hasOwn(target, property){
  return target.hasOwnProperty(property);
}
...
// 创建响应式对象的方法
function createReactiveObject(target){
	...
  const baseHandler = {
    get(target, property, receiver){
    	...
    },
    set(target, property, value, receiver){
      // 判断目标对象上是否已经存在该属性
      const hasProperty = hasOwn(target, property); 
      const oldValue = Reflect.get(target, property);
      const result = Reflect.set(target, property, value, receiver);
      
      if(!hasProperty){
        console.log('新增属性')
      }
      else if(oldValue !== value){ 
        console.log('修改属性')
      }
      return result;
    },
		...
  }
	...
}

针对上述使用 push() 方法向数组添加元素的代码,修改后的响应式代码只会输出“新增属性”,原因是添加元素后,数字的长度已经是4,当 push() 方法修改数组长度为 4 时,新值和旧值相同,因此不进行任何操作。完善后的响应式代码也避免了对属性值进行无意义的修改。
接下来,就是Vue3.0 中比较难理解的依赖手机了,所谓依赖,就是指当数据发生变化,要通知谁。
Vue3.0 使用了 effect() 函数来包装依赖,称为副作用。 effect() 函数的模拟实现如下:

...
// 保存effect的数组,以栈的形式存储
const effectStack = []; 
function effect(fn){
  // 创建响应式effect
  const effect = createReactiveEffect(fn);
  // 默认先执行一次effect,本质上调用的是传入的fn函数
  effect(); 
}
// 创建响应式effect
function createReactiveEffect(fn){
  // 响应式effect
  const effect = function(){  
    try{
      // 将effect保存到全局数组effectStack中,以栈的形式存储
      effectStack.push(effect);
      return fn();
    } finally{
      // 调用完依赖后,删除effect
      effectStack.pop();
    }
  }
  return effect;
}
...
const proxy = reactive({name: '张三'});
effect(()=>{
  console.log(proxy.name);
});
proxy.name = '李四';

上述代码运行的结果为

获取值
张三
修改属性

从输出结果中可以看到,除了默认执行一次的 effect 外,当name 属性发生变化时,effect 并没有被执行。为了在对象属性发生变化时,让 effect 再次执行,需要将对象的属性与 effect 进行关联,这可以采用 Map 来存储。
考虑到一个属性可能会关联多个依赖,那么存储的映射关系应该是以对象属性为 key ,保存所有的 effect 的 Set 对象为 value ,之所以选择 Set 而不是数组,是因为 Set 中不能保存重复的元素。
另外,属性毕竟是对象的属性,不可能脱离对象而单独存在,要跟踪不同对象属性的依赖,还需要一个 WeakMap ,将对象本身最为 key,保存所有属性与依赖映射关系的 Map 最为 value ,存储到这个 WeakMap 中。
定义好数据结构后,就可以编写一个依赖手机函数 track() ,如下:

// 保存对象与其属性依赖关系的Map,key是对象,value是Map
const targetMap = new WeakMap();
// 跟踪依赖
function track(target, property){ 
  // 获取全局数组effectStack中的依赖
  const effect = effectStack[effectStack.length - 1];
  // 如果存在依赖
  if(effect){  
    // 取出该对象对应的Map
    let depsMap = targetMap.get(target);
    // 如果不存在,则以目标对象为key,新建的Map为value,保存到targetMap中
    if(!depsMap){
      targetMap.set(target, depsMap = new Map());
    }
    // 从Map中取出该属性对应的所有effect
    let deps = depsMap.get(property);
    // 如果不存在,则以属性为key,新建的Set为value,保存到depsMap中
    if(!deps){
      depsMap.set(property, deps = new Set());
    }
    // 判断Set中是否已经存在effect,如果没有,则添加到deps中
    if(!deps.has(effect)){
      deps.add(effect);
    }
  }
}

接下来是当属性发生变化时,触发属性关联的所有 effect 执行,为此,再编写一个 trigger() 函数,代码如下:

// 执行属性关联的所有effect
// 参数type在本例中并没有使用,只是模拟Vue 3.0中的代码,用于区分修改属性还是新增属性
function trigger(target, type, property){
  const depsMap = targetMap.get(target);
  if(depsMap){
    let deps = depsMap.get(property);
    // 将当前属性关联的所有effect依次执行
    if(deps){
      deps.forEach(effect => {
        effect();
      });
    }
  }
}

依赖收集的函数和触发依赖的函数都写完了,那么自然需要在某个地方去收集依赖和触发依赖执行,收集依赖放到触发器对象的 get 陷阱中,而 触发依赖是在属性发生变化时执行依赖,自然是放到 set 陷阱中。
修改 createReactiveObject() 函数,添加 track() 和 trigger() 函数的调用。如下:

// 创建响应式对象的方法
function createReactiveObject(target){
	...
  const baseHandler = {
    get(target, property, receiver){
      const result = Reflect.get(target, property, receiver);
      // 收集依赖
      track(target, property); 
      return isObject(result) ? reactive(result) : result;
    },
    set(target, property, value, receiver){
      // 判断目标对象上是否已经存在该属性
      const hasProperty = hasOwn(target, property); 
      const oldValue = Reflect.get(target, property);
      const result = Reflect.set(target, property, value, receiver);
      
      if(!hasProperty){
        trigger(target, 'add', property);
      }
      else if(oldValue !== value){ 
        trigger(target, 'set', property);
      }
      return result;
    },
    deleteProperty(target, property){
      return Reflect.deleteProperty(target, property);
    }
  }
	...
}

至此,模拟实现 Vue 3.0 的响应式系统的代码全部编写完毕。

1.3.3 体验 Vue 3.0 响应式系统

例1-4

<!DOCTYPE html>
<html>
	<head>
		<meta charset="GBK">
		<title>Hello Vue.js</title>
	</head>
	<body>
		<!--View-->
		<div id="app">
		    <button @click="increment">count值:{{ state.count }}</button>
		</div>

        <!-- 引入Vue.js -->
		<script src="https://unpkg.com/vue@next"></script>
		<script>
			const App = {
			    setup(){
			        // 为目标对象创建一个响应式对象
			        const state = Vue.reactive({count: 0});
			        function increment(){
			            state.count++;
			        }
			        return {
			            state,
			            increment
			        }   
			    }
			};
			// 创建应用程序实例,该实例提供应用程序上下文。
			// 应用程序实例装载的整个组件树将共享相同的上下文.
			const app = Vue.createApp(App);
			// 在id为app的DOM元素上装载应用程序实例的根组件
			app.mount('#app');
		</script>
	</body>
</html>

在浏览器中打开此页面,运行效果如下:
在这里插入图片描述

点击按钮,可以看到计数值在增长。

1.4 Vue 3.0 带来的新变化

Vue 3.0 并没有沿用 Vue 2.x 版本的代码,而是从头重写了,代码采用 TypeScript 进行编写,新版的 API 全部采用普通函数,在编写代码时可以享受完整的类型推断。
Vue 3.0 具有以下 8 个亮点:

1. 更好的性能

Vue 3.0 重写了虚拟 DOM 的实现,并对模版的编译进行了优化,改进了组件初始化的速度,相比 Vue 2.x ,更新速度和内存占用方面都有显著的性能提升。

2. Tree-shaking 支持

对无用的模块进行“剪枝”,仅打包需要的,减少了产品发布版本的大小。Vue 3.0 支持按需引入,而 Vue 2.x 中即时用不到的功能也会打包进来。

3. 组合API(Composition API)

Vue 2.x 使用 mixin 来复用功能,但 mixin 存在的问题是:如果用多了,则很难知道某个功能是从哪个 mixin 混入的。
此外,mixin 的类型推断也很差。Vue 3.0 中新增的 Composition API 可以完美替代 mixiin,让用户课可以更灵活且无副作用地复用代码,且 Composition API 可以很好的进行类型推断。Composition API 解决了多组件间逻辑重用的问题。

4. 碎片(Fragmen)

Vue 2.x 的组件需要有一个唯一的根节点,而在 Vue 3.0 中,这成了历史,组件模版不再需要单个的根节点了,可以有很多个节点。

5. 传送(Teleport)

有时组件模版的一部分在逻辑上属于该组件,而从技术角度来看,最好将模版的这一部分移动到 DOM 中 Vue 应用程序之外的其它位置,使用 Teleport 内置组件可以很容易地实现这个需求。

6. 悬念(Suspense)

Suspense 内置组件可以在嵌套层级中等待嵌套的异步依赖项,支持 async setup() ,支持异步组件。

7. 更好的 TypeScript 支持

Vue 3.0 的代码完全采用 TypeScript 编写,具有更好的类型支持。前端开发人员现在可以采用 TypeScript 开发 Vue 应用,而无需担心兼容性问题,结合支持 Vue 3.0 的 TypeScript 插件,开发更加高效,并可以拥有类型检查、自动补全等功能。

8. 自定义渲染器 API

使用自定义渲染器 API ,用户可以尝试与第三方库集成,如编写 WebGL自定义渲染器。
需要注意的是,Vue 3.0 目前并不支持 IE 11。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

一只小熊猫呀

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

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

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

打赏作者

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

抵扣说明:

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

余额充值