如何实现一个前端框架2-响应式初步

194 阅读10分钟

上一节中,我们遗留下来一个主要的问题就是renderRule不够灵活
接下来我们思考一下如何解决它

其实,导致它不够灵活的一个关键原因,就在于在new ViewComponent调用的时候数据结构就已经确定,进而DOM的结构也已经确定,当数据发生改变时没法再次更新数据结构,也就没法更新DOM结构

因此,我们很自然会想到,是不是可以将renderRule改成函数的形式,就像下面这样:

new ViewComponent({
  renderRule: function () {
    return {
      tag: 'div',
      class: 'good-detail',
      children: [
        { tag: 'div', class: 'item', content: '名称:{{good.name}}' },
        { tag: 'div', class: 'item', content: 'CPU:{{good.cpuNum}}' },
        { tag: 'div', class: 'item', content: '内存:{{good.memory}}' },
        { tag: 'div', class: 'item', content: '品牌:{{good.brand}}' },
        { tag: 'div', class: 'item', content: '分类:{{good.category}}' },
        { tag: 'div', class: 'item', content: '颜色:{{good.color}}' }
      ]
    }
  }
})

这样一来,每次更新数据时,这个function会重新执行,执行的时候去拿改变过的good,再去取上面的值,自然就可以取到新值了

除此之外,上次我们还说了一个问题,就是json的格式实在是太不直观了,所以我们接下来再对这个renderRule做一些改动
当然,大家看了我的改动之后一定会很失望,因为改了之后的结果,似乎依然不直观,更糟糕的是,这种不直观的方式,我们需要用一段时间,直到其他重要的模块实现完毕时再来改造这里,我想这样一来,大家会更清楚为什么Vue中要有template,React中要有jsx,还要费很大劲把它们编译为render函数:

new ViewComponent({
  render: function () {
    return this.createElement('div', { class: 'good-detail' }, [
      this.createElement('div', { class: 'item' }, '名称:{{good.name}}' ),
      this.createElement('div', { class: 'item' }, 'CPU:{{good.cpuNum}}' ),
      this.createElement('div', { class: 'item' }, '内存:{{good.memory}}' ),
      this.createElement('div', { class: 'item' }, '品牌:{{good.brand}}' ),
      this.createElement('div', { class: 'item' }, '分类:{{good.category}}' ),
      this.createElement('div', { class: 'item' }, '颜色:{{good.color}}' )
    ]
  }
})

题外话:实际上,如果把我们这里的createElement替换成真正的html标签,其实就和jsx没什么区别了

可以看到,我们不断地调用一个名为createElement的方法,它的入参分别是
param1:要创建的DOM对象的标签名
param2:DOM对象的一些属性
param3:DOM对象的子元素

这个方法我们一会儿就会实现,大家现在要知道它是干啥的,一会儿看的时候更有目的性

还有一点,可以发现我把renderRule改成了render,这是因为renderRule意思是渲染规则,它是一个名词,而render意思是渲染,它是一个动词
我们这里做的工作其实是通过createElement的入参(主要是第3个参数)体现render的渲染规则,同时又把DOM元素创建了出来,所以这其实是一个动作,这一点和上一个版本不同,希望大家引起注意

在此基础之上,我们还要引入根据变量的不同来创建不同的DOM这个效果,为此我准备了一个例子,如图所示:
点击切换,可以展开更多的内容,就像下面这样: 再点切换,还可以回到初始状态,只展示iPhoneX和iPhone

从需求中来看,很显然,我们需要引入事件

还有一点很重要,我们要通过一个变量的值来跟踪展开收缩的状态,这个值将会作为我们是否渲染详细信息的依据,由于这个值和这个商品详情强关联,所以我们也把它放在data中,我个人觉得这也是封装的一种体现

对于经历过操作DOM方法,手动设置div的display为block或none的朋友来说,这种通过变量来跟踪DOM状态的开发方式,是一种开发思路的重要转变

废话少说,我们的调用代码如下:

new ViewComponent({
  el: 'body',
  data: {
    isShowDetail: false,
    good: {
      id: '8faw8cs4fw9760zt7tnesini4qup5hid',
      name: 'iPhoneX',
      cpuNum: 1,
      memory: 1073741824,
      brand: 'iPhone',
      category: 'phone',
      color: 'black'
    }
  },
  methods: {
    switchDetail () {
      this.data.isShowDetail != this.data.isShowDetail
    }
  },
  render: function () {
    let children = [
      this.createElement('div', { class: 'abstract' }, [
        this.createElement('div', { class: 'name' }),
        this.createElement('div', { class: 'brand' }),
        this.createElement('span', { class: 'switch', click: this.methods.switchDetail })
      ])
    ]
    if (this.isShowDetail) {
      children.push(
        this.createElement('div', { class: 'item' }, this.data.good.cpuNum),
        this.createElement('div', { class: 'item' }, this.data.good.memory),
        this.createElement('div', { class: 'item' }, this.data.good.category),
        this.createElement('div', { class: 'item' }, this.data.good.color)
      )
    }
    return this.createElement('div', { class: 'good-detail' }, 
      this.createElement('div', { class: 'detail' }, children)
    )
  }
})

data中isShowDetail就代表商品是否展开显示更多信息,与此配套的就是switchDetail事件

不过看到这个恶心的render,我觉得大家想要骂我,前端框架的一个最重要的目的就是要将前端从拼接DOM的繁琐中解脱出来,但是这个render所做的事情,它的麻烦程度一点都不亚于拼接DOM,不过还是那句话,只有我们认识到了这么做多么麻烦,我们才更能理解template和jsx为何而存在

接下来我们再看ViewComponent的实现:
看之前希望大家带着这样一个问题:我们在switchDetail中只是改变了this.data.isShowDetail这个数据,并没有重新调用render,因此VueComponent内部肯定有一套机制,它通过这套机制就知道this.data.isShowDetail这个数据变化了,进而帮我们调用render,那这个机制要如何实现呢?

我们的思路大概是这样的:
render函数会创建出我们想要的DOM,而且它还会按照我们每一次改变了的数据去创建,因此我们可以在每次改变数据之后,再次调用render,然后让render新生成的DOM替换掉之前的DOM

这里有几点需要我们注意:

  1. 外面传入的render应该只负责DOM的生成,移除旧DOM、替换成新DOM这些工作应该交给ViewComponent来处理,因为移除和新建替换这些操作是有一定规律性的,而且对每个组件来说也都是必须的
  2. 自然的,我们需要有这样一个方法:这个方法负责移除旧DOM,将生成的新DOM替换到原来的地方,可以将这个方法命名为update,在update中我们先通过this.el或this.renderedElement取到上一次(如果是从this.el取,那就是第一次,如果是第二次及之后那就是从renderedElement上取,renderedElement就代表渲染到页面上的DOM节点)创建的旧的DOM,再调用render生成新的DOM做替换,代码大概是这样:
function ViewComponent (options) {
  this.el = document.querySelector(options.el)
  this.data = options.data
  this.methods = options.methods
  this.render = options.render
  this.renderedElement = null
  this.init()
}
ViewComponent.prototype.init = function () {
  this.update()
}
ViewComponent.prototype.update = function () {
  // 清除旧的DOM
  let oldRenderedElement = this.renderedElement || this.el

  this.renderedElement = this.render()
  let parent = oldRenderedElement.parentNode
  let sibling = oldRenderedElement.nextElementSibling
  parent.removeChild(oldRenderedElement)
  if (sibling) {
    parent.insertBefore(this.renderedElement, sibling)
  } else {
    parent.appendChild(this.renderedElement)
  }
}

再废话一句: 组件初始化会调用init,我们就可以在init中完成对DOM的第一次创建并执行一次update,这次update其实是将this.el替换为render执行之后的值

上面的代码实现是第一次生成DOM的情况,接下来重点来了,如何在每次执行click事件回调时,触发后续的update调用?

Vue的设计思路是给组件的data中的各个属性加get和set钩子,get里面做的事情是收集依赖,set里面做的事情就是执行所有依赖,那这句话具体是什么意思呢?

在组件的update第一次执行时会调用render方法生成DOM,在render调用过程中会用到(其实就是访问到)组件data对象上定义的属性,此时属性的get钩子就会执行,通过get钩子将这个update函数放到某个地方,这个地方专门存储当这个属性将来被更改的时候需要执行的函数,而这个属性将来被更改时,会执行它的set钩子,set钩子从这个地方把需要更新的函数取出来挨个执行即可,在此处其实就是取出update并将旧的DOM删除,替换成新的DOM

由于data中的任何一个属性都有可能被赋值更改,因此我们针对各个属性都调用了setReactive,都添加了依赖,即每个属性都有一个地方存储专属于它的update,在它被赋值修改时就取出这个专属于它的update来执行,其实我们可以想象地到,同一个组件的data下的各个属性,它们的update应该是一样的,都是这个组件的update方法

每一次调用update,进而调用render的过程都不会走set钩子,只走get钩子,因为update只负责将render调用之后生成的DOM渲染出来

第一次update的调用是由ViewComponent.init触发的 第二次及后续update执行的原因,都是由于data里的属性的set钩子的触发,set钩子为什么会被触发,或者说由什么途径被触发呢?这些途径通常包含事件回调、ajax回调、定时器回调等,每个回调对应“一轮”DOM的更新,这个“一轮”的概念非常非常重要,理解这个一轮是怎么回事,会对后期异步调用、防止重复调用的逻辑有很大帮助

无论update执行多少次,改的总是data中这一份数据,因此get和set钩子只添加一次即可,进而很容易想到要将添加get、set钩子的代码放在只执行一次的地方,哪里的代码只执行一次呢?自然我们想到是init方法
因此遇到这种只需要存一份的东西、干一遍的事情都可以往init里放,于是我们在组件初始化时可以做这件事情:

ViewComponent.prototype.init = function () {
  setDataReactive(this.data)
  this.update()
}
function setDataReactive (data) {
  let keys = Object.keys(data)
  for (let i = 0; i < keys.length; i++) {
    let v = data[keys[i]]
    if (isObject(v)) {
      setDataReactive(v)
    }
    defineReactive(data, keys[i], v)
  }
}

let curExecUpdate = null
function defineReactive(data, key, val) {
  let updateFns = []
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get: function () {
      if (curExecUpdate && !updateFns.find(curExecUpdate)) {
        updateFns.push(curExecUpdate)
      }
      return val
    },
    set: function (newVal) {
      val = newVal
      for (let i = 0; i < updateFns.length; i++) {
        updateFns[i]()
      }
    }
  })
}

这个代码我们需要解释以下几点:

  1. updateFns就是要存储key属性将来更改时要执行的函数,考虑到之后会遇到更改属性时有多个对应的函数被执行,所以这里的updateFns是个数组,get中将curExecUpdate,即当前执行的update放到updateFns中,这个updateFns是专属于key这个属性的,意思就是说每次调用defineReactive结束后,updateFns这个变量依然存在于内存中,不会被回收,因为它将来在属性被get或set的时候还会被用到,每执行一次defineReactive内存中就会多一个updateFns,因此updateFns这个变量就形成了闭包,而且每个updateFns都和一个data上的属性一一对应,理解这一点非常重要
  2. 同样的,参数val也形成了闭包,我们可以看到val在调用defineReactive时被赋予在data中定义的初始值,之后在get的时候将该值返回,set时改的也是该值,值得注意的是set执行时,是先执行val = newVal,再执行updateFns里面的回调的,这是因为得先改成新的值,updateFns回调执行时调用get钩子拿到的才是新值,否则updateFns里面的回调执行时拿到的始终是旧值,计算出来的结果和上次一样,DOM自然也更新不了

不知道各位是否有困惑,我们突然引入了curExecUpdate这个东西,现在我们来解释下它是什么,它何时被赋值,它是怎么起作用的
curExecUpdate,顾名思义,就是当前正在执行的update,这个当前执行该怎么理解呢?我们可以再回顾一下我们这个代码的执行流程:

  • 第1步:构造函数ViewComponent初始化
  • 第2步:ViewComponent.init执行,进而执行里面的setDataReactive、defineReactive
  • 第3步:继续执行ViewComponent.init里的this.update
  • 第4步:this.update调用render生成DOM结构
  • 第5步:用户点击按钮触发回调后,第二次执行this.update,将render返回的DOM插入到HTML中

需要注意,在第2步执行时只是通过setDataReactive、defineReactive给每个属性添加了get、set钩子函数,但并没有执行get、set钩子函数里面的代码,那这些代码什么时候执行呢?
就是在第4步,因为我们的render函数调用时,才会真正访问data上的属性,进而触发get钩子(注意此时还没有触发set钩子),而render函数一定是在update函数里执行的

  • 在组件update执行之前,我们将这个当前正在执行的update赋值给curExecUpdate
  • 在组件update执行过程中,我们触发属性的get钩子将curExecUpdate放到属性自己的updateFns中
  • 在组件update执行完之后,必须将curExecUpdate置为null,当然此处还看不出将curExecUpdate置为null有什么必要,等到我们做到组件化那一步的时候就会明白
ViewComponent.prototype.init = function () {
  setDataReactive(this.data)

  this.update = this.update.bind(this)
  curExecUpdate = this.update
  this.update()
  curExecUpdate = null
}

随着init中的this.update执行完毕,一轮执行就算完成了
我们上面的代码还有一行没有解释它存在的原因

this.update = this.update.bind(this)

接下来我们分析按钮点击之后下一轮的执行流程,这行代码的作用顺便就会揭晓: 在用户点击的时候,首先会触发methods里面我们给这个元素绑定的事件回调:

switchDetail () {
  this.data.isShowDetail = !this.data.isShowDetail
}

data上的isShowDetail是个响应式的属性,对它赋值就会触发isShowDetail的set钩子

let curExecUpdate = null
function defineReactive(data, key, val) {
  let updateFns = []
  Object.defineProperty(data, key, {
    ...
    set: function (newVal) {
      val = newVal
      for (let i = 0; i < updateFns.length; i++) {
        updateFns[i]()
      }
    }
  })
}

set钩子里面做的事情,其实就是取出当初收集的updateFns,然后挨个执行,这里的updateFns,其实就是首次渲染时执行的this.update

可以看到,此时this.update执行的形式是从updateFns中取出来挨个以

updateFns[i]()

这样的方式来执行,这也会导致updateFns里面this指向的错误

换句话说,首次渲染时,是直接通过this.update()执行,这种执行方式保留了组件的上下文,而将这个函数引用再存到updateFns中遍历执行的时候,这个上下文就会丢失,这也正是这行代码

this.update = this.update.bind(this)

的作用

继续看this.update里面的逻辑,还是和第1次一样,执行render,移除旧的DOM,创建新的DOM,不过这次在执行render时this.data.isShowDetail的值不一样了,这个值已经在switchDetail中被我们改成true了,所以render中这部分就会执行:

render: function () {
  ...
  if (this.data.isShowDetail) {
    children.push(
      this.createElement('div', { class: 'item' }, this.data.good.cpuNum),
      this.createElement('div', { class: 'item' }, this.data.good.memory),
      this.createElement('div', { class: 'item' }, this.data.good.category),
      this.createElement('div', { class: 'item' }, this.data.good.color)
    )
  }
  ...
}

这样一来,商品的详细信息部分就被加上了

之后再点击切换时,isShowDetail会被置为false,再次触发set钩子,触发update函数,触发render函数,render中判断出this.data.isShowDetail为false,详情部分就不会生成

整个流程分析完后,还有一个地方值得我们注意:
我们很容易发现,页面渲染和用户操作之后的重新渲染,其实就是初次执行update,之后通过事件触发最终再次执行update,而每次update执行的时候都会触发render的执行,而每次执行render时对属性的访问又是一个高频操作,而且还往往会对同一个属性不停地、重复性地访问,我们在此处只是做了比较简单的防止重复添加的操作,其实一定还有优化的空间:

let curExecUpdate = null
function defineReactive(data, key, val) {
  let updateFns = []
  Object.defineProperty(data, key, {
	...
    get: function () {
      if (curExecUpdate && !updateFns.find(curExecUpdate)) {
        updateFns.push(curExecUpdate)
      }
    ...
}

在本节最后,我们再对代码做一些优化
首先第一次update执行的过程其实本质上是收集依赖再创建DOM进行渲染,所以我们可以将其抽到一个名为renderAndCollectDependencies的方法里面

ViewComponent.prototype.init = function () {
  setDataReactive(this.data)

  this.renderAndCollectDependencies()
}
ViewComponent.prototype.renderAndCollectDependencies = function () {
  this.update = this.update.bind(this)
  curExecUpdate = this.update
  this.update()
  curExecUpdate = null
}

其次,我们自己的render函数,访问data上的变量或methods上的方法时,都需要this.data.xxx来写这么一长串,我们希望将data、methods省掉,简化写法

这么简化之后,其缺点也很明显,就是少了一层命名空间,data里的变量和methods里的方法不能重复,这一点需要注意

Vue在实现这个功能时用了代理的方式,具体代码如下:

  function proxy (target, sourceKey, key) {
    sharedPropertyDefinition.get = function proxyGetter () {
      return this[sourceKey][key]
    };
    sharedPropertyDefinition.set = function proxySetter (val) {
      this[sourceKey][key] = val;
    };
    Object.defineProperty(target, key, sharedPropertyDefinition);
  }
  
  proxy(vm, "_data", key)

我开始时不太清楚为什么使用这种方式
如果是我,可能直接就把data、methods上的变量和方法直接拷贝到this上了
不过我猜测,可能是为了数据一致性原则,可以想象一下,如果是按照我的思路,将数据拷贝一份过去,如果是对象类型还好说,因为引用是一样的,但如果是基础类型的话,就意味着我们通过this.xxx和this.data.xxx修改的其实是不同的数据,这样一来就会造成数据的不一致性,违反了软件设计中的单一数据原则

除此之外,我们在加了这一层代理之后,通过this访问的属性本身有了get钩子,而通过get钩子再访问data上的数据时访问到的其实才是收集了update并放入updateFns中的属性,这句话不太好理解,我描述的也不够准确,我们可以举一个例子来说明:
当代码执行到this.isShowDetail时,其实细节如下:

  • 先触发组件对象上属性的钩子:
    sharedPropertyDefinition.get = function proxyGetter () {
      return this[sourceKey][key]
    };

这样通过this.isShowDetail拿到了this._data.isShowDetail,即访问到了data中的isShowDetail

  • 由于data上的isShowDetail也添加了get钩子,因此会进入这个get钩子中,即:
function defineReactive(data, key, val) {
	...
    get: function () {
      if (curExecUpdate && !updateFns.find(fn => fn === curExecUpdate)) {
        updateFns.push(curExecUpdate)
      }
      return val
    },

每个属性的访问,其实都是走了这两个钩子

我们这里也参考Vue的设计方式来实现:

ViewComponent.prototype.init = function () {
  this.initData()
  this.initMethods()

  this.renderAndCollectDependencies()
}
ViewComponent.prototype.initData = function () {
  let keys = Object.keys(this.data)
  for (let i = 0; i < keys.length; i++) {
    setProxy(this, 'data', keys[i])
  }
  setDataReactive(this.data)
}
ViewComponent.prototype.initMethods = function () {
  let keys = Object.keys(this.methods)
  for (let i = 0; i < keys.length; i++) {
    setProxy(this, 'methods', keys[i])
  }
}
function setProxy(target, sourceKey, key) {
  Object.defineProperty(target, key, {
    enumerable: true,
    configurable: true,
    get: function () {
      return target[sourceKey][key]
    },
    set: function (v) {
      target[sourceKey][key] = v
    }
  })
}

最后再啰嗦一下,我们的组件中如果在data上定义了一个名为good的属性,接下来在组件的methods、created钩子中通过this.good访问这个属性时,从微观上来看发生了以下操作:

  1. this.good访问的good是组件对象上的属性
  2. 触发通过setProxy给good添加的代理钩子中的get
  3. get中会返回this.data.good,这次访问的是data函数返回的对象上的good
  4. 触发通过defineReactive给data里的good添加的get钩子
  5. 判断curExecUpdate是否存在,从而收集依赖

最后还有一点,我们平时在写一个组件时,其实本质上写的是一个很大的对象(我们暂且可以将其称为options),这个options对象里有data、methods等等,但目前我们的data就是一个对象,可以想象到的是,当我们实例化多个相同的组件时,会将这个options给每个组件都传进去,但这么多个组件改的却是同一份data数据(由于对象是引用类型),所以我们需要给每个实例各自一份data数据,让它们互不影响
所以,组件实例化时的options中的data,就可以写成这样:

new ViewComponent({
  el: '#app',
  data: function () {
    return {
      isShowDetail: false,
      good: {
        id: '8faw8cs4fw9760zt7tnesini4qup5hid',
        name: 'iPhoneX',
        cpuNum: 1,
        memory: 1073741824,
        brand: 'iPhone',
        category: 'phone',
        color: 'black'
      }
    }
  },

组件内部需要做如下处理:

ViewComponent.prototype.initData = function () {
  let data = isFunction(this.data) ? this.data.call(this) : this.data
  let keys = Object.keys(data)
  this.data = data
  for (let i = 0; i < keys.length; i++) {
    setProxy(this, 'data', keys[i])
  }
  setDataReactive(this.data)
}

还有一点值得注意,initData的执行时机,也是一个值得研究的问题,在Vue当中,initData放在了initProps、initMethods之后执行了,所以在data函数当中,是可以通过this来访问到methods和props的属性、方法的,这一点我觉得知道就好,就我个人的习惯来讲,平时也很少在data里直接用props或methods

这一节写完后,我有个疑问,Vue的源码中,setReactive、defineReactive为什么没有加到原型上?而是独立的方法呢?希望有大神能来帮忙解释一下

点击此处获取本节完整代码