模板编译template的背后,究竟发生了什么事?带你了解template的纸短情长

2,130 阅读10分钟

🚢前言

这是我参与更文挑战的第29天,活动详情查看:更文挑战

依稀记得我们在写 vue 时,最上方总是有一个 template 包围着。而很多时候,我们也没有很在意的去意识到 <template></template> 究竟是什么。

在今天的这篇文章中,就带大家一起来了解,模板编译 template 的背后,究竟发生了什么事情?

一起来了解模板编译的纸短情长🚋🚋🚋

一、📑初识模板编译

1、vue组件中使用render代替template

template ,即模板。模板是 vue 开发中最常用的部分,即与vue的使用关联最紧密的原理。它不是 html ,它有指令、有插值、也有 JS 表达式,那它,到底是什么呢?我们来看个例子。

在 vue 中定义一个组件,通常会使用 template 模板字符串来定义一个组件。比如:

Vue.component('heading',{
	template:`xxx`
})

一般情况下,模板的定义是上面这种情况。同时,在程序编译期间,模板会将 template 的这种字符串类型,编译成 render 函数。


但是呢,在有些复杂的情况下,可能就不能用 template 函数了,这个时候会考虑直接用 render 函数来定义一个组件。比如:

Vue.component('heading',{
	render: function(createElement){
		return createElement(
		'h' + this.level, //tag props
		[  //children
		createElement('a',{
		attrs:{
			name:'headerId',
			href:'#' + 'headerId'
			}
		},'this is a tag')
		]
	)
	}
})

就像上面这样子,我们也可以通过使用一个 render 函数来定义一个组件。

2、模板编译总结

看完上面的例子,我们来做个小结✨

  • template,即模板。这个模板会编译成 render 函数,其中 render 函数用的是 with 语法。
  • 过程:模板→ render 函数→ vnode →组件渲染和更新过程。
  • vue 组件可以用 render 函数代替 template 。
  • React 一直都用 render ,没有模板(这里仅作知识补充,不做讲解)。

二、✏️感受模板编译的内在美

1、with语法

(1)例子展示🌰

先来了解模板编译中一个很重要的知识点, with 语法。下面先用一个例子来展示 with 语法与普通语法的不同。

不使用with语法执行程序时:

const obj = {a: 100, b: 200}

console.log(obj.a)
console.log(obj.b)
console.log(obj.c) //undefined

使用with语法执行程序时:

//使用with,能改变 {} 内自由变量的查找方式
// 将 {} 内自由变量,当作 obj 的属性来查找
with(obj){
	console.log(a)
	console.log(b)
	console.log(c) //会报错!!!
}

(2)知识点归纳

看完上面with语法的例子,我们来对 with 语法做一个知识点归纳。

  • with 语法会改变 {} 内自由变量的查找规则,当作 obj 属性来查找;
  • 如果在当前 {} 内找不到匹配的 obj 属性,就会报错;
  • with 要谨慎使用,它打破了作用域规则,会让其易读性变差。

三、📈编译模板

1、编译模板碎碎念

在前面中我们讲过,模板它不是 html ,它有指令、有插值、也有JS表达式,它能实现判断、也能实现循环。

试想一下模板为什么不是 html ?

思考一下,假如你在写程序时,能用 html 写出一个判断或者循环出来吗?答案自然时不行的。

所以说, html 只是一个静态的标签语言,你写什么它就显示什么,它没有办法实现一个逻辑,或者做循环和判断。

因此,对于前端浏览器而言,只有 JS 才能实现判断和循环等各种逻辑功能。

所以,模板一定是转换为某种 JS 代码之后才进行运行的。而这个模板怎么转换成 js 代码的这个过程,就称为编译模板。

那这个模板是怎么转的呢?接下来我们来看下编译模板的过程。

2、编译模板过程

(1)初始化一个npm环境

首先先建立一个新文件,可以命名为 vue-template-complier-demo 。之后用以下命令行初始化一个npm的环境:

npm init -y

(2)安装编译器

npm 安装模板编译器。命令行如下:

npm install vue-template-compiler --save

(3)新建新文件

在根目录下初始化新建一个 index.js 文件,并引入 vue-template-compiler 。代码如下:

//引入vue-template-compiler
const compiler = require('vue-template-compiler')

// 编译
const res = compiler.compile(template)
console.log(res.render)

接下来我们就来看下,模板中的插值、表达式、属性和动态属性等等类型的编译,到底是怎么样的?

(4)了解缩写函数

以下vue源码中的缩写函数先了解,将在下面的讲解中用到。

// 从 vue 源码中找到缩写函数的含义
function installRenderHelpers (target) {
    target._c = createElement;
    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;
    target._d = bindDynamicKeys;
    target._p = prependModifier;
}

(5)编译插值

//引入vue-template-compiler
const compiler = require('vue-template-compiler')

// 插值
const template = `<p>{{message}}</p>`
// with(this){return createElement('p',[createTextVNode(toString(message))])}
// h -> vnode
// createElement -> vnode

// 编译
const res = compiler.compile(template)
console.log(res.render)

编译以上内容,打印结果如下:

编译插值

从上图中可以看到,插值类型的模板最终被编译成一个 with 语句,并且这个 with 语句的参数都指向了 this 。

同时,大家可以看到,里面有一个 _c , _v , _s。那这几个元素是什么呢?

这个就是上面第四点中提到的 vue 源码中的缩写函数。 _c 对应的就是源码中的 createElement, _v 对应的就是源码中的 createTextVNode ,_s 对应的就是源码中的 toString 。

所以,以上编译后的 with 语句 with(this){return _c('p',[_v(_s(message))])} ,事实上就是 with(this){return createElement('p',[createTextVNode(toString(message))])} 。

以上这个语句的意思为,编译创建一个 p 元素,之后呢, p 元素就没有子元素了,于是就创建它的文本节点 message ,同时 message 是字符串的形式存在,因此要进行 toString 。

额外再补充一个知识点, createElement 其实就等于我们平常所说的 h 函数,返回的是一个 虚拟DOM 节点。

以上就是一个插值模板编译的过程,下面再用几个例子让大家熟悉。

(6)编译表达式

//引入vue-template-compiler
const compiler = require('vue-template-compiler')

// 表达式
const template = `<p>{{flag ? message : 'no message found'}}</p>`
// with(this){return _c('p',[_v(_s(flag ? message : 'no message found'))])}

// 编译
const res = compiler.compile(template)
console.log(res.render)

编译以上内容,打印结果如下:

表达式

依据上面插值的分析方法,我们来分析表达式的模板编译过程。

表达式编译后的结果返回了一个虚拟 DOM 节点,同样地,查询 vue 源码中的缩写函数我们可以发现, with(this){return _c('p',[_v(_s(flag ? message : 'no message found'))])} 最终的结果等于 with(this){return createElement('p',[createTextVnode(toString(flag ? message : 'no message found'))])} 。

先创建了一个 p 元素,之后 p 元素没有子元素了,于是创建文本节点,最终 toString 三目表达式里面的内容。

(7)编译属性和动态属性

//引入vue-template-compiler
const compiler = require('vue-template-compiler')

// 属性和动态属性
const template = `
    <div id="div1" class="container">
        <img :src="imgUrl"/>
    </div>
`
// with(this){return _c('div',
//      {staticClass:"container",attrs:{"id":"div1"}},
//      [
//          _c('img',{attrs:{"src":imgUrl}})])}

// 编译
const res = compiler.compile(template)
console.log(res.render)

编译以上内容,打印结果如下:

属性和动态属性

依据上面的分析方法,我们来分析属性和动态属性的模板编译过程。

属性和动态属性编译后的结果返回了一个虚拟 DOM 节点,同样地,查询 vue 源码中的缩写函数我们可以发现, with(this){return _c('div',{staticClass:"container",attrs:{"id":"div1"}},[_c('img',{attrs:{"src":imgUrl}})])} 最终的结果等于 with(this){return createElement('div',{staticClass:"container",attrs:{"id":"div1"}},[createElement('img',{attrs:{"src":imgUrl}})])} 。

此时我们可以看到,返回的 vnode 节点中,包含 class 名字, container 。此时 div 有一个 id 选择器,这个 id 选择器是该 div 的一个属性,于是就通过attrs来表示。

最外层结束后,里面还有一层, img 。 img 可以视其为跟 div 一样的标签,于是先创建 img 元素,又因为 img 绑定了一个具体的值,就像是 div 里面绑定了 id 选择器。所以在创建完 img 的值之后,继续用 attrs 来传递 img 所绑定的值。

(8)编译条件

// 条件
const template = `
    <div>
        <p v-if="flag === 'a'">A</p>
        <p v-else>B</p>
    </div>
`
// with(this){return _c('div',[(flag === 'a')?_c('p',[_v("A")]):_c('p',[_v("B")])])}

编译以上内容,打印结果如下:

条件

依据上面的分析方法,我们来分析条件的模板编译过程。

对于条件来说,首先是先创建一个 div 元素,之后呢,模板编译把 v-if 和 v-else 分割成一个三目表达式的方式来进行编译。

(9)编译循环

// 循环
const template = `
    <ul>
        <li v-for="item in list" :key="item.id">{{item.title}}</li>
    </ul>
`
// with(this){return _c('ul',_l((list),function(item){return _c('li',{key:item.id},[_v(_s(item.title))])}),0)}

编译以上内容,打印结果如下:

循环

依据上面的分析方法,我们来分析循环的模板编译过程。

对于以上循环来说,首先会创建一个 ul 元素,之后查询 _l 的缩写函数我们知道它是 renderlist , 所以 list 列表会被 renderList 函数进行编译。

最后渲染后的 item 被当作函数的参数进行传递,并列返回对应 item 的 li 列表元素。

(10)编译事件

// 事件
const template = `
    <button @click="clickHandler">submit</button>
`
// with(this){return _c('button',{on:{"click":clickHandler}},[_v("submit")])}

编译以上内容,打印结果如下:

事件

依据上面的分析方法,我们来分析事件的模板编译过程。

对于事件来说,首先会创建一个 button 元素,之后 @click 即 v-on:click 会被编译成 on:{"click":clickHandler} 。最后是 _v ,即 createTextVNode 。创建一个 submit 的文本节点,将 click 的内容提交上去。

(11)编译v-model

// v-model
const template = `<input type="text" v-model="name">`
// 主要看 input 事件
// with(this){return _c('input',{directives:[{name:"model",rawName:"v-model",value:(name),expression:"name"}],attrs:{"type":"text"},domProps:{"value":(name)},on:{"input":function($event){if($event.target.composing)return;name=$event.target.value}}})}

编译以上内容,打印结果如下:

v-model

依据上面的分析方法,我们来分析双向绑定v-model的模板编译过程。

对于 v-model 来说,主要看的是 input 事件。 v-model 的背后,绑定的是 name 和 value 这两个语法糖。之后通过 attrs 去创建 类型type 为 text 的属性。

最终是 input 事件, input 事件绑定 $event ,最后, name 的值就等同于 $event.target.value ,这样,数据就实现了双向绑定。

3、模板编译总结

看完上述的内容,我们来对模板编译做个小结:

(1)从render函数到vnode

模板编译后是一个 render 函数,执行 render 函数后返回一个 vnode ;

(2)vnode到patch和diff

基于 vnode 的基础上,再执行 patch 和 diff ;

(3)模板编译工具

在平常的开发中,我们可以使用 webpack 、 vue-loader 等构建工具,在开发环境下编译模板。

四、🔑组件渲染/更新过程

1、初识组件渲染/更新

讲完上完的内容,我们再来讲一个与编译模板关联性很强的知识点:组件渲染/更新过程。

一个组件,从渲染到页面上开始,再到修改 data 去触发更新(数据驱动视图),其背后的原理是什么,又需要掌握哪些要点呢?

事实上,组件在渲染之前,会先进行模板编译,模板 template 会编译成 render 函数。

之后就是数据的监听了,这就要谈到响应式数据。vue的响应式通过操作 Object.defineProperty() ,去监听 getter 和 setter 方法,来使得数据实时更新。

监听完数据之后,就是执行 render 函数,生成 vnode 。

到了 vnode (即 vdom )这一步之后,会进行 patch(elem,vnode) 和 patch(vnode,newVnode) 的比较。

关于响应式原理和vdom的解读,如有需要可以查看我的前两篇文章进行学习,这里不再展开细述~

2、组件渲染/更新过程

组件渲染和更新过程主要经过以下三个步骤:初次渲染过程→更新过程→异步渲染。

接下来就这三个步骤进行一一讲解。

(1)初次渲染过程

初次渲染过程,即组件第一次渲染是怎么样的,怎么把模板渲染到页面上。具体有以下三个步骤:

  • 解析模板为 render 函数;
  • 触发响应式,监听 data 属性 getter 和 setter ;
  • 执行 render 函数,生成 vnode ,进行 patch(elem,vnode) 。

下面就这三个步骤来进行一一讲解。

1)解析模板为render函数

在开发环境下,解析模板为 render 函数一般是由 vue-loader 这个插件来处理的。还有一种情况就是,用户直接用 cdn 的方式引入 vuejs 的文件进行本地代码练习,这种情况下,解析模板为 render 函数就是在浏览器环境运行的。

小知识了解完,我们来看下这个步骤。

解析模板为 render 函数,即解析 template 为 render 函数,这个就是上述文章中说的编译模板。

2)触发响应式

在编译完模板之后, render 函数有了,我们来开始监听 data 属性。

监听 data 属性,这个时候我们就需要触发响应式,也就是渲染数据。

那在这个阶段怎么渲染数据呢?

这个阶段我们需要执行 render 函数, render 函数会触发 getter 方法,因为数据没有进行更新,只是进行渲染。只有在进行渲染的时候才会操作 setter 方法。

3)执行render函数,生成vnode

最后,当数据渲染完毕后,就会执行第一步生成的 render 函数,然后生成虚拟 DOM 节点 vnode ,之后进行 patch(elem,vnode) 。

(2)更新过程

1)更新过程细述

更新过程,即 data 修改之后,组件是怎么更新的。

在这个阶段呢,将会修改 data ,并且触发 setter (注意:在此之前 data 在 getter 中已经被监听)。

触发完 setter 之后,重新执行 render 函数,并生成 newVnode ,最后进行 patch(vnode, newVnode) 的diff比较。

2)完成流程图

接下来我们用一张流程图来完整的回顾渲染和更新的过程。

模板编译流程图

(3)异步渲染

在渲染和更新结束之后,我们的程序可能还有可能会发生多个程序同时加载,这就涉及到一个异步渲染问题。

异步渲染问题,我们用 $nextTick 来作为例子讲解。

假设我们现在要实现一个功能,当我们点击按钮时,打印出列表的项数。这个时候我们大多人可能会这么操作。

<template>
  <div id="app">
      <!-- ref的设置时为了方便后续可以用来:取节点的DOM元素 -->
    <ul ref="ul1">
        <li v-for="(item, index) in list" :key="index">
            {{item}}
        </li>
    </ul>
    <button @click="addItem">添加一项</button>
  </div>
</template>

<script>
export default {
  name: 'app',
  data() {
      return {
        list: ['a', 'b', 'c']
      }
  },
  methods: {
    addItem() {
        this.list.push(`${Date.now()}`)
        this.list.push(`${Date.now()}`)
        this.list.push(`${Date.now()}`)
        
          // 获取 DOM 元素
          const ulElem = this.$refs.ul1
          console.log( ulElem.childNodes.length )
    }
  }
}
</script>

此时浏览器的显示效果如下:

nextTick

细心的小伙伴已经发现,浏览器并没有按照我们所想的打印。当页面上的列表显示 6项 内容时,此时控制台只打印 3项 ;当显示 9项 时,此时控制台直接只打印 6项 。

那这究竟时为什么呢?

其实,当我们点击的那一刻, data 发生变化,但是 DOM 并不会立刻进行渲染。所以等到我们点击完成的时候,获取的元素还是原来触发的内容,而不会增添上新的内容。

那我们所期望的是,当点击之后立刻触发 DOM 渲染并拿到最新的值。这个时候就需要用到 nextTick 。具体代码如下:

<script>
export default {
  name: 'app',
  data() {
      return {
        list: ['a', 'b', 'c']
      }
  },
  methods: {
    addItem() {
        this.list.push(`${Date.now()}`)
        this.list.push(`${Date.now()}`)
        this.list.push(`${Date.now()}`)

        // 1. 异步渲染,$nextTick 待 DOM 渲染完再回调,
        //    即NextTick函数会在多次data修改完并且全部DOM渲染完再触发,仅在最后触发一次
        // 2. 页面渲染时会将 data 的修改做整合
        this.$nextTick(() => {
          // 获取 DOM 元素
          const ulElem = this.$refs.ul1
          console.log( ulElem.childNodes.length )
        })
    }
  }
}
</script>

我们通过给获取 DOM 元素的代码外面再嵌套一层 $nextTick 函数,来达到我们想要的效果。在此过程中,当我们点击结束后, data 的值发生变化,此时 $nextTick 会等待DOM全部渲染完成之后再进行回调。

最终浏览器的打印效果如下:

nextTick2

所以,也就是说, $nextTick 通过汇总 data 的修改,最后再一次性更新视图。

这样可以减少 DOM 的操作次数,大大的提高了性能。

3、小结

经过上述一系列的讲解,我们可以把内容分割成以下两个要点:

  • 要理解清楚渲染和响应式、渲染和模板编译、渲染和vdom的关系。
  • 要理解组件渲染/更新的过程:初次渲染过程→更新过程→异步渲染。

五、✔️结束语

从模板编译,到组件渲染更新过程,我们了解了整个 template 背后的全过程。相信通过本文的学习,大家对模板编译有了一个更深的认识。

关于模板编译的内容就讲到这里啦!如有不理解或文章有误,欢迎评论区留言或公众号后台加我微信交流~

  • 关注公众号 星期一研究室 ,第一时间关注学习干货,更多有趣的专栏待你解锁~
  • 如果这篇文章对你有帮助,记得 点个赞加个关注 再走哦~
  • 我们下期见!🥂🥂🥂