4. 模板解析,生成render函数,渲染页面
解析模板,生成render函数,执行render函数,实现视图渲染
1.模板转化成ast语法树
2.ast语法树生成render函数
3.执行render函数生成虚拟dom
4.执行_update方法生成真实dom
5.真实dom替换掉模板
在初始化方法中(_init()), 对元素进行处理, 执行挂载方法
在init.js中
Vue.prototype._init = function(options) {
// 获取vue实例, 这里的this指向vue实例
const vm = this
// 获取用户选项, 方便后续获取参数, 很多地方都是挂载到vue上面的
vm.$options = options
// 初始化状态
initState(vm)
// 如果有元素的话, 执行挂载方法,然后添加该方法
if(options.el) {
vm.$mount(options.el)
}
}
// 添加 $mount方法
添加的$mount方法主要实现:
-
获取template模板, 如果没有模板, 就用包裹el的那层, 即el.outHTML作为template, 注意el.outHTML不是body, 如果有, 直接使用options的template
let template if(!ops.template && el) { template = el.outerHTML } else { if(el) { template = ops.template } }
-
通过template生成render方法, 关键方法 compileToFunction, 实现内容
1. 通过*parseHtml*方法, 将template转化为ast语法树 2. 通过with + new Function() 生成render方法
-
执行mountComponent方法实现视图的更新,里面包括两个关键方法
-
vm._render方法, 就是执行组转的render方法, 生成虚拟dom
-
vm._update方法, 生成真实dom, 并替换掉模板
-
具体实现的方法:
dist/3.template.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">
<div class="bx1" style="backgroundColor: red;fontWeight: bolder">{{name}} hello</div>
<li>{{age}}</li>
</div>
<script src="vue.js"></script>
<script>
// 新建一个vue实例
const vm = new Vue({
el: "#app",
data() {
return {
name: 'ywj',
age: 18
}
}
})
setTimeout(() => {
vm.name = 'jerry'
vm.age = 13
vm._update(vm._render())
}, 2000)
</script>
</body>
</html>
init.js
import { compileToFunction } from "./compiler"
import { mountComponent } from "./lifecycle"
import { initState } from "./state"
import { createElementVNode, createTextVNode } from "./vdom"
export function initMixin(Vue) {
Vue.prototype._init = function(options) {
// 获取vue实例, 这里的this指向vue实例
const vm = this
// 获取用户选项, 方便后续获取参数, 很多地方都是挂载到vue上面的
vm.$options = options
// 初始化状态
initState(vm)
// 如果有元素的话, 执行挂载方法,然后添加该方法
if(options.el) {
vm.$mount(options.el)
}
}
// 挂载方法
Vue.prototype.$mount = function(el) {
// 获取实例
const vm = this
// 将el变成一个真实的元素
el = document.querySelector(el)
// 获取options
let ops = vm.$options
// 获取render方法, 没有就生成, 如果没有, 先获取template, 有template生成render方法
if(!ops.render) {
let template
if(!ops.template && el) {
template = el.outerHTML
} else {
if(el) {
template = ops.template
}
}
// console.log('template:', template)
// 将template转化为render方法
if(template) {
// 新建文件compiler/index.js文件, 添加compileToFunction方法
const render = compileToFunction(template)
ops.render = render
// console.log('render:', render)
// 有了render之后, 挂载组件
// 就是执行一个render方法, 产生虚拟dom, 然后挂载到el中
mountComponent(vm, el)
}
}
}
}
新建文件 compiler/index.js
import { parseHtml } from "./parse";
export function compileToFunction(template) {
// 先将template转化为ast语法树, 同目录下新建parse.js文件, 添加parseHtml方法
let ast = parseHtml(template)
// console.log('ast:' , ast)
// 使用ast语法树生成代码
let code = codegen(ast)
console.log('code:', code)
// 通过code生成render方法 , 也是模板引擎的实现原理 with + new Function
code = `with(this){return ${code}}`
let render = new Function(code)
return render
}
function codegen(ast) {
let children = genChildren(ast.children)
let code = `_c('${ast.tag}', ${ast.attrs.length > 0 ? genProps(ast.attrs) : 'null'}${ast.children.length ? `,${children}` : ''})`
return code
}
/**
*
* 生成属性
* attrs: [{name: 'id', value: 'app'}]
* 要拼成的结构: id:app,key:value
* 如 id: app, class: appcalss
* 最外面加上一个 { id: app, class: app}
* 注意: 需要对style特殊处理
*/
function genProps(attrs) {
let str = ''
for(let i = 0; i < attrs.length; i ++) {
let attr = attrs[i]
// style需要特殊处理,
// 如不处理: style: "color: red;bgc: blue"
// 需要变成: style: {color: 'red',bgc: 'blue'}
// 所以 style的value是一个object
if(attr.name === 'style') {
let obj = {}
// debugger
attr.value.split(';').forEach(item => {
let [key, value] = item.split(':')
obj[key] = value
})
attr.value = obj
}
// 这里的value需要是一个字符串
str += `${attr.name}:${JSON.stringify(attr.value)},`
}
// 最后会多出一个逗号 去掉
return `{${str.slice(0, -1)}}`
}
// 生成孩子节点
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g // 匹配的内容就是表达式的变量
function gen(node) {
if(node.type === 1) { // 如果是元素, 直接codegen
return codegen(node)
} else {
// 如果是文本, 有两种情况, 'hello' 和 {{name}}
let text = node.text
if(!defaultTagRE.test(text)) { // 没匹配上, 表示纯文本
return `_v(${JSON.stringify(text)})`
} else {
let tokens = []
let match
defaultTagRE.lastIndex = 0
let lastIndex = 0
// match 长这样 ['{{name}}', 'name', index: 0, input: '{{name}}hello', groups: undefined]
while(match = defaultTagRE.exec(text)) {
let index = match.index // 匹配的位置
if(index > lastIndex) { // 匹配的位置大于上一次匹配的位置, 说明在匹配到的位置之前有文本, 要push进去, 需要添加json.stringify
tokens.push(JSON.stringify(text.slice(lastIndex, index)))
}
tokens.push(`_s(${match[1].trim()})`) // 将匹配到的变量加一个 _s , 去掉前后空格 {{ name }} 这种情况
lastIndex = index + match[0].length // 然后将lastindex 变成本次匹配到的位置加上匹配到的长度, 循环
}
if(lastIndex < text.length) { // 如果lastindex < text.length , 说明最后面还有文本, 也要stringify之后push进去
tokens.push(JSON.stringify(text.slice(lastIndex)))
}
return `_v(${tokens.join('+')})`
}
}
}
/**
*
* 生成孩子, 用逗号拼起来
*/
function genChildren(children) {
return children.map(child => gen(child)).join(',')
}
新建文件 compiler/parse.js
// copy一波正则表达式
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/ // 匹配属性, 第一个分组是属性的key, value可能是分组3或4或5
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`) // 匹配到的是 <div 最终匹配到的分组是开始标签的名称
const startTagClose = /^\s*(\/?)>/ // 结束标签 </div> <br/>
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`) // 匹配到的是</xxx>, 最终匹配到的分组是结束标签的名称
const doctype = /^<!DOCTYPE [^>]+>/i
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g // 匹配的内容就是表达式的变量
// 这里的html是字符串
export function parseHtml(html) {
const ELEMENT_TYPE = 1 // 元素类型
const TEXT_TYPE = 3 // 文本类型
const stack = [] // 用来存放元素
let currentParent; // 指向栈中的最后一个元素
let root // 指向根节点
function createASTElement(tag, attrs) {
return {
tag,
type: ELEMENT_TYPE,
children: [],
attrs,
parent: null
}
}
// 将处理标签的方法放在外面
function start(tag, attrs) {
let node = createASTElement(tag, attrs) // 先产生一棵树
if(!root) {
root = node // 如果没有跟节点, 这个就作根节点
}
if(currentParent) { // 如果有根节点, 当前节点的parent就是currentParent,
node.parent = currentParent // 同时, currentParent的children是node
currentParent.children.push(node)
}
stack.push(node)
currentParent = node // 当前节点作为栈中的最后一个
}
function chars(text) {
text = text.replace(/\s/g, '');
// 如果当前节点是文本
text && currentParent.children.push({
type: TEXT_TYPE,
text,
parent: currentParent
})
}
function end(tag) {
// 遇到结束标签, 直接当前的最后一个, 更新currentparent
stack.pop()
currentParent = stack[stack.length - 1]
}
function advance(n) {
html = html.substring(n)
}
function parseStartTag() {
const start = html.match(startTagOpen)
if(start) { // 如果没有匹配到, start是一个null, 直接return false, 如果匹配到, 第一个是匹配到的内容, 第二个是名称
const match = {
tagName : start[1],
attrs: []
}
// 匹配到之后, 将匹配到的内容删除
advance(start[0].length) // start[0]标签匹配到的内容, 初次是 <div
// 接下来就要匹配属性了
let attr, end
// 如果没有匹配到结束标签, 并且能匹配到属性, 就处理匹配信息, 之后将匹配到的内容删除
// 这种写法是 : 判断html.match(startTagClose) 和 html.match(attribute), 前面只是赋值,
while(!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
advance(attr[0].length)
// true 标签单标签
match.attrs.push({name: attr[1], value: attr[3] || attr[4] || attr[5] || true})
}
if(end) {
advance(end[0].length)
}
return match
}
//
return false
}
while(html) {
// 如果textEnd = 0, 说明是一个开始标签或结束标签
// 如果textEnd > 0, 说明是文本结束的位置
let textEnd = html.indexOf('<')
if(textEnd == 0) {
const startTagMatch = parseStartTag()
if(startTagMatch) {
start(startTagMatch.tagName, startTagMatch.attrs)
// 结束本轮循环
continue
}
let tagEndMatch = html.match(endTag)
if(tagEndMatch) {
end(tagEndMatch[1])
advance(tagEndMatch[0].length)
continue
}
}
if(textEnd > 0) {
// 如果 < 的位置大于0, 那么从 0 到 textEnd 中间的e部分就是文本
// 文本的内容就是
let text = html.substring(0, textEnd)
if(text) {
chars(text)
advance(text.length)
}
}
}
return root
}
新建文件 vdom/index.js
// h(), _c()
// with(this){return _c('div', {id:"app",style:{"color":"yellow","backgroundColor":"blue"}},_c('div', {style:{"color":" red"}},_v(_s(name)+"hello"+_s(age))),_c('span', null,_v(_s(age))))}
// render函数是自己拼起来的, 长上面的样子, 参数为
/**
*
* @param {vm} vm 实例
* @param {标签名} tag
* @param {属性} data 可能没有, 给个默认值
* @param {...any} children
*/
export function createElementVNode(vm, tag, data = {}, ...children) {
// 这里需要返回一个虚拟节点, 下面也需要返回虚拟节点, 单独创建一个方法
// 这里的data可能是null, 需要判断一下
// console.log('data:1', data)
if(data==null) {
data = {}
}
let key = data.key
if(key) {
delete data.key
}
// key一般在props里面, 这里的props就是data, 删除key之后把key属性从data里面删除
// 不知道为啥, 不过不删应该也是影响不大
return vnode(vm, tag, key, data, children)
}
// _v
export function createTextVNode(vm, text) {
return vnode(vm, undefined, undefined, undefined, undefined, text)
}
// 看起来和ast语法树一样 ?
// ast做的是语法层面的转化, 描述的语法本身
// 虚拟dom描述的是dom元素, 可以新增一些自定义属性
/**
*
* @param {实例} vm
* @param {标签名称} tag
* @param {key用于diff算法} key
* @param {属性} data
* @param {孩子} children
* @param {文本} text
*/
function vnode(vm, tag, key, data, children, text) {
return {
vm,
tag,
key,
data,
children,
text
}
}
新建文件 src/lifecycle.js
import { createElementVNode, createTextVNode } from "./vdom"
export function mountComponent(vm, el) {
// 将挂载的元素也放到实例上
vm.$el = el
// 1. 调用render方法产生虚拟节点
// vm._render() 生成虚拟节点 vm._update 生成真实节点 需要先扩展这两个方法
// vm._update(vm._render())
// 2. 虚拟dom产生真实dom
// 3. 插入到el元素中
// const vdom = vm._render()
// console.log('vdom:', vdom)
vm._update(vm._render())
}
export function initLifeCycle(Vue) {
Vue.prototype._render = function() {
const vm = this
// debugger
// 返回的结果是虚拟dom
// 注意this的指向, 需要call this
// 就是执行$options里面的render方法
// 需要拓展 _s _v _c方法
return vm.$options.render.call(vm)
}
Vue.prototype._c = function() {
// 返回一个元素的虚拟节点
return createElementVNode(this, ...arguments)
}
// _v(text)
Vue.prototype._v = function() {
// 返回一个文本的虚拟节点
return createTextVNode(this, ...arguments)
}
// 将数据转换成字符串
Vue.prototype._s = function(value) {
// 如果不是对象的话, 就直接返回, 不然字符串可会被加上""
if(typeof value !== 'object') return value
return JSON.stringify(value)
}
Vue.prototype._update = function(vnode) {
const vm = this
const el = vm.$el
vm.$el = patch(el, vnode)
}
}
function patch(oldVNode, vnode) {
// 现在是初次渲染
// 需要判断是不是真实节点
const isRealElement = oldVNode.nodeType // nodeType是原生
if(isRealElement) {
const elm = oldVNode // 获取真实元素
const parentElm = elm.parentNode // 拿到父元素
// 创建真实元素
let newElm = createElm(vnode)
parentElm.insertBefore(newElm, elm.nextSibling)
parentElm.removeChild(oldVNode)
return newElm // 如果是真实dom, 先返回一个新的dom, 暂时
} else {
// diff算法
}
}
function createElm(vnode) {
let {tag, data, children, text} = vnode
if(typeof tag === 'string') { // 如果tag是string, 说明是一个标签, 如div
vnode.el = document.createElement(tag) // 生成一个真实节点, 并将真实节点挂载到虚拟节点上. 将虚拟节点和真实节点意义对应, 后续如果修改了属性, 可以直接找到虚拟节点对应的真实节点
// 更新属性, 属性在data里面
patchProps(vnode.el, data)
// 标签会有儿子, 要处理儿子
children.forEach(child => {
// 同样生成元素并且插入到父元素的真实节点中, 递归调用
vnode.el.appendChild(createElm(child))
})
} else { // 不是元素就是文本
vnode.el = document.createTextNode(text) // 创建文本
}
// 这里返回一个真实dom是为了方便递归调用, 并且使用dom的方法
return vnode.el
}
/**
*
* @param {真实元素} el
* @param {属性} props 是一个对象
*/
function patchProps(el, props) {
for(let key in props) {
// style单独处理
if(key === 'style') {
for(let styleName in props.style) {
el.style[styleName] = props.style[styleName]
}
} else {
el.setAttribute(key, props[key])
}
}
}
至此可以实现页面的初次渲染和手动刷新