• 说一下vue响应式原理?可不只有proxy


    凭借着之前的学习积累,用自己的方式叙述一下自己所学的知识点,笔者高中讨厌写作文,硕士期间不喜写论文,水平肯定有限,能详述之前所学的知识已是不易,若能给读者带来一点启发,将倍感荣幸,同时也虚心接受大佬、同仁指点。

    Monorepo管理项目

    • Monorepo是管理代码的一种方式,可以在一个项目仓库下,管理多个 子项目,Vue3注重模块的拆分,单个模块可以单独使用,不需要引入完整的vuejs包。因此,Vue3使用Monorepo管理项目,每个模块都单独放在packages目录下。
    • 大佬的文章:Monorepo详解

    Monorepo环境搭建

    • pnpm是快速的,节省空间的包管理器,类似于npm、yarn。主要采用符号链接的方式管理模块。
    • 全局安装npm install pnpm -g # 全局安装pnpm
    • 初始化: pnpm init -y # 初始化配置文件
    • 这里我们尝试安装一下vue3:pnpm install vue@next,我们发现在node_modules下的vue文件夹下,只有vue的集成文件,没有其各个模块的依赖文件,这是因为pnpm对依赖文件做了处理,全部隐藏在node_modules/.pnpm文件夹下。这样操作避免了幽灵依赖的问题。
      • 所谓幽灵依赖,是指当项目中引入了A包后,如果A包内部引用了B包,在npm A包的时候同时也会把B包给下载下来,这就导致了一个问题,项目中没有要求下载B包,package.json中也只有A包的依赖记录,但是自然而然的,代码中却可以引用B包。
      • 我们的npm包管理方式,显然就是这种模式,要想按照npm的方式,把模块的依赖模块保留,只需要根目录创建.npmrc文件,将依赖提升即可:shamefully-hoist = true
      • 这样,重新安装vue3,发现已经能看到其所有的依赖
        [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ktYMW4IE-1666956629030)(https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/fb94da70ef964e98af5119498a20b2e8~tplv-k3u1fbpfcp-watermark.image?)]
    • vue3每个包都是一个独立的模块,并且可以单独引用,因此需要在项目的packages文件夹下,挨个创建vue3的模块,模块之间可以相互引用,新建pnpm-workspace.yaml文件将packages下的所有目录都标记为包进行管理,这样Monorepo的环境就搭建好了。
    packages:
     - 'packages/*'
    
    • 1
    • 2
    • 此时,如果我们卸载安装的Vue,重新安装,控制台将会报错,原因就是,你的包得安装在packages目录下,我们可以使用pnpm install vue -w,来强制安装到外层的node_modules中

    开发环境安装

    • 我们写的源码需要打包,这里使用esbuild来打包我们的Vue源码,使用typescript来标注类型,使用minimist来监视控制台命令,因此需要全部安装:pnpm install esbuild typescript minimist -D -w
    • 使用ts的话,需要配置ts相关的命令:pnpm tsc --init,生成tsconfig.json文件,在文件中配置:
    {
    
      "compilerOptions": {
    
        "outDir": "dist",
    
        "sourceMap": true, // 采用sourcemap
    
        "target": "es2016", // 目标语法
    
        "module": "esnext", // 模块格式
    
        "moduleResolution": "node", // 模块解析方式
    
        "strict": false, // 严格模式
    
        "resolveJsonModule": true, // 解析json模块
    
        "esModuleInterop": true, // 允许通过es6语法引入commonjs模块
    
        "jsx": "preserve", // jsx 不转义
    
        "lib": ["esnext", "dom"], // 支持的类库 esnext及dom
    
        "baseUrl": ".",
    
        "paths": {
    
          "@vue/*": ["packages/*/src"]
    
        }
    
      }
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35

    一个库必须要考虑的一步:打包源码

    打包的格式

    我们打开node_modules下的vue包的dist文件夹,发现vue打包的文件有多种格式,这是为了给用户在不同的使用场景下使用的,打包的格式不同,对用的使用规范也是不同的。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4DPOr93M-1666956629031)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/64a3797d36a241efb3d55ff0afb62215~tplv-k3u1fbpfcp-watermark.image?)]

    • 总体来说可以分为三种格式:
      • node中使用的格式:commonjs(cjs)格式
      • esmodule使用的格式:esm
        • esm-bundler:将所有模块打包时集成到一起
      • 浏览器直接通过script引入来使用的格式:iife自执行函数(global)

    包与包之间的依赖

    vue3的响应式是一个独立的包,通过包管理工具下载的vue3可以看出来,里面有一个reactivity包,就是vue的响应式模块,shared包是存放公共逻辑的模块。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-F7V5cimx-1666956629032)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/105a0ad02a3843c19183ecaba5f09ed4~tplv-k3u1fbpfcp-watermark.image?)]

    • 模仿vue包在项目中创建packages文件夹:
      • 分别在该文件夹下创建 reativity文件夹和shared文件夹
      • 分别在两个文件夹通过 pnpm init 初始化项目模块,然后创建scr/index.ts作为该模块打包的入口文件
      • 为方便模块的打包和模块间引用,两个模块的package.json分别配置如下:
    {
     "name": "@vue/reactivity", 
     "version": "1.0.0",
     "description": "",
     "main": "index.js",
     "module": "dist/reactivity.esm-bundler.js",
     "scripts": {},
     "buildOptions": {
       "name": "reactivity",
       "formats": [
         "esm-browser",
         "esm-bundler",
         "cjs",
         "global"
       ]
     },
     "dependencies": {},
     "devDependencies": {}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    {
     "name": "@vue/shared",
     "version": "1.0.0",
     "description": "",
     "module": "dist/shared.esm-bundler.js",
     "scripts": {},
     "keywords": [],
     "author": "",
     "license": "ISC",
     "buildOptions": {
       "name": "reactivity",
       "formats": [
         "esm-browser",
         "esm-bundler",
         "cjs",
         "global"
       ]
     },
     "dependencies": {},
     "devDependencies": {}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 我们在shared/src/index.ts下简单导出一个函数
    export function isObject(value:any){
       return value !==null && typeof value == 'object'
    }
    
    • 1
    • 2
    • 3
    • 而在reactivity中引入这个模块就很简单,只需要import {isObject } from '@vue/shared'即可。
      • 这里的路径@vue不会去node_modules下查找,原因是我们在tsconfig.json中配置了paths。

    模块的打包流程

    • 在根目录下新建script/dev.js脚本,通过运行该脚本实现模块的打包
    • 在根目录package.json的script下,配置运行脚本的命令"dev": "node scripts/dev.js reactivity -f esm",
      • 使用npm run dev ,会运行dev.js,默认打包reactivity模块,默认格式为esm
    • 至此,我们梳理一下打包的流程:
      • 首先,用户输入npm run dev **,运行打包脚本,同时传入打包的参数
      • dev.js运行,接收用户传入的参数
      • 根据参数,确定打包的模块,打包的格式,打包的输出目录
      • 调用esbuild模块,对模块进行打包。
    • 了解了基本的流程,我们开始对dev脚本进行完善。
      • 首先,接收用户的参数,我们知道,通过process.argv可以获取用户在命令台输入的命令,而minimist就是一个很好的解析命令的模块,将命令传给minmist,它可以解析成固定的格式传给我们,我们在控制台输入npm run dev:
      const args = require('minimist')(process.argv.slice(2))
      console.log(args) // { _: [ 'reactivity' ], f: 'esm' }
      
      • 1
      • 2
      • 了解了它的格式,我们就能解析,获取用户的命令,从而确定要打包的文件夹,打包格式,以及输出地址等。
      const target = args._[0] || 'reactivity'
      const format = agrs.f || 'global'
      //查找打包模块下的package.json
      const pkg = require(path.resolve(__dirname,`../packages/${target}/package.json`))
      
      //输出格式
      const outputFormat = format.startsWith('global') ? 'iife' : format== 'cjs'?'cjs':'esm'
      //输出地址
      const outFile = path.resolve(__dirname,`../packages/${target}/dist/${target}.${format}.js`)
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 然后调用esbuild中的build函数,打包即可
      //输出地址
      const outFile = path.resolve(__dirname,`../packages/${target}/dist/${target}.${format}.js`)
      build({
        entryPoints: [path.resolve(__dirname, `../packages/${target}/src/index.ts`)],
        outfile: outFile,
        bundle: true,
        sourcemap: true,
        format: outputFormat,
        globalName: pkg.buildOptions?.name,
        watch: {
          onRebuild(error) {
            if (error) console.log('~~~')
          }
        },
        platform: form
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 运行命令,可以看到reactivity文件夹下生成打包的文件

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LgZpYFc3-1666956629032)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/80e5a1116c4343e1a3602f338d273fd8~tplv-k3u1fbpfcp-watermark.image?)]

    万事俱备,响应式开造

    最开始的两个函数

    相信大家在最开始接触响应式的时候,必会接触一个函数reactive,函数的作用想必大家也都清楚:将一个对象变成响应式。还有一个函数叫effect,他能监听一个函数,当响应式数据变化后,让这个函数重新执行。这个函数可能大家有点陌生,原因就是在使用Vue的时候,基本使用的都是html模板,数据改变,模板重新刷新

    <body>
      <div id="app"></div>
      <script type="module">
        import { reactive,effect } from '/node_modules/@vue/reactivity/dist/reactivity.esm-browser.js'
        const state = reactive({
          name:'宫本',
          age:18
        })
        //传入一个副作用函数
        effect(()=>{
           document.getElementById('app').innerHTML = state.name +":"+ state.age
        })
        setTimeout(()=>{
          state.age=19
        },1000)
      </script>
    </body>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    可以看到,vue的响应式基本靠这个函数可以诠释,我们接下来就尝试实现这两个函数

    • 在reactivity/src下新建effect.ts文件和reactive.ts文件,分别声明这两个函数,并在index.ts中集成导出
    reactive函数实现
    • 首先尝试实现一下reactive函数,我们可以模仿着源码里的原函数的功能去构思:

      • 首先,reactive接收一个对象,不是对象那直接返回即可
      • 其次,要对这个对象做一下代理
      import { isObject } from "@vue/shared";
      export function reactive(target){
        if(!isObject(target)){
          return target
        }
      
        const proxyObj = new Proxy(target,{
          get(target,key,receiver){
            return target[key]
          },
          set(target,key,value,receiver){
            target[key] = value
            return true
          }
        })
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 我们可以很容易的想到利用proxy对数据做代理,这样取数据和改变数据的时候都可以监测到,但是这里面有一个很大的问题:
      const obj = {
      name:'小明',
      get getName(){
      return this.name
      }
      }
      
      const proxy = new Proxy(obj,{
        get(target,key,receiver){
           console.log(key)
          return target[key]
        },
        set(target,key,value,receiver){
          return true
        }
      })
      
      console.log('sx',proxy.getName)
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18

      我们可以梳理一下这个流程:访问proxy.getName,由于proxy是Porxy实例,所以访问属性的时候会触发get操作,返回对象的target[key],此时的target是obj,key是getName,控制台输出getName,相当于执行obj.getName。最关键的一步来了,getName里面会访问name属性,此时是会访问obj里面的name,还是会访问proxy里面的name?

      按照我们响应式的设想,访问了getName之后也会访问到name,既然有属性被访问,都应该被监测到,但是这里的name属性不会被监测,原因就是执行target[key]后,这里的this指向obj,所以会自然走到obj.name中,不会走proxy.name,既然不会代理,自然不会被监测到。

      显然,这不符合我们响应式的预期,那么如何解决属性里面this指向的问题,让它一直指向proxy实例呢?Reflect对象可以将this指针绑定在传入的对象上,完美解决这个问题。Reflect介绍

      const obj = {
          name:'小明',
          get getName(){
            return this.name
          }
            }
      
        const proxy = new Proxy(obj,{
          get(target,key,receiver){
             console.log(key)
            return Reflect.get(target,key,receiver)
          },
          set(target,key,value,receiver){
            return Reflect.set(target,key,value,receiver)
          }
        })
      
        console.log('sx',proxy.getName)
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18

      Reflect.get中最后一个参数receiver表示当前实例proxy,它会将操作的指针绑定在proxy上,这样访问任何属性,都会触发代理。

    • 上述对数据实现简单的代理,目的是在获取属性和修改属性的时候能有感知和拦截(get,set),但是这个代理对象依然有很大的问题。我们来仔细探究一下:

      • 假如有个老六写出下面这种代码:
       const obj = {
       name:'宫本',
       age:18
      }
      const state1 = reactive(obj)
      const state2 = reactive(obj)
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6

      对一个数据同时代理两次,很显然两个代理对象是不相等的,因为开辟了两个内存空间,这显然不符合我们的预期要求,因为对一个数据的代理对象只能有一个,不然后面使用代理对象,使用哪一个呢,数据都不同步了,不断创建空间,对性能也开销很大。

      对此在对一次代理对象的时候,可以在reactive内部对对象做缓存,当再次代理同一个对象的时候,取出这个缓存即可。

      import { isObject } from "@vue/shared";
      //get,set操作抽离到baseHandles中
      import { mutableHandlers } from './baseHandles'
      const reactiveMap =new WeakMap()
      export function reactive(target){
       if(!isObject(target)){
         return target
       }
       const existProxy = reactiveMap.get(target)
       if(existProxy){
         return existProxy
       }
       const proxyObj = new Proxy(target,mutableHandlers)
       reactiveMap.set(target,proxyObj)
       return proxyObj
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 假如老六又写出这种代码:
      const obj = {
          name:'宫本',
          age:18
        }
        const state1 = reactive(obj)
        const state2 = reactive(state2)
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6

    代理对象也是个对象,也能作为reactive的参数,但是对代理对象做代理,显然没有什么意义,vue中针对这种情况,是在内部判断传入的对象,如果已经是代理对象,则直接返回就行。而判断对象是代理对象的方式也很巧妙,它是通过尝试对对象进行取值,如果能触发get操作,则说明它是代理对象,直接返回该对象。

    export enum ReactiveFlag {
      IS_REACTIVE = '__is_Reactive'
    }
    
    const reactiveMap =new WeakMap()
    export function reactive(target){
      if(!isObject(target)){
        return target
      }
      //尝试触发对象的get操作
      if(target[ReactiveFlag.IS_REACTIVE]){
        return target
      }
      const existProxy = reactiveMap.get(target)
      if(existProxy){
        return existProxy
      }
      const proxyObj = new Proxy(target,mutableHandlers)
      reactiveMap.set(target,proxyObj)
      return proxyObj
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
      get(target,key,receiver){
        if(key === ReactiveFlag.IS_REACTIVE){
          return true
        }
        return target[key]
      },
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    effect函数实现

    reactive数据代理函数初步实现之后,需要完成effect函数,该函数传入一个副作用函数,实际上,这个副作用函数会在effect函数内部默认执行一次,如果副作用函数里面有代理的数据,那么数据就会记住你这个effect函数,然后某个时间段数据变化了,这个数据就会找到它记住的effect函数,依次让这些函数执行,那么对应的,页面开始刷新,刷新后的数据就是最新的数据。

    首先,effect函数内部会创建一个类,将传入的副作用函数传入这个类中,生成一个实例,实例有一个run方法,会在内部执行这个副作用函数。由于effect函数传入副作用函数后,会默认执行一次副作用函数。因此,其详细的流程应该是,effect传入副作用函数,内部根据副作用函数传入一个类中声明一个实例,实例调用run方法,执行副作用函数。

    
    class ReactiveEffect{
      public active = true
      public deps=[]
      constructor(public fn){
      }
      run(){}
    }
    
    
    export function effect(fn){
      const _effect = new ReactiveEffect(fn);
      _effect.run()
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    ReactiveEffect是一个响应式类,意味着,通过这个类会创建一个响应式的实例,这个类在effect内部实例化。类中的active属性,是一个控制器,默认为true,意味着是否创建响应式 实例,因为有些场合不需要响应式(false),可以理解为是一个控制操作,后面的需求中会讲到。deps属性是一个收集装置,执行run函数会执行传入的副作用函数,副作用函数中会调用代理的数据,deps的作用就是记录当前的响应式实例所对应的代理数据。在里面调用了哪个数据,它就记录上哪个。

    run函数内部判断active,若是为true,则执行副作用函数

    run(){
        if(this.active){
          return this.fn()
        }
      }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    下面就是最关键的一步,如何让effect与reactive联立起来,当effect内部调用副作用函数,让reactive感知到,并记录这个响应式函数以便下次更新再次执行这个函数,更新页面数据。

    其实,我们可以根据js单线程的特点实现这个逻辑,假设我们创建一个全局指针,当创建响应式实例后,执行run函数前,将指针指向响应式实例,然后执行run函数,run函数内部执行副副作用函数,副作用函数内部访问代理属性,触发get操作,get内部收集相关数据对应的指针。然后某个时刻,数据改变,触发数据的set操作,更改数据,获取属性对应的指针集合,通过指针执行其run函数,再次执行副作用函数,访问数据,此时的数据是最新的,然后页面刷新。

     run(){
        if(!this.active){
          //直接执行函数,不进行后面的依赖收集
          return this.fn()
        }
        try {
          activeEvent = this
          return this.fn()
        }finally{
          //执行完之后,让指针置空
          activeEvent = null
        }
      }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    在代理操作中,get读取到某个属性,监测当时是否在_effect中执行的(activeEffect存在),如果不是,则不需要进行依赖收集,如果是,则进行依赖收集,收集的格式是target -> key -> activeEffect,数据改变后,set中,取出收集的activeEffect集合,依次执行其run函数

    get(target,key,receiver){
        if(key === ReactiveFlag.IS_REACTIVE){
          return true
        }
        //收集依赖
        if(activeEffect){
          //从对象中寻找键值,纪录键值对应的指针集合
          let depsMap = targetMap.get(target)
          if(!depsMap){
            targetMap.set(target,(depsMap = new Map()))
          }
          let deps = depsMap.get(key)
          if(!deps){
            depsMap.set(key,(deps = new Set()))
          }
          deps.add(activeEffect)
        }
        return target[key]
      },
      set(target,key,value,receiver){
        target[key] = value
        //数据更改,找到对应的key收集的_effect实例
        let depsMap = targetMap.get(target)
        if(!depsMap){
          //不存在,说明之前副作用函数中没有使用过这个属性,则这个属性改变不需要重现刷新页面
          return ;
        }
        let effects = depsMap.get(key)
        //依次执行
        if(effects){
          effects.forEach(effect=>effect.run())
        }
        return true
      }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    我们尝试打包我们的代理,使用他们,发现已经可以实现简单的响应式

      <script type="module">
        import { reactive,effect } from './reactivity.esm.js'
        const obj = {
          name:'宫本',
          age:18
        }
        const state = reactive(obj)
       
    
        //传入一个副作用函数
        effect(()=>{
           document.getElementById('app').innerHTML = state.name +":"+ state.age
        })
        setTimeout(()=>{
          state.age=19
        },1000)
      </script>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Pd0wMHN3-1666956629032)(https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3c262470967f487aa78564d6926c8740~tplv-k3u1fbpfcp-watermark.image?)]

    我们在代理对象中的set和get属性中设置了依赖收集和重新执行依赖的逻辑,对于这部分逻辑,我们可以抽离出来,使逻辑直接负责的任务更加纯粹一些

    export const mutableHandlers = {
      get(target,key,receiver){
        if(key === ReactiveFlag.IS_REACTIVE){
          return true
        }
        track(target,key)
        return Reflect.get(target,key,receiver)
      },
      set(target,key,value,receiver){
    
        let oldValue =  target[key] 
        let r  = Reflect.set(target,key,value,receiver)
        if(oldValue !==value){
          trigger(target,key,value)
        }
        return r
       
      }
    }
    export function track(target,key){
       //收集依赖
       if(activeEffect){
        //从对象中寻找键值,纪录键值对应的指针集合
        let depsMap = targetMap.get(target)
        if(!depsMap){
          targetMap.set(target,(depsMap = new Map()))
        }
        let deps = depsMap.get(key)
        if(!deps){
          depsMap.set(key,(deps = new Set()))
        }
        deps.add(activeEffect)
        trackEffect(deps)
      }
    }
    
    export function trackEffect(deps){
      let shouldTrack = !deps.has(activeEffect)
      if(shouldTrack){
        deps.push(activeEffect)
      }
      //让_effect实例记住对应的key
      activeEffect.deps.push(deps)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    export function trigger(target,key,value){
       //数据更改,找到对应的key收集的_effect实例
       let depsMap = targetMap.get(target)
       if(!depsMap){
         //不存在,说明之前副作用函数中没有使用过这个属性,则这个属性改变不需要重现刷新页面
         return ;
       }
       let effects = depsMap.get(key)
      triggerEffects(effects)
    }
    
    export function triggerEffects(effects){
       //依次执行
       if(effects){
        effects.forEach(effect=>effect.run())
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    以上实现了一个简单的响应式模块,但是在实际使用中,依然会暴漏出很多问题,在Vue3源码中,做了很多hook的问题处理,这里我们只讲述其简单的逻辑,很一些比较常见的问题。

    • 问题一: effect嵌套,导致指针丢失

    根据我们的逻辑,假设老六写了以下代码:

     import { reactive,effect } from './reactivity.esm.js'
        const obj = {
          name:'宫本',
          age:18
        }
        const state = reactive(obj)
       
        //传入一个副作用函数
        effect(()=>{
          effect(()=>{
            console.log(state.name)
          })
            console.log(state.age)
        })
        setTimeout(()=>{
          state.age=19
        },1000)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    老六写了个effect嵌套,这种在vue中很常见,我们很自然可以想到组件嵌套,后面state.age改变,却不输出任何东西,这是什么原因呢?

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fjRtmn5T-1666956629033)(https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/2c4339c289f84d0290dcb83ad4032477~tplv-k3u1fbpfcp-watermark.image?)]

    我们按照之前响应式的逻辑,逐步分析一下,将外层的eefect设置为e1,内层effect设置为e2,e1创建好,会执行内部的run方法,此时指针指向e1(实际是指向e1内部的响应式类实例,这里简短的说),然后执行传入的副作用函数,副作用函数内部按顺序执行,会先执行e2,e2创建后执行e2内部的run函数,指针指向e2,然后执行e2内部副作用函数,触发stata.name的依赖收集,name属性收集到e2,然后指针 置空。此时e2执行完后,在e1的副作用函数中继续往下执行,执行到state.age的触发,由于此时指针为空,无法进行依赖收集,所以后面修改state.age,不会进行响应式更新

    很明显,这种情况属于内部的指针改掉了外部的指针,然后内部使用完成之后,外部指针没有复原。早期的vue解决方案是通过一个栈来存储指针,指针切换通过入栈出栈的方式来实现。还有一种方案,是在内部创建一个parent指针,记录其外层指针,结束之后将指针重新指向其parent

     public active = true
      public parent = undefined
      public deps=[]
      constructor(public fn){
      }
      run(){
        if(!this.active){
          //直接执行函数,不进行后面的依赖收集
          return this.fn()
        }
        try {
          this.parent = activeEffect
          activeEffect = this
          return this.fn()
        }finally{
          //执行完之后,让指针置空
          activeEffect = this.parent
          this.parent = undefined
        }
      }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 问题二: 代理的数据是多层对象

    对于proxy创建的实例,我们只是对传入的对象的第一层属性做了代理,但是如果属性值还是一个对象,则不会被代理,对此,在get操作中,应该对获取的数据再次判断,倘若是对象,则再次代理。这也是一个性能优化的表现,Vue3一开始并不是直接对传入的对象做深层代理,则是当用户访问到某个属性,触发get后,发现它是对象类型,才会对它做代理。换句话说,就是用到这个数据的时候才会处理它。

     get(target,key,receiver){
       if(key === ReactiveFlag.IS_REACTIVE){
         return true
       }
       track(target,key)
       let r = Reflect.get(target,key,receiver)
       if(isObject(r)){
         return reactive(r)
       }
       return r
     },
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 问题三:vue的分支切换

    分支切换关系到属性依赖收集的问题,对于后来不需要的属性,需要把这个属性收集的依赖给清空掉。

     import { reactive,effect } from './reactivity.esm.js'
        const state = reactive({
          flag:true,
          age:18,
          name:'sx'
        })
        effect(()=>{
          document.getElementById('app').innerHTML = state.flag? state.age:state.name
          console.log('sx')
        })
        setTimeout(()=>{
          state.flag = false
          setTimeout(()=>{
            state.age = 19
          },1000)
        },1000)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    可以看到,当flag改变。age属性已经和页面没有任何关系,但是改变age,依然会刷新页面,这显然不合理。

    分析上述代码属性收集依赖的过程,1.副作用函数fn第一次执行,flag ->effect,age->effect
    2. flag改变fn再次执行,flag->effect,name->effect。此时页面数据和age没有关系,但是在第一次fn执行 时,age->effect,因此age改变,依然会执行fn。

    xport function triggerEffects(effects){
       //依次执行
       if(effects){
        effects.forEach(effect=>effect.run())
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    可以发现,第2步出了问题,当 属性更新时,会找出属性收集的依赖effect集合,然后执行这个集合的run方法,重新调用fn,由于重新调用fn,所以会进行新的依赖收集,只收集有用到的数据。倘若在执行集合的run方法前,先把集合对应的旧的依赖删除掉(清除age属性影响),然后执行run方法,创建新的依赖(flag->effect;age->effect)不久可以了。

     run(){
        if(!this.active){
          //直接执行函数,不进行后面的依赖收集
          return this.fn()
        }
        try {
          this.parent = activeEffect
          activeEffect = this
          return this.fn()
        }finally{
          //执行完之后,让指针置空
          activeEffect = this.parent
          this.parent = undefined
        }
      }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    那么我们只需要在上面的代码里动一下手脚,执行run前,获取effect存储的deps,deps中存储了所有的属性收集的关于当前effect的依赖,依次清除各个属性对当前effect的依赖即可

    export function cleanTrack(effect){
      let { deps } = effect
      for(let i = 0;i<deps.length;++i){
        deps[i].delete(effect)
      }
      deps.length = 0
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    我们在run函数中执行fn前调用cleanTrack,清除当前依赖。但是,结果并不理想,页面直接死循环了。

    问题还是出在这里

    export function triggerEffects(effects){
       //依次执行
       if(effects){
        effects.forEach(effect=>effect.run())
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    effects是一个map类型的集合,effects找到对应的effect执行,effect调用cleanTrack找到对应的deps集合,deps中又找到当前effects,删除effect,然后执行fn,effects又添加这个effect,相当于下面这样。

    let test = new Set([1,2])
    test.forEach(t=>{
       test.delete(1)
       test.add(1)
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5

    Set类型的数据遍历时不能删除又添加自己,那么只需要拷贝一份数据,遍历拷贝的数据,操作原有数据即可

    export function triggerEffects(deps){
      const effects = [...deps]
       //依次执行
       if(effects){
        effects.forEach(effect=>effect.run())
      }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0HcAgBAQ-1666956629033)(https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e87a1868223543df8e4d28c5d6812e8d~tplv-k3u1fbpfcp-watermark.image?)]

    • 问题四: 副作用函数内部自己调用自己
     import { reactive,effect } from './reactivity.esm.js'
        const state = reactive({
          flag:true,
          age:18,
          name:'sx'
        })
        effect(()=>{
          document.getElementById('app').innerHTML =  state.age++
        })
        
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dCIc89ew-1666956629034)(https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b60e768dc4034ec0abc6adbbb12da11b~tplv-k3u1fbpfcp-watermark.image?)]

    我们分析一下流程为什么会无限调用,首先age++可以拆分为age;age = age+1两步,第一步访问age,age收集当前effect,第二步改变age,触发更新逻辑,执行fn,fn执行age改变,fn再次执行…无线循环

    明白了原因就好办了,我们的目的就是让数据更改一次就行了,后面的执行不需要,也就是避免自调用无限循环。那么我们需要思考一个问题,当fn调用之后,指针指向当前effect,然后触发再次更新,到代码这里.

    export function triggerEffects(deps){
      const effects = [...deps]
       //依次执行
       if(effects){
        effects.forEach(effect=>effect.run())
      }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    此时是上一个fn再执行到这,下一个fn即将执行,那么此时的指针必然是effect,在下一个fn执行的时候

      try {
          this.parent = activeEffect
          activeEffect = this
          cleanTrack(this)
          return this.fn()
        }finally{
          //执行完之后,让指针置空
          activeEffect = this.parent
          this.parent = undefined
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    这里的this还是这个effect,那么此时activeEffect == this,所以我们只需要添加一层判断,当内部指针和全局指针相同,说明是自调用情况,这种情况取消新一轮的fn执行即可

    export function triggerEffects(deps){
      const effects = [...deps]
       //依次执行
       if(effects){
       
        effects.forEach(effect=>{
          if(activeEffect !== effect){
            effect.run()
          }
        })
      }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    自此,我们基本实现vue的响应式原理

  • 相关阅读:
    PFC232-SOP8/14/16应广一级可带烧录程序编带
    gdb常用调试命令
    botocore.exceptions.NoCredentialsError: Unable to locate credentials
    [附源码]Python计算机毕业设计Django-Steam游戏平台系统论文
    【for lovelier】IDEA + LeetCode Editor 最佳实践
    Docker实用篇
    CRM 报告:跟踪销售业绩的强大工具
    智芯传感MEMS压力传感器促进无人机跨入极其广阔的应用市场
    1:开启慢查询日志 与 找到慢SQL
    智能热水器语音控制丨打造智能家居新体验
  • 原文地址:https://blog.csdn.net/wenyeqv/article/details/127577641