看mini-vue后对vue3的源码的记录复习

188 阅读8分钟

前言

看完了vue3源码,第一遍看明白了,但是又忘记了,所以看第二遍的时候,打算把一些细节记下来。本系列分成3章,第一章是vue如何初始化到渲染到页面到过程,第二章是它的diff算法,第三章是更新与nextTick。

第一章

我们从一个简单的demo开始

我们从简单的来,打开mini-vue源码,在mini-vue/packages/vue/example/helloWorld,文件主要有三个——main.js,index.html和App.js

image.png

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    
    <div id="root"></div>
    <!-- 引入下面的main.js文件 -->
    <script src="main.js" type="module"></script>
</body>
</html>

大致看了这个文件,就是把App文件export出来的函数作为参数给到createApp这个vue的api,然后再mount(挂载)到index.html的id为root的元素上。那么createApp里面做了些什么呢?我们继续分析

// main.js
import { createApp } from "../../dist/mini-vue.esm-bundler.js";
import App from "./App.js";
// 获取index.html中的根元素root
const rootContainer = document.querySelector("#root");
// createApp调用的是packages/runtime-dom/src/index.ts中createApp方法
/**
 * createApp返回的是
 * {
    render,
    createApp: createAppAPI(render),
  }
 * createAppAPI来自packages/runtime-core/src/createApp.ts的方法
 * */
createApp(App).mount(rootContainer);
// App.js
/** h函数可以理解为把vue文件的template标签转为了对应的h函数 */
import { h, ref } from "../../dist/mini-vue.esm-bundler.js";

const count = ref(0);

const HelloWorld = {
  name: "HelloWorld",
  setup() {},
  // TODO 第一个小目标
  // 可以在使用 template 只需要有一个插值表达式即
  // 可以解析 tag 标签
  // template: `
  //   <div>hi {{msg}}</div>
  //   需要编译成 render 函数
  // `,
  render() {
    return h(
      "div",
      { tId: "helloWorld" },
      `hello world: count: ${count.value}`
    );
  },
};

export default {
  name: "App",
  setup() {},

  render() {
    return h("div", { tId: 1 }, [h("p", {}, "主页"), h(HelloWorld)]);
  },
};

createApp函数

createAppAPI来自packages/runtime-dom/src/index.ts的方法,方法如下,首先执行createApp方法,然后执行ensureRenderer方法,如果本来已经把函数初始化了,那么renderer就会有值,但是这里是从零开始的,所以renderer一开始没值,只能走createRenderer逻辑

function ensureRenderer() {
  // 如果 renderer 有值的话,那么以后都不会初始化了
  /** createRenderer调用的是packages/runtime-core/src/renderer.ts的createRenderer方法 */
  // 传入createRenderer的参数都是一些操作DOM的元素,这样的意义就是,构建的renderer函数中随时就可以使用这些函数
  return (
    renderer ||
    (renderer = createRenderer({
      createElement, // 内部函数其实是document.createElement(type), type是html元素
      createText, // document.createTextNode(text);
      setText, // node.nodeValue = text; 这个是给注释节点或者文本节用的。html节点给nodeValue赋值没意义,读取时返回为null
      setElementText, // el.textContent = text; 设置节点的文本
      patchProp, // 这个函数是对新旧的DOM元素的属性和事件进行对比,然后更新或者删除属性和事件,主要函数是setAttribute和removeAttribute,addEventListener和removeEventListener
      insert, // parent.insertBefore(child, anchor); // 在父节点里面,且在anchor节点前插入child节点(anchor值为null的话,那么child就是parent的第一个子节点)
      remove, // parent.removeChild(child);
    }))
  );
}

// vue3的createApp就是从这里开始进入
export const createApp = (...args) => {
  return ensureRenderer().createApp(...args);
};

ensureRenderer()返回的是{ render, createApp: createAppAPI(render), },然后又调用createApp(其实就是createAppAPI方法)方法,那么这个createAppAPI在哪里呢?就是在packages/runtime-core/src/createApp.ts文件里,如下图:

// createVNode方法主要用于创建虚拟DOM节点,其实就是一个js对象而已
import { createVNode } from "./vnode";

export function createAppAPI(render) {
  /** rootComponent就是App组件 */
  // 直接返回一个函数,我们可以看到函数里面有个app的对象,里面有个mount函数,这个就是main.js调用的createApp(App).mount这个函数了
  return function createApp(rootComponent) {
    const app = {
      // rootComponent就是App.js导出的函数
      _component: rootComponent,
      // rootContainer是root根元素,就是index.html定义的那个根元素
      mount(rootContainer) {
        console.log("基于根组件创建 vnode");
        /** createVNode创建vnode虚拟dom对象,这里其实只是构建了根组件(App)的虚拟节点,根组件下面的子节点如果是组件节点,那么就会在render函数里面生成虚拟DOM */
        /**
         * createVNode返回的对象如下:
         * {
         *     type, // 是函数或者是html标签字符串,比如div,或者{ name: 'Home', render: fn, setup: fn, }
         *     props, // 属性,包括事件
         *     children, // 子节点对象
         *     key, // 经典的key值
         *     ...
         * }
         * */
        const vnode = createVNode(rootComponent);
        // vnode虚拟节点(js对象来的)生成好后,就开始进行渲染到页面了,调用render方法
        render(vnode, rootContainer);
      },
    };

    return app;
  };
}

createApp方法其实就是构建出一个根组件虚拟节点,然后进入到render逻辑,那么下一步,我们来看看render函数怎么实行虚拟节点和真实节点的映射

render函数

render函数就是packages/runtime-core/src/renderer.ts文件中的createRenderer函数中的render,如下图代码段,我们可以看到,render函数调用了patch方法(耳熟能详了),传入了null,vnode,container,这里可以看下下面的注释

const render = (vnode, container) => {
  console.log("调用 patch")
  // null代表没有旧节点,所以不是走更新逻辑而是走直接渲染逻辑,一般第一次初始化页面才会传null走这个逻辑,vnode就是上面所说的构建好了的App的vnode,container是index.html的id为root的DOM元素
  patch(null, vnode, container);
};

patch函数(关键)

patch函数就是用来渲染vnode节点的,根据vnode对象中的type属性,走不同的渲染逻辑,如下图:

/** n1是旧节点vdom,n2是新节点的vdom */
function patch(
  n1,
  n2,
  container = null,
  anchor = null,
  parentComponent = null
) {
  // 基于 n2 的类型来判断
  // 因为 n2 是新的 vnode
  const { type, shapeFlag } = n2;
  switch (type) {
    // 文本节点渲染
    case Text:
      processText(n1, n2, container);
      break;
    // 其中还有几个类型比如: static fragment comment
    case Fragment:
      // Fragment,vue3新增的,使用该节点可以解决vue2中一个vue文件只能有一个根元素的问题
      processFragment(n1, n2, container);
      break;
    default:
      // 这里就基于 shapeFlag 来处理
      if (shapeFlag & ShapeFlags.ELEMENT) {
        console.log("处理 element");
        /** 对原生dom进行处理 */
        processElement(n1, n2, container, anchor, parentComponent);
      } else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
        // 处理组件,组件就是h(Helloworld) Helloworld就是一个组件的构造函数
        console.log("处理 component");
        processComponent(n1, n2, container, parentComponent);
      }
  }
}

这里我们关键看处理dom元素和处理组件的逻辑,也就是default那段逻辑了,最简单的是processElement函数,也就是处理原生DOM函数,那我们就从简单的开始吧

processElement

如果n1为null,则跳到mountElement函数逻辑,因为我们这个是第一章,讲的是如何初始化和渲染DOM,不涉及更新,所以我们跳过updateElement逻辑(将在第三章讲解)

function processElement(n1, n2, container, anchor, parentComponent) {
  if (!n1) {
    mountElement(n2, container, anchor);
  } else {
    // todo
    updateElement(n1, n2, container, anchor, parentComponent);
  }
}

进入mountElement函数,如下图: 这里主要看vnode和container这两个参数就行了,做个标记:vnode就是App.js导出的函数,而container就是html文件的id为root的根元素

function mountElement(vnode, container, anchor) {
  const { shapeFlag, props } = vnode;
  // 1. 先创建 element,并赋值到vnode的el属性上
  // 基于可扩展的渲染 api
  /** hostCreateElement创建DOM元素,里面的逻辑就是document.createElement(vnode,type) */
  const el = (vnode.el = hostCreateElement(vnode.type));

  // 如果是子节点是文本节点
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 本质就是el.textContent = vnode.children;
    hostSetElementText(el, vnode.children);
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    // 举个栗子
    // render(){
    // Hello 是个 component
    //     return h("div",{},[h("p"),h(Hello)])
    // }
    // 这里 children 就是个数组了,就需要依次调用 patch 递归来处理
    // 注意,这里的el不是html的根元素了,而是上面创建出来的元素,也不是虚拟节点,是真实DOM元素
    /** el相当于是父元素(vnode对应的真实节点),用来给vnode.children进行挂载的 */
    mountChildren(vnode.children, el);
  }

  // 处理 props
  if (props) {
    for (const key in props) {
      // todo
      // 需要过滤掉vue自身用的key
      // 比如生命周期相关的 key: beforeMount、mounted
      const nextVal = props[key];
      // 更新dom元素属性和事件
      hostPatchProp(el, key, null, nextVal);
    }
  }

  // todo
  // 触发 beforeMount() 钩子
  // 子beforeMount -> 父beforeMount
  console.log("vnodeHook  -> onVnodeBeforeMount");
  console.log("DirectiveHook  -> beforeMount");
  console.log("transition  -> beforeEnter");

  // 插入
  // 本质调用了container.insertBefor(el, anchor)
  hostInsert(el, container, anchor);

  // todo
  // 触发 mounted() 钩子
  // 子mount -> 父mount
  console.log("vnodeHook  -> onVnodeMounted");
  console.log("DirectiveHook  -> mounted");
  console.log("transition  -> enter");
}

这里有个地方要注意: 1、首先在执行这段代码,是走beforeCreate和created方法(vue3的options api写法还是存在这两个钩子函数的),然后再递归执行vnode.children(mountChildren的逻辑),然后children里面又会执行beforeCreate和created方法,然后再执行beforeMount和mounted方法,再执行父元素的beforeMount和mounted方法,这个是面试经常问的,源码中就是这样体现了

我们可以看到,对于原生DOM元素,通过processElement函数,里面执行const el = document.createElement(type) -> container.insertBefore(el, anchor)就渲染到页面上了,所以源码其实也就那样子,比较简单,我们接下来看看processComponent函数逻辑,看看对于组件是怎么渲染的

processComponent

我们会到patch函数,找到下图的processComponent的逻辑:

image.png 然后点击进去看看processComponent代码,如下图,其实和processElement差不多,我们重点看下mountComponent函数

function processComponent(n1, n2, container, parentComponent) {
  // 如果 n1 没有值的话,那么就是 mount
  if (!n1) {
    // 初始化 component
    mountComponent(n2, container, parentComponent);
  } else {
    updateComponent(n1, n2, container);
  }
}

mountComponent

该方法创建了一个instance的组件实例(其实这个instance就是我们在vue文件中经常使用到的this,不过它将会被proxy代理) 使用createComponentInstance创建了一个对象,返回的结构下图注释代码上有写的了

function mountComponent(initialVNode, container, parentComponent) {
  /** initialVNode为 { type, setup, render } */
  // 1. 先创建一个 component instance
  /**
   * 返回的对象
   * instance = {
   *     type: '',
   *     vnode: vnode,
   *     next,
   *     props,
   *     ctx: { _: instance },
   *     setupState, // 存储setup的返回值
   * }
   * */

  /** initialVNode = vnode */
  const instance = (initialVNode.component = createComponentInstance(
    initialVNode,
    parentComponent
  ));
  console.log(`创建组件实例:${instance.type.name}`);
  // 2. 给 instance 加工加工
  /** 用来初始化props和slot */
  /** 是的instance挂上了setupState和render函数
   * instance: { ...instance, setupState, render, ctx: { _: instance, }, }
   * */
  setupComponent(instance);

  /** 配置组件渲染逻辑执行componentUpdateFn函数 */
  setupRenderEffect(instance, initialVNode, container);
}

创建完instance对象后,会继续执行setupComponent函数,再对instance对象增加一些属性,比如props和slot(插槽),setupComponent方法是在packages/runtime-core/src/component.ts文件中

export function setupComponent(instance) {
  // 1. 处理 props
  // 取出存在 vnode 里面的 props
  const { props, children } = instance.vnode;
  // 初始化props
  initProps(instance, props);
  // 2. 处理 slots
  initSlots(instance, children);

  // 源码里面有两种类型的 component
  // 一种是基于 options 创建的
  // 还有一种是 function 的
  // 这里处理的是 options 创建的
  // 叫做 stateful 类型
  /** 这里开始创建代理Proxy */
  setupStatefulComponent(instance);
}

然后执行setupStatefulComponent函数,里面就会执行组件的setup函数,并进行一系列proxy化:

function setupStatefulComponent(instance) {
  // todo
  // 1. 先创建代理 proxy
  console.log("创建 proxy");

  // proxy 对象其实是代理了 instance.ctx 对象
  // 我们在使用的时候需要使用 instance.proxy 对象
  // 因为 instance.ctx 在 prod 和 dev 坏境下是不同的
  // instance.ctx = { _: instance },这里其实就是代理了instance对象,我也不知道为什么写的那么复杂,还要instance.ctx._这样子
  instance.proxy = new Proxy(instance.ctx, PublicInstanceProxyHandlers);
  // 用户声明的对象就是 instance.type
  // const Component = {setup(),render()} ....
  const Component = instance.type;
  // 2. 调用 setup

  // 调用 setup 的时候传入 props
  const { setup } = Component;
  if (setup) {
    // 设置当前 currentInstance 的值
    // 必须要在调用 setup 之前
    setCurrentInstance(instance);

    /** 初始化setupContext
     *  setupContext = {
          attrs: instance.attrs,
          slots: instance.slots,
          emit: instance.emit,
          expose: () => {},
        }
     */
    const setupContext = createSetupContext(instance);
    // 真实的处理场景里面应该是只在 dev 环境才会把 props 设置为只读的
    /** props属性都不能被子组件设置值,所以在子组件的props属性的值,都是不会进行收集依赖的 */
    /** 到这里还没开始收集依赖 */
    /**
     * 返回的setupResult,比如:
     * {
     *     msg: { _value, _raw_value, deps: [] },  // RefImpl对象,已经变成了代理对象了
     *     change: Fn,
     * }
     * */
    const setupResult =
      setup && setup(shallowReadonly(instance.props), setupContext);

    setCurrentInstance(null);

    // 3. 处理 setupResult
    // 得到的setupResult将会赋值给instance.setupState
    handleSetupResult(instance, setupResult);
  } else {
    finishComponentSetup(instance);
  }
}

经过上面的一系列操作后,到了handleSetupResult函数,该函数主要是用来处理setup函数返回的对象,一个常用的术语就是解构它里面的值,这里我稍微举个例子,再开始看handleSetupResult的源码:

setup () {
    // msg经过ref后,变成了一个{ _value, _raw_value, deps: [] }对象
    const msg = ref(123); // 这里获取到msg其实是一个RefImpl对象,里面包含了_value的属性值,并且把123赋值给_value属性值
    return { msg };
}

如上面所述,如果我们要使用this.msg获取到值123的话,那该怎么办呢?于是handleSetupResult帮我们做了这件事,源码如下,当看到setup函数返回的是object类型时,就会instance.setupState = proxyRef(setupResults),而proxyRef做的事情就是解构出_value值 这里要注意的是:在setup函数中使用msg只能获取到ref对象,所以需要使用msg.value去获取值,但是经过下面的setup函数执行完,然后执行handleSetupResult函数后,这个msg就被代理解构了,所以在template标签里面可以直接使用{{ msg }}而不是{{ msg.value }}

function handleSetupResult(instance, setupResult) {
  // setup 返回值不一样的话,会有不同的处理
  // 1. 看看 setupResult 是个什么
  if (typeof setupResult === "function") {
    // 如果返回的是 function 的话,那么绑定到 render 上
    // 认为是 render 逻辑
    // setup(){ return ()=>(h("div")) }
    instance.render = setupResult;
  } else if (typeof setupResult === "object") {
    // 返回的是一个对象的话
    // 先存到 setupState 上
    // 先使用 @vue/reactivity 里面的 proxyRefs
    // 后面我们自己构建
    // proxyRefs 的作用就是把 setupResult 对象做一层代理
    // 方便用户直接访问 ref 类型的值
    // 比如 setupResult 里面有个 count 是个 ref 类型的对象,用户使用的时候就可以直接使用 count 了,而不需要在 count.value
    // 这里也就是官网里面说到的自动结构 Ref 类型
    /**
     * setupResult = {
     *     msg: RefImpl代理对象,
     *     change: Fn
     * }
     * */
    instance.setupState = proxyRefs(setupResult);
  }

  finishComponentSetup(instance);
}

解构的关键代码如下:

proxyRefs = new Proxy(target, {
    get(target, key, receiver) {
        // 如果里面是一个 ref 类型的话,那么就返回 .value
        // 如果不是的话,那么直接返回value 就可以了
        return unRef(Reflect.get(target, key, receiver));
    },
    set(target, key, value, receiver) {
        const oldValue = target[key];
        if (isRef(oldValue) && !isRef(value)) {
            return (target[key].value = value);
        } else {
          return Reflect.set(target, key, value, receiver);
        }
    },
})
// 把 ref 里面的值拿到
export function unRef(ref) {
  return isRef(ref) ? ref.value : ref;
}

export function isRef(value) {
  return !!value.__v_isRef;
}

解构完成后,就执行finishComponentSetup,把render函数赋值给instance

function finishComponentSetup(instance) {
  // 给 instance 设置 render
  // 先取到用户设置的 component options
  const Component = instance.type;
  ...
  if (!instance.render) {
    // 相当于把render函数给了instance
    instance.render = Component.render;
  }
}

于是整个代理过程就结束了,总结一下,就是创建了一个instance实例,然后对这个实例进行代理(这个代理就是我们经常使用的this),并且处理setup函数的返回值,对setup函数里面的值进行解构,然后把组件的render函数赋值给instance.render

最后进行组件渲染,就是执行所谓副作用(effect),把组件渲染成真实DOM,我们回到packages/runtime-core/src/renderer.ts文件的mountComponent函数当中,找到setupRenderEffect函数,我们开始研究这个函数

setupRenderEffect

该函数的作用渲染组件的DOM元素了

function setupRenderEffect(instance, initialVNode, container) {
  function componentUpdateFn() {
    // instance.isMounted肯定一开始是false的,所以直接进入该段逻辑
    if (!instance.isMounted) {
      /** instance.proxy就是instance的代理对象,然后传给render函数中,render函数的this值就是这个instance.proxy对象 */
      const proxyToUse = instance.proxy;
      // 可在 render 函数中通过 this 来使用 proxy
      /** instance.render.call执行的其实就是组件的render方法:h("div", { tId: 1 }, [h("p", {}, "主页"), h(HelloWorld)])函
       * 最终得到的是subTree是:
       * {
       *     type: 'div',
       *     props: {
       *         tId: 1,
       *     },
       *     children: [
       *         { type: 'p', props: null, children: '主页' },
       *         { type: { name: 'Helloworld', render, setup }, }
       *     ]
       * }
       * */
      /** render函数里面凡是用到this地方,都是指向proxyUse
       * 所以当render函数中使用this.msg,或者this.change等函数,都会进入proxy里面的代理函数
       * */
           // 这个subTree就是通过组件的render方法,构建出来的vnode对象({ type, props, children, })
      const subTree = (instance.subTree = normalizeVNode(
        instance.render.call(proxyToUse, proxyToUse)
      ));
      console.log("subTree", subTree);

      // todo
      console.log(`${instance.type.name}:触发 beforeMount hook`);
      console.log(`${instance.type.name}:触发 onVnodeBeforeMount hook`);

      // 得到了组件的vnode后,进入patch逻辑渲染成真实DOM
      patch(null, subTree, container, null, instance);
      // 把 root element 赋值给 组件的vnode.el ,为后续调用 $el 的时候获取值
      initialVNode.el = subTree.el;

      console.log(`${instance.type.name}:触发 mounted hook`);
      instance.isMounted = true;
    }

  // 在 vue3.2 版本里面是使用的 new ReactiveEffect
  // 至于为什么不直接用 effect ,是因为需要一个 scope  参数来收集所有的 effect
  // 而 effect 这个函数是对外的 api ,是不可以轻易改变参数的,所以会使用  new ReactiveEffect
  // 因为 ReactiveEffect 是内部对象,加一个参数是无所谓的
  // 后面如果要实现 scope 的逻辑的时候 需要改过来
  // 现在就先算了
  /** 相当于创建effect对象 */
  /** effect(xx)已经开始执行componentUpdateFn函数了,ReactiveEffect对象调用的run方法触发componentUpdateFn方法 */
  /** effect返回一个runner函数,runner.effect = 新建的ReactiveEffect对象 */

  /** 在这里执行了effect函数,其内部就开始执行以下逻辑
   * shouldTrack = true, activeEffect = componentUpdateFn;
   * 然后就会执行componentUpdateFn函数,而这个函数又会执行render函数
   * 执行render函数的时候就会触发读取this.msg
   * 于是把这个componentUpdateFn这个依赖收集到了msg的dep当中
   * */
  instance.update = effect(componentUpdateFn, {
    scheduler: () => {
      // 把 effect 推到微任务的时候在执行
      // queueJob(effect);
      queueJob(instance.update);
    },
  });
}

我们可以看到上面的instance.update = effect(componentUpdateFn)方法,其实effect(() => {})这种形式,多数是用来收集依赖了,那究竟怎么收集依赖呢,我看了下源码,感觉如果讲给别人听可能不好理解,于是我写了自己的一段伪代码,如下图:

let activeEffect = null;
let shouldTrack = false;
function effect (fn) {
    if (!activeEffect) return; // 这里是为了防止多次console.log某个值导致触发get代理,从而重复多次收集
    shouldTrack = true;
    activeEffect = fn;
    fn();
    shouldTrack = false;
    activeEffect = null;
}

当shouldTrack为true且activeEffect有值时,就会触发setup返回的每个属性的get,set代理,于是他们就会在get代理中收集这个activeEffect,然后等到改变它的值的时候,触发set代理,执行这个effect函数

未完待续...