3天学写mvvm框架[三]:浏览器端渲染

1,652 阅读2分钟

此前为了学习Vue的源码,我决定自己动手写一遍简化版的Vue。现在我将我所了解到的分享出来。如果你正在使用Vue但还不了解它的原理,或者正打算阅读Vue的源码,希望这些分享能对你了解Vue的运行原理有所帮助。

目标

通过之前的实践,我们已经实现了数据变动的监听与模板的解析,今天我们就将把两者结合起来,完成浏览器端的渲染工作。

Vue类

首先我们来编写类:Vue

Vue的构造函数将接受多个参数,包括:

  • el:实例的渲染将以此作为父节点。
  • data:一个函数,运行后将返回一个对象/数组,作为实例的数据。
  • tpl: 实例的模板字符串。
  • methods:实例的方法。

在构造函数中,我们将先设定根元素为$el,然后调用我们之前写的parseHtmlgenerateRender并最终生成Function实例作为我们的渲染函数render,同时使用proxy来创建可观察的数据:

class Vue {
  constructor({ el, data, tpl, methods }) {
    // set render
    if (el instanceof Element) {
      this.$el = el;
    } else {
      this.$el = document.querySelector(el);
    }
    const ast = parseHtml(tpl);
    const renderCode = generateRender(ast);
    this.render = new Function(renderCode);

    // set data
    this.data = proxy(data.call(this));

    ...
  }

  ...
}

这里,我们将再次使用proxy来创建一个代理。在Vue中,例如data方法创建了{ a: 1 }这样的数据,我们可以通过this.a而非类似this.data.a来访问。为了支持这样更简洁地访问数据,我们希望提供一个对象,同时提供对数据的访问以及其他内容例如方法的访问,同时又保持proxy对于新键值对的设置的灵活性,因此我这里采取的方式是创建一个新的proxy,它会优先访问实例的数据,如果数据不存在,再来访问方法等:

const proxyObj = new Proxy(this, {
  get(target, key) {
    if (key in target.data) return target.data[key];
    return target[key];
  },
  set(target, key, value) {
    if (!(key in target.data) && key in target) {
      target[key] = value;
    } else {
      target.data[key] = value;
    }
    return true;
  },
  has(target, key) {
    return (key in target) || (key in target.data);
  },
});
this._proxyObj = proxyObj;

接下去,我们将methods中的方法绑定到实例上:

Object.keys(methods).forEach((key) => {
  this[key] = methods[key].bind(proxyObj);
});

最后我们将调用watch方法,传入的求值函数updateComponent将完成渲染工作,同时收集依赖,以便在数据变动时重新渲染:

const updateComponent = () => {
  this._update(this._render());
};

watch(updateComponent, () => {/* noop */});

渲染与v-dom

_render方法将调用render来创建一棵由VNode节点组成的树,或称之为v-dom

class VNode {
  constructor(tag, text, attrs, children) {
    this.tag = tag;
    this.text = text;
    this.attrs = attrs;
    this.children = children;
  }
}

class Vue {
  ...

  _render() {
    return this.render.call(this._proxyObj);
  }

  _c(tag, attrs, children) {
    return new VNode(tag, null, attrs, children);
  }

  _v(text) {
    return new VNode(null, text, null, null);
  }
}

_update方法将根据是否已经创建过旧的v-dom来判断是进行创建过程还是比较更新过程(patch),随后我们需要保存本次创建的v-dom,以便进行后续的比较更新:

_update(vNode) {
  const preVode = this.preVode;
  if (preVode) {
    patch(preVode, vNode);
  } else {
    this.preVode = vNode;
    this.$el.appendChild(build(vNode));
  }
}

创建过程将遍历整个v-dom,使用document.createTextNodedocument.createElement来创建dom元素,并将其保存在VNode节点上,用以之后进行更新:

const build = function (vNode) {
  if (vNode.text) return vNode.$el = document.createTextNode(vNode.text);
  if (vNode.tag) {
    const $el = document.createElement(vNode.tag);
    handleAttrs(vNode, $el);
    vNode.children.forEach((child) => {
      $el.appendChild(build(child));
    });
    return vNode.$el = $el;
  }
};
const handleAttrs = function ({ attrs }, $el, preAttrs = {}) {
  if (preAttrs.class !== attrs.class || preAttrs['v-class'] !== attrs['v-class']) {
    let clsStr = '';
    if (attrs.class) clsStr += attrs.class;
    if (attrs['v-class']) clsStr += ' ' + attrs['v-class'];
    $el.className = clsStr;
  }
  if (attrs['v-on-click'] !== preAttrs['v-on-click']) { // 这里匿名函数总是会不等的
    if (attrs['v-on-click']) $el.onclick = attrs['v-on-click'];
  }
};

由于我们还不支持v-ifv-forcomponent组件等等,因此我们可以认为更新后的v-dom在结构上是一致的,这样就大大简化了比较更新的过程。我们只需要遍历新老两颗v-dom,在patch方法中传入对应的新老VNode节点,如果存在不同的属性,便进行跟新就可以了:

const patch = function (preVode, vNode) {
  if (preVode.tag === vNode.tag) {
    vNode.$el = preVode.$el;
    if (vNode.text) {
      if (vNode.text !== preVode.text) vNode.$el.textContent = vNode.text;
    } else {
      vNode.$el = preVode.$el;
      preVode.children.forEach((preChild, i) => { // TODO:
        patch(preChild, vNode.children[i]);
      });
      handleAttrs(vNode, vNode.$el, preVode.attrs);
    }
  } else {
    // 因为结构是一样的,因此暂时不必考虑
  }
};

最后,我们暴露一个方法来返回新建的Vue实例所绑定的_proxyObj对象,我们就可以通过这个对象来改变实例数据或是调用实例的方法等了:

Vue.new = function (opts) {
  return new Vue(opts)._proxyObj;
};

总结

我们通过3次实践,完成了数据监听、模板解析以及最后的渲染。当然这只是一个非常简陋的demo,容错性有限、支持的功能也非常有限。

也许之后我还会更新这一系列的文章,加入计算属性的支持、组件的支持、v-ifv-forv-model等directive的支持、templatekeep-alivecomponent等组件,等等。

最后谢谢您阅读本文,希望有帮助到您理解Vue的一部分原理。

参考: