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

1,760 阅读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来表示。

最外层结束后,里面还有一层, imgimg 可以视其为跟 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-ifv-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 被当作函数的参数进行传递,并列返回对应 itemli 列表元素。

(10)编译事件

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

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

事件

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

对于事件来说,首先会创建一个 button 元素,之后 @clickv-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 的背后,绑定的是 namevalue 这两个语法糖。之后通过 attrs 去创建 类型typetext 的属性。

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

3、模板编译总结

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

(1)从render函数到vnode

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

(2)vnode到patch和diff

基于 vnode 的基础上,再执行 patchdiff

(3)模板编译工具

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

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

1、初识组件渲染/更新

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

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

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

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

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

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

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

2、组件渲染/更新过程

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

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

(1)初次渲染过程

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

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

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

1)解析模板为render函数

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

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

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

2)触发响应式

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

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

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

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

3)执行render函数,生成vnode

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

(2)更新过程

1)更新过程细述

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

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

触发完 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 背后的全过程。相信通过本文的学习,大家对模板编译有了一个更深的认识。

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

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