开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第2天
vue初次渲染过程:
- 先初始化数据
- 编译模板
- 生成render函数
- 生成虚拟dom
- 生成真实dom
- 渲染页面
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="app">
hello{{name}}
</div>
<script src="./vue.js"></script>
<script>
const vm = new Vue({
data() {
return {
name: '阿伟',
age: 26,
}
},
el: '#app',
})
</script>
</body>
</html>
先判断是否有render函数,没有再去判断有没有template,再没有就拿el(必填)
获取到template
模板传入compileToFunction
函数编译成render
函数
// init.js
import { initState } from './state'
import { compileToFunction } from './comliper'
export function initMixin(Vue) {
// 初始化
Vue.prototype._init = function (options) {
// 将选项保存到实例上,方便在其他原型方法中使用
const vm = this
vm.$options = options
// 初始化状态
initState(vm)
// 判断选项中是否传了el参数
if (options.el) {
// 实现数据的挂载
vm.$mount(options.el)
}
}
Vue.prototype.$mount = function (el) {
const vm = this
const ops = vm.$options
el = document.querySelector(el)
// 先查找有没有render函数
if (!ops.render) {
/**
* <div id="app"> hello{{name}} </div>
*/
let template
// 没有写template但写了el的就是用el
if (!ops.template && ops.el) {
template = el.outerHTML
} else {
// 如果传了template就用template
if (el) {
template = ops.template
}
}
// 如果模板有值就把模板编译成ast语法树
if (template) {
const render = compileToFunction(template)
ops.render = render
}
}
// 组件挂载
mountComponent(vm, el)
}
}
parseHTML函数生成ast语法树,传入codegen
函数先通过genChildren函数处理子节点。在genChildren函数循环遍历将每个子节点传入gen函数
在gen
函数判断是文本还是元素节点,如果是元素节点就递归codegen
函数,如果是文本的话还需要区分是插值语法还是普通文本。如果是文本的话直接return _v(${JSON.stringify(text)})
,如果是插值语法需要通过defaultTagRE.exec(text)
来检验。其中正则多次匹配时,lastIndex
会增加,所以需要重置为0从头开始匹配,如果匹配到的索引index
大于lastIndex
说明匹配到了普通文本(hello{{name}},index为5,lastIndex为0),通过text.slice(lastIndex(0), index(5))
来获取到普通文本并转成josn字符串push到tokens
数组中。再把匹配到的插值语法中的的值以_s(${match[1].trim()})
形式push到tokens
数组中,循环最后把lastIndex的值修改为index+文本长度(match[0].length
),等于hello{{name}}的长度索引。结束while之后通过lastIndex < text.length(hello{{name}}123,lastIndex还在最后},后面还有123文本还需要判断)
来判读还有没有普通文本,如果有通过text.slice(lastIndex)
获取到最后的普通文本,gen
函数最终返回_v(${tokens.join('+')})
格式
如果ast
的attrs
数组有值的话,通过genProps
函数处理。genProps
函数将attrs
数组遍历,判断当前项是否是style
,如果是就把style
的value
以;号分割,再遍历以:号分割,把分割后的key、value保存到一个新对象,再把新对象赋值给style
的value
。最外层的for循环的最后将attrs的每一个对象以${attr.name}:${JSON.stringify(attr.value)},
格式保存到一个字符串中,genProps
函数最后通过{${str.slice(0, -1)}}
删除字符串中的最后一个,号,返回对象字符串({id:"app",style:{"color":" red"}}
)
// comliper/index.js
import { parseHTML } from './parse'
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g // {{ asdsadsa }} 匹配到的内容就是我们表达式的变量
// 对模板进行解析
export function compileToFunction(template) {
/**
* template:<div id="app"> hello{{name}} </div>
* 转换ast为:
* {
* tag: "div",
* attrs:[{name: 'id', value: 'app'},{name: "style",value: {color: ' red'}}],
* children:[{tag: 'span', type: 1, children: Array(1), attrs: Array(0), parent: {…}],
* }
*/
// 将template转化成ast语法树
let ast = parseHTML(template)
// 将ast语法树转化成render函数字符串
let code = codegen(ast)
code = `with(this){return ${code}}`
// 生成render函数
// ƒ anonymous() {
// with(this){return _c('div',{id:"app",style:{"color":"red"}},_c('span',null,_v("hello"+_s(name)+_s(age))))}
// }
let render = new Function(code)
return render
}
// 拼接rander函数
function codegen(ast) {
let children = genChildren(ast.children)
// 如果有属性就执行genProps
// _c处理标签,_v是处理文本,_s是处理插值语法
let code = `_c('${ast.tag}',${
ast.attrs.length > 0 ? genProps(ast.attrs) : 'null'
}${ast.children.length ? `,${children}` : ''})`
return code
}
// 拼接根标签的属性
function genProps(attrs) {
//attrs:[{"name": "id","value": "app"},{"name": "style","value": {"color": " red"}}]
let str = ''
// 遍历属性,转换成字符串形式
for (let index = 0; index < attrs.length; index++) {
let attr = attrs[index]
// 当遇到style属性时,需要把属性转换成对象形式
if (attr.name === 'style') {
let obj = {}
attr.value.split(';').forEach((item) => {
let [key, value] = item.split(':')
obj[key] = value
})
attr.value = obj
}
// str:id:"app",style:{"color":" red"},
str += `${attr.name}:${JSON.stringify(attr.value)},`
}
// 删除最后的逗号
// {id:"app",style:{"color":" red"}}
return `{${str.slice(0, -1)}}`
}
// 处理子节点1
function genChildren(children) {
return children.map((child) => gen(child)).join(',')
}
// 处理子节点2
function gen(node) {
// 元素节点处理
if (node.type === 1) {
return codegen(node)
} else {
// 文本处理
let text = node.text
// 不是匹配到{{变量}}的处理
if (!defaultTagRE.test(text)) {
return `_v(${JSON.stringify(text)})`
} else {
let tokens = []
let match
// 正则多次匹配时,lastIndex会增加,所以需要重置为0从头开始匹配
defaultTagRE.lastIndex = 0
let lastIndex = 0
while ((match = defaultTagRE.exec(text))) {
// match:['{{name}}', 'name', index: 5, input: 'hello{{name}}', groups: undefined]
// 匹配到的索引
let index = match.index
/**
* index为第一个{的索引
* text.slice(lastIndex, index)截取表达式文本之间的纯文本
*/
if (index > lastIndex) {
tokens.push(JSON.stringify(text.slice(lastIndex, index)))
}
// match[1].trim():{{变量}}中的变量
tokens.push(`_s(${match[1].trim()})`)
// 假设子节点为hello{{name}}123
// match[0].length为匹配到的表达式文本长度,index+文本长度等于hello{{name}}的长度
lastIndex = index + match[0].length
}
// hello{{name}}123,lastIndex还在最后},后面还有123文本还需要判断
if (lastIndex < text.length) {
tokens.push(JSON.stringify(text.slice(lastIndex)))
}
return `_v(${tokens.join('+')})`
}
}
}
将html文本一直循环匹配正则,匹配到一个就删除一个直到全部删除
首先先用indexOf匹配html中<的索引,如果值为0就代表是开始标签就执行parseStartTag
函数
parseStartTag
函数中通过匹配startTagOpen正则获取到开始结束标签、属性等
parseStartTag
函数返回match对象,对象包括标签名与属性数组,通过start函数将匹配到的结果放到ast语法树中,并将html删除已匹配到的开始结束标签、属性等。如果超过0就代表是结束标签,获取文本通过end函数将匹配到的结果放到ast语法树中
// comliper/parse.js
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`) // 他匹配到的分组是一个 标签名 <xxx 匹配到的是开始 标签的名字
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`) // 匹配的是</xxxx> 最终匹配到的分组就是结束标签的名字
const attribute =
/^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/ // 匹配属性
// 第一个分组就是属性的key value 就是 分组3/分组4/分组五
const startTagClose = /^\s*(\/?)>/ // 匹配>
export function parseHTML(html) {
const ELEMENT_TYPE = 1
const TEXT_TYPE = 3
/**<div>1111 <h></h> </div>,当匹配到div的开始标签时就push到stack栈中(["div"]),
*匹配到h的开始标签时就push到stack栈中(["div","h"]),
*匹配到h的结束标签时就把h从stack栈中删除,div是最后一个元素为h的父元素
*/
const stack = []
// currentParent:当前元素的父元素,root:根节点
let currentParent, root
// 循环解析模板字符串,解析一段删除一段
while (html) {
/**
* <div>1111</div>或者<div/>单标签,如果textEnd为0说明是一个开始或者结束标签
* 1111</div>,如果textEnd>0说明是文本结束标签
*/
let textEnd = html.indexOf('<')
// 开始、结束标签处理
if (textEnd === 0) {
// startTagMatch就是匹配到的开始标签与属性
const startTagMatch = parseStartTag()
if (startTagMatch) {
// 将匹配到的开始标签与属性传入start函数创建ast语法树
start(startTagMatch.tagName, startTagMatch.attrs)
continue
}
// 匹配结束标签
let endTagMatch = html.match(endTag)
if (endTagMatch) {
end(endTagMatch[1])
advance(endTagMatch[0].length)
continue
}
}
// 文本处理
if (textEnd > 0) {
// 截取文本开始到<的内容
let text = html.substring(0, textEnd)
if (text) {
chars(text)
advance(text.length)
}
}
}
// 创建ast语法树
function createASTelement(tag, attrs) {
return {
tag, // 元素标签
type: ELEMENT_TYPE, // 元素类型,
children: [], // 是否有子节点,默认为空数组
attrs, // 属性
parent: null, // 是否有父节点
}
}
// 开始标签
function start(tag, attrs) {
// 创建一个ast节点
let node = createASTelement(tag, attrs)
// 判断是否是空树,如果为空就把当前节点作为根节点
if (!root) {
root = node
}
// 当前父节点为空时就把当前节点作为父节点
if (currentParent) {
node.parent = currentParent
currentParent.children.push(node)
}
stack.push(node)
currentParent = node
}
// 获取文本
function chars(text) {
// 去除空格
text = text.replace(/\s/g, '')
// 遇到文本就直接放到当前节点的children中
text &&
currentParent.children.push({
type: TEXT_TYPE,
text,
parent: currentParent,
})
}
// 结束标签
function end() {
stack.pop()
currentParent = stack[stack.length - 1]
}
// 根据文本长度删除html
function advance(n) {
html = html.substring(n)
}
// 解析开始标签,并返回match对象,对象包括标签名与属性数组
function parseStartTag(params) {
const start = html.match(startTagOpen)
/**
* start => ["<div", "div"]
* <div为匹配到的开始标签,div为匹配到的开始标签名
*/
if (start) {
// 保存开始标签的标签名、属性
const match = {
// 标签名
tagName: start[1],
// 属性数组
attrs: [],
}
// 删除<+标签名,删除后html为:id="app"> hello{{name}} </div>
advance(start[0].length)
// 如果不是开始标签的结束就一直匹配
let attr, end
// 当没有匹配到>并且匹配到属性时删除属性
while (
!(end = html.match(startTagClose)) &&
(attr = html.match(attribute))
) {
// 删除后html为:> hello{{name}} </div>
advance(attr[0].length)
match.attrs.push({
name: attr[1],
value: attr[3] || attr[4] || attr[5] || true,
})
}
// 删除>
if (end) {
// 删除后html为:hello{{name}} </div>
advance(end[0].length)
}
return match
}
// 匹配到</标签名>就返回false
return false
}
return root
}
调用vm._update(vm._render())
虚拟dom生成真实dom,其中在vm._render
函数中执行vm.$options.render.call(vm)
将with的this指向vue实例,触发_c、_v、_s
函数生成虚拟dom。
在vm._update
函数中执行patch(el, vnode)
传入旧dom、虚拟dom,首先判断旧dom是否有nodeType,如果有就代表是元素节点,将虚拟dom传入createElm
函数。在createElm
函数判断虚拟dom的tag是不是字符串,如果是字符串就创建元素并保存在虚拟dom的el属性、处理属性、递归添加children,否则创建文本节点添加到虚拟dom的el属性,createElm
函数最终虚拟dom的el属性。
createElm(vnode)
返回虚拟dom创建真实dom,接下来就是把真实dom替换旧的dom,先获取旧dom的父节点在旧dom之前插入生成的真实dom,再把旧dom删除并返回最新的真实dom
// lifecycle.js
import Watcher from './observe/watcher'
import { createElementVNode, createTextVNode } from './vdom'
export function mountComponent(vm, el) {
vm.$el = el
// vm._render将render函数生成vnode虚拟dom,vm._update将vnode虚拟dom生成真实dom
vm._update(vm._render())
}
// 根据虚拟dom创建真实dom
function patch(oldVNode, vnode) {
// 判断是否是元素节点
const isRealElement = oldVNode.nodeType
if (isRealElement) {
const elm = oldVNode
// 根据虚拟dom创建真实dom
let newElm = createElm(vnode)
/**
* nextSibling:返回其父节点的 childNodes 列表中紧跟在其后面的节点
* insertBefore:在 elm.nextSibling 之前插入一个newElm
*/
const parentElm = elm.parentNode
parentElm.insertBefore(newElm, elm.nextSibling)
// 删除旧dom
parentElm.removeChild(elm)
// 返回最新dom
return newElm
}
}
// 创建元素
function createElm(vnode) {
const { tag, data, children, text } = vnode
// tag是字符串代表是元素节点,否则是文本节点
if (typeof tag === 'string') {
vnode.el = document.createElement(tag)
// 处理属性
patchProps(vnode.el, data)
// 递归添加子节点
children.forEach((child) => {
vnode.el.appendChild(createElm(child))
})
} else {
vnode.el = document.createTextNode(text)
}
return vnode.el
}
// 处理标签属性
function patchProps(el, props) {
for (const key in props) {
if (key === 'style') {
for (const styleName in props.style) {
el.style[styleName] = props.style[styleName]
}
} else {
// 设置指定元素上的某个属性值
el.setAttribute(key, props[key])
}
}
}
// 向vue原型添加_update、_c、_v、_s、_render方法
export function initLifeCycle(Vue) {
Vue.prototype._update = function (vnode) {
const vm = this
/**el:
*<div id="app" style="color: red;">
* <span>hello{{name}}{{age}}</span>
*</div>
*/
const el = vm.$el
// 传入旧dom、虚拟dom生成真实dom更新到页面
vm.$el = patch(el, vnode)
}
// render函数中标签处理
Vue.prototype._c = function (params) {
// 创建虚拟dom
return createElementVNode(this, ...arguments)
}
// render函数中普通文本处理
Vue.prototype._v = function (params) {
return createTextVNode(this, ...arguments)
}
// render函数中插值语法处理
Vue.prototype._s = function (params) { // _s(name)
// 是对象需要转换成json字符串
if (typeof value !== 'object') {
return params
}
return JSON.stringify(params)
}
Vue.prototype._render = function (params) {
const vm = this
// 让with中this绑定到vm
return vm.$options.render.call(vm)
}
}
生成元素、文本虚拟dom
vdom/index.js
// 创建虚拟dom
export function createElementVNode(vm, tag, data = {}, ...children) {
return vnode(vm, tag, key, data, children)
}
export function createTextVNode(vm, text) {
return vnode(vm, undefined, undefined, undefined, undefined, text)
}
function vnode(vm, tag, key, data, children, text) {
return {
vm,
tag,
key,
data,
children,
text,
}
}