miniVue3的简单实现-初始化渲染流程

442 阅读12分钟

vue3初始化渲染流程

1.初始化渲染流程流程图

avatar

2.理解runtime-dom和runtime-core核心模块的区别

理解vue3的初始化渲染过程首先要弄清楚vue3非常重要的两个核心模块runtime-dom和runtime-core。 runtime-dom名字中有dom, 明显可以看出runtime-dom是和平台相关的操作,所以和平台操作相关的东西全部封装在runtime-dom模块中,而runtime-core是底层渲染逻辑,不涉及专属某个平台的操作,各平台相关的操作方法定义好提供给runtime-core模块使用来进行渲染

3.总体描述初始化渲染流程

  • 3.1 应用程序初始化并执行mount挂载, 当用户调用createApp创建app时,进行函数的一系列初始化定义,并返回app中是方法集合包括mount方法

  • 3.2 用户调用mount方法挂载,mount内部调用 createVNode创建vnode,并调用baseCreateRenderer中定义的render函数进行渲染

  • 3.3 render函数调用patch传入新老虚拟dom和容器container

  • 3.4 patch内部进行节点类型区分处理逻辑 如果节点是元素节点调用processElement处理, 如果是组件节点调用processComponent处理, 初始挂载应该是组件节点

  • 3.5 processComponent内判别旧的虚拟dom即n1是否存在,不存在执行mountComponent挂载组件,存在即为组件 updateComponent更新组件逻辑

  • 3.6 mountComponent内逻辑

    • 1.执行createComponentIstance生成组件实例instance
    • 2.执行setUpComponent设置props emits等 并 执行setup函数,并存储setup函数的结果
      1. 执行setUpComponentEffect触发effect副作用函数渲染界面
  • 3.7 setUpComponentEffect函数内根据组件是否挂载区分逻辑

      1. 组件未挂载过,执行instance的render函数获取渲染树,patch第一参数传入null标识初始挂载
      1. 组件已挂载过,获取到新老dom渲染子树,用patch对新老dom对比
  • 3.8 processElement函数调用

      1. 不存在老的虚拟dom即n1==null 执行mountElement创建元素和其子元素,并将元素挂载,挂载完界面显示内容
      1. n1!==null,执行updateElement更新元素,并进行页面内容渲染

4.代码实现

1.用户调用的createApp及生成渲染器函数

export function createApp (rootComponent) {
  return ensureRenderer(rootComponent);
}
// 基础dom操作,创建元素,插入元素,移除元素等
export const nodeOps = {
// 创建元素
  createElement (tag) {
    return document.createElement(tag);
  },
  // 插入元素
  insert (child, parent, ancher = null) {
    parent.insertBefore(child, ancher);
  },
  // 移除元素
  remove (child) {
    const parent = child.parentNode;
    if (parent) {
      parent.removeChild(child)
    }
  },
  // 设置文本内容
  setElementText (el, text) {
    el.textContent = text;
  }
}
// 处理元素的class操作
function patchClass (el, val) {
  if (val == null) {
    val = "";
  }
  el.className = val;
}
// 处理元素的央视操作
function patchStyle (el, oldVal, newVal) {
  for (let key in newVal) {
    el.style[key] = newVal[key];
  }
  for (let key in oldVal) {
    if (!(key in newVal)) {
      el.style[key] = "";
    }
  }
}
// 处理元素的属性操作
function patchAttr (el, key, val) {
  if (!val) {
    el.removeAttribute(key);
  } else {
    el.setAttribute(key, val);
  }
}
// 关于元素属性的操作,将属性、class和style区分处理
export function patchProps (el, key, oldVal = null, newVal = null) {
  switch (key) {
    case "class":
      patchClass(el, newVal)
      break;
    case "style":
      patchStyle(el, oldVal, newVal);
      break;
    default:
      patchAttr(el, key, newVal);
      break;
  }
}
// 和dom操作相关的方法集合
let nodeOptions = { ...nodeOps, patchProps };
// 生成一个渲染器
function ensureRenderer (rootComponent) {
  // createRenderer关于渲染的具体操作在runtime-core核心代码中
  let app = createRenderer(nodeOptions).createApp(rootComponent);
  const mount = app.mount;
  // 重写mount 方法
  app.mount = function (root) {
    // 这是与浏览器平台相关的dom操作, 所以不适合在runtime-core
    // 中操作, 所以在这里重新写
    // root 请传选择器
    const container = document.querySelector(root);
    // 清空跟组件的原有内容
    container.innerHTML = "";
    // 利用runtime-core中的mount进行挂在操作, 传入跟组件和最外层容器
    mount(container);
  }
  // 返回app
  return app;
}
  • 1.createApp函数传入组件配置,并调用ensureRenderer函数生成渲染器
  • 2.ensureRenderer调用runtime-core中提供的createRenderer函数,createRenderer内部调用baseCreateRenderer(后续涉及说明其返回值及内部的各种方法)生成渲染器,并在此传入dom相关操作集合nodeOptions, 在渲染的过程中,会用提供的集合nodeOptions中的方法,创建、删除和更新界面相关内容(例如:创建dom插入元素,更新界面),执行调用createRenderer(nodeOptions)返回值的createApp返回app(一个对象包含mount方法)
  • 3.缓存并重写mount方法,因为调用mount挂载时,我们需要操作和web平台相关的dom如获取根元素,挂载逻辑mount中不能涉及某个平台相关的操作,所以这里重写mount方法处理dom操作,同vue2中的mount重写思路差不多,vue2中重写mount方法是为了进行运行时没有render函数编译组件模板。
  • 4.执行缓存的最初始mount方法进行挂载,渲染页面
2.调用baseCreateRenderer生成基础渲染器的过程
function baseCreateRenderer (options) {
// 对在runtime-dom中定义的dom操作方法引入并重命名
  const {
    createElement: hostCreateElement,
    insert: hostInsert,
    remove: hostRemove,
    setElementText: hostSetElementText,
    patchProps: hostPatchProps
  } = options;
  // 渲染函数
  const render = (vnode, container) => {}
  // path挂载
  const patch = (n1, n2, container, ancher = null) => {}
  // 处理元素节点
  const processElement = (n1, n2, container, ancher) => {}
  // 处理组件节点
  const processComponent = (n1, n2, container) => {}
  // 创建挂载元素
  const mountElement = (n2, container, ancher) => {}
  // 挂载子元素
  const mountChildren = (children, container) => {}
  // 进行dom对比, 更新元素
  const updateElement = (n1, n2, container) => {}
  // 对比子元素
  const patchChildren = (n1, n2, container) => {}
  // dom diff流程
  const patchKeyChildren = (c1, c2, container) => {}
  // 对比元素属性
  const patchProps = (n1, n2, el) => {}
  const isSameVnode = (n1, n2) => {
    return n1.type === n2.type && n1.key === n2.key;}
  const mountComponent = (vnode, container) => {}
  const setUpComponentEffect = (instance, container) => {}
  return {
    render,
    createApp: createAppApi(render)
  }
}
  • 1.baseCreateRenderer函数中包含了挂在过程中很多逻辑方法处理,包括渲染函数render、patch进行虚拟dom对比挂载、processElement处理元素节点、processComponent处理组件节点、mountElement创建挂载元素节点、patchKeyChildren进行dom比对的过程、执行setUpComponentEffect执行组件的渲染函数,渲染界面等
    1. 返回一些方法集合,其中包括给用户使用的createApp
3.调用createAppApi生成用户使用的createApp方法
export function createAppApi (render) {
  const createApp = (rootComponnet) => {
    const app = {
      mount (root) {
        const vnode = createVnode(rootComponnet);
        render(vnode, root)
      }
    }
    return app;
  }
  return createApp;
}
  • 1.createAppApi函数中的createApp就是最终提供给用户使用创建app的,执行createApp的用户获得一个方法集合其中包括mount 方法, 用户执行了其中的mount方法就开始了初始化渲染逻辑的执行
  • 2.mount通过createVnode创建虚拟dom 通过render函数渲染vnode
4.执行createVnode创建虚拟vnode
// 元素类型集合标识
export const shapFlags = {
  ELEMENT: 1,
  FUNCTIONAL_COMPONENT: 1 << 1,
  STATEFUL_COMPONENT: 1 << 2,
  ARRAY_CHILD: 1 << 3,
  TEXT_CHILD: 1 << 4
}
// 创建虚拟dom
export function createVnode (type, props = {}, children = null) {
  // 素类型是字符串shapFlag设置成1, 组件类型是对象shapFlag设置成2
  let shapFlag = isString(type) ? shapFlags.ELEMENT : isObject(type) ? shapFlags.STATEFUL_COMPONENT : 0;
  const vnode = {
    type,       // 类型 元素类型是字符串, 组件类型是对象
    props,     //属性
    children,  //子元素
    component: null, //组件实例
    key: props.key,   //key
    shapFlag
  }
  /* 
    因为shapFlags中的字段数据都是二进制1向前位移的值,
    所以在二进制相同的位上不会存在都是1的情况
    然后在这里用异或 操作两个二进制, 相当于数据相加
    相加后的值即能代表父元素的类型也能知道子元素的类型
    相当于 现在 元素节点是1, 子节点是数组为16
    当 1 | 16 等于17 
    当下次判断时
    17 & 1 是1 代表当前元素是元素节点, 当16 & 17 输出是16代表当前类型是数组
    &相当于二进制当前位数相乘的结果
  */
  // 如果子元素是数组
  if (Array.isArray(children)) {
    vnode.shapFlag = shapFlag | shapFlags.ARRAY_CHILD;
  } else {
    // 如果子元素是字符串或null
    vnode.shapFlag = shapFlag | shapFlags.TEXT_CHILD;
  }
  // 返回虚拟节点
  return vnode;
}
  • 1.首先需要定义shapFlags对象标识各种类型节点,元素节点ELEMENT用1表示,函数式组件FUNCTIONAL_COMPONENT1向左移动1位即用2表示,有状态的组件STATEFUL_COMPONENT 1向左移动2位即用4表示,子节点是数组ARRAY_CHILD向左移动3位用8表示、子节点是文本TEXT_CHILD向左移动4位用16表示
  • 2.根据当前传入的节点类型type得到标识shapFlag,type有两种类型:字符串和对象,当为字符串时就是dom节点, 当为对象时就是组件节点。例如初始挂载时createVnode传入的是用户调用createApp函数传入的根组件(假定根组件是有状态的组件),那么当前的shapFlag值就等于4。
  • 3.定义vnode对象,其中包括标识节点类型的type属性, 节点属性props、子节点children、key值和shapFlag
  • 4.处理shapFlag,用来标识当前节点和子节点的类型,简化类型判断时的处理
  • 5.返回vnode对象
5.执行render函数进行渲染逻辑
// 渲染函数
  const render = (vnode, container) => {
    // 用patch挂载组件或元素, 因为是第一次挂载所以旧的虚拟dom是null
    patch(null, vnode, container);
  }
  const patch = (n1, n2, container, ancher = null) => {
    // 这里要用shapFlags区分是处理元素节点还是处理组件
    let { shapFlag } = n2;
    if (shapFlag & shapFlags.ELEMENT) {
      // 处理元素节点
      processElement(n1, n2, container, ancher);
    } else if (shapFlag & shapFlags.STATEFUL_COMPONENT) {
      // 处理组件节点
      processComponent(n1, n2, container);
    }
  }
    1. 调用patch方法,因为是初始挂载,所以旧的vnode不存在,第一个参数直接传入null,第二个参数为当前的vnode,第三个参数container为用户传入的根元素
    1. patch内部对元素类型分类处理(这里只对元素节点和组件节点进行处理,源码中处理的类型很多,请自行查看), 取出shapFlag标识,如果是元素节点就用processElement方法处理, 组件节点用processComponent处理

    这里的类型判断以有状态组件并且子元素为数组为例,所以shapFlag就是12 二进制位0000 1100,而shapFlags.STATEFUL_COMPONENT二进制位0000 0100,所以两者相与值为0000 0100也就是4,就会执行processComponent。如果当shapFlag为1时,二进制为0000 0001与shapFlags.STATEFUL_COMPONENT相与值为0 所以就不会进入processComponent的处理逻辑

6.执行processComponent函数处理组件节点
  const processComponent = (n1, n2, container) => {
    // 根据n1是否为null 判断是挂载组件,还是更新组件
    if (n1 == null) {
      mountComponent(n2, container)
    } else {
      updateComponent(n1, n2, container);
    }
  }
  // 挂载组件
  const mountComponent = (vnode, container) => {
    // 第一步生成组件实例
    const instance = vnode.instance = createComponentIstance(vnode);
    // 第二部,设置props emits等 并 执行setup函数
    setUpComponent(instance);
    // 组件的effect函数, 形成响应式依赖, 数据改变,组件重新渲染
    setUpComponentEffect(instance, container);
  }
  // 生成组件实例
  export function createComponentIstance (vnode) {
    const { setUp } = vnode.type;
    return {
      vnode,
      setUp,
      isMounted: false,   // 标识组件是否挂载
      setUpState: null,   // setup函数的返回结果对象
      render: null,       // 组件的渲染函数
    }
  }
  export function setUpComponent (instance) {
    // 设置props
    // 设置emits
    // instance
    // 执行setup函数
    const { setUp } = instance;
    if (setUp) {
      const setUpResult = setUp();
      if (isObject(setUpResult)) {
        instance.setUpState = setUpResult;
      } else {
        instance.render = setUpResult;
      }
    }
    // 完成setUp后的操作, 需要查看vnode是否有渲染函数, 和最后如果无渲染函数
    // 需要执行模板编译过程
    finishSetUpComponent(instance);

    // 这里应该还有vue2 和vue3版本的兼容
  }
  // 存在render函数就存入instance实例中,不存在就进行模板编译
  function finishSetUpComponent (instance) {
    // 如果在vnode里写了专门的render函数, 此函数优先级高于setup中的
    // 渲染函数优先级
    const { render } = instance.vnode;
    if (render) {
      instance.render = render;
    } else if (!instance.render) {
      // 如果还是不存在render 函数,就需要进行模板编译过程
      // compiler()
    }
  }
  // 执行组件的effect副作用函数渲染组件
  const setUpComponentEffect = (instance, container) => {
    instance.update = effect(function componentEffect () {
      if (!instance.isMounted) {
        // 如果组件没有挂载, subTree 用于下次组件更新比对
        const subTree = instance.subTree = instance.render();
        // 标识组件已挂载
        patch(null, subTree, container)
        instance.isMounted = true;
      } else {
        const prev = instance.subTree;
        const next = instance.render();
        instance.subTree = next;
        patch(prev, next, container)
      }
    }, { scheduler: queueJob }) // 更新时用queueJob将界面渲染添加为异步任务
  }
    1. 当旧的虚拟dom n1不存在时执行挂载组件逻辑,n1存在执行更新组件逻辑
  • 2.mountComponent方法中先执行createComponentIstance生成组件的实例,实例中包含vnode属性、setUp函数、是否已挂载标识isMounted、setUp函数返回的结果setUpState、render函数等。
  • 3.执行setUpComponent设置组件实例props和emit等,获取用户的setUp函数区别处理。setUp的执行返回的结果是函数,那么函数就是渲染函数 存入instance.render中,如果不是函数用户返回的就是对象结果,存入instance.setUpState中,最后finishSetUpComponent函数处理render函数的优先级和模板编译流程
    1. 执行setUpComponentEffect进行组件渲染

    • 1.引入响应式effect函数,传入处理函数为参数,并将副作用函数存入instance.update。
    • 2.根据instance.isMounted判断组件是否已经挂载过,如果为false就是第一次挂载,执行render函数获取组件渲染树subTree,调用patch渲染,instance.isMounted = true 标识组件已挂载
    • 3.如果instance.isMounted == true 表示是更新组件,先获取老的渲染树prev,再执行render函数获取新的渲染树next,调用patch进行组件更新
7.执行processElement函数处理元素节点
 处理完组件节点调用patch最后会走到处理元素的逻辑
 ```javascript
 const processElement = (n1, n2, container, ancher) => {
  // 根据n1是否为null 判断是挂载元素节点,还是更新元素节点
  if (n1 == null) {
    mountElement(n2, container, ancher);
  } else {
    updateElement(n1, n2, container);
  }
}
// 创建挂载元素
const mountElement = (n2, container, ancher) => {
  const { type, children, props } = n2;
  // 创建元素, 并将元素节点映射到虚拟dom中
  const el = n2.el = hostCreateElement(type);
  // 循环处理props
  for (let key in props) {
    hostPatchProps(el, key, null, props[key])
  }
  // 将元素插入父元素中
  hostInsert(el, container, ancher);
  // 处理子元素
  if (!Array.isArray(children)) {
    // 如果children不是数组, 那就是文本, 直接设置
    hostSetElementText(el, children)
  } else {
    // children是数组, 需要循环处理
    mountChildren(children, el)
  }
}
// 挂载子元素
const mountChildren = (children, container) => {
  for (let i = 0; i < children.length; i++) {
    mountElement(children[i], container);
  }
}
  // 进行dom对比, 更新元素
const updateElement = (n1, n2, container) => {
  // 1. n1,n2 是不是同一个节点
  // 删除n1节点重置为null
  const el = n2.el = n1.el;
  if (n1 && !isSameVnode(n1, n2)) {
    hostRemove(el);
    n1 = null;
  }
  if (n1 == null) {
    patch(null, n2, container)
  } else {
    // 元素一样, 先更新属性
    patchProps(n1, n2, el);
    // 然后对比子元素
    patchChildren(n1, n2, el);
  }
}
// 判断是否是同一节点
 const isSameVnode = (n1, n2) => {
  return n1.type === n2.type && n1.key === n2.key;
}
 ```
  • 1.当旧的节点n1不存在时执行mountElement挂载元素
    • 1.获取新dom的type, children, props属性
    • 2.调用hostCreateElement创建节点
      1. 循环属性props集合调用hostPatchProps方法更新节点属性
      1. 调用hostInsert将节点插入父节点下
      1. 判断children类型,如果children不是数组,说明子元素是文本字符串,直接通过hostSetElementText创建文本即可,当children是数组调用mountChildren,对数组中的每一个元素执行mountElement进行创建挂载
  • 2.当旧的节点n1存在时执行updateElement更新元素
      1. 获取当前元素节点el
    • 2.根据标签名和key判断当前元素是不是同一个,如果不是同一个节点元素,就需要将老的节点从父节点下删除掉,并将你 设置为null
      1. 当n1为null的时, 说明新老元素不一样并且老元素已经从dom中删除掉了, 只要将新的节点创建并插入进父节点即可
      1. 当n1 存在时,复用元素节点,用patchProps更新节点的属性,调用patchChildren对比其子元素
8.patchChildren对比节点子元素
 // 对比子元素
  const patchChildren = (n1, n2, container) => {
    const c1 = n1.children;
    const c2 = n2.children;
    const prveShapFlag = n1.shapFlag;
    const nextShapFlag = n2.shapFlag;
    // 子元素的类型分为数组和字符串类型, 所以, 这有四种情况
    // 1.c2 为字符串, c1为字符串
    // 2.c2为字符串, c1 为数组
    // 3. c2 为数组, c1为字符串
    // 4. c2为数组, c1为数组

    if (nextShapFlag & shapFlags.TEXT_CHILD) {
      // 如果c2是字符串, 不管c1是字符串还是数组, 直接用 textContent设置新值即可
      // 所以不用区分情况, 只是需要判别c1和c2都为字符串时 相等就不用更改
      if (c1 !== c2) {
        hostSetElementText(container, c2);
      }
    } else if (nextShapFlag & shapFlags.ARRAY_CHILD) {

      if (prveShapFlag & shapFlags.ARRAY_CHILD) {
        // c2 是数组 c1是数组, 最复杂的dom对比
        patchKeyChildren(c1, c2, container)
      }
      if (prveShapFlag & shapFlags.TEXT_CHILD) {
        // c1 是字符串, 先将字符串删除, 再循环挂在新元素
        hostSetElementText(container, "");
        for (let i = 0; i < c2.length; i++) {
          // 将每个新子元素挂载
          patch(null, c2[i], container);
        }
      }
    }
  }
  • 1.获取到新老节点shapFlag标识和子元素集合
  • 2.分为两种情况处理子元素,一种是子元素children为字符串,另一种是子元素是一个数组集合
    • 1.当前新节点元素的children为字符串类型,即表示子节点是文本节点, 当c1 !== c2前后文本节点不相同,直接更新文本即可
    • 2.当前新节点元素的children为数组集合,需再判断老节点的子元素集合类型进行分类处理
      • 1.老节点的children集合也是数组, 前后children都是数组就要进行最为复杂的diff操作
        1. 老节点的children为字符串类型说明子元素是文本,而新元素children是数组,只需用hostSetElementText将元素中的文本清空,循环新节点children,执行patch将每一个子元素挂载即可

git地址

代码链接 miniVue3文件夹为vue3的简单实现代码