Vue3源码学习5——组件的renderer渲染器

551 阅读8分钟

继上一篇学习了renderer渲染器之后,这一篇继续来看看组件在Vue3源码中是怎么实现渲染的

引言

Vue3的组件本质,其实是一个对象

Vue3的组件有以下这些特点

  • 必须包括一个render函数,这部分决定了渲染内容
  • 定义的数据放在data里,也应该是一个函数,并且返回响应性数据
  • 其他可选的包括生命周期computed计算属性watch监听……

组件的挂载、更新和卸载

根据有无data,我们可以区分组件为有状态组件无状态组件

通过之前h函数可以知道,Vue3里的组件渲染的模板是在render函数中返回的h函数,那我们需要做的就是把render函数里的h函数获取到并渲染

当组件更新的时候,也是将h函数中对应的部分做更新即可

无状态组件

无状态组件即没有响应式数据的组件,只有组件最基本的render方法

const component = {
  render() {
    return h("div", "hello this is component");
  },
};

const vnode = h(component);

阅读源码可知,组件的挂载/更新由processComponent方法开始,同样是包括了新旧节点容器锚点这四个属性

const processComponent = (oldVNode, newVNode, container, anchor) => {
  if (oldVNode == null) {
    mountComponent(newVNode, container, anchor);
  }
};

无状态组件的挂载

挂载组件使用了mountComponent方法,阅读源码可知,里面做了3个事情

  • 创建组件实例并双向绑定(component里的instance属性,以及instance里的component属性
    • 组件实例是为了添加状态属性,在数据更新组件更新等时候可以判断
  • 设置组件数据和属性
  • 设置组件渲染的副作用(后续更新用)
const mountComponent = (initialVNode, container, anchor) => {
  initialVNode.component = createComponentInstance(initialVNode);
  const instance = initialVNode.component;

  setupComponent(instance);

  setupRenderEffect(instance, initialVNode, container, anchor);
};
组件实例的创建

createComponentInstance方法创建组件实例,给实例添加一系列属性,例如vnode节点type类型render返回值(组件实际渲染的内容)、update方法(组件更新用)等等

let uid = 0;

function createComponentInstance(vnode) {
  const type = vnode.type;

  const instance = {
    uid: uid++, // 自增,组件的唯一值
    vnode,
    type,
    subTree: null,
    effect: null,
    update: null,
    render: null,
  };

  return instance;
}
组件数据的初始化

创建完实例以后,要对组件的数据做初始化,用到了setupComponent方法

因为是无状态组件,所以没有数据,初始化的目的在于把render方法绑定到实例上面去

function setupComponent(instance) {
  const setupResult = setupStatefulComponent(instance)
  return setupResult
}

function setupStatefulComponent(instance) {
  finishComponentSetup(instance)
}

function finishComponentSetup(instance) {
  const Component = instance.type
  
  instance.render = Component.render
}
组件渲染

最后就是设置组件渲染的setupRenderEffect方法

effect还是用了响应式里的ReactiveEffect类,基本的响应式方法是componentUpdateFn,即组件更新方法,这里无论初次渲染还是后续更新都用的这个方法,另外还添加了一个调度器

const setupRenderEffect = (instance, initialVNode, container, anchor) => {
  const componentUpdateFn = () => {
    // 初次渲染
    if (!instance.isMounted) {
      const subTree = (instance.subTree = renderComponentRoot(instance));

      patch(null, subTree, container, anchor);

      initialVNode.el = subTree.el;
    } else {
      // TODO: 更新组件
    }
  };
  
  const effect = (instance.effect = new ReactiveEffect(
    componentUpdateFn,
    () => queuePreFlushCb(update)
  ));

  const update = (instance.update = () => effect.run());

  update();
};

因为组件的h函数是在render方法里的,所以在patch挂载组件前,需要拿到里面的VNode数据,这里用了一个renderComponentRoot方法

export function renderComponentRoot(instance) {
  const { vnode, render } = instance;

  let result;

  try {
    if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
      result = normalizeVNode(render!());
    }
  } catch (err) {
    console.error(err);
  }

  return result;
}

无状态组件的更新

通过阅读源码可以知道,无状态组件的更新本质是先卸载-后挂载,这些目前都已经实现

有状态组件

所谓的有状态组件就是那些带有响应式数据的组件,这些响应式数据放在data属性中,并以对象形式返回

const component = {
  data() {
    return {
      msg: "hello this is component",
    };
  },
  render() {
    return h("div", this.msg);
  },
};

const vnode = h(component);

有状态组件的挂载

对比之前无状态组件,有状态组件的挂载核心在于

  • 获取数据并设置响应式
  • this指向处理
获取数据&设置数据响应式

阅读源码可以知道,响应式数据的设置封装在applyOptions方法中,并在完成组件的setup后调用

function applyOptions(instance: any) {
  const { data: dataOptions } = instance.type;

  if (dataOptions) {
    // 因为data是通过return返回的,所以要执行方法才能拿到真正的data对象
    const data = dataOptions();
    if (isObject(data)) {
      // 把数据变成响应式的
      instance.data = reactive(data);
    }
  }
}

function finishComponentSetup(instance) {
  ......

  applyOptions(instance);
}
修改this指向

组件的响应式数据一般是使用this来获取的,这里的this指向需要绑定为data

源码中这个this绑定发生在renderComponentRoot方法中,具体的是发生在**生成组件VNode**的时候

function renderComponentRoot(instance) {
  const { vnode, render, data = {} } = instance;

  let result;

  try {
    if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
      result = normalizeVNode(render!.call(data));
    }
  } catch (err) {
    console.error(err);
  }

  return result;
}

有状态组件的更新

我们一般用改变响应式数据的值的方式对组件进行更新

const component = {
  data() {
    return {
      msg: "hello this is component",
    };
  },
  render() {
    return h("div", this.msg);
  },
  created() {
    setTimeout(() => {
      this.msg = "hello world";
    }, 2000);
  },
};

这里的更新其实本质上是监听响应式数据的变化,触发之前绑定好的effect函数,即componentUpdateFn方法

要注意把之前挂载的方法结尾,给组件实例添加一个isMounted的标记,以便后续数据变动可以进入else分支

const componentUpdateFn = () => {
  if (!instance.isMounted) {
    ......
    // 渲染完成后,更新渲染标记
    instance.isMounted = true;
  } else {
    let { next, vnode } = instance;
    // 标记下次要渲染的vnode
    if (!next) {
      next = vnode;
    }

    const nextTree = renderComponentRoot(instance);

    const prevTree = instance.subTree;
    instance.subTree = nextTree;

    patch(prevTree, nextTree, container, anchor);

    next.el = nextTree.el;
  }
};

生命周期的处理

Vue中的生命周期,本质上是在特定时间执行的回调函数

beforeCreate&created

beforeCreate实例初始化后、数据/watch等配置前被调用

created则是数据都配置完了,还没开始渲染前调用

因此,这两个生命周期在源码中都可以放在applyOptions中触发回调

function applyOptions(instance: any) {
  ......

  // beforeCreate在数据初始化之前
  if (beforeCreate) {
    callHook(beforeCreate);
  }

  if (dataOptions) {
    const data = dataOptions();
    if (isObject(data)) {
      instance.data = reactive(data);
    }
  }

  // 数据初始化完成后,created执行
  if (created) {
    callHook(created);
  }

  ......
}

function callHook(hook: Function) {
  hook();
}

beforeMount&mounted

beforeMount在组件渲染之前调用,渲染完成再调用mounted

但是,由于我们解析生命周期的函数发生在applyOptions中,此时还没开始组件渲染,所以得想办法把这些生命周期函数存起来

这里可以存储在组件实例中,等到渲染方法调用的时候再去执行回调

Vue源码中,整个过程分成两步

  • 存储
  • 调用

生命周期回调函数的存储(注册)

applyOptions中拿到生命周期函数,并用registerLifecycleHook这个注册方法,把回调方法存储在组件实例

function applyOptions(instance: any) {
  const {
    ......
    beforeMount,
    mounted,
  } = instance.type;

  ......

  function registerLifecycleHook(register: Function, hook?: Function) {
    register(hook, instance);
  }

  registerLifecycleHook(onBeforeMount, beforeMount);
  registerLifecycleHook(onMounted, mounted);
}

创建组件实例的时候,也需要对应补充一些用来存储生命周期回调方法的属性

const enum LifecycleHooks {
  BEFORE_CREATE = "bc",
  CREATED = "c",
  BEFORE_MOUNT = "bm",
  MOUNTED = "m",
}

// 创建实例的时候,添加若干用来存储生命周期回调方法的属性
export function createComponentInstance(vnode) {
  ......

  const instance = {
    ......
    // 生命周期相关
    isMounted: false,
    bc: null,
    c: null,
    bm: null,
    m: null,
  };

  return instance;
}

onBeforeMountonMounted都是Vue源码里封装好的方法,这些方法用不同的名字标记了不同生命周期,最终的目的就是把生命周期的回调方法存储在组件实例上

const onBeforeMount = createHook(LifecycleHooks.BEFORE_MOUNT);
const onMounted = createHook(LifecycleHooks.MOUNTED);

export function injectHook(type: LifecycleHooks, hook: Function, target: any) {
  if (target) {
    target[type] = hook;
    return hook;
  }
}

export const createHook = (lifecycle: LifecycleHooks) => {
  return (hook, target) => injectHook(lifecycle, hook, target);
};

生命周期调用

存储完了,就可以在对应位置触发回调函数了(这里两个生命周期都在componentUpdateFn方法中)

const componentUpdateFn = () => {
  if (!instance.isMounted) {
    // beforeMount和mounted生命周期
    const { bm, m } = instance;

    // 挂载前,触发beforeMount
    bm && bm();

    const subTree = (instance.subTree = renderComponentRoot(instance));

    patch(null, subTree, container, anchor);

    // 挂载完成后,触发mounted
    m && m();

    initialVNode.el = subTree.el;
  } else {
    // TODO: 组件更新
  }
};

获取响应式数据

在生命周期中获取响应式数据,我们会用this.XXX来访问,那么实现的方法也很明确,就是改变this指向

因为生命周期不是即刻调用,所以我们只考虑使用bind方法改变this指向

function callHook(hook: Function, proxy: any) {
  hook.bind(proxy)();
}

最后在调用生命周期函数的时候,传入data来改变this指向即可

function applyOptions(instance: any) {
  ......

  // beforeCreate在数据初始化之前
  if (beforeCreate) {
    callHook(beforeCreate, instance.data);
  }

  ......

  // 数据初始化完成后,created执行
  if (created) {
    callHook(created, instance.data);
  }

  // 注册非即刻调用的生命周期,也用bind改变this指向
  function registerLifecycleHook(register: Function, hook?: Function) {
    register(hook?.bind(instance.data), instance);
  }

  registerLifecycleHook(onBeforeMount, beforeMount);
  registerLifecycleHook(onMounted, mounted);
}

setup方法处理

Vue3新增了一个通过setup函数挂载响应式数据的方式

const component = {
  setup() {
    const obj = reactive({ msg: "hello world" });

    return () => h("div", obj.msg);
  },
};

const vnode = h(component);

观察发现,其实setup函数和普通的render方法区别只是

  • render函数setup函数返回值
  • 响应式数据分散在setup函数中,单独声明

所以处理起来,只需要对setup做个判断,拿到这个函数的返回值塞到render中即可,其他的和之前都一样

function setupStatefulComponent(instance) {
  const Component = instance.type;

  // 提供了两种api:composition和setup
  const { setup } = Component;

  if (setup) {
    // 拿到setup函数返回值作为render,多处理一层
    const setupResult = setup();

    handleSetupResult(instance, setupResult);
  } else {
    finishComponentSetup(instance);
  }
}

function handleSetupResult(instance, setupResult: any) {
  if (isFunction(setupResult)) {
    instance.render = setupResult;
  }
  finishComponentSetup(instance);
}