前言
通过手写Vue2源码,更深入了解Vue; 在项目开发过程,一步一步实现Vue核心功能,我会将不同功能放到不同分支,方便查阅; 另外我会编写一些开发文档,阐述编码细节及实现思路; 源码地址:手写Vue2源码
流程分析
- 在
vm._init()
中如果存在vm.$options.el
,则需要进行渲染:
Vue.prototype._init = function (options) {
const vm = this;
vm.$options = options; // 后面会对options进行扩展
callHook(vm, "beforeCreate");
initState(vm);
callHook(vm, "created");
if (vm.$options.el) {
vm.$mount(vm.$options.el);
}
};
在Vue中,我们既可以直接写render函数,还可以写template;当写template时,需要进行模板编译,将模板编译成render函数;
所以思考一下,我们的$mount
如何实现:
- 将
$mount
写在Vue原型上,可以通过vm.$mount()
直接调用 - 分情况处理render和template:
- 当存在render函数时,直接将render函数生成VNode,转换成真实DOM挂载到页面
- 不存在render,且存在template时,将template编译成render函数
- 不存在render,也不存在template,直接将template赋值成el元素,再编译成render函数
// src/init.js
Vue.prototype.$mount = function (el) {
// $mount 由vue实例调用,所以this指向vue实例
const vm = this;
const options = vm.$options;
el = document.querySelector(el);
/**
* 1. 把模板转化成render函数
* 2. 执行render函数,生成VNode
* 3. 更新时进行diff
* 4. 产生真实DOM
*/
// 可以直接在options中写render函数,它的优先级比template高
if (!options.render) {
let template = options.template;
// 如果不存在render和template但是存在el属性,则直接将template赋值为el元素
if (!template && el) {
template = el.outerHTML;
}
// 最终需要把tempalte模板转化成render函数
if (template) {
// 将template转化成render函数
const render = compileToFunctions(template);
options.render = render;
}
}
// 调用render方法,渲染成真实DOM
// 组件挂载方法
return mountComponent(vm, el);
};
这里面核心的方法就是 mountComponent(vm, el)
(将render函数转化成真实DOM,挂载到页面,后续章节再实现) 和 compileToFunctions(template)
(即将template编译成render函数)。
compileToFunctions
compileToFunctions一共分成四个步骤:
- parse:把template转成AST语法树
- optimize:优化静态节点
- generate:通过ast,重新生成代码
- 通过new Function生成函数
// src/compiler/index.js
import { parse } from "./parse";
import { generate } from "./codegen";
export function compileToFunctions(template) {
// 1. 把template转成AST语法树;AST用来描述代码本身形成树结构,不仅可以描述html,也能描述css以及js语法
let ast = parse(template);
console.log("AST", ast);
// 2. 优化静态节点
// 这个有兴趣的可以去看源码 不影响核心功能就不实现了
// if (options.optimize !== false) {
// optimize(ast, options);
// }
// 3. 通过ast,重新生成代码
// 我们最后生成的代码需要和render函数一样
// 类似_c('div',{id:"app"},_c('div',undefined,_v("hello"+_s(name)),_c('span',undefined,_v("world"))))
// _c代表创建元素 _v代表创建文本 _s代表文Json.stringify--把对象解析成文本
let code = generate(ast);
console.log("code", code);
// 通过new Function生成函数
// with(this){return code}语法,使得再code中直接通过属性名访问到vm中的属性(this指向vm实例)
let renderFn = new Function(`with(this){return ${code}}`);
return renderFn;
}
parse
实现思路:
- 将template匹配不同的正则(开始标签正则、结束标签正则、标签关闭正则、标签属性正则等),匹配成功则交由不同别的方法处理(返回tagName、attributes、text等)
- 在处理方法handleStartTag中,返回一个描述元素的对象(包含tag、type、children、parent、attrs等属性)将他们push到一个栈;
- 在处理方法handleEndTag中,将元素pop出栈,并设置它及上一个元素的parent、children关系;
- 在处理方法handleChars中,设置文本为currentParent的children元素;
- 解析完一部分,template就截取掉一部分,然后循环继续匹配,直到template为空;
- 通过进出栈操作,以及parent、children关系,建立一个树状结构(通过parent、children描述)
具体实现如下:
// src/compiler/parse.js
// 以下为vue源码的正则表达式
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`; //匹配标签名;形如 abc-123
const qnameCapture = `((?:${ncname}\\:)?${ncname})`; //匹配特殊标签;形如 abc:234,前面的abc:可有可无;获取标签名;
const startTagOpen = new RegExp(`^<${qnameCapture}`); // 匹配标签开头;形如 < ;捕获里面的标签名
const startTagClose = /^\s*(\/?)>/; // 匹配标签结尾,形如 >、/>
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`); // 匹配结束标签 如 </abc-123> 捕获里面的标签名
const attribute =
/^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; // 匹配属性 形如 id="app"
export function parse(template) {
/**
* handleStartTag、handleEndTag、handleChars将初始解析的结果,组装成一个树结构。
* 使用栈结构构建AST树
*/
let root; // 根节点
let currentParent; // 下一个子元素的父元素
let stack = []; // 栈结构;栈中push/pop元素节点,对于文本节点,直接push到currentParent.children即可,不用push到栈中
// 表示元素和文本的type
const ELEMENT_TYPE = 1;
const TEXT_TYPE = 3;
// 创建AST节点
function createASTElement(tagName, attrs) {
return {
tag: tagName,
type: ELEMENT_TYPE,
children: [],
attrs,
parent: null,
};
}
// 对开始标签进行处理
function handleStartTag({ tagName, attrs }) {
let element = createASTElement(tagName, attrs);
// 如果没有根元素,则当前元素即为根元素
if (!root) {
root = element;
}
currentParent = element;
// 将元素放入栈中
stack.push(element);
}
// 对结束标签进行处理
function handleEndTag(tagName) {
// 处理到结束标签时,将该元素从栈中移出
let element = stack.pop();
if (element.tag !== tagName) {
throw new Error('标签名有误')
}
// currentParent此时为element的上一个元素
currentParent = stack[stack.length - 1];
// 建立parent和children关系
if (currentParent) {
element.parent = currentParent;
currentParent.children.push(element);
}
}
// 对文本进行处理
function handleChars(text) {
// 去掉空格
text = text.replace(/\s/g, "");
if (text) {
currentParent.children.push({
type: TEXT_TYPE,
text,
});
}
}
/**
* 递归解析template,进行初步处理
* 解析开始标签,将结果{tagName, attrs} 交给 handleStartTag 处理
* 解析结束标签,将结果 tagName 交给 handleEndTag 处理
* 解析文本门将结果 text 交给 handleChars 处理
*/
while (template) {
// 查找 < 的位置,根据它的位置判断第一个元素是什么标签
let textEnd = template.indexOf("<");
// 当第一个元素为 '<' 时,即碰到开始标签/结束标签时
if (textEnd === 0) {
// 匹配开始标签<div> 或 <image/>
const startTagMatch = parseStartTag();
if (startTagMatch) {
handleStartTag(startTagMatch);
continue; // continue 表示跳出本次循环,进入下一次循环
}
// 匹配结束标签</div>
const endTagMatch = template.match(endTag);
if (endTagMatch) {
// endTagMatch如果匹配成功,其格式为数组:['</div>', 'div']
advance(endTagMatch[0].length);
handleEndTag(endTagMatch[1]);
continue;
}
}
// 当第一个元素不是'<',即第一个元素是文本时
let text;
if (textEnd >= 0) {
// 获取文本
text = template.substring(0, textEnd);
}
if (text) {
advance(text.length);
handleChars(text);
}
}
// 解析开始标签
function parseStartTag() {
// 1. 匹配开始标签
const start = template.match(startTagOpen);
// start格式为数组,形如 ['<div', 'div'];第二项为标签名
if (start) {
const match = {
tagName: start[1],
attrs: [],
};
//匹配到了开始标签,就把 <tagname 截取掉,往后继续匹配属性
advance(start[0].length);
// 2. 开始递归匹配标签属性
// end代表结束符号 > ;如果匹配成功,格式为:['>', '']
// attr 表示匹配的属性
let end, attr;
// 不是标签结尾,并且能匹配到属性时
while (
!(end = template.match(startTagClose)) &&
(attr = template.match(attribute))
) {
// attr如果匹配成功,也是一个数组,格式为:["class=\"myClass\"", "class", "=", "myClass", undefined, undefined]
// attr[1]为属性名,attr[3]/attr[4]/attr[5]为属性值,取决于属性定义是双引号/单引号/无引号
// 匹配成功一个属性,就在template上截取掉该属性,继续往后匹配
advance(attr[0].length);
attr = {
name: attr[1],
value: attr[3] || attr[4] || attr[5], //这里是因为正则捕获支持双引号() 单引号 和无引号的属性值
};
match.attrs.push(attr);
}
// 3. 匹配到开始标签结尾
if (end) {
// 代表一个标签匹配到结束的>了 代表开始标签解析完毕
advance(1);
return match;
}
}
}
// 截取template字符串 每次匹配到了就【往前继续匹配】
function advance(n) {
template = template.substring(n);
}
// 返回生成的ast;root包含整个树状结构信息
return root;
}
generate
思考一下如何将ast树转化成render函数:
- render函数的格式:
_c('div',{id:"app"},_c('div',undefined,_v("hello"+_s(name)),_c('span',undefined,_v("world"))))
;其中_c
是创建元素,_v
是创建文本,_s
是创建字符串,所以我们需要实现这三个方法,否则在执行render函数的时候会报错;它们都是可以直接通过实例调用的,则直接在Vue原型上挂载这三个方法即可;(在后续章节实现) - 考虑到元素节点的子元素可能依然是一个元素节点,所以需要递归调用
generate()
,需要把generate()
设置成一个入口函数,children的生成用外部方法getChildren(children)
。 - 一个元素的子节点可能不止一个,需要对children进行遍历,使用
gen(child)
生成每一个子元素。 - 在生成children时需要分类型;当child为文本时,创建文本节点;当child是元素时,递归调用
generate()
。
具体实现:
// src/compiler/
import { ELEMENT_TYPE } from './parse'
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g // 匹配花括号 {{ }};捕获花括号里面的内容
function gen(node) {
// 1. 如果是元素,则递归调用generate
if (node.type === ELEMENT_TYPE) {
return generate(node) // 【关键】递归创建元素
} else {
// 2. 如果是文本
let text = node.text
// 2.1. 如果text中不含花括号变量表达式
if (!defaultTagRE.test(text)) {
// _v表示创建文本
return `_v(${JSON.stringify(text)})`
}
// 正则是全局模式,每次需要重置正则的lastIndex属性
let lastIndex = (defaultTagRE.lastIndex = 0)
let tokens = [] // 存放解析的文本
let match, index
// 2.2. 如果text中存在花括号变量
while ((match = defaultTagRE.exec(text))) {
// match如果匹配成功,其结构为:['{{myValue}}', 'myValue', index: indexof({) ]
// match.index值为indexof({),表示匹配到的位置
index = match.index
// 2.2.1 初始 lastIndex 为0,index > lastIndex 表示在 {{ 前有普通文本
if (index > lastIndex) {
// 在tokens里面放入 {{ 之前的普通文本
tokens.push(JSON.stringify(text.slice(lastIndex, index)))
}
// 2.2.2 tokens中放入捕获到的变量内容
tokens.push(`_s(${match[1].trim()})`)
// 匹配指针后移,移到 }} 后面
lastIndex = index + match[0].length
}
// 2.2.3. 匹配完了花括号,text里面还有剩余的普通文本,那么继续push
if (lastIndex < text.length) {
tokens.push(JSON.stringify(text.slice(lastIndex)))
}
// _v表示创建文本
return `_v(${tokens.join('+')})`
}
}
// 处理attrs/props属性,将[{name: 'class', value: 'home'}, {name: 'style', value: "font-size:12px;color:red"}]
// 转化成 "class:"home",style:{"font-size":"12px","color":"red"}"
function genProps(attrs) {
let str = ''
for (let i = 0; i < attrs.length; i++) {
let attr = attrs[i]
// 对attrs属性里面的style做特殊处理
if (attr.name === 'style') {
let obj = {}
attr.value.split(';').forEach((item) => {
let [key, value] = item.split(':')
obj[key] = value
})
attr.value = obj
}
str += `${attr.name}:${JSON.stringify(attr.value)},`
}
return `{${str.slice(0, -1)}}`
}
// 生成子节点:遍历children,调用gen(item),使用逗号拼接每一项的结果
function getChildren(ast) {
const children = ast.children
if (children) {
return `${children.map((c) => gen(c)).join(',')}`
}
}
// 将AST转化成字符串形式的render函数
export function generate(ast) {
let children = getChildren(ast)
let code = `_c('${ast.tag}',${ast.attrs.length ? `${genProps(ast.attrs)}` : 'undefined'}${children ? `,${children}` : ''})`
return code
}
将generate生成的code转化成函数
思考一下如何转化成函数:
- 使用
new Function()
- 在code中有很多vue实例中的属性及方法,还有渲染方法
_c
、_v
、_s
(定义在vue实例原型上),我们需要绑定它们的this到vue实例;并且还要省略this.xxx
前的 this ,直接访问vm属性。
具体实现:
// src/compiler/index.js
let renderFn = new Function(`with(this){return ${code}}`);
系列文章
- 手写Vue2源码(一)—— 环境搭建
- 手写Vue2源码(二)—— 数据劫持
- 手写Vue2源码(三)—— 模板编译
- 手写Vue2源码(四)—— 初次渲染
- 手写Vue2源码(五)—— 观察者模式
- 手写Vue2源码(六)—— 异步更新及nextTick
- 手写Vue2源码(七)—— 侦听属性
- 手写Vue2源码(八)—— 计算属性
- 手写Vue2源码(九)—— 混入原理与生命周期
- 手写Vue2源码(十)—— 组件原理
- 手写Vue2源码(十一)—— diff算法
- 手写Vue2源码(十二)—— keep-alive
- 手写Vue2源码(十三)—— 全局API
- vue-router原理解析
- vuex原理解析
- vue3原理解析