手写 Vue2 系列 之 初始渲染

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

image

前言

上一篇文章 手写 Vue2 系列 之 编译器 中完成了从模版字符串到 render 函数的工作。当我们得到 render 函数之后,接下来就该进入到真正的挂载阶段了:

挂载 -> 实例化渲染 Watcher -> 执行 updateComponent 方法 -> 执行 render 函数生成 VNode -> 执行 patch 进行首次渲染 -> 递归遍历 VNode 创建各个节点并处理节点上的普通属性和指令 -> 如果节点是自定义组件则创建组件实例 -> 进行组件的初始化、挂载 -> 最终所有 VNode 变成真实的 DOM 节点并替换掉页面上的模版内容 -> 完成初始渲染

目标

所以,本篇文章目标就是实现上面描述的整个过成,完成初始渲染。整个过程中涉及如下知识点:

  • render helper

  • VNode

  • patch 初始渲染

  • 指令(v-model、v-bind、v-on)的处理

  • 实例化子组件

  • 插槽的处理

实现

接下来就正式进入代码实现过程,一步步实现上述所有内容,完成页面的初始渲染。

mount

/src/compiler/index.js

/**
 * 编译器
 */
export default function mount(vm) {
  if (!vm.$options.render) { // 没有提供 render 选项,则编译生成 render 函数
    // ...
  }
  mountComponent(vm)
}

mountComponent

/src/compiler/mountComponent.js

/**
 * @param {*} vm Vue 实例
 */
export default function mountComponent(vm) {
  // 更新组件的的函数
  const updateComponent = () => {
    vm._update(vm._render())
  }

  // 实例化一个渲染 Watcher,当响应式数据更新时,这个更新函数会被执行
  new Watcher(updateComponent)
}

vm._render

/src/compiler/mountComponent.js

/**
 * 负责执行 vm.$options.render 函数
 */
Vue.prototype._render = function () {
  // 给 render 函数绑定 this 上下文为 Vue 实例
  return this.$options.render.apply(this)
}

render helper

/src/compiler/renderHelper.js

/**
 * 在 Vue 实例上安装运行时的渲染帮助函数,比如 _c、_v,这些函数会生成 Vnode
 * @param {VueContructor} target Vue 实例
 */
export default function renderHelper(target) {
  target._c = createElement
  target._v = createTextNode
}

createElement

/src/compiler/renderHelper.js

/**
 * 根据标签信息创建 Vnode
 * @param {string} tag 标签名 
 * @param {Map} attr 标签的属性 Map 对象
 * @param {Array<Render>} children 所有的子节点的渲染函数
 */
function createElement(tag, attr, children) {
  return VNode(tag, attr, children, this)
}

createTextNode

/src/compiler/renderHelper.js

/**
 * 生成文本节点的 VNode
 * @param {*} textAst 文本节点的 AST 对象
 */
function createTextNode(textAst) {
  return VNode(null, null, null, this, textAst)
}

VNode

/src/compiler/vnode.js

/**
 * VNode
 * @param {*} tag 标签名
 * @param {*} attr 属性 Map 对象
 * @param {*} children 子节点组成的 VNode
 * @param {*} text 文本节点的 ast 对象
 * @param {*} context Vue 实例
 * @returns VNode
 */
export default function VNode(tag, attr, children, context, text = null) {
  return {
    // 标签
    tag,
    // 属性 Map 对象
    attr,
    // 父节点
    parent: null,
    // 子节点组成的 Vnode 数组
    children,
    // 文本节点的 Ast 对象
    text,
    // Vnode 的真实节点
    elm: null,
    // Vue 实例
    context
  }
}

vm._update

/src/compiler/mountComponent.js

Vue.prototype._update = function (vnode) {
  // 老的 VNode
  const prevVNode = this._vnode
  // 新的 VNode
  this._vnode = vnode
  if (!prevVNode) {
    // 老的 VNode 不存在,则说明时首次渲染根组件
    this.$el = this.__patch__(this.$el, vnode)
  } else {
    // 后续更新组件或者首次渲染子组件,都会走这里
    this.$el = this.__patch__(prevVNode, vnode)
  }
}

安装 __patch__、render helper

/src/index.js

/**
 * 初始化配置对象
 * @param {*} options 
 */
Vue.prototype._init = function (options) {
  // ...
  initData(this)
  // 安装运行时的渲染工具函数
  renderHelper(this)
  // 在实例上安装 patch 函数
  this.__patch__ = patch
  // 如果存在 el 配置项,则调用 $mount 方法编译模版
  if (this.$options.el) {
    this.$mount()
  }
}

patch

/src/compiler/patch.js

/**
 * 初始渲染和后续更新的入口
 * @param {VNode} oldVnode 老的 VNode
 * @param {VNode} vnode 新的 VNode
 * @returns VNode 的真实 DOM 节点
 */
export default function patch(oldVnode, vnode) {
  if (oldVnode && !vnode) {
    // 老节点存在,新节点不存在,则销毁组件
    return
  }

  if (!oldVnode) { // oldVnode 不存在,说明是子组件首次渲染
    createElm(vnode)
  } else {
    if (oldVnode.nodeType) { // 真实节点,则表示首次渲染根组件
      // 父节点,即 body
      const parent = oldVnode.parentNode
      // 参考节点,即老的 vnode 的下一个节点 —— script,新节点要插在 script 的前面
      const referNode = oldVnode.nextSibling
      // 创建元素
      createElm(vnode, parent, referNode)
      // 移除老的 vnode
      parent.removeChild(oldVnode)
    } else {
      console.log('update')
    }
  }
  return vnode.elm
}

createElm

/src/compiler/patch.js

/**
 * 创建元素
 * @param {*} vnode VNode
 * @param {*} parent VNode 的父节点,真实节点
 * @returns 
 */
function createElm(vnode, parent, referNode) {
  // 记录节点的父节点
  vnode.parent = parent
  // 创建自定义组件,如果是非组件,则会继续后面的流程
  if (createComponent(vnode)) return

  const { attr, children, text } = vnode
  if (text) { // 文本节点
    // 创建文本节点,并插入到父节点内
    vnode.elm = createTextNode(vnode)
  } else { // 元素节点
    // 创建元素,在 vnode 上记录对应的 dom 节点
    vnode.elm = document.createElement(vnode.tag)
    // 给元素设置属性
    setAttribute(attr, vnode)
    // 递归创建子节点
    for (let i = 0, len = children.length; i < len; i++) {
      createElm(children[i], vnode.elm)
    }
  }
  // 如果存在 parent,则将创建的节点插入到父节点内
  if (parent) {
    const elm = vnode.elm
    if (referNode) {
      parent.insertBefore(elm, referNode)
    } else {
      parent.appendChild(elm)
    }
  }
}

createTextNode

/src/compiler/patch.js

/**
 * 创建文本节点
 * @param {*} textVNode 文本节点的 VNode
 */
function createTextNode(textVNode) {
  let { text } = textVNode, textNode = null
  if (text.expression) {
    // 存在表达式,这个表达式的值是一个响应式数据
    const value = textVNode.context[text.expression]
    textNode = document.createTextNode(typeof value === 'object' ? JSON.stringify(value) : String(value))
  } else {
    // 纯文本
    textNode = document.createTextNode(text.text)
  }
  return textNode
}

setAttribute

/src/compiler/patch.js

/**
 * 给节点设置属性
 * @param {*} attr 属性 Map 对象
 * @param {*} vnode
 */
function setAttribute(attr, vnode) {
  // 遍历属性,如果是普通属性,直接设置,如果是指令,则特殊处理
  for (let name in attr) {
    if (name === 'vModel') {
      // v-model 指令
      const { tag, value } = attr.vModel
      setVModel(tag, value, vnode)
    } else if (name === 'vBind') {
      // v-bind 指令
      setVBind(vnode)
    } else if (name === 'vOn') {
      // v-on 指令
      setVOn(vnode)
    } else {
      // 普通属性
      vnode.elm.setAttribute(name, attr[name])
    }
  }
}

setVModel

/src/compiler/patch.js

/**
 * v-model 的原理
 * @param {*} tag 节点的标签名
 * @param {*} value 属性值
 * @param {*} node 节点
 */
function setVModel(tag, value, vnode) {
  const { context: vm, elm } = vnode
  if (tag === 'select') {
    // 下拉框,<select></select>
    Promise.resolve().then(() => {
      // 利用 promise 延迟设置,直接设置不行,
      // 因为这会儿 option 元素还没创建
      elm.value = vm[value]
    })
    elm.addEventListener('change', function () {
      vm[value] = elm.value
    })
  } else if (tag === 'input' && vnode.elm.type === 'text') {
    // 文本框,<input type="text" />
    elm.value = vm[value]
    elm.addEventListener('input', function () {
      vm[value] = elm.value
    })
  } else if (tag === 'input' && vnode.elm.type === 'checkbox') {
    // 选择框,<input type="checkbox" />
    elm.checked = vm[value]
    elm.addEventListener('change', function () {
      vm[value] = elm.checked
    })
  }
}

setVBind

/src/compiler/patch.js

/**
 * v-bind 原理
 * @param {*} vnode
 */
function setVBind(vnode) {
  const { attr: { vBind }, elm, context: vm } = vnode
  for (let attrName in vBind) {
    elm.setAttribute(attrName, vm[vBind[attrName]])
    elm.removeAttribute(`v-bind:${attrName}`)
  }
}

setVOn

/src/compiler/patch.js

/**
 * v-on 原理
 * @param {*} vnode 
 */
function setVOn(vnode) {
  const { attr: { vOn }, elm, context: vm } = vnode
  for (let eventName in vOn) {
    elm.addEventListener(eventName, function (...args) {
      vm.$options.methods[vOn[eventName]].apply(vm, args)
    })
  }
}

createComponent

/src/compiler/patch.js

/**
 * 创建自定义组件
 * @param {*} vnode
 */
function createComponent(vnode) {
  if (vnode.tag && !isReserveTag(vnode.tag)) { // 非保留节点,则说明是组件
    // 获取组件配置信息
    const { tag, context: { $options: { components } } } = vnode
    const compOptions = components[tag]
    const compIns = new Vue(compOptions)
    // 将父组件的 VNode 放到子组件的实例上
    compIns._parentVnode = vnode
    // 挂载子组件
    compIns.$mount()
    // 记录子组件 vnode 的父节点信息
    compIns._vnode.parent = vnode.parent
    // 将子组件添加到父节点内
    vnode.parent.appendChild(compIns._vnode.elm)
    return true
  }
}

isReserveTag

/src/utils.js

/**
 * 是否为平台保留节点
 */
export function isReserveTag(tagName) {
  const reserveTag = ['div', 'h3', 'span', 'input', 'select', 'option', 'p', 'button', 'template']
  return reserveTag.includes(tagName)
}

插槽原理

以下示例是插槽的常用方式。插槽的原理其实很简单,只是实现起来稍微有些麻烦罢了。

  • 解析

    如果组件标签有子节点,在解析的时候将这些子节点,解析成一个特定的数据结构,该结构中包含了插槽的全部信息,然后将该数据结构放到父节点的属性上,其实就是找个地方存放这些信息,然后在 renderSlot 中使用时取出来。当然这个解析过程是发生在父组件的解析过程中的。

  • 生成渲染函数

    在生成子组件的渲染函数阶段,如果碰到 slot 标签,则返回一个 _t 的渲染函数,函数接收两个参数:属性的 JSON 字符串形式,slot 标签的所有子节点的渲染函数组成的 children 数组。

  • render helper

    在执行子组件的渲染函数时,如果执行到 vm._t,就会调用 renderSlot 方法,该方法会返回插槽的 VNode,然后进入子组件的 patch 阶段,将这些 VNode 变成真实的 DOM 并渲染到页面上。

以上就是插槽的原理,然后接下来实现的时候,在某些地方可能会稍微有点绕,多多少少是因为整体架构存在一些问题,所以里面会有一些修补性质的代码,这些代码你可以理解为为了实现插槽功能,而写的一点业务代码。你只需要把住插槽的本质即可。

示例

<!-- comp -->
<template>
  <div>
    <div>
      <slot name="slot1">
        <span>插槽默认内容</span>
      </slot>
    </div>
      <slot name="slot2" v-bind:test="xx">
        <span>插槽默认内容</span>
      </slot>
    <div>
    </div>
  </div>
</template>

<comp></comp>
<comp>
  <template v-slot:slot2="xx">
    <div>作用域插槽,通过插槽从父组件给子组件传递内容</div>
  </template>
<comp>

parse

/src/compiler/parse.js

function processElement() {
    // ...

    // 处理插槽内容
    processSlotContent(curEle)

    // 节点处理完以后让其和父节点产生关系
    if (stackLen) {
      stack[stackLen - 1].children.push(curEle)
      curEle.parent = stack[stackLen - 1]
      // 如果节点存在 slotName,则说明该节点是组件传递给插槽的内容
      // 将插槽信息放到组件节点的 rawAttr.scopedSlots 对象上
      // 而这些信息在生成组件插槽的 VNode 时(renderSlot)会用到
      if (curEle.slotName) {
        const { parent, slotName, scopeSlot, children } = curEle
        // 这里关于 children 的操作,只是单纯为了避开 JSON.stringify 的循环引用问题
        // 因为生成渲染函数时需要对 attr 执行 JSON.stringify 方法
        const slotInfo = {
          slotName, scopeSlot, children: children.map(item => {
            delete item.parent
            return item
          })
        }
        if (parent.rawAttr.scopedSlots) {
          parent.rawAttr.scopedSlots[curEle.slotName] = slotInfo
        } else {
          parent.rawAttr.scopedSlots = { [curEle.slotName]: slotInfo }
        }
      }
    }
  }

processSlotContent

/src/compiler/parse.js

/**
 * 处理插槽
 * <scope-slot>
 *   <template v-slot:default="scopeSlot">
 *     <div>{{ scopeSlot }}</div>
 *   </template>
 * </scope-slot>
 * @param { AST } el 节点的 AST 对象
 */
function processSlotContent(el) {
  // 注意,具有 v-slot:xx 属性的 template 只能是组件的根元素,这里不做判断
  if (el.tag === 'template') { // 获取插槽信息
    // 属性 map 对象
    const attrMap = el.rawAttr
    // 遍历属性 map 对象,找出其中的 v-slot 指令信息
    for (let key in attrMap) {
      if (key.match(/v-slot:(.*)/)) { // 说明 template 标签上 v-slot 指令
        // 获取指令后的插槽名称和值,比如: v-slot:default=xx
        // default
        const slotName = el.slotName = RegExp.$1
        // xx
        el.scopeSlot = attrMap[`v-slot:${slotName}`]
        // 直接 return,因为该标签上只可能有一个 v-slot 指令
        return
      }
    }
  }
}

generate

/src/compiler/generate.js

/**
 * 解析 ast 生成 渲染函数
 * @param {*} ast 语法树 
 * @returns {string} 渲染函数的字符串形式
 */
function genElement(ast) {
  // ...

  // 处理子节点,得到一个所有子节点渲染函数组成的数组
  const children = genChildren(ast)

  if (tag === 'slot') {
    // 生成插槽的处理函数
    return `_t(${JSON.stringify(attrs)}, [${children}])`
  }

  // 生成 VNode 的可执行方法
  return `_c('${tag}', ${JSON.stringify(attrs)}, [${children}])`
}

renderHelper

/src/compiler/renderHelper.js

/**
 * 在 Vue 实例上安装运行时的渲染帮助函数,比如 _c、_v,这些函数会生成 Vnode
 * @param {VueContructor} target Vue 实例
 */
export default function renderHelper(target) {
  // ...
  target._t = renderSlot
}

renderSlot

/src/compiler/renderHelper.js

/**
 * 插槽的原理其实很简单,难点在于实现
 * 其原理就是生成 VNode,难点在于生成 VNode 之前的各种解析,也就是数据准备阶段
 * 生成插槽的的 VNode
 * @param {*} attrs 插槽的属性
 * @param {*} children 插槽所有子节点的 ast 组成的数组
 */
function renderSlot(attrs, children) {
  // 父组件 VNode 的 attr 信息
  const parentAttr = this._parentVnode.attr
  let vnode = null
  if (parentAttr.scopedSlots) { // 说明给当前组件的插槽传递了内容
    // 获取插槽信息
    const slotName = attrs.name
    const slotInfo = parentAttr.scopedSlots[slotName]
    // 这里的逻辑稍微有点绕,建议打开调试,查看一下数据结构,理清对应的思路
    // 这里比较绕的逻辑完全是为了实现插槽这个功能,和插槽本身的原理没关系
    this[slotInfo.scopeSlot] = this[Object.keys(attrs.vBind)[0]]
    vnode = genVNode(slotInfo.children, this)
  } else { // 插槽默认内容
    // 将 children 变成 vnode 数组
    vnode = genVNode(children, this)
  }

  // 如果 children 长度为 1,则说明插槽只有一个子节点
  if (children.length === 1) return vnode[0]
  return createElement.call(this, 'div', {}, vnode)
}

genVNode

/src/compiler/renderHelper.js

/**
 * 将一批 ast 节点(数组)转换成 vnode 数组
 * @param {Array<Ast>} childs 节点数组
 * @param {*} vm 组件实例
 * @returns vnode 数组
 */
function genVNode(childs, vm) {
  const vnode = []
  for (let i = 0, len = childs.length; i < len; i++) {
    const { tag, attr, children, text } = childs[i]
    if (text) { // 文本节点
      if (typeof text === 'string') { // text 为字符串
        // 构造文本节点的 AST 对象
        const textAst = {
          type: 3,
          text,
        }
        if (text.match(/{{(.*)}}/)) {
          // 说明是表达式
          textAst.expression = RegExp.$1.trim()
        }
        vnode.push(createTextNode.call(vm, textAst))
      } else { // text 为文本节点的 ast 对象
        vnode.push(createTextNode.call(vm, text))
      }
    } else { // 元素节点
      vnode.push(createElement.call(vm, tag, attr, genVNode(children, vm)))
    }
  }
  return vnode
}

结果

好了,到这里,模版的初始渲染就已经完成了,如果你能看到如下效果图,则说明一切正常。因为整个过程涉及的内容还是比较多的,如果觉得某些地方不太清楚,建议再看看,仔细梳理下整个流程。

动图链接:https://gitee.com/liyongning/typora-image-bed/raw/master/202203141833484.image

[图片上传失败...(image-1f2b8-1647492112124)]

可以看到,原始标签、自定义组件、插槽都已经完整的渲染到了页面上,完成了初始渲染之后,接下来就该去实现后续的更新过程了,也就是下一篇 手写 Vue2 系列 之 patch —— diff

链接

感谢各位的:关注点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,602评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,442评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,878评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,306评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,330评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,071评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,382评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,006评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,512评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,965评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,094评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,732评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,283评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,286评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,512评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,536评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,828评论 2 345

推荐阅读更多精彩内容