美文网首页vue
手写Vue2核心(二):模板渲染

手写Vue2核心(二):模板渲染

作者: 羽晞yose | 来源:发表于2021-02-06 17:46 被阅读0次

模板渲染


因为vue模板中存在指令,修饰符,循环等,只替换变量是不健全的。因此需要有一套模板渲染,来识别vue模板并执行
涉及知识点:模板编译原理 AST语法树 先识别出HTML,将其转换成js语法

  1. 需要将模板变成一个 render 方法
  2. 需要去当前的实例上取值 with
  3. 虚拟DOM => 对象 可以描述DOM结构(diff算法)
  4. 生成一个真实的DOM结构,到页面中渲染

获取HTML模板,统一render

通过判断vm.options中的属性

  • 如果有render 就直接使用 render
  • 没有render 看有没有template属性
  • 没有template 就接着找外部模板(el)
Vue.prototype.$mount = function (el) {
    el = document.querySelector(el)
    const vm = this
    const options = vm.$options

    // 如果有render 就直接使用 render
    // 没有render 看有没有template属性
    // 没有template 就接着找外部模板
    if (!options.render) {
        let template = options.template
        if (!template && el) {
            // 返回内容包含描述元素及其后代的序列化HTML片段,火狐不兼容,可以使用document。createElement('div').appendChild('app').innerHTML来获取
            template = el.outerHTML
        }
        const render = compileToFunctions(template)
        options.render = render // 通过这个步骤,统一为render
    }
}

解析HTML,获取标签、文本、属性

获取到HTML后开始解析模板。使用正则来匹配获取标签与属性,这一步主要在于实现如何解析HTML,分别分析出开始标签、文本、结束标签
这里没处理单标签,还有一些特殊情况如style/script/pre/textarea标签等也不会处理,只是学习实现原理,有兴趣的自行看源码 html-parser源码

<!-- DOM结构 -->
<div id="app" class="wrap" disabled>
    <div style="color: red">
        <span>{{name}}</span>
    </div>
</div>
// compiler\parse.js
// 直接从github上源码搬运过来的正则 https://github.com/vuejs/vue/blob/edf7df0c837557dd3ea8d7b42ad8d4b21858ade0/packages/vue-template-compiler/build.js#L270
const ncname = '[a-zA-Z_][\\w\\-\\.]*' // 匹配标签名
const qnameCapture = "((?:" + ncname + "\\:)?" + ncname + ")" // 匹配带前缀的标签名 <aa:span>,有命名空间的标签
const startTagOpen = new RegExp(("^<" + qnameCapture))
const endTag = new RegExp(("^<\\/" + qnameCapture + "[^>]*>"))
// 例如 style     =     "xxx" 或 style="xxx" 或 style='xxx' 或 style=xxx
// 如果不需要捕获=号,则改成/^\s*([^\s"'<>\/=]+)(?:\s*=\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const startTagClose = /^\s*(\/?)>/
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g

export function parseHTML (html) {
    // 前进,为了减少解析,需要在解析后则删除掉(前进一段)
    function advance (n) {
        html = html.substring(n)
    }

    // 解析起始标签
    function parseStartTag () {
        const start = html.match(startTagOpen)
        if (start) {
            let match = {
                tagName: start[1],
                attrs: []
            }

            advance(start[0].length) // 截取至<div
            // console.log(html, match) // 到这一步可查看下图《截取后的结果》

            // 查找属性
            let end, attr
            // 不是开头标签结尾并且有属性值
            // !(end = html.match(startTagClose) 这里是赋值跟返回放在一起,相当于先赋值end = html.match(startTagClose,再判断end
            while(!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
                advance(attr[0].length)

                match.attrs.push({
                    name: attr[1],
                    value: attr[3] || attr[4] || attr[5] || true // 可能是 a="1" a='1' a=1,都没有则为true,比如 disabled 等同于 disabled="true"
                })

                // console.log(attr) // 到这一步可查看下图《匹配出来的attr》
            }

            if (end) {
                advance(end[0].length)
                return match
            }
        }
    }

    while (html) {
        let textEnd = html.indexOf('<')
        // 匹配的 < 号有可能是开始标签,也可能是结束标签
        if (textEnd === 0) {
            // 开始标签
            let startTagMatch = parseStartTag(html)
            if (startTagMatch) {
                console.log('开始标签:' + startTagMatch.tagName)
                continue
            }
            
            // 结束标签,不需要解析获取,只用于前进即可
            let endTagMatch = html.match(endTag)
            if (endTagMatch) {
                advance(endTagMatch[0].length)
                console.log('结束标签:' + endTagMatch[1])
                continue
            }
        }

        let text
        if (textEnd > 0) { // 开始解析文本(有可能是文本,也可能是标签换行留下的空白)
            text = html.substring(0, textEnd)
        }

        if (text) {
            advance(text.length)
            console.log('文本:', text)
        }

        console.log(html)
    }
}

export function compileToFunctions (template) {
    parseHTML(template)
}
截取后的结果
匹配出来的attr

生成AST语法树

在解析过程中,根据标签,生成AST语法树(其实解析与生成AST语法树是同步的,分开来讲好理解点)
在分析出开始标签、结束标签和文本后,分别去执行对应的start、end、chars方法,start负责生成语法树,end负责父子节点相互指定
此处涉及两个知识点:

  • 标签通过栈处理,起始标签入栈,结束标签则出栈
  • 双指针,父子节点相互记录
export function parseHTML (html) {
+   // vue3里面支持多个根元素(外层加了一个空元素),vue2中只有一个根节点
+   function createASTElment (tag, attrs) {
+       return {
+           tag, // 标签
+           type: 1, // 元素类型,1为节点,3为文本
+           children: [], // 子节点
+           attrs, // 属性
+           parent: null // 父节点
+       }
+   }

+   let root = null
+   let currentParent // 当前处理的节点
+   let stack = []

+   // 根据开始标签、结束标签、文本内容,生成AST语法书
+   function start (tagName, attrs) {
+       let element = createASTElment(tagName, attrs)
+       // 创建树根
+       if (!root) {
+           root = element
+       }
+       currentParent = element
+       stack.push(element) // 开始标签入栈
+   }

+   // 栈处理,这里没写异常处理,可以判断标签是否正常闭合,是否有多余标签等
+   function end (tagName) {
+       let element = stack.pop()
+       currentParent = stack[stack.length - 1] // 结束标签出栈
+       // 双指针(父子互相记录)
+       if (currentParent) {
+           element.parent = currentParent
+           currentParent.children.push(element)
+       }
+   }
+
+   
+   function chars (text) {
+       text = text.replace(/\s/g, '') // 去除空格,源码是更改为一个空格,这里为了好判断直接全部去掉
+       if (text) {
+           currentParent.children.push({
+               type: 3,
+               text
+           })
+       }
+   }

    // code...

    while (html) {
        if (textEnd === 0) {
            if (startTagMatch) {
+               start(startTagMatch.tagName, startTagMatch.attrs)
                continue
            }

            if (endTagMatch) {
+               end(endTagMatch[1])
                continue
            }
        }

        if (text) {
            advance(text.length)
+           chars(text)
        }
    }
}
AST语法树

生成可执行的代码块

这一步只有一个目标:根据AST语法树,生成一个可被后续执行的js语句(可以将其看成生成vue createElement方法所需的参数)

这一块写起来比较恶心,先说一下实现目标,拿到文本后区分是鬓语法还是普通文本(通过正则defaultTagRE
如果是鬓语法,则生成_s(变量),否则直接拼接上,最后将拼接后的文本放到_v(处理后的文本)

关键实现代码:

// 文本不能用_c来处理
// 有{{}} 普通文本 混合文本(前两者集合)
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g
const text = '{{name}} abc {{age}} code'
// 是鬓语法
function gen (text) {
    if (defaultTagRE.test(text)) {
        let tokens = [] // 混合文本存放
        let match
        let index = 0
        let lastIndex = defaultTagRE.lastIndex = 0 // lastIndex获取匹配后指针的位置,由于上面用过一次test,所以指针不已经不是0开始了,需要重置为0

        while(match = defaultTagRE.exec(text)) {
            index = match.index
            console.log(match, index, lastIndex)
            if (index > lastIndex) {
                tokens.push(JSON.stringify(text.slice(lastIndex, index)))
            }
            tokens.push(`_s(${match[1].trim()})`)
            lastIndex = index + match[0].length
        }

        if (lastIndex < text.length) {
            tokens.push(JSON.stringify(text.slice(lastIndex)))
        }
        return `_v(${tokens.join('+')})`
    } else { // 普通文本
        console.log('普通文本')
        return `_v(${JSON.stringify(text)})`
    }
}
console.log(gen(text))

_c_v 方法还未实现,到这里主要是创建出可执行的js代码,输出结果:

// 为了方便观看,格式化后的输出结果:
_c('div',
    {
        id: "app",
        class: "wrap",
        style: {"background-color":" pink"}
    },
    _c('div',
        {style: {"color":" red"}},
        _c('span',
            undefined,
            _v(_s(name))
        )
    )
)

generate完整代码:

// compiler\generate.js
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g

function genProps (attrs) {
    let str = ''
    for (let i = 0; i < attrs.length; i++) {
        let attr = attrs[i]

        // 如果有行内样式,例如 style="color: red"
        if (attr.name === 'style') {
            let obj = {}
            attr.value.split(';').forEach(item => {
                let [key, value] = item.split(':')
                obj[key] = value
            })
            attr.value = obj
        }

        // 由于字符串拼接,attr.value 会丢失 双引号(或单引号),{id: app},这样待会解析就变成变量了
        // 所以需要使用JSON.stringify重新将字符串补充上引号 {id: "app"}
        str += `${attr.name}: ${JSON.stringify(attr.value)},` // _c('div', {id: "app",class: "wrap",style: {"background-color":" pink"}})
    }

    return `{${str.slice(0, -1)}}` // 去除最后多余的逗号
}

function genChildren(el) {
    const children = el.children
    if (children) {
        return children.map(child => gen(child)).join(',')
    }
}

// 区分是元素还是文本
function gen(node) {
    if (node.type === 1) {
        return generate(node)
    } else {
        // 文本不能用_c来处理
        // 有{{}} 普通文本 混合文本(前两者集合)
        const text = node.text
        // 是鬓语法
        if (defaultTagRE.test(text)) {
            let tokens = [] // 混合文本存放
            let match
            let index = 0
            let lastIndex = defaultTagRE.lastIndex = 0 // lastIndex获取匹配后指针的位置,由于上面用过一次test,所以指针不已经不是0开始了,需要重置为0

            while(match = defaultTagRE.exec(text)) {
                index = match.index
                if (index > lastIndex) {
                    tokens.push(JSON.stringify(text.slice(lastIndex, index)))
                }
                tokens.push(`_s(${match[1].trim()})`)
                lastIndex = index + match[0].length
            }

            if (lastIndex < text.length) {
                tokens.push(JSON.stringify(text.slice(lastIndex)))
            }
            return `_v(${tokens.join('+')})`
        } else { // 普通文本
            return `_v(${JSON.stringify(text)})`
        }
    }
}

// 创建元素
export function generate (el) {
    console.log(el)
    let children = genChildren(el)

    // 转换成render代码,这里看不懂需要先移步去学习相关文档:createElement https://cn.vuejs.org/v2/guide/render-function.html
    // _c('div', {id: "app",class: "wrap",style: {"background-color":" pink"}})
    let code = `_c('${el.tag}', ${
        el.attrs.length ? genProps(el.attrs) : 'undefined'
    }${
        children ? ',' + children : ''
    })`

    return code
}

处理后的结果:
_c('div', {id: "app",class: "wrap",style: {"background-color":" pink"}},_c('div', {style: {"color":" red"}},_c('span', {style: {"color":" plum"}},_v(_s(name)))))

编译成render函数

with:with语句用于设置代码在特定对象中的作用域,但该语句运行速度差,且使用不当会引起内存泄漏
new Function: 执行传入的字符串,用于替代eval
需要使用with是因为后面需要向

// compiler\index.js
+ import { generate } from './generate.js'

export function compileToFunctions (template) {
    // with是动态的插入当前的词法作用域,所以可以在外部将Vue传入,这样就能获取到对应的属性
+   const render = `with(this){return ${code}}`
+   const fn = new Function(render)
+   return fn
}

这里可能对到大部分人比较陌生,所以写一个简单的Demo

class Test {
    constructor (a, b) {
        this.a = a
        this.b = b
    }
    sum () {
        return this.a + this.b
    }
}

const render = `with(this){return sum()}` // 会去查传入对象中是否存在sum

const res = new Function(render).call(new Test(3, 4)) // new Function执行字符串,并将this指向Test
console.log(res) // 7

相关文章:
JavaScript中 with的用法
把字符串当做javascript代码执行

产生虚拟DOM

虚拟Dom 与 AST语法树 虽然都为对象,但AST语法树描述的是语法本身,也就是不得无中生有。而虚拟Dom是自行定义,用于根据自身需求而指定schema的对象

src下补充三个文件,在index.js引用:
render.js - 拓展 vue._updata 方法,用于更新虚拟Dom
lifecycle.js - 拓展 vue._render 方法,用于更新真实Dom
observer\watcher.js - 目前步骤,该文件中的watcher仅实现渲染watcher

// index.js
+ import { lifecycleMixin } from './lifecycle.js'
+ import { renderMixin } from './render.js'

+ lifecycleMixin(Vue) // 扩展 _updata 方法
+ renderMixin(Vue) // 扩展 _render 方法

// init.js
+ import { mountComponent } from './lifecycle.js'

export function initMixin (Vue) {
    Vue.prototype.$mount = function (el) {
+       mountComponent(vm, el) // 组件挂载
    }
}

通过混入 renderMixin 和 lifecycleMixin 来拓展Vue的渲染及生命周期(这里暂时只实现生命周期中的渲染方法_update)

// lifecycle.js
import Watcher from './observer/watcher.js'

export function lifecycleMixin (Vue) {
    // 视图更新方法,用于渲染真实DOM
    Vue.prototype._update = function (vnode) {
        
    }
}

export function mountComponent (vm, el) {
    let updateComponent = () => {
        vm._update(vm._render()) // vm._render()返回虚拟节点,update返回真实节点
    }

    // 默认vue是通过watcher来渲染的 渲染watcher(每一个组件都有一个渲染watcher)
    new Watcher(vm, updateComponent, () => {}, true)
}

// render.js
import { createdElement, createTextVnode } from './vdom/index.js'

export function renderMixin (Vue) {
    // 为什么要写在prototype上?因为render中传入this,也就是只能在vue中的方法和变量才能被获取到
    Vue.prototype._c = function (...args) { // 创建元素虚拟节点
        return createdElement(this, ...args)
    }

    Vue.prototype._v = function (text) { // 创建元文本拟节点
        return createTextVnode(this, text)
    }

    Vue.prototype._s = function (value) { // 鬓语法转化成字符串
        // 如果值是个对象,输出成对象字符串,否则输出值
        return value == null ? '' : (typeof value === 'object') ? JSON.stringify(value) : value
    }

    // 用于执行自定义render方法
    Vue.prototype._render = function () {
        const vm = this
        const render = vm.$options.render // 获取编译后的render方法

        // 调用render方法产生虚拟节点
        const vnode = render.call(vm) // 调用时会自动将变量进行取值
        return vnode
    }
}

这一步比较简单,就是将render转成虚拟Dom

// vdom\index.js
// 创建 Dom虚拟节点
export function createdElement (vm, tag, data = {}, ...children) {
    return vnode(vm, tag, data, data.key, children, undefined)
}

// 创建文本虚拟节点
export function createTextVnode (vm, text) {
    return vnode(vm, undefined, undefined, undefined, undefined, text)
}

function vnode (vm, tag, data, key, children, text) {
    return {
        vm,
        tag,
        children,
        data,
        key,
        text
    }
}

生成真实DOM

新建 vdom\patch.js 文件,用于生成真实的节点

// init.js
export function initMixin (Vue) {
    Vue.prototype.$mount = function (el) {
+       vm.$options.el = el
    }
}
// lifecycle.js
import { patch } from './vdom/patch.js'

export function lifecycleMixin (Vue) {
    // 视图更新方法,用于渲染真实DOM
    Vue.prototype._update = function (vnode) {
        // 首次渲染,需要用虚拟节点,来更新真实的dom元素,后续会改,目前是每次替换都会直接替换掉整个#app
+       vm.$options.el = patch(vm.$options.el, vnode)
    }
}

主要代码:

// vdom\patch.js
// 将虚拟节点转换成真实节点
// 将虚拟节点转换成真实节点
export function patch(oldVnode, newVnode) {
    // oldVnode 第一次是一个真实的元素,也就是#app
    const isRealElement = oldVnode.nodeType

    if (isRealElement) {
        // 初次渲染
        const oldElm = oldVnode // id="app"
        const parentElm = oldElm.parentNode
        const el = createdElm(newVnode) // 根据虚拟节点创建真实节点
        // 将创建的节点插入到原有节点的下一个,因为不比vue template,index.html除了入口还可能有其他元素
        parentElm.insertBefore(el, oldElm.nextSibling)
        parentElm.removeChild(oldElm)
        return el // vm.$el
    } else {
        // diff算法

    }
}

function createdElm (vnode) { // 根据虚拟节点创建真实节点,不同于createElement
    let { vm, tag, data, key, children, text } = vnode
    

    if (typeof tag === 'string') {
        // 可能是组件
        vnode.el = document.createElement(tag) // 用vue的指令时,可以通过vnode拿到真实dom
        updateProperties(vnode)
        children.forEach(child => {
            vnode.el.appendChild(createdElm(child)) // 递归创建插入节点,现代浏览器appendChild并不会插入一次回流一次
        })
    } else {
        vnode.el = document.createTextNode(text)
    }

    return vnode.el
}

// 更新属性,注意这里class与style无法处理表达式,因为从前面解析的时候就没处理,还是那句,重点不在完全实现,而是学习核心思路
function updateProperties (vnode) {
    const newProps = vnode.data || {}
    const el = vnode.el

    for (let key in newProps) {
        if (key === 'style') {
            for (let styleName in newProps.style) {
                el.style[styleName] = newProps.style[styleName]
            }
        } else if (typeof tag === 'class') { // 静态的class可以没有这段,但还是写上,假装如果是class可以处理简单的表达式
            vnode.className = newProps.class
        } else {
            el.setAttribute(key, newProps[key])
        }
    }
}

相关文章

  • 手写Vue2核心(二):模板渲染

    模板渲染 因为vue模板中存在指令,修饰符,循环等,只替换变量是不健全的。因此需要有一套模板渲染,来识别vue模板...

  • Vue 快速上手(实例)

    Vue 快速上手 实例1: 渲染声明 渲染声明是 vue 的最核心模块。 vue 基于传统的 HTML 模板语法,...

  • 手写Vue2核心(八):vuex实现

    准备工作 如果前面有自行实现过vue-router,那这里就没有工作了,否则移步手写Vue2核心(七):vue-r...

  • day09 vue.js起步

    Vue核心:采用简洁的模板语法来声明式地将数据渲染进DOM

  • Flask jinja2模板

    Python Flask JIJIA2模板渲染 A.Flask渲染Jinja2模板和模板传参 如何渲染模板:Fla...

  • Python flask 学习笔记(二)

    模板引擎 模板渲染 变量 流程控制 1. 模板渲染 Jinja2 模板引擎 页面渲染流程 一个简单的例子: 2. ...

  • 浅谈vue设计核心 01-数据响应式

    vue的设计核心是 mvvmmvvm的核心三要素:数据响应式、 模板引擎、 渲染1、数据响应式:监听数据变化并且在...

  • dot模板引擎

    基本模板 for 循环渲染 数组渲染 条件渲染

  • 前端面试题笔记

    模板替换,发布订阅,手写promise,手写bind 模板替换 let tem = '我是{{name}},今年{...

  • artTemplate 总结

    编写模板 渲染模板 简介语法 方法 template(id,data) 根据id渲染模板,内部会根据documen...

网友评论

    本文标题:手写Vue2核心(二):模板渲染

    本文链接:https://www.haomeiwen.com/subject/jnzbtltx.html