1.5w字手摸手教你实现Vue3响应式原理(reactivity),助力2021金三银四面试

230 阅读16分钟

19年的时候,Vue3就以山呼海啸之势向前端圈涌来,在2020.9.19号已经发布了正式版。Vue3相较与Vue2还是有很大改变的,带来了 Composition API RFC 版本,类似 React Hook 一样的写 Vue,可以自定义自己的 hook ,让使用者更加的灵活。本文会从一下几个角度讲讲(文章last version:2020-11-23):

  1. 一款框架的出生要经历哪些过程

  2. Vue3中有那些新特性

  3. Vue3会给我们的开发带来些什么(与Vue2对比)​​​

  4. Vue3源码文件结构

  5. Vue3项目搭建的方式与Vue2的对比

  6. 本文重点:Vue3响应式原理reactivity模块是如何使用以及如何实现

这几个方面来讲解Vue3(需要有Vue2源码的相关知识基础,不然有些地方还是比较难理解的)

文章相关的demo及实现响应式原理代码地址

实现响应式原理代码地址:github.com/AlexGoing/v…

Vue3实战 demo:github.com/AlexGoing/v…

​一款框架诞生需要的阶段

  • 开发

  • alpha版:内部测试版 α

  • beta版:公开测试版 β

  • rc版:Release Candidate(候选版本)

  • stable版:稳定版

vue现阶段生态版本

Project

Status

vue-next

正式版v3.0.3 [GitHub地址]

vue-class-component

v8.0.0-rc1 [GitHub地址]

Vuex

rc2 [GitHub地址]

Vue Router

rc5 [GitHub地址]

Vue3新特性

这里讲解vue3的一些新特性已经是“老生长谈”的一些东西了,但是在说“废话”的同时,我对Vue3的一写新特性也有一写自己的理解,如果有不对,欢迎指正

  1. Performance
  • 重写了虚拟Dom的实现(跳过静态节点,只处理动态节点,Vue2中vdom是不管动态静态节点都会全部diff)

  • 优化编译

  • 更高效的组件初始化

  • 1.3~2 倍的更新性能

  • 2~3 倍的 SSR 速度

  1. Tree shaking
  • 按需求引用的内置的指令和方法,可以将无用模块“剪辑”,仅打包需要的模块
  1. Fragment
  • 与Vue2.x不同的是,Vue3.x不再限于模板中的单个根节点,之前组件的节点必须只有一个根元素,Vue3可以有多个根元素,与React的<React.Fragment/>同理

  • 早先版本称为,译作传送门 ,用于在当前组件之外呈现某些内容

  • 可在嵌套层级中等待嵌套的异步依赖项 ,在异步组件未加载完成的时候,加载里的备用内容

  • Vue 3 的 Template 会支持多个根标签

  1. TypeScript
  • vue3.0 对 TS 的支持度更高了,同时也支持 TSX 的使用;API 在 JS 与 TS 中的使用相同;类组件仍然可用,但是需要我们引入 vue-class-component@next,该模块目前在beta阶段。
  1. Custom Renderer API
  • 自定义渲染器API

  • 用户可以尝试WebGL自定义渲染器 (没用过,但是我个人觉得未来将会是个亮眼的功能)

  1. Composition API
  • Composition API 主要是提高了代码逻辑的可复用性,并且将 Reactivity 模块独立出来,这也使得 vue 3 变得更加灵活地与其他框架组合使用

  • 组合式API,替换原有的 Options API

  • 根据逻辑相关性组织代码,提高可读性和可维护性

  • 更好的重用逻辑代码(避免mixins混入时命名冲突的问题)

  • 但是依然可以延用 Options API

尤雨溪2020.4.16号关于Vue3进展的PPT链接(需要翻墙):PPT地址

Vue3会给我们的开发带来些什么(与Vue2对比):

写法上的不同

  • Vue 3 的 Template 支持多个根标签,Vue 2 不支持
  • 在Vue 3 的setup中用context代替this
  • vue3中 v-model可以使用到任意组件,默认通过modelValue属性和自定义事件实现,不依赖于表单元素事件,且通过传递参数同一组件可以使用多个v-model,不需要固定的属性名和事件名了,手动去处理
  • vue3由于fragments特性,vue3可以将v-for绑定在template标签上
  • v-bind合并策略改变,同一属性的绑定后者会覆盖前者
  • vue3中通过refs获取子组件或dom元素
  • vue3监听数组改变 必须启动deep:true,否则,只能监控到地址的改变
  • emits选项指定的事件由emit触发,其它事件绑定到emit触发,其它事件绑定到attrs属性上,交开发者控制
  • 现在attrs不但会获取父作用域中不作为组件props的值,也可以获取到自定义事件(包含了attrs不但会获取父作用域中不作为组件props的值,也可以获取到自定义事件(包含了listeners的功能)
  • teleport(新增)将内容插入到目标元素中

一些移除的方法

  • 移除functional(函数式组件),使用了新的异步组件的生成方法

  • 移除filter

  • 移除$mount,统一使用createApp的mount方法来挂载

  • 移除$destory,使用createApp的unmount方法来卸载

  • 移除$children

  • 移除inline template attributes内联模版

  • 移除$on,$once,$off实例方法

  • 移除$listener

  • 移除$scopedSlots

  • 移除.native事件修饰符,并由emits选项代替

  • 指令的生命周期钩子函数与组件生命周期对应

Vue源码项目结构

  • reactivity:响应式系统

  • runtime-core:与平台无关的运行时核心 (可以创建针对特定平台的运行时 - 自定义渲染器)

  • runtime-dom: 针对浏览器的运行时。包括DOM API,属性,事件处理等

  • runtime-test:用于测试

  • server-renderer:用于服务器端渲染

  • compiler-core:与平台无关的编译器核心

  • compiler-dom: 针对浏览器的编译模块

  • compiler-ssr: 针对服务端渲染的编译模块

  • template-explorer:调试编译器输出的开发工具

  • shared:多个包之间共享的内容

  • vue:完整版本,包括运行时和编译器

monorepo介绍

Vue3中 运用了monorepo,使用 yarn workspace + lerna来管理项目

monorepo 是一种管理代码的方式,在这种方式下会摒弃原先一个 module 一个 repo 的方式,取而代之的是把所有的 modules 都放在一个 repo 内来管理。简单来说,就是一个大的Git仓库,管理所有的代码(案例:create-react-app, Babel, react-router)

常见解决方案:

  1. lerna

lerna, 在项目 repo 中以lerna.json声明 packages 后,lerna 为项目提供了统一的 repo 依赖安装 (lerna bootstrap),统一的执行 package scripts (lerna run),统一的 npm 发版 (lerna publish) 等特性 官网:Learn

  • 全局安装

    npm install lerna -g lerna init

  • 常用命令

    //安装依赖生成软链 lerna bootstrap //查看所有包 lerna ls //发布包 lerna publish

  1. yarn

yarn workspace特性,yarn 突出的是对依赖的管理,包括 packages 的相互依赖、packages 对第三方的依赖,yarn 会以 semver 约定来分析 dependencies 的版本,安装依赖时更快、占用体积更小。 yarn 官网对 workspace的详细说明:Workspaces | Yarn

// vue-next项目中package.json
"workspaces": [
    "packages/*"
  ],

Vue3项目搭建的方式

回顾Vue2的搭建方式

npm install -g @vue/cli
vue create xxx

基于 vue/cli 配置 vue3.0

npm install -g @vue/cli
vue --version   // cli版本必须大于等于4.3.1
vue create xxx
vue add vue-next 或者 npm install @vue/composition-api --save

基于vite配置vue3.0

vite简介

  • 基于浏览器原生 ES imports 的开发服务器,利用浏览器去解析 imports

  • 同时不仅有 Vue 文件支持,还搞定了热更新,省略了打包的过程,开发时项目的整体启动速度和文件多少无关

  • 快速的冷启动

  • 即时的模块热更新

  • 真正的按需编译

  • vite github地址:[vite]

    npm init vite-app xxx cd xxx npm install npm run dev // 运行 npm run build // 打包

reactivity响应式原理介绍与实现

reactivity模块将响应式的功能和vue代码解耦,单独作为一个js库来调用在其他任何js运行的框架当中,都可以引入和使用vue3的这个响应式功能

响应式原理介绍

这里不全面讲解在Vue3中如何使用Composition API,只大概讲解其中的一部分,主要讲解实现响应式原理的思想

setup

  1. setup 函数是一个新的组件选项,作为在组件内使用 Composition API 的入口点

  2. 运行在组件被实例化时候,在初始化props和beforeCreate之间调用

  3. 可以接收 props 和 context

  4. this在setup()中不可用

  5. 返回的是一个对象,单文件组件模板中会用到返回的对象来渲染响应式视图

reactive

  1. 将一个普通对象经过Proxy的加工变为一个响应式的对象,reactive 基于proxy对数据进行深度监听,以此构建响应式(ref不是基于Proxy的,ref是基于Object.defineProperty())Proxy介绍:Proxy|MDN

  2. 用于处理引用数据类型

  3. 等同于 Vue2.x 的 Vue.observable()

  4. 与Vue2.x不同的是,Vue2.x是会先把数据对象递归遍历转化成为响应式对象,然后再使用,Vue3是当使用到数据的时候,再通过Proxy转化为响应式对象,这一点上也相比较Vue2.x节省了性能

  5. 加工后的对象属于深度克隆的对象,并非原对象

  6. 如果模板中的数据不想使用获取对象的方式来渲染的话,toRefs(把reactive中的每一项变为ref响应式的数据)

ref

  1. 接受一个参数值并返回一个响应式且可改变的 ref 对象

  2. 通常处理基本数据类型

  3. ref 对象拥有一个指向内部值的单一属性 .value,当ref在模板中使用的时候,它会自动解套,无需在模板内额外书写 .value (例如const a = ref(0),在逻辑代码中使用a的值要加.value,在模板中使用可以直接使用a,不用加.value)

computed

  1. 传入一个 getter 函数,返回一个默认不可手动修改的 ref 对象

  2. 参数可修改为一个包含get和set函数的对象,创建一个可手动修改的计算状态

  3. Vue3 的computed与 Vue2.x 的computed相比,他的用法稍有不同,内部实现不同,但是概念理解完全相同

watch

  1. 完全等效于 Vue2.x this.$watch

  2. watch 需要侦听特定的数据源,并在回调函数中执行副作用

  3. 默认情况是懒执行的,也就是说仅在侦听的源变更时才执行回调

watchEffect

  1. 传入一个函数,并响应式追踪其依赖,并在其依赖变更时重新运行该函数

  2. 与watch不同的是,他会在项目初始化的时候就立即执行一次

响应式原理简单实现

进入正题,实现reactive,ref,computed,effect

项目src目录下建立reactivity目录,用来存放响应式的api(reactive等),每个响应式api对应一个js文件,每个文件导出相应的方法,reactivity目录如下

reactivity
│   ├── baseHandlers.js
│   ├── computed.js
│   ├── effect.js
│   ├── index.js
│   ├── operations.js
│   ├── reactive.js
│   └── ref.js

在reactivity目录下建立一个index.js文件,整合这些方法,对每个api文件既导入又导出

//index.js

export {computed} from './computed';
export {effect} from './effect';
export {reactive} from './reactive';
export {ref} from './ref';

reactive实现思想

Vue3 里使用了 Proxy 和 Reflect 实现reactive,前者拦截赋值操作,后者完成赋值的默认行为,Proxy介绍:Proxy|MDN、Reflect介绍:Reflect|MDN

Proxy 用于修改某些操作的默认行为,等同于在语言层面做出修改,所以属于一种“元编程”(meta programming),即对编程语言进行编程。

reactive用法:

import {reactive} from '@vue/reactivity'
const state = reactive({name: 'GSX', number: 20000, arr: [1 , 2, 3]});
//打印
console.log(state)
console.log(state.name)
//输出
Proxy {name: "GSX", number: 20000, arr: Array(3), __v_reactive: Proxy}
GSX

reactive.js简单实现

// target是我们传入的,要变成响应式的目标对象
export function reactive(target) {
    // 用proxy进行代理 创建一个响应式的对象 目标对象可能不一定是数组或者对象
    // mutableHandlers是baseHandlers.js导入的,对proxy处理过的对象进行操作的方法
    return createReactiveObject(target, mutableHandlers);
}
function createReactiveObject(target, baseHandler) {
    // 如果不是对象,就不执行
    if(!isObject(target)) 
        return
    }
    const observed = new Proxy(target, baseHandler);
    // 返回代理后的结果
    return observed;
}

把一个普通对象变成响应式的对象,大概流程如下

082FE0DE-DAD6-44BC-AA7B-3F968A228110.png

baseHandlers.js

const get = createGetter();
const set = createSetter();
function createGetter() {
    // 进行取值操作
    // proxy + reflect
    return function get(target, key, reciver) {
        const result = Reflect.get(target, key);
        // 依赖收集
        track(target, 'get', key);
        if (isObject(result)) {
            return reactive(result);
        }
        return result
    }
}

function createSetter() {
    // 进行设置值
    return function set(target, key, value, reciver) {
        const hadKey = hasOwn(target, key);
        const oldVal = target[key];
        const result = Reflect.set(target, key, value, reciver);
        if (!hadKey) {
            // 依赖更新(新增)
            trigger(target, 'add', key, value, oldVal)
        }
        else if(hasChanged(value, oldVal)) {
            // 依赖更新(修改)
    

流程图:

B43B872F-9215-4403-9B08-559C3CBBA128.png

ref实现思想

ref的原理就是将一个普通值,转化成对象,并且在获取和设置值时可以增加依赖收集和触发更新的功能,ref通常用于处理基本数据类型的值,也可以处理引用数据类型,例如const value = ref({a:1, b:2});但是传入一个引用数据类型,ref会将传入的值用reactive进行处理成响应式数据

ref用法(其用法类似于React的useState()):

import {reactive, effect, ref, computed} from '@vue/reactivity'
const status = ref(true);
//打印
console.log(status)
console.log(status.value)
//输出
RefImpl 

ref的简单实现:

export function ref(value) {
    return createRef(value);
}
function convert(rawValue) {
    return isObject(rawValue) ? reactive(rawValue) : rawValue
}
function createRef(rawValue) {
    let value = convert(rawValue);
    let result = {
        __v_isRef: true,
        get value() {
           // 取值依赖收集
            track(result, TrackOpTypes.GET, 'value')
            return value
        },
        set value(newVal) { 
            if (hasChanged(newVal, rawValue)) {
                rawValue = newVal;
                value = newVal
              // 设置时触发更新
                trigger(result, TriggerOpTypes.SET, 'value')
            }
        }
   }
    retun result
}

ref实现流程图:

6072AB1D-CF60-49A6-ABC5-044923A5EEA2.png

effect实现思想

  1. effect并不是Composition API,在写Vue3的时候,也不会直接使用到effect,但是很多Composition API都是基于effect来实现的

  2. effect翻译过来就是影响、副作用的意思

  3. 默认effect会立即执行,在执行之前,先把effect变成全局的activeEffect,以供响应式数据收集依赖。当依赖的值发生变化时effect会重新执行(也就是依赖收集、依赖更新的过程)

  4. effect接收两个参数,一个是要执行的function,一个是传入Composition API的option选项

  5. computed、watchEffect、watch等都是基于effect实现的,不同的api传入不同的option

  6. effecf抛出两个很重要的方法,track()用于处理依赖收集,trigger()用于处理依赖更新

  7. effecf相当于Vue2.x中的Watcher

  8. effect其实就是一个依赖收集、依赖更新函数,在它内部访问了响应式数据,响应式数据就会把这个effect函数作为依赖收集起来,下次响应式数据改了就触发它重新执行。

effect的简单实现:

export function effect(fn, options = {}) {
    // 数据一变 自动更新
    const effect = createReactiveEffect(fn, options);
    if (!options.lazy) {
        effect();
    }
    return effect;
}
// 创建响应式effect
let uid = 0;
let activeEffect;
const effectStack = [];
function createReactiveEffect(fn, options) {
    const effect = function reactiveEffect() {
        if (!effectStack.includes(effect)) {
            // 是一个依赖收集的过程
            try {
                effectStack.push(effect);
                // 将effect放到了当前effect上
                activeEffect = effect;
                // 计算属性会用到
                return fn();
            }
            finally {
                effectStack.pop()
                activeEffect = effectStack[effectStack.length -1]
            }
        }
    }
    effect.options = options;
    effect.id = uid++;
    // 依赖了哪些属性
    effect.deps = []
    // todo
    return effect;
}
// 用法和map一致  但是WeakMap是弱引用 不会导致内存泄漏
const targetMap = new WeakMap();
export function track(target, type, key) {
    // 如果当前没有activeEffect
    if (activeEffect == undefined) {
        return;
    }
    // 根据key进行取值
    let depsMap = targetMap.get(target);
    // 如果没有key,就构建一个
    if(!depsMap) {
        targetMap.set(target, (depsMap = new Map()));
    }
    let dep = depsMap.get(key);
    // 如果没有dep就接着构建
    if(!dep) {
        depsMap.set(key, (dep = new Set()))
    }
    // 防止effect中多次记录
    // 这里dep就是set
    if(!dep.has(activeEffect)) {
        // 属性依赖了effect
        dep.add(activeEffect);
        // 让这个effect记录dep属性
        activeEffect.deps.push(dep);
    }
}
export function trigger(target, type, key) {
    console.log(111,targetMap,target, key);
    // 获取当前对应的map
    const depsMap = targetMap.get(target);
    if (!depsMap) {
        return;
    }
    const run = (effects) => {
        if (effects) {
            effects.forEach(effect => effect())
        }
    }
    // 出发的时候判断两种情况,判断是修改还是添加
    if (key !== null) {
        // 去执行map下对应的kay的effec
        run(depsMap.get(key));
    }
    if (type === 'add') {
        // 对数组新增属性 会触发length对应的依赖,在取值的时候会对length进行依赖收集
        run(depsMap.get(Array.isArray(target) ? 'length' : ''))
    }
}

流程图,如果下面流程图的图看不清,请打开流程图的地址effect流程图

effect实现流程图:

screencapture-processon-view-link-5f57aab35653bb53ea9899f2-2020-09-09-00_10_49.png

computed实现思想

  1. computed是基于effect实现的

  2. 和Vue2.x一样,默认执行getter,也可以设置setter

computed用法:

import {reactive, effect, ref, computed} from '@vue/reactivity'
const state = reactive({name: 'GSX', number: 20000, arr: [1 , 2, 3]});
// computed是lazy属性为true的effect
let allNumber = computed(()=>{ 
    console.log('ok')
    return state.number * 2;
});
//打印
console.log(allNumber.value);
//输出
ok
40000

简易版计算属性实现

export function computed(getterOrOptions) {
    let getter;
    let setter;
    if (isFunction(getterOrOptions)) {
        getter = getterOrOptions;
        setter = () => {}
    }
    else{
        getter = getterOrOptions.get;
        setter = getterOrOptions.set;
    }
    // 默认第一次取值是执行getter方法的
    let dirty = true; 
    let computed;
    // 计算属性也是一个effect 
    let runner = effect(getter,{
        // 懒加载标识
        lazy: true,
        // 这里仅仅是标识而已 是一个计算属性
        computed: true,
        scheduler:()=>{
            if(!dirty){
                // 计算属性依赖的值发生变化后 就会执行这个scheduler
                dirty = true;
                trigger(computed,TriggerOpTypes.SET,'value')
            }
        }
    })
    let value;
    computed = {
        get value(){
            // 多次取值 不会重新执行effect
            if(dirty){ 
                value = runner();
                dirty = false;
                track(computed,TrackOpTypes.GET,'value')
            }
            return value;
        },
        set value(newValue){
            setter(newValue);
        }    
    }
    return computed;
}

computed实现思想流程图:

7BB6D30A-4853-4EDF-91F8-F75546AA14F7.png

总结

以上就是对reactive、ref、effect、computed的一个简单实现,在使用Vue3的过程中,需要注意一些点:

  • 使用reactive时,你的数据不止要是 typeof === ‘object’,你还必须的是以下的几种数据结构, Object, Array, Map, Set, WeakMap, WeakSet ,不然得不到响应式数据,相应的api有reactive、shallowReactive、readonly、shallowReadonly

  • 对象不能被 makRaw 处理过(判断ReactiveFlags.SKIP === "__v_skip"),也不能经过 Object.frozen() 处理

  • 一个响应式数据不能即是readonly又是reactive,如果你给reactive传入一个已经是响应式数据的数据,reactive不会再次把数据变为响应式数据,会给你返回你传入的原值

  • 我们在访问和设置某个响应式数据state的时候实际是调用了他们属性的 getter/setter 方法,但是这个state作为一一个返回值或者参数的时候,它实际是作为一个值传递到了另外要给方法中,所以他的 getter/setter 将会丢失,数据无法响应。 以下是举例

    function getValue() { const value = reactive({a: 0, b: 0}) return value } export default { setup() { // 只取得getValu方法返回值的引用值,而值里面的getter/setter丢失 const { a, b } = getValue() // 响应丢失 return { a, b } // 响应丢失 return { ...getValue() } // 响应不丢失,这里的pos实际是getValu里面的pos的值,所以pos.x与pos.y的属性依旧存在 return { pos: getValue() } } }

解决方法:给返回的数据放到toRefs中,把一个响应式对象转换成普通对象,该普通对象的每个 property 都是一个 ref ,和响应式对象 property 一一对应

function getValue() {
  const value = reactive({a: 0, b: 0})
  return toRefs(value)
}
export default {
  setup() {
    // 下面的使用方法都可以,响应式不会丢失
    const { a, b } = getValue()
    return { a, b }
    return { ...getValue() }
  }
}
  • 值得一提的是,Vue3中并没有抛弃Object.defineProperty(),响应式ref还是基于Object.defineProperty(),除过reactivity,其他模块也有用到Object.defineProperty()的地方,所以在面试中或者看文章时如果有人说Vue3完全抛弃了Object.defineProperty(),这个说法一定是错误的

品效合一,科技助力!

科技赋能,有效增长!

将心注入,全力以赴!