手写一个简单的vue——响应系统1

动机

做为一个在从事前端工作已经三年了,总想写一些有价值的框架、组件或事工具类,但是能力有限,也想不到什么想不到什么创新的点子,只能说看看能不能参考别人写出一个简单版的vue,但是说白了以自己现在的实力想要手写一个面面具到的vue框架定是不可能的,所以只能根据已经掌握的vue知识、看过的博客和vue.js设计与实现来初略的手写一个vue,如果后面内容有不严谨的请多多担待,若出现侵权的问题,请及时联系我,我也会尽量表明每段引用的出处,尊重原创。好了废话不多说,说了也白说!

基础框架的搭建

这里我先创建了一个工作目录vue-core,然后我使用初始化了pnpm,并下载了vite,这里使用喜欢用vite来进行构建是我个人的一个习惯比较省事,不管你用什么都可以,即使你不初始化npm也不会也有一点问题。

接下来要运行起来先配置一下package.json

json 复制代码
{
  "name": "vue-core",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "vite" // 启动服务
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "vite": "^5.2.8"
  }
}

在根目录下创建一个index.html

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
<div id="app">
    <h1>hello vue</h1>
</div>
</body>
</html>

在终端内执行 pnpm run dev就可以看到已经把服务运行起来了

到这里我们已经实现了阶段的一小步,但是是自己的一大步哈哈哈哈哈。

响应系统

响应系统是vue重要的组成部分,但是大多数人都只停留在vue2使用Object.defineProperty和vue3使用Proxy,但是连内部到底如果实现的都不知道,所以就让我们一起揭开响应系统的面纱。

1.副作用函数

在说响应系统之前我们先了解一下副作用函数,什么是副作用函数?简单来说就是我这个函数改变了函数以外的数据或者事让别的函数执行结果发生了变化,就可以我这个函数的之前产生了副作用,那副作用函数有什么用呢? 我们先从一个简单的简单的例子来说明 我们创建一个effect.js(在packages下目录)

js 复制代码
const obj = {
  text: 'hello vue'
}

function effect() {
  const appDom = document.getElementById('app')

  appDom.innerText = obj.text
}

export default effect;

然后在根目录下创建一个main.js,引入并调用effect

js 复制代码
import effect from "./packages/effect";

effect()

当然了还需要在index.html中引入一下,注意需要在dom创建完成后执行

html 复制代码
...
<body>
<div id="app"></div>
<script type="module" src="main.js"></script>
</body>
...

那就来解释一下上面的代码,上面的代码希望调用effect()然后获取id为app的元素,为这个元素中添加内容,内容是hello vue

运行发现和我的预期一样,现在我们想通过改变obj.text的值,然后让新的内容重新渲染在页面上,但是我们现在的代码是做不到的。

那要怎么才能做到呢?我们可以先如果数据改变后可以调用我们的副作用函数effect是不是就可以实现重新渲染目录了,既然要调用函数,那要知道哪些函数是要被调用的,所以我们需要拿一个"桶"把这些函数都装起来,下次数据改变的时候吧"桶"里的函数都倒出来执行一下。

我们先吧之前的effect改一下,需要接受一个副作用函数,然后创建一个桶把函数装起来

2.Proxy数据代理

既然我们已经实现思路了,那我们就动手实现一下, 首次对effect函数进行调整,所有的业务逻辑都不直接写在effect函数中,而是通过参数的形成传递进来,有effect进行首次调用然后将参数先缓存起来。

js 复制代码
// effect.js
// 当前副作用
let activeEffect = null

// 获取当前副作用
export function getActiveEffect() {
  return activeEffect
}

function effect(eFn) {
  activeEffect = eFn
  eFn()
}

export default effect;

接着我们要写一个数据代理在packages/reactive.js,作用就和import {reactive} form 'vue'中的作用一样,接受一个的对象,返回一个代理对象。

js 复制代码
// reactive.js
import {getActiveEffect} from "./effect";

// 用来存储副作用函数
export const bucket = new Set();

export default function reactive(obj) {
   // 创建一个代理对象
  return new Proxy(obj, {
    get(target, key) {
       // 获取当前副作用函数
      const activeEffect = getActiveEffect()
      if (activeEffect) {
          // 由于这里用力set可以先不考虑重复问题
          bucket.add(activeEffect)
      }
      return target[key];
    },

    set(target, key, newValue) {
      target[key] = newValue;
      
      // 如果数据发生了改变,就吧所有的副作用函数都拿出来重新执行
      bucket.forEach(fn => fn())
      
      return true
    }
  })
}

写到这里已经基本实现了,接下来我们来验证一下:

javascript 复制代码
// main.js
import effect from "./packages/effect";
import reactive from "./packages/reactive";

const obj = {
  text: 'hello vue'
}

const pObj = reactive(obj)

effect(() => {
  const appDom = document.getElementById('app')

  appDom.innerText = pObj.text
})

setTimeout(() => {
  pObj.text = "hello xnb"
}, 3000)

main.js中先创建一个基本对象obj,然后通过reactive获取一个代理对象 ,调用effect并传递一个匿名函数,函数的作用是获取id为app的元素,然后将代理对象的text内容设置个app元素。然后3秒后将text的值设置成hello xnb

⚠️注意:这里为什么要用代理对象不用之前的普通对象?

  • 在副作用函数中appDom.innerText = pObj.text这里其实就是调用了get操作,就会对副作用的收集,
  • 3秒后对text重新赋值,就会调用set,重新执行"桶"中的副作用,如果这里都是用普通对象就无法告知程序收集和调用副作用函数。

3秒后。。。

到目前为止已经满足了我们之前的需求,又成功了一小小步。 接下来我们对代码进行一些修改

js 复制代码
// main.js

effect(() => {
  const appDom = document.getElementById('app')
   // 标记
  console.log(pObj.text)
  appDom.innerText = pObj.text
})

...
setTimeout(() => {
  pObj.text2 = "hello xnb"
}, 3000)

如上面代码如果我们修改的是text2不是text,因为我们副作用函数中只使用了text,所有副作用函数应该不会执行才对,但是运行代码发现副作用执行了2次

那我们来分析一下,第一次打印是在最开始effect函数内部会帮我调用一次,然后3秒后修改了text2的值,然后调用了proxy.set,会去除所有副作用函数重新执行,就进行了第二次的打印,那么就知道问题出在哪里了,无论谁的变化都会导致全部副作用函数都重新执行。

如果我们想解决这个问题,就要对属性和副作用之间产生一定联系,我们来进行一下设计

如果,如果我们在收集副作用的时候通过对象属性进行一个分类整合的话是不是,在取的时候就可以知道我当前属性下有哪些副作用,然后取对应副作用进行执行就行,那我们通过代码来试试

js 复制代码
import {getActiveEffect} from "./effect";

// 用来存储副作用函数由原来的Set变成一WeakMap的形式进行存储
export const bucket = new WeakMap();

export default function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      // 收集被使用到的响应属性与副作用函数创建联系
      gather(target, key)
      return target[key];
    },

    set(target, key, newValue) {
      target[key] = newValue;
      // 触发对应的副作用函数
      trigger(target, key, newValue)
      return true
    }
  })
}

// 采集器
function gather (target, key) {
  const activeEffect = getActiveEffect()
  if (activeEffect) {
     // 查询这个对象之前有没有其他联系
    let targetMap = bucket.get(target)
    if(!targetMap) {
       // 如果没有就创建一个新的
      bucket.set(target, (targetMap = new Map()))
    }
    // 查询当前对象的属性key有没有其他联系
    let targetSet = targetMap.get(key)
    if(!targetSet) {
      // 如果没有就创建一个新的(这里用Set存储方式和上一个步骤是一样的,无需考虑重复)
      targetMap.set(key, (targetSet = new Set()))
    }
    // 添加联系
    targetSet.add(activeEffect)
  }
}

// 触发器
function trigger(target, key, newValue) {
  let targetMap = bucket.get(target)
  if(!targetMap) {
    bucket.set(target, (targetMap = new Map()))
  }
  let targetSet = targetMap.get(key)
  // 和采集的时候一样,逐步判断,如果有那就循环出来执行
  targetSet && targetSet.forEach(fn => fn())
}

这里对采集和触发过程做了一个封装方便后续的编写,这里我们做了一下步:

  1. 修改"桶"的数据类型,使用WeakMap来对数据进行存储,主要是考虑到性能优化,不影响后续的垃圾回收,具体在这里不做多的解释,可以前往MDN自行了解
  2. 当数据被访问的时候,会进行副作用函数收集,通过代理对象,属性来归类副作用函数。
  3. 当数据发生变化的时候,就可以通过之前收集的副作用函数,通过代理对象,属性取出正确的出来执行。

接下来我们来验证一下是否如我们所想

js 复制代码
// main.js
...
effect(() => {
  const appDom = document.getElementById('app')

  console.log(pObj.text)

  appDom.innerText = pObj.text
})

setTimeout(() => {
  console.log('timeout1')
  pObj.text2 = "hello xnb"
}, 3000)

setTimeout(() => {
  console.log('timeout2')
  pObj.text = "hello xnb"
}, 5000)

如果我们的代码都正确执行的流程应该是,最开始副作用函数执行的时候打印hello vue,同时收集副作用函数,这时pObj -> text -> fn就有一个副作用函数,3秒后定时器一执行打印timeout1,但是犹豫之前副作用函数并没有使用text2所以同理并没有pObj.text2的相关依赖,就不会有副作用函数执行,再过2秒后定时器一执行打印timeout2,同时对text重新赋值,但是之前有pObj.text下有函数就会拿出来重新执行然后就会在打印hello xnb,然后程序执行完成。

可以看到和我们的分析是一致的。

3.清楚依赖

前面我们基本已经实现了根据使用到的数据,自动执行副作用函数来修改页面展示。但是如果我们通过一个三目运算符来创建一个条件分支,"桶"内数据结构会是怎么样的呢。

js 复制代码
// main.js
const obj = {
  isOK: true,
  text: 'hello vue',
}

const pObj = reactive(obj)

effect(() => {
  const appDom = document.getElementById('app')

  appDom.innerText = pObj.isOK? pObj.text: 'hello app'
})

现在元素的内容是什么,首先要通过isOK来决定,如果是true,值就应该是hello vue,反之应该是hello app。

我们来写一下上面代码副作用函数与响应式数据之间的联系,当isOK一开始为true的时候,应该先访问了pObj.isOK字段,然后与当前副作用函数之间进行关联,因为isOK为true分支会访问pObj.text字段,然后与当前副作用函数之间进行关联。

在采集器的最后一行添加一个打印,将"桶"输出,可以看到和我们之前的推理一样。

接下来,我们先对isOK赋值为true,随后将text赋值为hello xnb,我们来看看结果会是怎么样

js 复制代码
// main.js
...

effect(() => {
  const appDom = document.getElementById('app')
  // 证明函数被执行了几次
  console.log('effect')

  appDom.innerText = pObj.isOK? pObj.text: 'hello app'
})

pObj.isOK = false

pObj.text = "hello xnb"

可以看到这里effect打印了三次,说明副作用函数被执行了三次,按照我们之前的写份这里确实应该是要被执行3次

  1. 最开始调用副作用函数执行的
  2. 当isOK赋值成false执行一次
  3. 最后text赋值成hello xnb执行一次 但是你有没有发现,既然我这里已经把isOK设置成了false,是不是text的属性就不可能被执行到,为什么他的改变还行执行副作用函数呢?感觉有点多此一举。

那是因为最开始isOK的值是true,这个时候text已经和副作用进行了关联,你后面对他修改就是会调用副作用函数,要解决这个办法,那我们只能在调用副作用函数的时候先把之前的副作用全部清除,然后更具需要的重新创建一遍联系,这样就可以了,

js 复制代码
// effect.js
...
function effect(eFn) {
  const effectFn = () => {
    activeEffect = effectFn
    eFn()
  }
  // 记录当前副作用纯在的集合
  effectFn.events = []

  effectFn()
}

首先需要优化一下effect函数,内部创建一个effectFn,主要目的是为了在他身上添加一个属性events,用于记录了所有与这个副作用有关的集合(也就是说这个副作用存在桶里的那些属性里)。

js 复制代码
reactive.js
...
// 采集器
function gather (target, key) {
  const activeEffect = getActiveEffect()
  if (activeEffect) {
    let eventMap = bucket.get(target)
    if(!eventMap) {
      bucket.set(target, (eventMap = new Map()))
    }
    let events = eventMap.get(key)
    if(!events) {
      eventMap.set(key, (events = new Set()))
    }
    events.add(activeEffect)
    // 将集合添加到副作用函数的events中
    activeEffect.events.push(events)
  }

  console.log(bucket);
}
...

这样我们就可以通过副作用找到与之有关的集合,因为每次访问副作用函数都会建立联系,由于用的是Set类型来存储我们对这一点的感受并不深,但是事实确实是如此,所以我们只需要考虑在调用副作用函数的时候先删除之前的联系,后面调用的时候自然会重新添加。

js 复制代码
function effect(eFn) {
  const effectFn = () => {
     // 清除
    cleanup(effectFn)
    activeEffect = effectFn
    eFn()
  }

  effectFn.events = []

  effectFn()
}

function cleanup(effectFn) {
  for (let i = 0; i < effectFn.events.length; i++) {
      // 便利所有集合,将前副作用函数删除
    const events = effectFn.events[i]

    events.delete(effectFn)
  }
}

这里我们循环的删除自己的副作用函数,其他的副作用函数依然会保留

⚠️注意:执行发现这里其实会死循环 那是因为属性修改的时候触发器里进行了forEach, 依次执行effectFn的时候,cleanup被调用先删除等于调用delete,而后面马上调用eFn这里会读取属性,为属性添加联系等于调用了add,这一次循环内,先删除了,又添加了,所以导致了死循环。

要解决这一点也很简单,就是执行触发器的forEach前,先进行拷贝,这样两个几个就没有联系了,我循环我的,你删除你的。

js 复制代码
...
// 触发器
function trigger(target, key, newValue) {
  let eventMap = bucket.get(target)
  if(!eventMap) {
    bucket.set(target, (eventMap = new Map()))
  }
  let events = eventMap.get(key)

  const eventsCopy = new Set(events)
  eventsCopy.forEach(fn => fn())
}

经过修改现在effect只执行了两次,text下并没有副作用函数,这样text值的改变就不会多余的调用了。

js 复制代码
const obj = {
  num: 1
}

const pObj = reactive(obj)

effect(() => {
  console.log(pObj.num++);
})

上面的代码,我在副作用函数里面做了一个自增操作,让我们看看结果会发生什么

控制台报错effect.js:6 Uncaught RangeError: Maximum call stack size exceeded,那我们来分析一下为什么会出现死循环。

在副作用函数中pObj.num++等价于pObj.num = pObj.num + 1这里我们可以看作是一次get和一次set,我们之前的代码每次调用触发器的时候都会调用副作用函数,每次调用副作用函数又会重新赋值调用触发器,就导致了死循环,那要怎么避免这个问题呢?我们可以根据activeEffect和触发器里执行副作用函数进行一个比较,如果他们是同一个的话,就不要继续执行了

js 复制代码
...
// 触发器
function trigger(target, key, newValue) {
  const activeEffect = getActiveEffect()
  let eventMap = bucket.get(target)
  if(!eventMap) {
    bucket.set(target, (eventMap = new Map()))
  }
  let events = eventMap.get(key)
   
  const eventsCopy = new Set()
  events && events.forEach(fn => {
     // 循环集合,如果集合里有当前激活的副作用函数就不添加到新的集合里这样就不会被执行
    if (fn !== activeEffect) {
      eventsCopy.add(fn)
    }
  })
  eventsCopy.forEach(fn => fn())
}

这里确实好像只执行了一次,但是我们换一个方式来试一下,我不在自增,而是在副作用外对num重新赋值

js 复制代码
...
effect(() => {
  console.log(pObj.num);
})

pObj.num = 2

发现这里好像不对,为什么我下面赋值没有导致副作用函数重新执行,原因也很简单就是我们现在activeEffect相同的副作用函数不执行,我们在对num赋值成2的时候activeEffect一直都是之前的副作用函数,所以不执行,只要在每一次副作用函数执行完成将activeEffect清空就好。

js 复制代码
...
function effect(eFn) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    eFn()
    // 清除activeEffect
    activeEffect = null
  }

  effectFn.events = []

  effectFn()
}
...

这样就没有问题了。

副作用栈

写到这里我们要想到如果哪一天,有人突然加effect进行了嵌套调用怎么办,从我们之前的代码看我们似乎还不支持,我们来试试

js 复制代码
const obj = {
  num: 1,
  text: 'hello vue'
}

const pObj = reactive(obj)

effect(() => {
  console.log('effect1')
  effect(() => {
    console.log('effect2')
    pObj.text
  })
  pObj.num
})

pObj.num = 2

让我们先分析一下,正常应该是一个什么样的执行结果,首先外出的副作用函数执行先回打印effect1,然后执行内层的副作用函数打印effect2,因为内存的访问了响应数据text,让他与activeEffect进行一个关联,然后内层执行完成,外层访问了响应数据num,让他与activeEffect进行一个关联,到这里第一次执行副作用函数就完成了,然后重新对num进行赋值,应该执行num关联的副作用函数,先打印effect1,然后执行内层的副作用函数打印effect2,那我们运行一下

可以发现这里只打印了effect1effect2,跟我们的推论是不一样的,这里是为什么呢?

我们把桶的结构打印出来,发现里面没有num的副作用函数集合,明明外面使用了pObj.num为什么没有打印呢?

通过断点调试发现在对num进行依赖采集的时候,activeEffect是null,所以导致不走后面的逻辑了,导致"桶"里没有num的副作用函数集合,出现这个问题主要是内部的副作用函数第一次调用完成吧activeEffect赋值成null,然后在回到外层的副作用函数读取pObj.num调用采集器的时候activeEffect就是null了。

既然找到问题那我们想个办法来解决,只要activeEffect指向相对应的副作用函数就行,可以先创建一个"栈"来保存所有副作用函数,程序运行过程中取出对应的副作用即可

js 复制代码
// 副作用栈
let effectWare = []

// 获取当前副作用
export function getActiveEffect() {
   // 栈顶的副作用函数就是当前激活的副作用函数
  return effectWare[effectWare.length - 1]
}


function effect(eFn) {
  const effectFn = () => {
    cleanup(effectFn)
    // 压栈
    effectWare.push(effectFn)
    eFn()
    // 出栈
    effectWare.pop()
  }

  effectFn.events = []

  effectFn()
}
...

经过调整现在就和我们之前的推理一样了。

总结

由于响应系统的内容比较多,可能会分为好多篇更新,也不知道能更新多少篇哈哈哈哈哈哈。

源码地址:全网最简单的vue

相关推荐
Fantastic_sj18 分钟前
CSS-in-JS 动态主题切换与首屏渲染优化
前端·javascript·css
鹦鹉00721 分钟前
SpringAOP实现
java·服务器·前端·spring
再学一点就睡4 小时前
手写 Promise 静态方法:从原理到实现
前端·javascript·面试
再学一点就睡4 小时前
前端必会:Promise 全解析,从原理到实战
前端·javascript·面试
前端工作日常5 小时前
我理解的eslint配置
前端·eslint
前端工作日常5 小时前
项目价值判断的核心标准
前端·程序员
90后的晨仔6 小时前
理解 Vue 的列表渲染:从传统 DOM 到响应式世界的演进
前端·vue.js
OEC小胖胖6 小时前
性能优化(一):时间分片(Time Slicing):让你的应用在高负载下“永不卡顿”的秘密
前端·javascript·性能优化·web
烛阴6 小时前
ABS - Rhomb
前端·webgl
植物系青年6 小时前
10+核心功能点!低代码平台实现不完全指南 🧭(下)
前端·低代码