Runtime Only 、 Runtime + Compiler
- vue基于源码构建的两个版本,一个是runtime only(一个只包含运行时的版本),另一个是runtime + compiler(一个同时包含编译器和运行时的版本)。而两个版本的区别仅在于后者包含了一个编译器。
- 内置的编译器实现了将template模板转换编译为可执行javascript脚本的功能。
Runtime + Compiler
个完整的Vue版本是包含编译器的,我们可以使用template进行模板编写。编译器会自动将模板字符串编译成渲染函数的代码,源码中就是render函数。如果你需要在客户端编译模板 (比如传入一个字符串给 template 选项,或挂载到一个元素上并以其 DOM 内部的 HTML 作为模板),就需要一个包含编译器的版本。
// 需要编译器的版本
new Vue({
template: '<div>{{ hi }}</div>'
})
Runtime Only
只包含运行时的代码拥有创建Vue实例、渲染并处理Virtual DOM等功能,基本上就是除去编译器外的完整代码。Runtime Only的适用场景有两种:
- 我们在选项中通过手写render函数去定义渲染过程,这个时候并不需要包含编译器的版本便可完整执行。
// 不需要编译器
new Vue({
render (h) {
return h('div', this.hi)
}
})
- 借助vue-loader这样的编译工具进行编译,当我们利用webpack进行Vue的工程化开发时,常常会利用vue-loader对.vue进行编译,尽管我们也是利用template模板标签去书写代码,但是此时的Vue已经不需要利用编译器去负责模板的编译工作了,这个过程交给了插件去实现。
由于加入了编译的流程代码,Vue代码的总体积也更加庞大(运行时版本相比完整版体积要小大约 30%)。因此在实际开发中,我们需要借助像webpack的vue-loader这类工具进行编译,将Vue对模板的编译阶段合并到webpack的构建流程中
Vue.prototype._init = function (options) {
···
// 选项合并
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
);
// 数据代理
initProxy(vm);
vm._self = vm;
initLifecycle(vm);
// 初始化事件处理
initEvents(vm);
// 定义渲染函数
initRender(vm);
// 构建响应式系统
initState(vm);
// 等等
···
if (vm.$options.el) {
vm.$mount(vm.$options.el);
}
}
$mount
// 内部真正实现挂载的方法
Vue.prototype.$mount = function (el, hydrating) {
el = el && inBrowser ? query(el) : undefined;
// 调用mountComponent方法挂载
return mountComponent(this, el, hydrating)
};
// mountComponent方法思路
function mountComponent(vm, el, hydrating) {
// 定义updateComponent方法,在watch回调时调用。
updateComponent = function () {
// render函数渲染成虚拟DOM, 虚拟DOM渲染成真实的DOM
vm._update(vm._render(), hydrating);
};
// 实例化渲染watcher
new Watcher(vm, updateComponent, noop, {})
...
}
// 缓存了原型上的 $mount 方法
var mount = Vue.prototype.$mount;
// 重新定义$mount,为包含编译器和不包含编译器的版本提供不同封装,最终调用的是缓存原型上的$mount方法
Vue.prototype.$mount = function (el, hydrating) {
// 获取挂载元素
el = el && query(el);
// 挂载元素不能为跟节点
if (el === document.body || el === document.documentElement) {
warn(
"Do not mount Vue to <html> or <body> - mount to normal elements instead."
);
return this
}
var options = this.$options;
// 需要编译 or 不需要编译
// render选项不存在,代表是template模板的形式,此时需要进行模板的编译过程
if (!options.render) {
···
// 使用内部编译器编译模板
}
// 无论是template模板还是手写render函数最终调用缓存的$mount方法
return mount.call(this, el, hydrating)
}
- 确定挂载的DOM元素,这个DOM需要保证不能为html,body这类跟节点。
- 我们知道渲染有两种方式,一种是通过template模板字符串,另一种是手写render函数,前面提到template模板需要运行时进行编译,而后一个可以直接用render选项作为渲染函数。因此挂载阶段会有两条分支,template模板会先经过模板的解析,最终编译成render渲染函数参与实例挂载,而手写render函数可以绕过编译阶段,直接调用挂载的$mount方法。
- 针对template而言,它会利用Vue内部的编译器进行模板的编译,字符串模板会转换为抽象的语法树,即AST树,并最终转化为一个类似function(){with(){}}的渲染函数,这是我们后面讨论的重点。
- 无论是template模板还是手写render函数,最终都将进入mountComponent过程,这个阶段会实例化一个渲染watcher,具体watcher的内容,另外放章节讨论。我们先知道一个结论,渲染watcher的回调函数有两个执行时机,一个是在初始化时执行,另一个是当vm实例检测到数据发生变化时会再次执行回调函数。
- 回调函数是执行updateComponent的过程,这个方法有两个阶段,一个是vm._render,另一个是vm._update。 vm._render会执行前面生成的render渲染函数,并生成一个Virtual Dom tree,而vm._update会将这个Virtual Dom tree转化为真实的DOM节点。
template模板编译
基本使用:
var vm = new Vue({
el: '#app',
template: '<div>模板字符串</div>'
})
<div id="app">
<div>test1</div>
<script type="x-template" id="test">
<p>test</p>
</script>
</div>
var vm = new Vue({
el: '#app',
template: '#test'
})
<div id="app">
<div>test1</div>
<span id="test"><div class="test2">test2</div></span>
</div>
var vm = new Vue({
el: '#app',
template: document.querySelector('#test')
})
模板编译的前提需要对template模板字符串的合法性进行检测,三种写法对应代码的三个不同分支
Vue.prototype.$mount = function () {
···
if(!options.render) {
var template = options.template;
if (template) {
// 针对字符串模板和选择符匹配模板
if (typeof template === 'string') {
// 选择符匹配模板,以'#'为前缀的选择器
if (template.charAt(0) === '#') {
// 获取匹配元素的innerHTML
template = idToTemplate(template);
/* istanbul ignore if */
if (!template) {
warn(
("Template element not found or is empty: " + (options.template)),
this
);
}
}
// 针对dom元素匹配
} else if (template.nodeType) {
// 获取匹配元素的innerHTML
template = template.innerHTML;
} else {
// 其他类型则判定为非法传入
{
warn('invalid template option:' + template, this);
}
return this
}
} else if (el) {
// 如果没有传入template模板,则默认以el元素所属的根节点作为基础模板
template = getOuterHTML(el);
}
}
}
// 判断el元素是否存在
function query (el) {
if (typeof el === 'string') {
var selected = document.querySelector(el);
if (!selected) {
warn(
'Cannot find element: ' + el
);
return document.createElement('div')
}
return selected
} else {
return el
}
}
var idToTemplate = cached(function (id) {
var el = query(id);
return el && el.innerHTML
});
调用模板解析
Vue.prototype.$mount = function () {
···
if(!options.render) {
var template = options.template;
if (template) {
...检测过程
var ref = compileToFunctions(template, {
outputSourceRange: "development" !== 'production',
shouldDecodeNewlines: shouldDecodeNewlines,
shouldDecodeNewlinesForHref: shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
}, this);
var render = ref.render;
}
...
}
}
compileToFunctions
-
compileToFunctions有三个参数,一个是template模板,另一个是编译的配置信息,并且这个方法是对外暴露的编译方法,用户可以自定义配置信息进行模板的编译。最后一个参数是Vue实例
-
有两个选项配置可以提供给用户,用户只需要在实例化Vue时传递选项改变配置,他们分别是:
1.delimiters: 该选项可以改变纯文本插入分隔符,当不传递值时,Vue默认的分隔符为 {{}}。如果我们想使用其他模板,可以通过delimiters修改。
2.comments : 当设为 true 时,将会保留且渲染模板中的 HTML注释。默认行为是舍弃它们
// 将compileToFunction方法暴露给Vue作为静态方法存在
Vue.compile = compileToFunctions;
compileToFunctions的来源createCompilerCreator
var createCompiler = createCompilerCreator(function baseCompile (template,options) {
//把模板解析成抽象的语法树
var ast = parse(template.trim(), options);
// 配置中有代码优化选项则会对Ast语法树进行优化
if (options.optimize !== false) {
optimize(ast, options);
}
var code = generate(ast, options);
return {
ast: ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
});
var ref$1 = createCompiler(baseOptions);
var compile = ref$1.compile;
var compileToFunctions = ref$1.compileToFunctions;
baseCompile()
这就是模板编译的入口函数,它接收两个参数
- template:就是要转换的模板字符串
- options:就是转换时需要的参数
编译的流程,主要有三步:
- 模板解析:通过正则等方式提取出 <template></template> 模板里的标签元素、属性、变量等信息,并解析成抽象语法树 AST,(parse)
- 优化:遍历 AST 找出其中的静态节点和静态根节点,并添加标记,(optimize)
- 代码生成:根据 AST 生成渲染函数 render,(generate)
ast结果:
<template>
<div id="app">{{name}}</div>
</template>
=====================================
{
ast: {
type: 1,
tag: 'div',
attrsList: [ { name: 'id', value: 'app' } ],
attrsMap: { id: 'app' },
rawAttrsMap: {},
parent: undefined,
children: [
{
type: 2,
expression: '_s(name)',
tokens: [ { '@binding': 'name' } ],
text: '{{name}}',
static: false
}
],
plain: false,
attrs: [ { name: 'id', value: '"app"', dynamic: undefined } ],
static: false,
staticRoot: false
},
render: `with(this){return _c('div',{attrs:{"id":"app"}},[_v(_s(name))])}`,
staticRenderFns: [],
errors: [],
tips: []
}
- ast 字段,就是第一步生成的
- static 字段,就是标记,是在第二步中根据 ast 里的 type 加上去的
- render 字段,就是第三步生成的
parse()
解析器的主函数,就是它通过正则等方法提取出 <template></template> 模板字符串里所有的 tag、props、children 信息,生成一个对应结构的 ast 对象
parse 接收两个参数
- template :就是要转换的模板字符串
- options:就是转换时需要的参数。它包含有四个钩子函数,就是用来把 parseHTML 解析出来的字符串提取出来,并生成对应的 AST
核心步骤是这样的:
- 调用 parseHTML 函数对模板字符串进行解析
- 解析到开始标签、结束标签、文本、注释分别进行不同的处理
- 解析过程中遇到文本信息就调用文本解析器 parseText 函数进行文本解析
- 解析过程中遇到包含过滤器,就调用过滤器解析器 parseFilters 函数进行解析
每一步解析的结果都合并到一个对象上(就是最后的 AST)
src/complier/parser/index.js - 79行
export function parse (
template: string, // 要转换的模板字符串
options: CompilerOptions // 转换时需要的参数
): ASTElement | void {
parseHTML(template, {
warn,
expectHTML: options.expectHTML,
isUnaryTag: options.isUnaryTag,
canBeLeftOpenTag: options.canBeLeftOpenTag,
shouldDecodeNewlines: options.shouldDecodeNewlines,
shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
shouldKeepComment: options.comments,
outputSourceRange: options.outputSourceRange,
// 解析到开始标签时调用,如 <div>
start (tag, attrs, unary, start, end) {
// unary 是否是自闭合标签,如 <img />
...
},
// 解析到结束标签时调用,如 </div>
end (tag, start, end) {
...
},
// 解析到文本时调用
chars (text: string, start: number, end: number) {
// 这里会判断判断很多东西,来看它是不是带变量的动态文本
// 然后创建动态文本或静态文本对应的 AST 节点
...
},
// 解析到注释时调用
comment (text: string, start, end) {
// 注释是这么找的
const comment = /^<!\--/
if (comment.test(html)) {
// 如果是注释,就继续找 '-->'
const commentEnd = html.indexOf('-->')
...
}
})
// 返回的这个就是 AST
return root
}
optimize()
在 AST 里找出静态节点和静态根节点,并添加标记,为了后面 patch 过程中就会跳过静态节点的对比,直接克隆一份过去,从而优化了 patch 的性能
标记静态节点(markStatic)。就是判断 type,上面介绍了值为 1、2、3的三种类型
- type 值为1:就是包含子元素的节点,设置 static 为 false 并递归标记子节点,直到标记完所有子节点
- type 值为 2:设置 static 为 false
- type 值为 3:就是不包含子节点和动态属性的纯文本节点,把它的 static = true,patch 的时候就会跳过这个,直接克隆一份去
标记静态根节点(markStaticRoots),这里的原理和标记静态节点基本相同,只是需要满足下面条件的节点才能算作是静态根节点
- 节点本身必须是静态节点
- 必须有子节点,子节点不能只有一个文本节点
src/complier/optimizer.js - 21行
export function optimize (root: ?ASTElement, options: CompilerOptions) {
if (!root) return
isStaticKey = genStaticKeysCached(options.staticKeys || '')
isPlatformReservedTag = options.isReservedTag || no
// 标记静态节点
markStatic(root)
// 标记静态根节点
markStaticRoots(root, false)
}
generate()
生成的render正是虚拟 DOM 的结构
// 比如有这么个模板
<template>
<div id="app">{{name}}</div>
</template>
// 上面模板编译后返回的 render 字段 就是这样的
render:`with(this){
return _c(
'div',
{ attrs:{"id":"app"} },
[ _v(_s(name)) ]
)
}`
缩写函数:
src/core/instance/render-helpers/index.js - 15行
// 其实不止这几个,由于本文例子中没有用到就没都复制过来占位了
export function installRenderHelpers (target: any) {
target._s = toString // 转字符串函数
target._l = renderList // 生成列表函数
target._v = createTextVNode // 创建文本节点函数
target._e = createEmptyVNode // 创建空节点函数
}
// 补充
_c = createElement // 创建虚拟节点函数
先判断 AST 是不是为空,不为空就根据 AST 调用genElement 创建 vnode,否则就创建一个空div 的 vnode
export function generate (
ast: ASTElement | void,
options: CompilerOptions
): CodegenResult {
const state = new CodegenState(options)
// 就是先判断 AST 是不是为空,不为空就根据 AST 创建 vnode,否则就创建一个空div的 vnode
const code = ast ? (ast.tag === 'script' ? 'null' : genElement(ast, state)) : '_c("div")'
return {
render: `with(this){return ${code}}`,
staticRenderFns: state.staticRenderFns
}
}
genElement()
通过不同条件,生成不同的render字符串函数,_c(...)
- 这里还可以发现另一个知识点 v-for 的优先级要高于 v-if,因为先判断 for 的
src/complier/codegen/index.js - 56行
export function genElement (el: ASTElement, state: CodegenState): string {
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) { // v-once
return genOnce(el, state)
} else if (el.for && !el.forProcessed) { // v-for
return genFor(el, state)
} else if (el.if && !el.ifProcessed) { // v-if
return genIf(el, state)
// template 节点 && 没有插槽 && 没有 pre 标签
} else if (el.tag === 'template' && !el.slotTarget && !state.pre) {
return genChildren(el, state) || 'void 0'
} else if (el.tag === 'slot') { // v-slot
return genSlot(el, state)
} else {
// component or element
let code
// 如果有子组件
if (el.component) {
code = genComponent(el.component, el, state)
} else {
let data
// 获取元素属性 props
if (!el.plain || (el.pre && state.maybeComponent(el))) {
data = genData(el, state)
}
// 获取元素子节点
const children = el.inlineTemplate ? null : genChildren(el, state, true)
code = `_c('${el.tag}'${
data ? `,${data}` : '' // data
}${
children ? `,${children}` : '' // children
})`
}
// module transforms
for (let i = 0; i < state.transforms.length; i++) {
code = state.transforms[i](el, code)
}
// 返回上面作为 with 作用域执行的内容
return code
}
}
总的来说最后创建出来的 vnode 节点类型无非就三种,元素节点、文本节点、注释节点
回到createCompilerCreator
var createCompiler = createCompilerCreator(function baseCompile (template,options) {
//把模板解析成抽象的语法树
var ast = parse(template.trim(), options);
// 配置中有代码优化选项则会对Ast语法树进行优化
if (options.optimize !== false) {
optimize(ast, options);
}
var code = generate(ast, options);
return {
ast: ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
});
var ref$1 = createCompiler(baseOptions);
var compile = ref$1.compile;
var compileToFunctions = ref$1.compileToFunctions;
- 不同平台对Vue的编译过程是不一样的,也就是说基础的编译方法会随着平台的不同有区别,编译阶段的配置选项也因为平台的不同呈现差异。但是设计者又不希望在相同平台下编译不同模板时,每次都要传入相同的配置选项。这才有了源码中较为复杂的编译实现
- 基本方式为:将基础编译方法进行包装提供额外编译配置型-》将上一步包装后的方法再放入其他方法中传入包装,提供缓存等功能-》上一步包装后的返回的方法具有待编译的内容和额外编译配置项两个参数-》最终拿到的是上一步返回的方法,返回render等方法
- createCompilerCreator在传递了一个baseCompile函数作为参数后,返回了一个编译器的生成器,也就是createCompiler,有了这个生成器,当将编译配置选项baseOptions传入后,这个编译器生成器便生成了一个指定环境指定配置下的编译器,而其中编译执行函数就是返回对象的compileToFunctions
createCompilerCreator
function createCompilerCreator (baseCompile) {
return function createCompiler (baseOptions) {
// 内部定义compile方法
function compile (template, options) {
var finalOptions = Object.create(baseOptions);
var errors = [];
var tips = [];
var warn = function (msg, range, tip) {
(tip ? tips : errors).push(msg);
};
// 选项合并
if (options) {
···
// 这里会将用户传递的配置和系统自带编译配置进行合并
}
finalOptions.warn = warn;
// 将剔除空格后的模板以及合并选项后的配置作为参数传递给baseCompile方法
var compiled = baseCompile(template.trim(), finalOptions);
{
detectErrors(compiled.ast, warn);
}
compiled.errors = errors;
compiled.tips = tips;
return compiled
}
return {
compile: compile,
compileToFunctions: createCompileToFunctionFn(compile)
}
}
}
compileToFunctions
function createCompileToFunctionFn (compile) {
var cache = Object.create(null);
return function compileToFunctions (template,options,vm) {
options = extend({}, options);
···
// 缓存的作用:避免重复编译同个模板造成性能的浪费
if (cache[key]) {
return cache[key]
}
// 执行编译方法
var compiled = compile(template, options);
···
// turn code into functions
var res = {};
var fnGenErrors = [];
// 编译出的函数体字符串作为参数传递给createFunction,返回最终的render函数
//他们的核心是将 with语句封装成执行函数
res.render = createFunction(compiled.render, fnGenErrors);
res.staticRenderFns = compiled.staticRenderFns.map(function (code) {
return createFunction(code, fnGenErrors)
});
···
return (cache[key] = res)
}
}
- 我们看到compile真正执行的方法,是一开始在创建编译器生成器时,传入的基础编译方法baseCompile,baseCompile真正执行的时候,会将用户传递的编译配置和系统自带的编译配置选项合并,这也是开头提到编译器设计思想的精髓。
createFunction
function createFunction (code, errors) {
try {
return new Function(code)
} catch (err) {
errors.push({ err: err, code: code });
return noop
}
}
- 通过闭包进行基本编译方法的内置,向外暴露的方法只需要提供编译内容喝额外的编译配置型
- 这正是作者思路巧妙的地方。Vue在不同平台上有不同的编译过程,而每个编译过程的baseOptions选项会有所不同,同时也提供了一些选项供用户去配置,整个设计思想深刻的应用了偏函数的设计思想,而偏函数又是闭包的应用。
- 同种编译方式结果进行缓存,同时剥离出编译相关的选项合并,这些方式都是值得我们日常学习的