Vue 编译三部曲:最后一曲,render code 生成

·  阅读 2216
Vue 编译三部曲:最后一曲,render code 生成

对编译过程的了解会让我们对 Vue 的指令、内置组件等有更好的理解。不过由于编译的过程是一个相对复杂的过程,我们只要求理解整体的流程、输入和输出即可,对于细节我们不必抠太细。由于篇幅较长,这里会用三篇文章来讲 Vue 的编译。这是第三篇, render code 生成

前两篇分别是:

Vue 编译三部曲:模型树优化

Vue 编译三部曲:如何将 template 编译成 AST ?

前言

前面两篇文章分别分享了 Vue 编译三部曲的前两曲:「 parse,template 转换为 AST」,「optimize,模型树优化」。

我们先简单回顾一下前两曲。

parse: 将开发者写的 template 模板字符串转换成抽象语法树 AST ,AST 就这里来说就是一个树状结构的 JavaScript 对象,描述了这个模板,这个对象包含了每一个元素的上下文关系。那么整个 parse 的过程是利用很多正则表达式顺序解析模板,当解析到开始标签、闭合标签、文本的时候都会分别执行对应的回调函数,来达到构造 AST 树的目的。

optimize: 深度遍历这个 AST 树,去检测它的每一棵子树是不是静态节点,如果是静态节点表示生成的 DOM 永远不需要改变,这对运行时对模板的更新起到极大的优化作用,提升了运行效率。

而编译的最后一步就是把优化后的 AST 树转换成可执行的代码,即 generate生成 render code。 再执行 render code渲染函数生成 vnode

接下来我们来看看 Vue generate 是如何将 AST 树转换为render code?。

前置知识,渲染函数

在这之前我们需要先了解一个前置的知识渲染函数,Vue 推荐在绝大多数情况下使用模板来创建你的 HTML。 然而在一些场景中,你真的需要 JavaScript 的完全编程的能力。这时你可以用渲染函数,它比模板更接近编译器。在之前的文章我们也讨论过为什么 Vue 推荐模板,但是有一些场景我们更愿意使用渲染函数?详情戳这里

为什么默认推荐的模板语法,引用一段 Vue 官网的原话如下: 任何合乎规范的 HTML 都是合法的 Vue 模板,这也带来了一些特有的优势:

  • 对于很多习惯了 HTML 的开发者来说,模板比起 JSX 读写起来更自然。这里当然有主观偏好的成分,但如果这种区别会导致开发效率的提升,那么它就有客观的价值存在。
  • 基于 HTML 的模板使得将已有的应用逐步迁移到 Vue 更为容易。
  • 这也使得设计师和新人开发者更容易理解和参与到项目中。
  • 你甚至可以使用其他模板预处理器,比如 Pug 来书写 Vue 的模板。

有些开发者认为模板意味着需要学习额外的 DSL (Domain-Specific Language 领域特定语言) 才能进行开发——我们认为这种区别是比较肤浅的。首先,JSX 并不是没有学习成本的——它是基于 JS 之上的一套额外语法。同时,正如同熟悉 JS 的人学习 JSX 会很容易一样,熟悉 HTML 的人学习 Vue 的模板语法也是很容易的。最后,DSL 的存在使得我们可以让开发者用更少的代码做更多的事,比如 v-on 的各种修饰符,在 JSX 中实现对应的功能会需要多得多的代码。

更抽象一点来看,我们可以把组件区分为两类:一类是偏视图表现的 (presentational),一类则是偏逻辑的 (logical)。我们推荐在前者中使用模板,在后者中使用 JSX 或渲染函数。这两类组件的比例会根据应用类型的不同有所变化,但整体来说我们发现表现类的组件远远多于逻辑类组件。

在深入渲染函数之前,了解一些浏览器的工作原理是很重要的。以下面这段 HTML 为例:

<div>
  <h1>My title</h1>
  Some text content
  <!-- TODO: Add tagline -->
</div>
复制代码

上述 HTML 对应的 DOM 节点树如下图所示:


每个元素都是一个节点。每段文字也是一个节点。甚至注释也都是节点。在之前的编译三部曲第一步中我们也介绍了,template生成 AST时,会把元素、文字、注释都创建成节点描述对象

  • type = 1基础元素节点
  • type = 2含有expressiontokens文本节点
  • type = 3纯文本节点或者是注释节点
child = {
  type: 1,
  tag:"div",
  parent: null,
  children: [],
  attrsList: []
};

child = {
  type: 2,
  expression: res.expression,
  tokens: res.tokens,
  text: text
};

child = {
  type: 3,
  text: text
};
child = {
  type: 3,
  text: text,
  isComment: true
};
复制代码

每一个节点都是页面的一个部分。就像家谱树一样,每个节点都可以有孩子节点、兄弟节点 (也就是说每个部分可以包含其它的一些部分)。

高效地更新所有这些节点会是比较困难的,不过所幸你不必手动完成这个工作。你只需要告诉 Vue 你希望页面上的 HTML 是什么,这可以是在一个模板里:

<h1>{{ blogTitle }}</h1>
复制代码

或者一个渲染函数里:

render: function (createElement) {
  return createElement('h1', this.blogTitle)
}
复制代码

在这两种情况下,Vue 都会自动保持页面的更新,即便 blogTitle 发生了改变。

Vue 通过建立一个虚拟 DOM 来追踪自己要如何改变真实 DOM。请仔细看这行代码:

return createElement('h1', this.blogTitle)
复制代码

createElement到底会返回什么呢? 其实不是一个实际的 DOM 元素。它更准确的名字可能是 createNodeDescription,因为它所包含的信息会告诉 Vue 页面上需要渲染什么样的节点,包括及其子节点的描述信息。我们把这样的节点描述为“虚拟节点 (virtual node)”,也常简写它为“VNode”“虚拟 DOM”是我们对由 Vue 组件树建立起来的整个 VNode 树的称呼。那createElement是如何映射成虚拟DOM的了?

createElement

export function createElement (
  context: Component,
  tag: any,
  data: any,
  children: any,
  normalizationType: any,
  alwaysNormalize: boolean
): VNode | Array<VNode> {
  ...
  return _createElement(context, tag, data, children, normalizationType)
}
复制代码

createElement 函数就是对 _createElement 函数的一个封装,它允许传入的参数更加灵活,在处理这些参数后,调用真正创建 VNode 的函数 _createElement:

export function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
  ...
  return vnode;
}
复制代码

_createElement 方法有 5 个参数:

  • context 表示 VNode 的上下文环境。
  • tag 表示标签,它可以是一个字符串,也可以是一个 Component。
  • data 表示 VNode 的数据。
  • children 表示当前 VNode 的子节点,它是任意类型的,它接下来需要被规范为标准的 VNode 数组。
  • normalizationType 表示子节点规范的类型,类型不同规范的方法也就不一样,它主要是参考 render 函数是编译生成的还是用户手写的。

_createElement 实现内容略多,这里就不详细分析了,反正最后都会创建一个 VNode ,每个 VNode 有 children,children 每个元素也是一个 VNode,这样就形成了一个 VNode Tree,它很好的描述了我们的 DOM Tree。

接下来熟悉一下如何在 createElement 函数中使用模板中的功能。这里是createElement 接收的参数:

/ @returns {VNode}
createElement(
  // {String | Object | Function}
  // 一个 HTML 标签名、组件选项对象,或者
  // resolve 了上述任何一种的一个 async 函数。必填项。
  'div',

  // {Object}
  // 一个与模板中属性对应的数据对象。可选。
  {
    // (详情见下一节)
  },

  // {String | Array}
  // 子级虚拟节点 (VNodes),由 `createElement()` 构建而成,
  // 也可以使用字符串来生成“文本虚拟节点”。可选。
  [
    '先写一些文字',
    createElement('h1', '一则头条'),
    createElement(MyComponent, {
      props: {
        someProp: 'foobar'
      }
    })
  ]
)
复制代码

到这里我们回过头去看看上文说的这句话:「Vue 推荐在绝大多数情况下使用模板来创建你的 HTML。 然而在一些场景中,你真的需要 JavaScript 的完全编程的能力。这时你可以用渲染函数,它比模板更接近编译器。」

而本文的重点generate编译器,它的作用和目的就是将 AST 转换为渲染函数 , 解析成如下格式:

_c('div',{attrs:{"id":"app"}},[_v(_s(message))])
复制代码

接下来我们就一起走进 generate 源码世界。

generate

我们先用一个示例来看看 AST经过 generate 之后生成的render code 到底长什么样?

例如有这样一段模块:

data: {
  isShow: true,
    list: [
      '小白',
      '小黄',
      '小黑',
      '小绿'
    ],
}


<div>
  <ul class="list" v-if="isShow">
    <li 
      v-for="(item, index) in list" 
      @click="clickItem(item)"
    >
      {{item}}:{{index}}
    </li>
  </ul>
</div>
复制代码

它经过编译,执行 const code = generate(ast, options),生成的 render code就会是如下这样一个字符串,注意这是一个字符串,只是为了方便大家阅读,我进行了格式化。

with (this) {
  return _c('div', [
    isShow
      ? _c(
          'ul',
          { staticClass: 'list' },
          _l(list, function (item, index) {
            return _c(
              'li',
              {
                on: {
                  click: function ($event) {
                    return clickItem(item);
                  },
                },
              },
              [_v('\n              ' + _s(item) + ':' + _s(index) + '\n            ')],
            );
          }),
          0,
        )
      : _e(),
  ]);
}
复制代码

with 字符串?

大家发现生成的渲染函数字符串居然是一个with包裹的字符串,这样做的原因是with的作用域和模板的作用域是契合的,可以极大的简化编译流程。

但是肯定会有同学质疑with不是不推荐使用?并且有性能问题吗?为什么还要用?

尤雨溪本人的回答是这样的:


“ 因为没有什么太明显的坏处(经测试性能影响几乎可以忽略),但是 with 的作用域和模板的作用域正好契合,可以极大地简化模板编译过程。Vue 1.x 使用的正则替换 identifier path 是一个本质上 unsound 的方案,不能涵盖所有的 edge case;而走正经的 parse 到 AST 的路线会使得编译器代码量爆炸。虽然 Vue 2 的编译器是可以分离的,但凡是可能跑在浏览器里的部分,还是要考虑到尺寸问题。用 with 代码量可以很少,而且把作用域的处理交给 js 引擎来做也更可靠。

用 with 的主要副作用是生成的代码不能在 strict mode / ES module 中运行,但直接在浏览器里编译的时候因为用了 new Function(),等同于 eval,不受这一点影响。

当然,最理想的情况还是可以把 with 去掉,所以在使用预编译的时候(vue-loader 或 vueify),会自动把第一遍编译生成的代码进行一次额外处理,用完整的 AST 分析来处理作用域,把 with 拿掉,顺便支持模板中的 ES2015 语法。也就是说如果用 webpack + vue 的时候,最终生成的代码是没有 with 的。”


而对于性能的影响,使用with 的确会造成一定的性能降低。但是真实 DOM 的渲染时间比 Virtual DOM 要长,而是否使用 with 只是影响了 Virtual DOM 的渲染,对真实 DOM 的渲染没有影响。所以对于普通需求来说,这种性能的影响比较小。

并且使用 with, 就不需要在模板里面写 this 了。而编译生成的 with(this) 可以在某种程度上实现对于作用域的动态注入。这样写方便又简单,极大的简化编译流程,虽然有小的性能影响,但是权衡之下肯定利大于弊。

_c 函数

并且在生成的渲染字符串中有这样一些醒目的标记,例如:_c

vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
复制代码

_c 其实在源码中对应的就是createElement函数,用于创建vnode

而还有一些常用的_(下划线)函数 如:_l 对应 renderList 渲染列表;_v 对应 createTextVNode 创建文本 VNode;_e 对于 createEmptyVNode创建空的 VNode。它们都被定义在 installRenderHelpers中。

export function installRenderHelpers (target: any) {
  target._o = markOnce
  target._n = toNumber
  target._s = toString
  target._l = renderList
  target._t = renderSlot
  target._q = looseEqual
  target._i = looseIndexOf
  target._m = renderStatic
  target._f = resolveFilter
  target._k = checkKeyCodes
  target._b = bindObjectProps
  target._v = createTextVNode
  target._e = createEmptyVNode
  target._u = resolveScopedSlots
  target._g = bindObjectListeners
}
复制代码

好,接下来,我们回到 generate 源码中来。

generate 函数

const code = generate(ast, options)
复制代码
  function generate (
    ast,
    options
  ) {
    var state = new CodegenState(options);
    var code = ast ? genElement(ast, state) : '_c("div")';
    return {
      render: ("with(this){return " + code + "}"),
      staticRenderFns: state.staticRenderFns
    }
  }
复制代码

进入generate流程调用 generate函数。generate函数代码不是很复杂,参数也比较简单

  • ast:转换优化后的语法树
  • options:编译器运行时的配置项

函数首先调用 CodegenState 构造函数,创建实例对象 state 初始化编译的状态。CodegenState 的主要作用就是给实例初始化一些相关的属性。

  • options:基础的配置项
  • warn:警告函数
  • transforms:静态样式和属性、非静态样式和属性的处理函数引用
  • dataGenFns:模块数据函数的引用
  • directives:v-bind、v-model、v-text、v-html、v-on、内置指令对应 处理函数
  • isReservedTag:检查是否是保留标签
  • maybeComponent:检查元素是否为组件
  • staticRenderFns:存放静态节点的 render 函数
  • pre:记录标签是否使用了 v-pre
  var CodegenState = function CodegenState (options) {
    this.options = options;
    this.warn = options.warn || baseWarn;
    this.transforms = pluckModuleFunction(options.modules, 'transformCode');
    this.dataGenFns = pluckModuleFunction(options.modules, 'genData');
    this.directives = extend(extend({}, baseDirectives), options.directives);
    var isReservedTag = options.isReservedTag || no;
    this.maybeComponent = function (el) { return !!el.component || !isReservedTag(el.tag); };
    this.onceId = 0;
    this.staticRenderFns = [];
    this.pre = false;
  };
复制代码

接下来就是最重要的一步是,生成render code string

var code = ast ? genElement(ast, state) : '_c("div")';
复制代码

ast就调用 genElement函数,没有的话,默认就创建一个div。这里的重点是genElement函数, 接下来我们来重点看看 genElement干了些什么?

genElement

当调用 genElement函数时,传入已经优化处理好的ast。然后在函数中根据不同的节点属性执行不同的生成函数。

①,判断 el.parent是否有值,来进行 pre 属性的设置。

②,如果节点是一个静态根节点staticRoot = ture,并且节点还没有被解析过staticProcessed = undefined就会调用 genStatic 函数。此函数用于生成静态节点的渲染函数字符串。生成一个_m的函数字符串。详情请看genStatic函数解析↓。

③,如果节点存在v-once,并且节点还没有被解析过onceProcessed = undefined就会调用 genOnce 函数。此函数用于生成v-once节点的渲染函数字符串。生成一个_o的函数字符串。详情请看genonce函数解析↓。

④,如果存在v-for循环,并且节点还没有被解析过forProcessed = undefined就会调用 genFor函数。此函数用于节点存在循环的情况,生成一个_l的函数字符串。详情请看genFor函数解析↓。

⑤,如果存在v-if循环,并且节点还没有被解析过ifProcessed = undefined就会调用 genIf函数。此函数用于节点存在v-if、v-else-if、v-else的情况,生成一个包含三目表达式的字符串(或者是嵌套的三目表达式:a ? b ? ... : c : d),详情请看genIf函数解析↓。

⑥,如果元素为template,并且节点 !el.slotTarget && !state.pre就调用genChildren根据子节点信息进行render code生成,详情请看genChildren函数解析↓。

⑦,如果元素为slot,调用genSlot函数生成一个_t的函数字符串。详情请看genSlot函数解析↓。

⑧,当以上条件都不满足进入 else 在此检测当前元素是否为组件,如果是调用 genComponent函数并且返回生成虚拟dom渲染函数所需对应的参数格式

 function genElement (el, state) {
    // ①
    if (el.parent) {
      el.pre = el.pre || el.parent.pre;
    }
    // ②
    if (el.staticRoot && !el.staticProcessed) {
      return genStatic(el, state)
    // ③
    } else if (el.once && !el.onceProcessed) {
      return genOnce(el, state)
    // ④
    } else if (el.for && !el.forProcessed) {
      return genFor(el, state)
    // ⑤
    } else if (el.if && !el.ifProcessed) {
      return genIf(el, state)
    // ⑥ 
    } else if (el.tag === 'template' && !el.slotTarget && !state.pre) {
      return genChildren(el, state) || 'void 0'
    // ⑦
    } else if (el.tag === 'slot') {
      return genSlot(el, state)
    } else {
      // component or element
      var code;
      if (el.component) {
        code = genComponent(el.component, el, state);
      } else {
        var data;
        if (!el.plain || (el.pre && state.maybeComponent(el))) {
          data = genData$2(el, state);
        }

        var children = el.inlineTemplate ? null : genChildren(el, state, true);
        code = "_c('" + (el.tag) + "'" + (data ? ("," + data) : '') + (children ? ("," + children) : '') + ")";
      }
      // module transforms
      for (var i = 0; i < state.transforms.length; i++) {
        code = state.transforms[i](el, code);
      }
      return code
    }
  }
复制代码

genStatic

function genStatic (el, state) {
  el.staticProcessed = true;
  if (el.pre) {
    state.pre = el.pre;
  }
  // 将静态元素添加到 staticRenderFns 中
  state.staticRenderFns.push(("with(this){return " + (genElement(el, state)) + "}"));
  state.pre = originalPreState;
  return ("_m(" + (state.staticRenderFns.length - 1) + (el.staticInFor ? ',true' : '') + ")")
  }
复制代码

用于静态元素的 render code生成,生成的render code是一个 _m的函数字符串。

为了方便理解举一个例子如下:

<div>
  <div><span>一则头条</span></div>
  <div><span>{{text}}</span></div>
  <div><span>一则头条</span></div>
</div>
复制代码

经过parse后,生成这样的一段 AST 描述对象。

再经过generate后,就会生成如下的 render code

_m(0)
_m(1)
复制代码

这里会发现,为什么函数字符串有一个数字?

这是因为在执行renderStatic函数时,也就是生成静态元素 vnode时,会从staticRenderFns数组中读取序列元素,staticRenderFns存放的是静态节点的 render 函数

上面的模板示例,就会生成这样一个 staticRenderFns

staticRenderFns = [
    "with(this){return _c('div',[_c('span',[_v("一则头条")])])}",
    "with(this){return _c('div',[_c('span',[_v("一则头条")])])}"
]
复制代码

当生成 render 时就会读取指定的渲染字符串。

  function renderStatic (
    index,
    isInFor
  ) {
    var cached = this._staticTrees || (this._staticTrees = []);
    var tree = cached[index];
    // 如果缓存存在,就直接返回
    if (tree && !isInFor) {
      return tree
    }
    // 这里是执行 render 的地方,读取 staticRenderFns 对应的静态元素
    tree = cached[index] = this.$options.staticRenderFns[index].call(
      this._renderProxy,
      null,
      this 
    );
    markStatic(tree, ("__static__" + index), false);
    return tree
  }
复制代码

并且最后将我们vnode节点,放入到 Vue 实例_staticTrees中。

genOnce

function genOnce (el, state) {
    el.onceProcessed = true;
    if (el.if && !el.ifProcessed) {
      return genIf(el, state)
    } else if (el.staticInFor) {
      ...
      if (!key) {
        return genElement(el, state)
      }
      return ("_o(" + (genElement(el, state)) + "," + (state.onceId++) + "," + key + ")")
    } else {
      return genStatic(el, state)
    }
  }
复制代码

用于生成包含v-once元素的 render code,生成的render code是一个 _o的函数字符串。genOnce函数本身逻辑会根据其他的元素属性来做处理。

  • 如果包含属性 v-if就会将逻辑分发到 genIf
  • 如果是一个静态节点包含在for循环中,就会生成_o的函数字符串
  • 除开上面的情况就会当做静态节点处理

这里简单提一下 v-once

v-once只渲染元素和组件一次。随后的重新渲染,元素/组件及其所有的子节点将被视为静态内容并跳过。这可以用于优化更新性能。

渲染普通的 HTML 元素在 Vue 中是非常快速的,但有的时候你可能有一个组件,这个组件包含了大量静态内容。在这种情况下,你可以在根元素上添加 v-once attribute 以确保这些内容只计算一次然后缓存起来。

不过,请试着不要过度使用这个模式。当你需要渲染大量静态内容时,极少数的情况下它会给你带来便利,除非你非常留意渲染变慢了,不然它完全是没有必要的——再加上它在后期会带来很多困惑。例如,设想另一个开发者并不熟悉 v-once 或漏看了它在模板中,他们可能会花很多个小时去找出模板为什么无法正确更新。

genFor

  function genFor (
    el,
    state,
    altGen,
    altHelper
  ) {
    var exp = el.for;
    var alias = el.alias;
    var iterator1 = el.iterator1 ? ("," + (el.iterator1)) : '';
    var iterator2 = el.iterator2 ? ("," + (el.iterator2)) : '';

     ...
    el.forProcessed = true; // avoid recursion
    return (altHelper || '_l') + "((" + exp + ")," +
      "function(" + alias + iterator1 + iterator2 + "){" +
        "return " + ((altGen || genElement)(el, state)) +
      '})'
  }
复制代码

用于生成节点存在循环的render code,生成的render code是一个_l的函数字符串。_l源码中对应的是 renderList函数。

为了方便理解举一个例子如下:

<div>
  <ul v-for="(item, index, arr) in list">
    <li>{{ item }}</li>
  </ul>
</div>
复制代码

经过parse后,生成这样的一段 AST 描述对象。包含了一些genFor需要用到的信息,用到的信息我在截图中进行了标记。

再经过generate后,就会生成如下的 render code(为方便阅读,render code已被格式化过)。

_l(list, function (item, index, arr) {
  return _c('ul', [_c('li', [_v(_s(item))])]);
});
复制代码

genFor 的处理逻辑很简单,从 AST 元素节点中获取了和 for 相关的一些属性,然后拼接成一个代码字符串。

genIf

  function genIf (
    el,
    state,
    altGen,
    altEmpty
  ) {
    el.ifProcessed = true;
    return genIfConditions(el.ifConditions.slice(), ...)
  }
复制代码

用于生成节点存在条件判断的render codegenIf 主要是通过执行 genIfConditions来执行,获取节点信息中 ifConditions列表进行解析。ifConditions是一个存储条件判断相关信息的数组。这个ifConditions是怎么来的了?来源于编译的第一步中optimize解析标签时,如果节点存在属性v-if、v-else-if、v-else时,就会将表达式和节点的信息分析放入存储到ifConditions中。

为了方便理解,举一个小例子:

<div>
  <div v-if="isShow === 1">1</div>
  <div v-else-if="isShow === 2">2</div>
  <div v-else>3</div>
</div>
复制代码

有这样一段模板代码,在解析的过程中就会将v-if、v-else-if、v-else分成抽离成一个含有表达式和节点信息的对象,存储到 ifConditions 中,如下所示,这样在genIfConditions执行时,就可以在ifConditions中去读取节点和属性表达式的相关信息。

ifConditions = [
  {exp: 'isShow === 1', block: ...},
  {exp: 'isShow === 2', block: ...},
  {exp: undefined, block: ...}
]
复制代码

接着回来,看看genIfConditions中具体是怎么利用存储在ifConditions中的信息。

function genIfConditions (
 conditions,
 state,
 altGen,
 altEmpty
) {
  ...
  // 
  var condition = conditions.shift();
  if (condition.exp) {
    return ("(" + (condition.exp) + ")?" + (genTernaryExp(condition.block)) + ":" + (genIfConditions(conditions, state, altGen, altEmpty)))
  } else {
    return ("" + (genTernaryExp(condition.block)))
  }
  
  // v-if with v-once should generate code like (a)?_m(0):_m(1)
  function genTernaryExp (el) {
    return altGen
      ? altGen(el, state)
    : el.once
      ? genOnce(el, state)
    : genElement(el, state)
  }
  }
复制代码

conditions 就是 ifConditions

它是依次从 conditions 获取第一个元素信息,然后通过对 condition.exp去生成一段三元运算符的代码,: 后是递归调用 genIfConditions,这样如果有多个 conditions,就生成多层嵌套的三元运算逻辑。

上面的例子就会生成一个嵌套的三元运算逻辑字符串表达式(为方便阅读,render code已被格式化过)。

(isShow === 1) 
  ? _c('div',[_v("1")])
  : (isShow === 2)
    ? _c('div',[_v("2")])
     : _c('div',[_v("3")])
复制代码

genChildren

  function genChildren (
    el,
    state,
    checkSkip,
    altGenElement,
    altGenNode
  ) {
    var children = el.children;
    if (children.length) {
      ...
      return ("[" + (children.map(function (c) { return gen(c, state); }).join(',')) + "]" + (normalizationType$1 ? ("," + normalizationType$1) : ''))
    }
  }
复制代码

用于生成子级虚拟节点信息字符串。核心关键就是这样一段代码。

'[' +
  children
    .map(function (c) {
      return gen(c, state);
    })
    .join(',') +
  ']' +
  (normalizationType$1 ? ',' + normalizationType$1 : '');
复制代码

生成返回字符串格式数组对象children是根节点(相对)下子级节点的数组对象。通过 map再对子级节点进行字符串格式的处理。

对子节点的处理调用 gen函数,也就是源码中对于的 genNode函数。

function genNode (node, state) {
  if (node.type === 1) {
    return genElement(node, state)
  } else if (node.type === 3 && node.isComment) {
    return genComment(node)
  } else {
    return genText(node)
  }
 }
复制代码

genNode函数中:

  • 当是元素节点时递归调用genElement() 函数
  • 当是注释节点时调用genComment 函数
  • 当是文本节点时调用genText 函数

genComment

genComment 函数逻辑很简单,把注释JSON 成字符串,包含在 _e的函数字符串中。

 function genComment (comment) {
   return ("_e(" + (JSON.stringify(comment.text)) + ")")
 }
复制代码

genText

genText 函数会处理含有表达式的文本或者是纯文本。表达式的文本将text.expression包裹在_v函数字符串中,纯文本就格式化后包裹在_v函数字符串中。

纯文本的处理会将文本中的\u2028-行分隔符\u2029-段落分隔符进行全局转义。原因是这两个特殊字符会导致程序报错。

详情请阅读 issues:

  function genText (text) {
    return ("_v(" + (text.type === 2
      ? text.expression
      : transformSpecialNewlines(JSON.stringify(text.text))) + ")")
  }

  function transformSpecialNewlines (text) {
    return text
      .replace(/\u2028/g, '\u2028')
      .replace(/\u2029/g, '\u2029')
  }
复制代码

为了加深理解,举一个小例子:

<div>
  <template>
    <!-- 注释 -->
    <div>{{text}}</div>
  </template>
</div>
复制代码

上面这段模板经过parse后,生成这样的一段 AST 描述对象。

再经过generate后,就会生成如下的 render code(为方便阅读,render code已被格式化过)。也就是上文说的生成字符串格式数组对象

[_e(' 注释 '), _v(' '), _c('div', [_v(_s(text))])];
复制代码

genData

var code;
if (el.component) {
  code = genComponent(el.component, el, state);
} else {
  var data;
  if (!el.plain || (el.pre && state.maybeComponent(el))) {
    data = genData$2(el, state);
  }

  var children = el.inlineTemplate ? null : genChildren(el, state, true);
  code = "_c('" + (el.tag) + "'" + (data ? ("," + data) : '') + (children ? ("," + children) : '') + ")";
}
for (var i = 0; i < state.transforms.length; i++) {
  code = state.transforms[i](el, code);
}
return code
复制代码

这里发现一个有意思的属性 el.plain,这个属性为真的条件就是「如果标签既没有使用特性key,又没有任何属性」,那么该标签的元素描述对象的 plain 属性将始终为 true。这个属性是在 processElement 阶段给 ast 对象进行的扩展(后面专门写一篇文章来分析 processElement)。

当节点不是staticRoot没有once没有if没有for不是 template不是 slot ,那就会走到最后的判断逻辑中,在此检测当前元素是否为组件,如果是调用 genComponent函数并且返回生成虚拟dom渲染函数所需对应的参数格式。

这里有三个重点逻辑。

  • 是组件,走 genComponent函数调用
  • 属性,走 genData对元素属性解析
  • 子节点,走 genChildren对元素的子元素解析

对于 genComponent函数内部其实就是对 genDatagenChildren的封装处理,一个组件本身其实也就是很多元素的集合,只是这些元素套在了一个名为某某某的组件内部。所以用genComponentgenDatagenChildren封装也就想得通了。其中genChildren中又会递归调用genElement来处理元素。最后生成对于的 render code

  function genComponent (
    componentName,
    el,
    state
  ) {
    var children = el.inlineTemplate ? null : genChildren(el, state, true);
    return ("_c(" + componentName + "," + (genData$2(el, state)) + (children ? ("," + children) : '') + ")")
  }
复制代码

genChildren在上一小节已经讲过,所以这里就把重点放在genData上。

在 Vue 中一个 VNode代表一个虚拟节点,而该节点的虚拟属性信息用 VNodeData描述。而VNodeData的生成就是用 genData函数来实现的。

genData 函数就是根据 AST 元素节点的属性构造出一个 data 对象字符串,这个在后面创建 VNode 的时候的时候会作为参数传入。

Vue 中对处理节点属性其实有三个 genData 函数。分别是 genData genData$1genData$2 。三个函数分别处理不同类型的节点属性。

在 Vue 中可以使用 绑定 Class 与 绑定 Style来生成动态class 列表内联样式。在源码中genData的作用就是处理静态的class绑定的 classgenData$1用来处理静态的 style绑定的 stylegenData$2用来处理其他属性。

为了便于理解,我引入一个例子作为下面解析的用例:

<div
     ref="test-ref"
     id="testId"
     key="test-key"
     class="text-clasee" 
     :class="{ active: isActive, bindClass: hasBind }" 
     style="color: red" 
     :style="{ color: activeColor, fontSize: fontSize + 'px' }"
     data-a="test-a"
     data-b="test-b"
>
  {{text}}
</div>
复制代码

上面这段模板经过parse后,生成这样的一段 AST 描述对象。

再经过generate后,就会生成如下的 render code(为方便阅读,render code已被格式化过)。

_c(
  'div',
  {
    key: 'test-key',
    ref: 'test-ref',
    staticClass: 'text-clasee',
    class: { active: isActive, bindClass: hasBind },
    staticStyle: { color: 'red' },
    style: { color: activeColor, fontSize: fontSize + 'px' },
    attrs: { id: 'testId', 'data-a': 'test-a', 'data-b': 'test-b' },
  },
  [_v('\n          ' + _s(text) + '\n        ')],
);
复制代码

从 template 属性到 render code data 对象字符串 Vue 是如何进行操作的了?

属性是如何添加到 AST 中的了?

在这之前我们先来回忆一下,之前template 生成 AST 的过程中关于属性挂载的过程,我们写的这么多属性,是如何添加到AST中的?

template 生成 AST 的过程中会对标签的开始标记和结束标记进行解析,在解析时,会利用正则来匹配元素中的静态属性和动态属性。

// 匹配属性,例如 id、class
var attribute = /^\s*([^\s"'<>/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/;

// 匹配动态属性,例如 v-if、v-else
var dynamicArgAttribute = /^\s*((?:v-[\w-]+:|@|:|#)[[^=]+][^\s"'<>/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/;
复制代码

并将正则匹配到信息存储到attrs中。

然后在回调 start 钩子函数进行createASTElement创建AST时,将所有解析到的属性存储到attrsMap中。

{
    "ref": "test-ref",
    "id": "testId",
    "key": "test-key",
    "class": "text-clasee",
    ":class": "{ active: isActive, bindClass: hasBind }",
    "style": "color: red",
    ":style": "{ color: activeColor, fontSize: fontSize + 'px' }",
    "data-a": "test-a",
    "data-b": "test-b"
}
复制代码

最后在processElement将所有的属性通过转换函数转换成相应的元素属性描述。

例如:class属性的转换函数是transformNode

  function transformNode (el, options) {
    var warn = options.warn || baseWarn;
    var staticClass = getAndRemoveAttr(el, 'class');
    ...
    if (staticClass) {
      el.staticClass = JSON.stringify(staticClass);
    }
    var classBinding = getBindingAttr(el, 'class', false /* getStatic */);
    if (classBinding) {
      el.classBinding = classBinding;
    }
  }
复制代码

style的转换函数是transformNode$1

function transformNode$1 (el, options) {
    var warn = options.warn || baseWarn;
    var staticStyle = getAndRemoveAttr(el, 'style');
    if (staticStyle) {
      ...
      el.staticStyle = JSON.stringify(parseStyleText(staticStyle));
    }

    var styleBinding = getBindingAttr(el, 'style', false /* getStatic */);
    if (styleBinding) {
      el.styleBinding = styleBinding;
    }
  }
复制代码

其他属性的转换函数有:

  • processKey,转换节点中的 key 属性
  • processRef,转换节点中的 ref 属性
  • 等等...

经过转换函数的转换之后,所有的属性都会被挂载到生成的AST中。

{
    "type": 1,
    "tag": "div",
    "attrsList": [...],
    "attrsMap": {
        "ref": "test-ref",
        "id": "testId",
        "key": "test-key",
        "class": "text-clasee",
        ":class": "{ active: isActive, bindClass: hasBind }",
        "style": "color: red",
        ":style": "{ color: activeColor, fontSize: fontSize + 'px' }",
        "data-a": "test-a",
        "data-b": "test-b"
    },
    "rawAttrsMap": {...},
    "children": [...],
    "start": 0,
    "end": 363,
    "key": ""test-key"",
    "plain": false,
    "ref": ""test-ref"",
    "refInFor": false,
    "staticClass": ""text-clasee"",
    "classBinding": "{ active: isActive, bindClass: hasBind }",
    "staticStyle": "{"color":"red"}",
    "styleBinding": "{ color: activeColor, fontSize: fontSize + 'px' }",
    "attrs": [...],
    "static": false,
    "staticRoot": false
}
复制代码

只有AST中属性信息挂载完之后才能在 generate时,将属性添加到render code中。

属性是如何解析到 render code 中的了?

render code的生成过程中,属性的解析逻辑都在genData$2函数中。函数就是根据 AST 元素节点的属性,构造出一个 data 对象字符串,这个在后面创建 VNode 的时候的会将 data 对象字符串作为参数传入。

data 对象字符串的拼接场景会根据不同属性进行不同的操作。我列举两个。

directives

指令的解析,例如我们在节点上写了一个自定义的指令:

v-has:a:b:c={isShow}
复制代码

在解析时用 genDirectives函数解析,生成指令描述的字符串。添加到 data 对象字符串中。

directives:[{name:"has",rawName:"v-has:a:b:c",value:({isShow}),expression:"{isShow}",arg:"a:b:c"}]
复制代码
dataGenFns
for (var i = 0; i < state.dataGenFns.length; i++) {
  data += state.dataGenFns[i](el);
}
复制代码

dataGenFns 处理的主要是针对我们节点属性中的绑定 class、静态 class,绑定 style、静态style的处理。分别调用 genData函数和genData$1函数。这两个函数内部实现也是做data 对象字符串的拼接。

  function genData (el) {
    var data = '';
    if (el.staticClass) {
      data += "staticClass:" + (el.staticClass) + ",";
    }
    if (el.classBinding) {
      data += "class:" + (el.classBinding) + ",";
    }
    return data
  }
复制代码
  function genData$1 (el) {
    var data = '';
    if (el.staticStyle) {
      data += "staticStyle:" + (el.staticStyle) + ",";
    }
    if (el.styleBinding) {
      data += "style:(" + (el.styleBinding) + "),";
    }
    return data
  }
复制代码

小结

到这里整个 genElement的解析流程也基本完成了,genElement的流程其实本身也是 generate流程的核心。将 AST 转换生成可执行的render code

当然整个流程并没有我本文写到的这么简单,篇幅有限很多细节点并没有很细致的深入,但是如有有兴趣可以自己去深入,也可以我们一起交流。

总结

整个generate也算分析完了,这也是编译三部曲的最后一步,算上前面的两篇文章,编译三部曲也全部完结了。

我们再整体来回顾一下 Vue 的编译三部曲:parseoptimizegenerate

三部曲第一步parse 将开发者写的 template 模板字符串转换成抽象语法树 AST ,AST 就这里来说就是一个树状结构的 JavaScript 对象,描述了这个模板,这个对象包含了每一个元素的上下文关系。

三部曲第二步optimize 深度遍历parse流程生成的 AST 树,去检测它的每一颗子树是不是静态节点,如果是静态节点表示生成的 DOM 永远不需要改变,这对运行时对模板的更新起到极大的优化作用,提升了运行效率。

三部曲第三步generate 也就是本文解析 把优化后的 AST 树转换成可执行的代码,即生成 render code。 为后续 vnode生成提供基础。

到此三部曲全部都分析完毕了,这里也留下一个小问题,Vue2.x 版本的编译流程是否有什么不足了?如有不足,在 Vue 3.0 版本又是如何去优化的了?欢迎评论区一起交流讨论。

最后

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿

参考

分类:
前端
收藏成功!
已添加到「」, 点击更改