说一下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/*'
  • 此时,如果我们卸载安装的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"]

    }

  }

}

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

打包的格式

我们打开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": {}
}
{
 "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": {}
}
  • 我们在shared/src/index.ts下简单导出一个函数
export function isObject(value:any){
   return value !==null && typeof value == 'object'
}
  • 而在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' }
    
    • 了解了它的格式,我们就能解析,获取用户的命令,从而确定要打包的文件夹,打包格式,以及输出地址等。
    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`)
    
    • 然后调用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
    
    • 运行命令,可以看到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>

可以看到,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
        }
      })
    }
    
    • 我们可以很容易的想到利用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)
    

    我们可以梳理一下这个流程:访问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)
    

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

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

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

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

    对此在对一次代理对象的时候,可以在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
    }
    
    • 假如老六又写出这种代码:
    const obj = {
        name:'宫本',
        age:18
      }
      const state1 = reactive(obj)
      const state2 = reactive(state2)
    

代理对象也是个对象,也能作为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
}
  get(target,key,receiver){
    if(key === ReactiveFlag.IS_REACTIVE){
      return true
    }
    return target[key]
  },
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()
}

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

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

run(){
    if(this.active){
      return this.fn()
    }
  }

下面就是最关键的一步,如何让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
    }
  }

在代理操作中,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
  }

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

  <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>

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(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)
}
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())
  }
}

以上实现了一个简单的响应式模块,但是在实际使用中,依然会暴漏出很多问题,在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)

老六写了个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
    }
  }
  • 问题二: 代理的数据是多层对象

对于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
 },
  • 问题三: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)

可以看到,当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())
  }
}

可以发现,第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
    }
  }

那么我们只需要在上面的代码里动一下手脚,执行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
}

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

问题还是出在这里

export function triggerEffects(effects){
   //依次执行
   if(effects){
    effects.forEach(effect=>effect.run())
  }
}

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)
})

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

export function triggerEffects(deps){
  const effects = [...deps]
   //依次执行
   if(effects){
    effects.forEach(effect=>effect.run())
  }
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(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++
    })
    

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(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())
  }
}

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

  try {
      this.parent = activeEffect
      activeEffect = this
      cleanTrack(this)
      return this.fn()
    }finally{
      //执行完之后,让指针置空
      activeEffect = this.parent
      this.parent = undefined
    }

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

export function triggerEffects(deps){
  const effects = [...deps]
   //依次执行
   if(effects){
   
    effects.forEach(effect=>{
      if(activeEffect !== effect){
        effect.run()
      }
    })
  }
}

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

问也去

创作不易,感谢支持

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

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

打赏作者

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

抵扣说明:

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

余额充值