Vue 源码实现-computed 实现

193 阅读5分钟

前言:

Vue 用了好多年了,还只会用!那你就out 了,来思考下Vue 源码的实现。相信使用过computed 的同学都知道。computed 非常好用。因为他有几个显著的优点:

计算属性优点:

1. 解决模板中复杂数据的问题

2. 只有内部依赖数据发生变化时才会再次调用

3. 计算属性会缓存上一次的计算结果

4. 多次复用相同的数值,只会调用一次

既然这么好用。且有这么多优点,那他到底是怎么实现的呢?

开发环境搭建

首先我们用vite 搭建一个简单环境

npm i vite -D

package.json 加入下面代码

"scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },

现在执行 npm run dev

新建一个index.html 这时我们访问 http://localhost:5173/ 就能访问到页面了

新建一个文件夹 source source 下面新建index.js

我们就在这个index.js 里面来写computed的源码实现。

在根目录下新建main.js,index.html 里面引用main.js

准备工作完成,现在就开始写代码吧。

首先回顾一下我们使用Vue 的代码基本格式是这样的。

import Vue from './source/index.js'
var vm = new Vue({
  el: '#app',
  data () {
    return {
      a: 1,
      b: 2
    }
  },
  template: `
    <span>a</span>
    <span>*</span>
    <span>b</span>
    <span>=</span>
    <span>{{ result }}</span>
  `,
  computed: {
    result () {
      return this.a * this.b
    }
  }
})

在index.html 里面写一个 <div id="app"></div>

在这里,我们之前是要在main.js中引用Vue官方的Vue.js, 今天我们是写源码,所以我们引用自己的js文件, main.js 引用 source 下面的index.js

import Vue from './source/index.js'

准备工作完毕,现在就来实现实现下computed,

实现前的代码结构思考和架构

思考:

  1. Vue 是个构造函数,接收一个对象,
  2. 接收到参数我们给实例属性添加$el 方便后续操作
  3. 执行 选项中的data函数,获取到返回的结果,赋值给实例方便后续操作
  4. 往原型上挂载一个总的初始化函数_init, 一般下划线命名的代表给内部使用
  5. 总的初始方法,需要分别调用处理data响应式 数据的函数,处理computed 处理响应式的函数

所以先写出一个大纲结构大概是这样的

var Vue = (function () {
  var Vue = function(options) {
    this.$el = document.querySelector(el)
    this.$data = options.data()

    this._init(this, options.computed, options.template)
  }
  Vue.prototype._init = function (vm, computed, template) {
    dataReactive(vm)
    computedReactive(vm, computed)
    render(vm, template)
  }

  function dataReactive(vm) {
  
  }
  
  function computedReactive(vm, computed) {
  
  }
  
  function render(vm, template) {
    
  }
  return Vue
})()


data 响应式的实现

紧接着来写具体方法的实现,首先实现下dataReactive. 来看代码

function dataReactive(vm) {
    var data = vm.$data

    for(let key in data) {
      (function (key) {
        Object.defineProperty(vm, key, {
          get () {
            return data[key]
          },
          set (newVal) {
            data[key] = newVal
          }
        })
      })(key)
    }
  }

主要运用了Object.defineProperty 来实现响应式拦截, 当然set 方法里面是还需要做些事情的,比如说渲染,后面再来看。

接着来看computed 的响应式, 在处理computed 的响应式之前, 先来分析下compouted 数据的特点。

  1. computed 里面的数据有可能直接是一个方法, 有可能是一个对象, 对象下面才是get 方法。
  2. computed 里面的数据更新是依赖data 里面的数据的, 所以们要收集依赖

根据这些特点所以我们在实现computed 响应式之前需要处理一下computed 的数据,用一个变量保存起来

声明存储数据的变量:var computedData = {}

来看具体代码实现:

computed 数据初始化处理

function initComputedData (vm, computed) {
    for (var key in computed) {
      var descriptor = Object.getOwnPropertyDescriptor(computed, key)
      var descriptorFn = descriptor.value.get ? descriptor.value.get : descriptor.value // 判断式直接方法还是对象里面的get方法
      computedData[key] = {}
      computedData[key].value = descriptorFn.call(vm)
      computedData[key].get = descriptorFn.bind(vm)
      computedData[key].dep = cllectDep(descriptorFn)
    }
  }

  // 收集依赖
  function cllectDep(fn) {
    var _c = fn.toString().match(/this.(.+?)/g)
    if (_c.length > 0) {
      for (var i = 0; i < _c.length; i++) {
        _c[i] = _c[i].split('.')[1]
      }
    }

    return _c
  }

computed 响应式实现

function computedReactive(vm, computed) {
    initComputedData(vm ,computed)

    for (var key in computedData) {
      (function (key) {
        Object.defineProperty(vm, key, {
          get () {
            return computedData[key].value
          },
          set (newVal) {
            computedData[key] = newVal
          }
        })
      })(key)
    }
  }

这里和data 的响式实现差不多,

渲染函数实现

computed 响应式实现完成之后,接下就是数据渲染了,当然需要编译一下模板,这里没有用虚拟节点,主要通过正则替换实现。通过compileTemplate 函数编译, 通过render 函数渲染

具体代码如下:

function render(vm, template) {
    var container = document.createElement('div'),
        _el = vm.$el;

    container.innerHTML = template

    var domTree = compileTemplate(vm, container)
    _el.appendChild(domTree)
  }

  function compileTemplate (vm, container) {
    var allNodes = container.getElementsByTagName('*')
    var nodeItem = null

    for (var i = 0; i < allNodes.length; i++) {
      nodeItem = allNodes[i]
      console.log(nodeItem, '00')

      var matched = nodeItem.textContent.match(reg)
      if (matched) {
        nodeItem.textContent = nodeItem.textContent.replace(reg, function (node, key) {
          dataPool[key.trim()] = nodeItem
          console.log(key, 'key', vm[key.trim()], vm)
          return vm[key.trim()]
        })
      }
      console.log(vm, 'vm')
    }
    return container
  }

这个时候我们来访问下页面,就能看到具体的内容了

image.png

数据更新,模板更新

现在我们只实现了渲染,但是还没有实现更新,这就是之前说的还需要在响应式的set 里面还需要做点工作。

function update (vm, key) {
    dataPool[key].textContent = vm[key]
  }

dataReactive 的set

set (newVal) {
  data[key] = newVal
  update(vm, key) //增加了更新函数的调用
}

这时候我们在main.js 手动给vm.b 赋值等于1000 看看效果

可以看到 页面上数据也更新了,

image.png

但是你会发现,计算结果没有更新, 因为, 计算属性的更新逻辑还没写。

计算属性更新方法实现:

function updateComputed (vm, key, update) {
    var _dep = null
    for (let _key in computedData) {
      _dep = computedData[_key].dep
      for (var i = 0; i < _dep.length; i++) {
        if (key === _dep[i]) {
          vm[_key] = computedData[_key].get()
          
          update(_key)
        }
      }
    }
  }

主要思想:当data 里面的数据变化时, 循环遍历之前处理的computed 的数据, 然后遍历对应key 的 依赖, 如果对应key 有和更新data 的相等, 重新执行get 方法更新模板数据, computed 是在data 变化时更新, 所以也是在dataReactive 的设置里面调用函数。

dataReactive 的设置如下:

data[key] = newVal
update(vm, key)
updateComputed(vm, key, function (key) {
  update(vm, key)
})

现在再来看效果, 结果就正常了。

image.png

我们现在来改变下a 的值,然后访问五次result ,在computed 的result 函数里面答应reslut 看下函数执行了多少次。

代码如下:

vm.b = 1000
console.log(vm.result)
vm.a = 55
console.log(vm.result)
console.log(vm.result)
console.log(vm.result)
console.log(vm.result)
console.log(vm.result)

result

result () {
  console.log('result')
  return this.a * this.b
 }

运行效果如下:

image.png

可以看到函数运行了三次, 一次初始化的时候, 一次是改变b 的值的时候, 再次是改变a的值的时候。改变了a之后,我们连续访问了5次reslult 也没在执行result 函数。 做到了和Vue 的计算属性一样的缓存效果。

感谢收看, 若有更好的想法欢迎一起讨论学习,一起进步