前言
本文内容是在上一章——数据劫持的基础之上编写,建议小伙们先去查看。本文主要参考 vue2.0 源码和小野森森,因只围绕核心原理进行编写,所以更易理解(不严谨之处,还请谅解)。最后,奉上案例源码,以便大家学习理解。
目录结构
下方图片中画红色圆圈的是新增的目录和文件
代码解析
vue/index.js 入口
同上一篇数据劫持相比,这里主要新增了两个函数:lifecycleMixin 和 renderMixin。关于它们的具体作用,稍后会做详细介绍。接下来我们先看看如何编译模板。
import { initMixin } from './init';
import { lifecycleMixin } from './lifecycle';
import { renderMixin } from './vdom/index';
function Vue(options) {
// 通过关键字 new 创建 Vue实例时,便会调用 Vue 原型方法 _init 初始化数据
this._init(options);
}
// 初始化相关操作,主要是在 Vue.prototype 上挂载 _init() 和 $mount() 方法
initMixin(Vue);
// 生命周期相关操作,主要是在 Vue.prototype 上挂载 _update 方法
lifecycleMixin(Vue);
// 渲染相关操作,主要是在 Vue.prototype 上挂载 _render()、 _c()、 _v() 和 _s()函数
renderMixin(Vue);
export default Vue;
vue/init.js 初始化
同上一篇文章相比,在初始化的过程中,我们不在仅是调用 initState(vm) 对 data 数据进行处理。而是在处理完数据之后,又调用 vm.options.el) 挂载并编译模板,从而生成 AST 抽象语法树和 render 渲染函数 。
import { initState } from './state';
import { compileToFunctions } from './compiler';
import { mountComponent } from './lifecycle';
function initMixin(Vue) {
Vue.prototype._init = function (options) {
const vm = this; // 存储 this( Vue实例 )
vm.$options = options; // 将 options 挂载到 vm 上,以便后续使用
// Vue 实例中的 data、 props、methods、computed 和 watch,都会在 initState 函数中
// 进行初始化。由于我们主要解说:Vue 数据劫持,所以只对 data 进行处理。
initState(vm);
if (vm.$options.el) {
// Vue.prototype.$mount --> 挂载函数
vm.$mount(vm.$options.el);
}
}
Vue.prototype.$mount = function (el) {
const vm = this;
const options = vm.$options;
// Vue 选项中的 render 函数若存在,则 Vue 构造函数不会从
// template 选项或通过 el 选项指定的挂载元素中提取出的 HTML 模板编译渲染函数。
// 处理模板(优先级): render > template > html模板
// 若是 render 函数不存在,就生成 render
if (!options.render) {
let template = options.template; // 获取模板
// el存在,且 template 不存在
if (el && !template) {
// 挂载 el( HTML 模板),以便在实例的 _update 方法中使用
vm.$el = document.querySelector(el);
template = vm.$el.outerHTML;
}
// 编译模板,生成 AST 抽象语法树并将其生成渲染函数 render
const render = compileToFunctions(template);
options.render = render; // 挂载 render
}
mountComponent(vm); // 挂载组件
}
}
export {
initMixin
}
vue/compiler/index.js 编译模板生成 AST 和 render 函数
import { parseHtml } from './parser';
import { generate } from './generate';
//编译:HTML字符串( template ) => AST => render
function compileToFunctions(html) {
// 解析 HTML字符串,将其转换成 AST 抽象语法树
const ast = parseHtml(html);
// 将 AST 转换成字符串函数
const code = generate(ast);
// 生成 render 渲染函数(with语句,是理解此段代码的关键)
const render = new Function(`with(this){ return ${code} }`);
return render;
}
export {
compileToFunctions
}
vue/compiler/parser.js 生成 AST 抽象语法树
// 匹配属性: id="app"、id='app' 或 id=app
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/;
// 匹配标签:<my-header></my-header>
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`;
// 匹配标签:<my:header></my:header>
const qnameCapture = `((?:${ncname}\\:)?${ncname})`;
// 匹配开始标签:<div
const startTagOpen = new RegExp(`^<${qnameCapture}`);
// 匹配闭合标签: > 或 />
const startTagClose = /^\s*(\/?)>/;
// 匹配结束标签: </div>
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`);
/*
假设模板样例:
<div id="app" style="color: #f66;font-size: 20px;">
函数字符串,{{ tip }}
<span class="cla">{{ studentNum }}</span>
</div>
*/
// 解析模版字符串,生成 AST 语法树
function parseHtml(html) {
const stack = []; // 所有开始标签的初始 AST 对象
let root; // 最终返回的 AST 对象
let text; // 纯文本
let currentParent; // 当前元素的父级
// vue2.0 源码中对以下几种情况分别进行了处理:注释标签、条件注释标签、Doctype、
// 开始标签、结束标签。而每当处理完一种情况时,都会阻断代码继续往下执行且开启新
// 的一轮循环(注:使用 continue 实现 ),并且会重置 html 字符串,也就是删掉匹配
// 到的 html 字符串,保留未匹配的 ,以便在下一次循环处理。
// 提示:在解读以上几种情况的源码时,配合模板样例来理解会让你更容易明了。
while (html) {
// textEnd 为 0,则说明是一个开始标签。
let textEnd = html.indexOf('<');
if (textEnd === 0) {
// 解析开始标签及其属性并将其存放在一个对象中返回,例如:
// { tagName: 'div', attrs: [{ name: 'id', value: 'app' }] }
const startTagMatch = parseStartTag();
// console.log('解析——开始标签——结果', startTagMatch);
// 处理开始标签
if (startTagMatch) {
start(startTagMatch.tagName, startTagMatch.attrs);
continue; // 执行到 continue,将开始新的一轮循环,后续代码不会执行
}
const endTagMatch = html.match(endTag); // 匹配结束标签
// 处理结束标签
if (endTagMatch) {
advance(endTagMatch[0].length);
end(endTagMatch[1]);
continue;
}
}
// 截取 HTML 模版字符串中的文本
if (textEnd > 0) {
text = html.substring(0, textEnd);
}
// 处理文本内容
if (text) {
advance(text.length);
chars(text);
}
}
// 解析开始标签及其属性,例如:<div id="app">
function parseStartTag() {
// 如果没有找到任何匹配的文本, match() 将返回 null。否则,它返回一个数组,
// 其中存放了与它找到的匹配文本有关的信息。
const start = html.match(startTagOpen); // 匹配开始标签
let end, attr;
if (start) {
// 存放开始标签名和属性
const match = {
tagName: start[1], // 开始标签的名,例如:div
attrs: [] // 开始标签的属性,例如:{ name: 'id', value: 'app' }
}
// 删除已匹配到的 HTML 字符串,保留未匹配到的。
// 例如:匹配到 <div id="app"></div> 中的 <div,调用 advance() 方法后,
// 原 HTML 字符窜就是这样:id="app"></div>
advance(start[0].length);
// 当匹配到属性( 形如:id='app'),但未匹配到开始标签的闭合( 形如:> )时,进入循环
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
match.attrs.push({
name: attr[1], // 属性名: id
// 若是你在通过 new 关键字创建 vue 实例时,提供了 template 选项
// 且在它的字符串中,有的标签的属性使用的是单引号或者没有带引号,
// 例如:<div id='app'></div> 或 <div id=app></div> 这种形式,那么在匹配
// 标签的属性时,其返回的数组中这个属性的值,可能在此数组的 下标4 或 下标5
value: attr[3] || attr[4] || attr[5] // 属性值: app
});
advance(attr[0].length);
}
// 如果匹配到开始标签的闭合( 形如:> ),则返回 match 对象
if (end) {
advance(end[0].length);
return match;
}
}
}
// 截取 HTML 字符串,将已匹配到的字符从原有字符中删除。
function advance(n) {
// substring() 方法用于提取字符串中介于两个指定下标之间的字符。
html = html.substring(n);
}
function start(tag, attrs) {
// 创建 AST 对象
const element = createASTElement(tag, attrs);
// 如果 root 根节点不存在,则说明当前节点即是整个模版的最顶层节点,也就是第一个节点
if (!root) {
root = element;
}
// 保存当前父节点(AST 对象)
currentParent = element;
// 将 AST 对象 push 到 stack 栈中,当解析到其相对应的结束标签时,
// 则将这个标签对应的 AST 对象 从栈中 pop 出来。
// 原因:解析开始标签时,是顺时针;解析结束标签时,是逆时针。结合模板样例看,
// 解析顺序如下:<div> => <span> => ... => </span> => </div>
// 因此,解析开始标签生成的 AST 对象被 push 到栈中后,若想在解析到其相应的结束标签时
// 取出,则要使用 pop。整个操作流程,结合 start() 和 end() 方法一起看,会更易理解。
stack.push(element);
}
function end(tag) {
// pop() 方法将删除数组的最后一个元素,把数组长度减 1,并且返回它删除的元素的值。
// 如果数组已经为空,则 pop() 不改变数组,并返回 undefined 值。
const element = stack.pop(); // 获取当前元素标签的 AST 对象
currentParent = stack[stack.length - 1]; // 获取当前元素标签的父级 AST 对象
if (currentParent) {
// 标记父子元素
element.parent = currentParent; // 子元素存储父元素
currentParent.children.push(element); // 父元素存入子元素
}
}
function chars(text) {
text = text.trim(); // 去掉首尾空格
// 若文本存在,则直接放入父级的 children 中
if (text && text !== ' ') {
const element = {
type: 3, // 文本元素的节点类型(nodeType):3
text
};
currentParent.children.push(element);
}
}
return root;
}
// 生成 AST 对象
function createASTElement(tagName, attrs) {
return {
tag: tagName, // 标签名
type: 1, // 标签元素的节点类型(nodeType):1
children: [], // 标签子级
attrs, // 标签属性
parent // 标签父级
}
}
export {
parseHtml
}
vue/compiler/generate.js 生成字符串函数
generate() 函数返回的变量 code,是一个字符串函数,它是生成 render 渲染函数的关键所在。
/*
以下三个个函数的作用:
_c() => createElement() 创建元素节点
_v() => createTextNode() 创建文本节点
_s(value) => _s(tip) 解析双大括号,例如:{{tip}}
AST => 字符串函数,最终结果:
function generate() {
return `_c("div",{id: "app",style:{ "color":"#f66","font-size":"20px"}},
_v("字符串,"+_s(tip)),_c("span", { "class": "cla", "style": { "color": "green" } },
_v(_s(studentNum))))`;
}
*/
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; // 匹配双大括号 => {{tip}}
// 生成函数字符串
function generate(el) {
const children = genChildren(el);
const code = `_c('${el.tag}', ${el.attrs.length > 0 ? `${jointAttrs(el.attrs)}` : 'undefined'}${children ? `,${children}` : ''})`;
return code;
}
// 将属性拼接成字符串,例如:`style:{ "color":"#f66","font-size":"20px"}`
function jointAttrs(attrs) {
let str = '';
for (let i = 0; i < attrs.length; i++) {
let attr = attrs[i];
// 处理 style 属性
if (attr.name === 'style') {
let attrValue = {};
attr.value.split(';').map((itemArr) => {
let [key, value] = itemArr.split(':');
if (key) {
attrValue[key] = value;
}
});
attr.value = attrValue;
}
// 拼接属性(注意:不要忘记逗号)
str += `${attr.name}:${JSON.stringify(attr.value)},`
}
// str.slice(0, -1) 是为了去掉字符串最后一个逗号
return `{${str.slice(0, -1)}}`;
}
// 生成子节点
function genChildren(el) {
const children = el.children;
// 是否存在子节点
if (children.length) {
return children.map(c => genNode(c)).join(',');
}
}
// 根据节点类型的不同进行相应处理
function genNode(node) {
if (node.type === 1) { // 元素节点
return generate(node);
} else if (node.type === 3) { // 文本节点
let text = node.text;
if (defaultTagRE.test(text)) { // 处理双大括号
let match,
index,
textArray = [],
// lastIndex 下一次匹配开始的位置。每次循环时,都将其初始为 0,是为防止处理其它文本时,
// 取到 lastIndex 是上一个循环结束后保留下的值而导致出错。
lastIndex = defaultTagRE.lastIndex = 0;
// 样例参考:<div>函数字符串,{{ tip }} 哈哈</div>
// 处理双大括号和其之前的纯文本:函数字符串,{{ tip }}
while (match = defaultTagRE.exec(text)) {
index = match.index; // 双大括号的下标位置
if (index > lastIndex) { // 截取双大括号前面的纯文本
textArray.push(JSON.stringify(text.slice(lastIndex, index)));
}
textArray.push(`_s(${match[1].trim()})`); // 双大括号
lastIndex = index + match[0].length; // 标记下一次匹配开始的位置
}
// 处理双大括号之后的存文本:哈哈
if (lastIndex < text.length) {
textArray.push(JSON.stringify(text.slice(lastIndex)));
}
return `_v(${textArray.join('+')})`; // 拼接整行文本
} else { // 处理纯文本
return `_v(${JSON.stringify(text)})`;
}
}
}
export {
generate
}
vue/lifecycle.js 更新组件
还记得吗?在vue/index.js 入口文件,我们执行了 lifecycleMixin(Vue) 函数,它的主要作用(在 vue 源码中此函数作用可不止于此):是用来更新组件,也就是我们的模板。这也是,为何我们可以在 vue/init.js 中执行 mountComponent() 函数来更新组件的原因。
import { patch } from './vdom/patch';
function mountComponent(vm) {
// vm._render() 返回虚拟节点 vnode
vm._update(vm._render()); // 更新组件
}
function lifecycleMixin(Vue) {
// 挂载 _update() 更新函数
Vue.prototype._update = function (vnode) {
const vm = this;
patch(vm.$el, vnode); // 将 vnode 虚拟节点生成相应的 HTML 元素
}
}
export {
lifecycleMixin,
mountComponent
}
vue/vdom/index.js 挂载 _render()、 _c()、 _v() 和 _s()函数
在vue/index.js入口文件中调用的 renderMixin() 函数,就是从当前这个文件模块中导出的。它的作用,已在下方的代码注释中标明。
import { createElement, createTextNode } from './vnode';
function renderMixin(Vue) {
// 创建虚拟元素节点对象
Vue.prototype._c = function () {
return createElement(...arguments);
}
// 创建虚拟文本节点对象
Vue.prototype._v = function (text) {
return createTextNode(text);
}
// 处理双大括号,例如:{{tip}}
Vue.prototype._s = function (value) {
if (value === null) return;
return typeof value === 'object' ? JSON.stringify(value) : value;
}
// 调用 vm.$options.render 渲染函数,生成虚拟节点
Vue.prototype._render = function () {
const vm = this;
const vnode = vm.$options.render.call(vm); // 生成虚拟节点对象并返回
return vnode;
}
}
export {
renderMixin
}
vue/vdom/vnode.js 生成虚拟节点对象
createElement() 函数和 createTextNode() 函数,分别由于创建元素虚拟节点和文本虚拟节点。
// 元素 vnode
function createElement (tag, attrs = {}, ...children) {
return vnode(tag, attrs, children);
}
// 文本 vnode
function createTextNode (text) {
return vnode(undefined, undefined, undefined, text);
}
// vnode(虚拟节点)对象
function vnode (tag, props, children, text) {
return {
tag,
props,
children,
text
}
}
export {
createElement,
createTextNode
}
vue/vdom/patch.js 将虚拟节点转换成 HTML 元素
/**
* 样例展示:结合 patch函数中的 insertBefore 和 removeChild 方法看
* <body>
* <div id="app"></div> 原有的
* <div id="app"></div> 新生成的
* <script></script>
* </body>
*
*/
/**
* 将 vnode 虚拟节点生成相应的 HTML 元素
* @param { HTMLDivElement } template => html
* @param { Object } vNode => 虚拟节点对象
*/
function patch(template, vNode) {
const el = createElement(vNode);
// template.parentNode => body
const parentElement = template.parentNode;
// 将新生成的元素插入到 body中。在 template 的后面,script标签的前面。
parentElement.insertBefore(el, template.nextSibling);
parentElement.removeChild(template); // 移除原有节点
}
// 创建节点(为求简便,逻辑上并未最求严谨,但是它能跑!)
function createElement(vnode) {
const { tag, props, children, text } = vnode;
if (typeof tag === 'string') {
vnode.el = document.createElement(tag); // 创建元素
updateProps(vnode); // 为元素设置属性
children.map((child) => {
// 为父级元素添加子元素
vnode.el.appendChild(createElement(child));
})
} else {
// 创建纯文本节点
vnode.el = document.createTextNode(text);
}
return vnode.el;
}
// 为元素设置属性,这里主要处理了 style 和 class
function updateProps(vnode) {
const el = vnode.el;
const nodeAttrs = vnode.props || {};
for (let key in nodeAttrs) {
if (key === 'style') { // 设置 style 属性
for (let sKey in nodeAttrs.style) {
el.style[sKey] = nodeAttrs.style[sKey];
}
} else if (key === 'class') { // 设置 class 属性
el.className = el.class;
} else {
// 设置自定义属性,并未做特殊处理
el.setAttribute(key, nodeAttrs[key]);
}
}
}
export {
patch
}
结束语
文章内看不明白的地方,不要一直瞪眼歪头想,可以将案例源码下载下来,边看边调试。好了,快速掌握的秘诀告诉你了,加油干吧,骚年!