1

上一篇文章我们了解了怎样实现一个简单模板引擎。但这个模板引擎只适合静态模板,因为它是将模板整体编译成字符串进行全量替换。如果每次数据改变都进行一次替换,会有两个最主要的问题:

  1. 性能差。DOM 操作本身就非常大的开销,更别说每一次都替换这么大的量。
  2. 破坏事件绑定。这个是最麻烦的,如果我们没有给解绑移除 DOM 绑定的事件,还会造成内存泄露。而且每一次替换都要重新绑定事件。

因此,没有人会将这种模板引擎用来编译动态模板。那我们如何编译动态模板呢?

回答这个问题之前,我们先要了解前端的世界何时出现了动态模板:它是由 MVVM 框架带来的,动态模板是 MVVM 框架的视图层(view)。我们知道的 MVVM 框架有 knockout.jsangular.jsavalonvue

对于这些框架,大部分人最熟悉的应该就是 vue,所以我下面也是以 vue 1.0 作为参考,来实现一个功能更简单的动态模板引擎。它是框架自带的一个功能,让框架能够响应数据的改变。从而刷新页面。

MVVM 动态模板的特点是能最小化刷新:哪个变量改变了,与之相关的节点才会更新。这样我们就能避免上面提到的静态模板的两大问题。

要实现最小化刷新,我们要将模板中的每个绑定都收集起来。这个收集工作是框架在完成第一次渲染前就已经完成了,每个绑定都会生成一个 Directive 实例:

class Directive {
  constructor(vm, el, exp, update) {
    this.vm = vm
    this.el = el
    this.exp = exp
    this.update = update
    this.watchers = []
    this.get = getEvaluationFn(exp).bind(this, vm.$data)

    this.bind()
  }
}

function getEvaluationFn(exp) {
  return new Function('data', 'with(data) { return ' + exp + '}')
}

我们知道,每个绑定都由指令和指令值(指令值可能是表达式,可能是语句,也可能就是一个变量,还可能是框架自定义的语法)构成,每种指令都有对应的刷新函数(update)。如节点值的绑定的刷新函数是:

function updateTextNode() {
  const value = this.get()
  this.el.nodeValue = value
  console.log(this.exp + ' updated: ' + value)
}

有了刷新函数,那如何做到在数据改变时调用刷新函数更新节点的值呢?我们就还要将每个指令里的相关变量都跟这个 Directive 实例关联起来。我们用一个 $binding 对象来记录,它的键是变量,值是 Binding 实例:

class Binding {
  constructor() {
    this.subs = []
  }

  addChild(key) {
    return this[key] || new Binding()
  }

  addSub(watcher) {
    this.subs.push(watcher)
  }
}

那上面的 subs 里添加的为什么不是 Directive 实例呢,而是 watcher 呢?它其实是 Watcher 的实例,这是为了以后能够实现 $watch 方法提前引入的概念,Watcher 实例的 cb 既可以是指令的刷新函数,也可以是 $watch 方法的回调函数:

class Watcher {
  constructor(vm, path, cb, ctx) {
    this.id = ++uid
    this.vm = vm
    this.path = path
    this.cb = cb
    this.ctx = ctx || vm

    this.addDep()
  }
}
class Directive {
  bind() {
    this.watchers.push(new Watcher(this.vm, this.exp, this.update, this))
  }
}

我们先考虑最简单的情况,指令值就是一个变量,根据上面的思路,我们就可以写出最简单的实现了,代码就不贴了,有兴趣的直接看源码

<div id="app">
  <h1>MVVM</h1>
  <p>
    <span>My name is {{name.first}}-{{name.last }},</span>{{age}} years old
  </p>
</div>
<script src="../dist/eve.js"></script>
<script>
    const app = new Eve({
      el: '#app',
      data: {
        name: {
          first: 'hugo',
          last: 'seth'
        },
        age: 1
      }
    })
    console.log(app)
</script>

图片描述

上面实现的动态模板是在我们假定了指令值是最简单的变量的情况下实现的。那要是把上面的模板改为下面这样呢?

<h1>MVVM</h1>
  <p>
      <span>My name is {{name.first}}-{{name.last }},</span>{{'age: ' + age}} years old
  </p>
  <p>salary: {{ salary.toLocaleString() }}</p>

那我们上面的实现有一些数据就不能动态刷新了,原因很简单,就是我们是直接将 'age: ' + ageDirective 实例关联,而我们修改的只是 age,自然就找不到对应的实例了。那我们如何解决呢?

首先想到的肯定是按照现有的实现来扩展,让它支持模板插值是表达式的情况。已有的实现是直接解析得到变量,那我们就继续想办法直接解析表达式得到变量。像 'age: ' + age 这种表达式直接解析出 age 其实不难。但 salary.toLocaleString() 这种就不好做了,要是 salary.toLocaleString().slice(1) 这种可以说是没办法解析了。

既然这条路行不通,其实我们是有更简单的方法。既然我们都已经将 data 进行了代理,那我们就可以在 get 获取变量值时进行依赖收集。因为我们本来就会运行 Directive 实例的求值函数进行初始值的替换,这就会触发变量的 get 。具体的代码怎么写就不说了,详细的修改支持表达式的源码

图片描述

当然现在只实现动态模板最简单的插值指令。还有一些更复杂的指令如:iffor 的实现方式,下次有机会再分享。

思考题

在最后的实现下,我们把模板改为下面这样(虽然很少会有人这样写),就会出现重复的 Watcher 实例,该如何解决这个问题?

<h1>MVVM</h1>
<p>
   hello,<span>My name is {{name.first + '-' + name.last }}</span>
</p>

参考

vue早期源码学习系列之四:如何实现动态数据绑定


hugo_seth
544 声望22 粉丝

Stay hungry,Stay foolish