手写Vue2源码(四)—— 初次渲染

550 阅读3分钟

前言

通过手写Vue2源码,更深入了解Vue; 在项目开发过程,一步一步实现Vue核心功能,我会将不同功能放到不同分支,方便查阅; 另外我会编写一些开发文档,阐述编码细节及实现思路; 源码地址:手写Vue2源码

流程分析

$mount中最后需要将生成的render函数转化成真实DOM渲染到页面:

// src/init.js
export function initMixin(Vue) {
    Vue.prototype.$mount = function (el) {
        const vm = this;
        const options = vm.$options;
        el = document.querySelector(el);
        const render = compileToFunctions(options.template);
        options.render = render;

        return mountComponent(vm, el);
    }
}

看一下简化版的mountComponent(vm, el)

// src/lifesycle.js
export function mountComponent(vm, el) {
  vm.$el = el;
  // 执行beforeMount生命周期钩子
  callHook(vm, "beforeMount");

  let updateComponent = () => {
    vm._update(vm._render());
  };
  updateComponent();
  //   创建一个Watcher,后续在响应式时再实现
  //   new Watcher(
  //     vm,
  //     updateComponent,
  //     () => {
  //       callHook(vm, "beforeUpdate");
  //     },
  //     true
  //   );
  callHook(vm, "mounted");
}

主要执行了两个方法:vm._render()vm._update()

vm._render() 执行了render函数,生成VNode;

vm._update() 有两个过程:

  1. 初次渲染时直接Vnode挂载到页面上
  2. 更新时,比较新旧VNode,经过Diff算法,渲染真实DOM

render函数如何生成VNode

首先在Vue原型上定义一下_render()方法,以及render函数中调用的几个创建节点的方法(_c_v_s):

// src/render.js
import { createElement, createTextNode } from "./vdom/index";
export function renderMixin(Vue) {
  Vue.prototype._render = function () {
    const vm = this;
    // 获取模板编译生成的render方法
    const { render } = vm.$options;
    
    // 生成vnode--虚拟dom
    const vnode = render.call(vm);
    return vnode;
  };
    Vue.prototype._c = function (...args) {
    // 创建虚拟dom元素
    return createElement(this,...args);
  };
  Vue.prototype._v = function (text) {
    // 创建虚拟dom文本
    return createTextNode(this,text);
  };
  Vue.prototype._s = function (val) {
    // 如果模板里面的是一个对象,需要JSON.stringify
    return val == null
      ? ""
      : typeof val === "object"
      ? JSON.stringify(val)
      : val;
  };
}

小结一下它们的实现思路:

  • _render()就是执行vm.$options.render.call(vm)(在$mount中生成render函数,并赋值到options.render)
  • _s(val) :如果val基础类型,直接展示;如果是对象,调用JSON.stringify(val)转化成字符串
  • _v:调用createTextNode(this,text)方法创建文本Vnode
  • _c:调用createElement(this,...args)方法创建元素Vnode

思考一下如何创建元素VNode及文本Vnode:

  • VNode就是用来描述元素的js对象
  • 文本VNode和元素VNode的区别就是js对象的一些属性不同
  • createTextNodecreateElement返回不同的VNode实例,传入不同的参数即可。

具体实现如下:

// src/vdom/index.js
export default class Vnode {
  /**
   * @param {标签名} tag
   * @param {属性} data
   * @param {标签唯一的key} key
   * @param {子节点} children
   * @param {文本节点} text
   * @param {组件节点的其他属性} componentOptions
   */
  constructor(tag, data, key, children, text, componentOptions) {
    this.tag = tag;
    this.data = data;
    this.key = key;
    this.children = children;
    this.text = text;
    this.componentOptions = componentOptions;
  }
}
// 创建文本vnode
export function createTextNode(vm, text) {
  return new Vnode(undefined, undefined, undefined, undefined, text);
}
// 创建元素vnode
export function createElement(vm, tag, data = {}, ...children) {
  let key = data.key;
  // 如果是普通标签
  if (isReservedTag(tag)) {
    return new Vnode(tag, data, key, children);
  } else {
    // 否则就是组件
    // TODO...........后续章节再处理组件元素
    // let Ctor = vm.$options.components[tag]; //获取组件的构造函数
    // return createComponent(vm, tag, data, key, children, Ctor);
  }
}

_update(Vnode)如何生成真实DOM

_update()方法是通过实例调用的,可以将该方法定义在vue原型上; 思考一下_update()怎么实现:

  1. 需要实现两个功能:初次渲染和组件更新
  2. 通过是否能获取到上一次的oldVnode判断是否是初次渲染

具体实现:

// src/lifecycle.js
import { patch } from './vdom/patch'

export function lifecycleMixin(Vue) {
  // 初始挂载及后续更新
  // 更新的时候,不会重新进行模板编译,因为更新只是数据发生变化,render函数没有改变
  Vue.prototype._update = function (vnode) {
    const vm = this
    const preVnode = vm._vnode // 获取上一次的vnode
    vm._vnode = vnode // 在组件实例上增加一个_vnode属性,将本次的vnode存到vm._vnode中

    // 通过 vm._vnode 判断是否是初次渲染,patch方法既用于初次渲染,也用于后续更新
    // 如果是初次渲染
    if (!preVnode) {
      // 存储创建的真实DOM到vm.$el
      // patch中第一个参数vm.$el可能是真实dom(options中定义过el时)或空(options中没定义过el)
      vm.$el = patch(vm.$el, vnode)
    } else {
      // 如果是视图更新
      vm.$el = patch(preVnode, vnode, vm)
    }
  }
}

其中核心方法就是 patch(oldVnode, vnode),该方法既可用于初次渲染,也可用于后续更新。

思考一下如何实现patch方法?

  1. 根据oldVnode,分情况处理
    1. 如果没有oldVnode,则直接创建一个真实dom,赋值为vnode.el
    2. 如果oldVnode为真实DOM,则将vnode转化成真实dom,替换掉老的DOM
    3. 如果oldVnode为虚拟DOM,则说明是更新,后续章节再分析

具体实现:

// src/vdom/patch.js
export function patch(oldVnode, vnode, vm) {
  /**
   * 情况1:如果options中没有el,也没有oldVnode,直接创建真实dom
   */
  if (!oldVnode) {
    return createElm(vnode)
  } else {
    // Vnode没有设置nodeType,真实节点可以获取到nodeType
    const isRealElement = oldVnode.nodeType
    /**
     * 情况2:如果oldVnode为真实DOM(即初次渲染,且options.el存在),则将vnode转化成真实dom,替换掉老的DOM
     */
    if (isRealElement) {
      const oldElm = oldVnode
      const parentElm = oldElm.parentNode
      // 创建新节点的真实DOM
      const el = createElm(vnode)
      // 插入新节点
      parentElm.insertBefore(el, oldElm.nextSibling)
      // 移除老节点
      parentElm.removeChild(oldVnode)
      return el
    } else {
      /**
       * 情况3:如果oldVnode为虚拟DOM,则说明是更新视图
       * 涉及到diff算法,后续再补充
       */
      console.log(oldVnode, vnode)
      console.log('diff更新视图')
    }
  }
}

// 虚拟Vnode转化成真实dom
function createElm(vnode) {
  const { tag, data, key, children, text } = vnode
  // 1. 如果是元素节点/自定义组件
  if (typeof tag === 'string') {
    // 1.1 如果是组件Vnode,返回组件渲染的真实DOM
    if (createComponent(vnode)) {
      return vnode.componentInstance.$el
    }

    // 1.2 否则是元素Vnode
    vnode.el = document.createElement(tag)
    // 解析vnode中的data属性
    updateProperties(vnode)
    // 遍历子节点Vnode,递归调用createElm生成真实DOM后,插入到父节点里
    children.forEach((child) => {
      return vnode.el.appendChild(createElm(child))
    })
  } else {
    // 2. 如果是文本节点
    vnode.el = document.createTextNode(text)
  }
  return vnode.el
}

// 创建组件Vnode的真实DOM
function createComponent(vnode) {
  let i = vnode.data
  /**
   * 如果i.hook存在,赋值i=i.hook;如果i.init存在,赋值i=i.init
   * 相当于执行:vnode.data.hook.init(vnode)
   */
  if ((i = i.hook) && (i = i.init)) {
    i(vnode)
  }

  // 如果组件实例化完毕,有componentInstance属性,那证明是组件
  if (vnode.componentInstance) {
    return true
  }
}

// 解析vnode中的data属性
function updateProperties(vnode, oldProps = {}) {
  const newProps = vnode.data || {}
  const el = vnode.el

  // 如果新的节点没有该属性,需要把老的节点属性移除
  for (const k in oldProps) {
    if (!newProps[k]) {
      el.removeAttribute(k)
    }
  }

  // 对style属性进行处理:如果新的style中没有,需要把老的style值置空
  const newStyle = newProps.style || {}
  const oldStyle = oldProps.style || {}
  for (const key in oldStyle) {
    if (!newStyle[key]) {
      el.style[key] = ''
    }
  }

  // 遍历新属性,设置相关属性
  for (const key in newProps) {
    if (key === 'style') {
      for (const styleName in newProps.style) {
        el.style[styleName] = newProps.style[styleName]
      }
    } else if (key === 'class') {
      el.className = newProps.class
    } else {
      el.setAttribute(key, newProps[key])
    }
  }
}

系列文章