手写vue(三)模板渲染解析

一、目标

        创建一个Vue实例时,我们可以传入el配置项,去指定一个DOM元素作为Vue容器,而这个Vue容器中,可以使用例如插值表达式等Vue框架提供的语法,并且能够渲染到浏览器页面上。

        而浏览器并不能解析这些Vue语法,因此,Vue框架是通过获取到Vue容器,然后对容器内容进行解析,重新生成DOM元素,去替换掉容器内容。

二、执行流程

        Vue是如何重新解析,并且生成新的DOM对象的呢?

1、拿到模板字符串,然后解析为语法树,语法树节点描述了这个DOM的父节点、属性、子节点、

节点类型等。

{
        type,
        tag,
        attrs,
        parent,
        children: []
}

2、使用语法树生成虚拟DOM,虚拟DOM节点与语法树节点内容类似,不过虚拟DOM上面我们可以去挂一些自定义的属性,方便生成真实dom等等,而语法树节点,则是直接描述HTML内容,因此解析语法树,用第三方提供的包,也是可以完成的。

3、通过虚拟DOM生成真实DOM,然后替换Vue容器

三、模板解析

我们拿到HTML字符串后,可以通过正则去解析HTML内容,主要是要解析出标签、标签属性、文本内容。

解析HTML入口方法:parseHTML()

通过循环匹配,不断地按顺序解析HTML

例如:

下面这段html代码的解析流程:

初始状态

<div id="app">test<span></span></div>

树节点栈:【】

1、匹配到开始标签,生成一个树节点,类型为节点元素,设置tag和属性,然后截去解析完的内容,并把生成的节点放入栈中:

test<span></span></div>

树节点栈:【node{tag:'div', {id:'app'}, children: [], type: 'element'}】

2、匹配到文本内容,生成一个树节点,类型为文本元素,放入到栈顶元素的children数组中

<span></span></div>

树节点栈:【node{tag:'div', {id:'app'}, children: [node(text:'test', type: 'text')], type: 'element'}】

3、匹配到span的开始标签,生成节点,放入到栈顶元素的children数组中,并将自身放入栈中

</span></div>

树节点栈:【node{tag:'div', {id:'app'}, children: [node(text:'test', type: 'text'), node(span...)], type: 'element'}, node(span.....)】

4、匹配到span结束标签,栈中弹出span

</div>

树节点栈:【node{tag:'div', {id:'app'}, children: [node(text:'test', type: 'text'), node(span...)], type: 'element'}】

5、匹配到div结束标签,栈中弹出div

''

树节点栈:【】

最后得到的节点则是整颗语法树的根节点,顺着children往下找,可以生成整颗语法树

// Non-Colonized Name,xml元素和属性的名称
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`;
// 标签名
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
// 匹配开始标签, 分组1为标签名
const startTagOpen = new RegExp(`^<${qnameCapture}`)
// 结束标签
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
// 标签属性,分组1:key,value: 分组3/分组4/分组5
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/;
// 闭合标签
const startTagClose = /^\s*(\/?)>/;

export const AstNodeType = {
    ELEMENT_NODE: 1,
    TEXT_NODE: 3
}

/**
 * 创建AST语法树节点
 */
function createASTElement(tag, attrs, parent) {
    return {
        type: AstNodeType.ELEMENT_NODE,
        tag,
        attrs,
        parent,
        children: []
    }
}

/**
 * 解析HTML字符串为抽象语法树
 * @param {String} html Html字符串
 * @returns 抽象语法树
 */
export function parseHTML(html) {
    // 使用栈存储结构,逐层解析HTML
    const stack = []
    // 栈顶指针
    let topNode = null
    // 树根
    let root = null

    // 处理开始标签
    function start(tag, attrs) {
        const node = createASTElement(tag, attrs, topNode)
        if (topNode) {
            topNode.children.push(node)
        } else {
            root = node
        }
        if (tag != 'br') {
            stack.push(node)
            topNode = node
        }
    }

    // 处理文本内容
    function content(text) {
        topNode.children.push({
            type: AstNodeType.TEXT_NODE,
            text,
            parent: topNode
        })
    }

    // 处理结束标签
    function end(tag) {
        if (topNode.tag === tag) {
            stack.pop()
            topNode = stack[stack.length - 1]
        } else {
            topNode.children.push(createASTElement(tag, null, topNode))
        }
    }

    // html解析前进,截掉已解析内容
    function advance(length) {
        html = html.substring(length)
    }

    // 尝试解析开始标签,返回一个标签节点,包含标签名、属性等信息
    function parseStartTag() {
        let startTag = null
        const match = html.match(startTagOpen)
        if (match) {
            startTag = {
                tag: match[1],
                attrs: {}
            }
            advance(match[0].length)

            while (true) {
                const attrMatch = html.match(attribute)
                if (attrMatch) {
                    // 获取正则匹配到的分组值
                    startTag.attrs[[attrMatch[1]]] = attrMatch[3] || attrMatch[4] || attrMatch[5]
                    advance(attrMatch[0].length)
                } else {
                    break
                }
            }
            let end = html.match(startTagClose)
            advance(end[0].length)
        }
        return startTag
    }

    // 解析完成的html代码片段会被截掉,一直循环到html所有内容都解析完毕
    while (html) {
        // 匹配到首个<,可能时开始标签,可能是结束标签,文本内容是不会包含的,需要被转义
        const endIdx = html.indexOf('<')
        // 如果当前已经解析到标签
        if (endIdx === 0) {
            // 尝试作为开始标签去解析
            const startTag = parseStartTag()
            if (startTag) {
                start(startTag.tag, startTag.attrs)
                // 如果是开始标签,则continue, 因为开始标签在parseStartTag中已经被截掉了
                continue
            }
            // 如果走到这,证明是结束标签,则处理结束标签
            const endTagMatch = html.match(endTag)
            if (endTagMatch) {
                end(endTagMatch[1])
                advance(endTagMatch[0].length)
            }
        } else {
            // 处理文本标签
            const text = html.substring(0, endIdx).trim()
            if (text) {
                content(text)
            }
            advance(endIdx)
        }
    }
    return root
}

四、生成渲染函数代码

渲染函数可以生成虚拟dom,渲染函数的格式:h(【tag标签名】,【属性节点】,【子节点】,【子节点】....... )

可以发现,上一步生成的抽象语法树,包含了渲染函数所需要的全部信息,而我们生成渲染函数代码,可以避免每次更新页面时,需要重新解析一遍html。

渲染函数示例:

模板:

<div id="app">test{{value}}<span></span></div>

解析为语法树后,根据语法树,生成的渲染函数代码为:

_c('div', {attrs:{id: 'app'}}, _v('test' + _s(value)), _c('span'))

// _c 创建虚拟dom节点

// _v 创建文本虚拟dom节点

// _s 包裹插值语法内容,将插值表达式的值转化为字符串

此时,如果实现了_c、_v、_s, 并且将这个表达式放入vm上下文中运行(_s中的value,是直接取值的,不是一个字符串),就可以获得一个虚拟节点。

生成表达式:

import { AstNodeType } from "../parser";

// 匹配 插值语法
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g
/**
 * 
 * @param {Object} ast ast语法树
 * 
 * @returns 渲染函数字符串
 * 例如_c('div', {id: 'app'}, _v('age:' + _s(age)), _c(......))
 * _c 创建虚拟dom节点
 * _v 创建文本虚拟dom节点
 * _s 包裹插值语法内容,将插值表达式的值转化为字符串
 */
export function generate(ast) {
    if (!ast) {
        return null;
    }
    // 生成子节点
    const childrenCodeList = []
    if (ast.children) {
        ast.children.forEach((child) => {
            childrenCodeList.push(generate(child))
        })
    }
    const childrenCode = childrenCodeList.join(',') || null
    let code = ""
    // 根据节点类型,生成对应虚拟DOM
    switch (ast.type) {
        case AstNodeType.ELEMENT_NODE:
            code = `_c('${ast.tag}',${JSON.stringify(ast.attrs)}, ${childrenCode})`
            break
        case AstNodeType.TEXT_NODE:
            let text = ast.text
            let match
            let tokens = []

            // 匹配插值表达式,用_s包裹起来
            while (match = defaultTagRE.exec(text)) {
                if (match.index !== 0) {
                    const t = text.substring(0, match.index)
                    tokens.push(`'${t}'`)
                }
                text = text.substring(match.index + match[0].length)
                tokens.push(`_s(${match[1].trim()})`)
            }
            if (text) {
                tokens.push(`'${text}'`)
            }
            // 拼接文本块
            code = `_v(${tokens.join('+')})`
        default:
            break;
    }
    return code
}

 将字符串表达式生成渲染函数:

import { generate } from "./codegen";
import { parseHTML } from "./parser";

/**
 * 解析模板,生成渲染函数
 * @param {String} template 模板
 * @returns 渲染函数
 */
export function compileToFunctions(template) {
    // 1、解析模板,生成抽象语法树
    const ast = parseHTML(template)
    // 2、通过抽象语法树,生成渲染函数表达式,表达式执行的值为虚拟dom
    const exp = generate(ast)
    // 3、添加绑定作用域,添加返回语句,最后生成函数
    // (调用这个函数的时候,需要使用call(this), 表达式中的响应式数据可以直接取到,不用通过this.XXX)
    const code = `with(this){return ${exp}}`
    return new Function(code)
}

五、执行渲染函数

执行渲染函数,需要实现c\v\s方法, 并挂到vue原型对象中去

$option._render 就是刚刚生成的渲染函数

    // 生成虚拟DOM
    Vue.prototype._render = function () {
        return this.$options.render.call(this)
    }
    /**
     * 给定标签名称、属性、子节点,生成虚拟DOM
     * @param {String} tag 标签名
     * @param {Object} attrs 属性集合
     * @param  {Array} children 子节点
     * @returns 虚拟dom
     */
    Vue.prototype._c = function (tag, attrs, ...children) {
        return new VNode(tag, { attrs }, children, undefined, undefined, this)
    }
    // 生成文本虚拟节点
    Vue.prototype._v = function (text) {
        return new VNode(undefined, undefined, undefined, text)
    }
    /**
     * 将插值表达式的值转化为字符串
     * @param {any} value 任意值
     * @returns 字符串
     */
    Vue.prototype._s = function (value) {
        let result = null
        if (typeof value === 'object') {
            result = JSON.stringify(value)
        } else {
            result = value
        }
        return result
    }

/***   vnode.js  *****/

// 虚拟节点定义
export class VNode {
    tag
    data
    children
    text
    elm
    context
    constructor(tag, data, children, text, elm, context) {
        this.tag = tag
        this.data = data
        this.children = children
        this.text = text
        this.elm = elm
        this.context = context
    }
}

六、生成真实DOM

根据虚拟DOM生成真实节点

/**
 * 根据虚拟节点创建真实节点
 * @param {vnode} vnode 虚拟节点
 * @returns 真实节点
 */
export function createElm(vnode) {
    const { children, data: { attrs } = {}, tag, text } = vnode || {}
    let elm = null
    if (tag) {
        elm = document.createElement(tag)
        Object.keys(attrs).forEach(key => {
            elm.setAttribute(key, attrs[key])
        })
        children && children.forEach(child => {
            elm.appendChild(createElm(child))
        })
    } else {
        elm = document.createTextNode(text)
    }
    return elm
}

gitee提交:

登录 - Gitee.com

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值