Skip to content

12. 组件的实现原理

12.1  渲染组件

从用户的角度来看,一个有状态组件就是一个选项对象,但是,如果从渲染器的内部实现来看,一个组件则是一个特殊类型的虚拟 DOM 节点。实际上,组件本身是对页面内容的封装,它用来描述页面内容的一部分

js
const MyComponent = {
  // 组件名称,可选
  name: "MyComponent",
  // 组件的渲染函数,其返回值必须为虚拟 DOM
  render() {
    // 返回虚拟 DOM
    return {
      type: "div",
      children: `我是文本内容`,
    };
  },
};
// 用来描述组件的 VNode 对象,type 属性值为组件的选项对象
const CompVNode = {
  type: MyComponent,
};
// 调用渲染器来渲染组件
renderer.render(CompVNode, document.querySelector("#app"));

12.2  组件状态与自更新

js
const MyComponent = {
  name: "MyComponent",
  // 用 data 函数来定义组件自身的状态
  data() {
    return {
      foo: "hello world",
    };
  },
  render() {
    return {
      type: "div",
      children: `foo 的值是: ${this.foo}`, // 在渲染函数内使用组件状态
    };
  },
};
function mountComponent(vnode, container, anchor) {
  const componentOptions = vnode.type;
  const { render, data } = componentOptions;

  // 调用 data 函数得到原始数据,并调用 reactive 函数将其包装为响应式数据
  const state = reactive(data());
  // 将组件的 render 函数调用包装到 effect 内
  effect(
    () => {
      // 调用 render 函数时,将其 this 设置为 state,
      // 从而 render 函数内部可以通过 this 访问组件自身状态数据
      const subTree = render.call(state, state);
      patch(null, subTree, container, anchor);
    },
    {
      // 指定该副作用函数的调度器为 queueJob 即可
      scheduler: queueJob,
    }
  );
}

// 任务缓存队列,用一个 Set 数据结构来表示,这样就可以自动对任务进行去重
const queue = new Set();
// 一个标志,代表是否正在刷新任务队列
let isFlushing = false;
// 创建一个立即 resolve 的 Promise 实例
const p = Promise.resolve();

// 调度器的主要函数,用来将一个任务添加到缓冲队列中,并开始刷新队列
function queueJob(job) {
  // 将 job 添加到任务队列 queue 中
  queue.add(job);
  // 如果还没有开始刷新队列,则刷新之
  if (!isFlushing) {
    // 将该标志设置为 true 以避免重复刷新
    isFlushing = true;
    // 在微任务中刷新缓冲队列
    p.then(() => {
      try {
        // 执行任务队列中的任务
        queue.forEach((job) => job());
      } finally {
        // 重置状态
        isFlushing = false;
        queue.clear = 0;
      }
    });
  }
}

12.3  组件实例与组件的生命周期

组件实例本质上就是一个状态集合(或一个对象)​,它维护着组件运行过程中的所有信息,例如注册到组件的生命周期函数、组件渲染的子树(subTree)​、组件是否已经被挂载、组件自身的状态(data)​,等等。

实际上,我们可以在需要的时候,任意地在组件实例 instance 上添加需要的属性。但需要注意的是,我们应该尽可能保持组件实例轻量,以减少内存占用。

js
function mountComponent(vnode, container, anchor) {
  const componentOptions = vnode.type;
  // 从组件选项对象中取得组件的生命周期函数
  const {
    render,
    data,
    beforeCreate,
    created,
    beforeMount,
    mounted,
    beforeUpdate,
    updated,
  } = componentOptions;

  // 在这里调用 beforeCreate 钩子
  beforeCreate && beforeCreate();

  const state = reactive(data());

  const instance = {
    // 组件自身的状态数据,即 data
    state,
    // 一个布尔值,用来表示组件是否已经被挂载,初始值为 false
    isMounted: false,
    // 组件所渲染的内容,即子树(subTree)
    subTree: null,
  };
  // 将组件实例设置到 vnode 上,用于后续更新
  vnode.component = instance;

  // 在这里调用 created 钩子
  created && created.call(state);

  effect(
    () => {
      // 调用组件的渲染函数,获得子树
      const subTree = render.call(state, state);
      // 检查组件是否已经被挂载
      if (!instance.isMounted) {
        // 在这里调用 beforeMount 钩子
        beforeMount && beforeMount.call(state);
        // 初次挂载,调用 patch 函数第一个参数传递 null
        patch(null, subTree, container, anchor);
        // 重点:将组件实例的 isMounted 设置为 true,这样当更新发生时就不会再次进行挂载操作,
        // 而是会执行更新
        instance.isMounted = true;
        // 在这里调用 mounted 钩子
        mounted && mounted.call(state);
      } else {
        // 在这里调用 beforeUpdate 钩子
        beforeUpdate && beforeUpdate.call(state);
        // 当 isMounted 为 true 时,说明组件已经被挂载,只需要完成自更新即可,
        // 所以在调用 patch 函数时,第一个参数为组件上一次渲染的子树,
        // 意思是,使用新的子树与上一次渲染的子树进行打补丁操作
        patch(instance.subTree, subTree, container, anchor);

        // 在这里调用 updated 钩子
        updated && updated.call(state);
      }
      // 更新组件实例的子树
      instance.subTree = subTree;
    },
    { scheduler: queueJob }
  );
}

12.4   props 与组件的被动更新

对于一个组件来说,只需要关心为组件传递的 props 数据组件选项对象中定义的 props 选项这两部分

html
<MyComponent title="A Big Title" :other="val" />
js
const vnode = {
  type: MyComponent,
  props: {
    title: "A big Title",
    other: this.val,
  },
};
const MyComponent = {
  name: "MyComponent",
  // 组件接收名为 title 的 props,并且该 props 的类型为 String
  props: {
    title: String,
  },
  render() {
    return {
      type: "div",
      children: `count is: ${this.title}`, // 访问 props 数据
    };
  },
};
js
function mountComponent(vnode, container, anchor) {
  const componentOptions = vnode.type;
  // 从组件选项对象中取出 props 定义,即 propsOption
  const { render, data, props: propsOption /* 其他省略 */ } = componentOptions;

  beforeCreate && beforeCreate();

  const state = reactive(data());
  // 调用 resolveProps 函数解析出最终的 props 数据与 attrs 数据
  const [props, attrs] = resolveProps(propsOption, vnode.props);

  const instance = {
    state,
    // 将解析出的 props 数据包装为 shallowReactive 并定义到组件实例上
    props: shallowReactive(props),
    isMounted: false,
    subTree: null,
  };
  vnode.component = instance;

  // 省略部分代码
}

// resolveProps 函数用于解析组件 props 和 attrs 数据
function resolveProps(options, propsData) {
  const props = {};
  const attrs = {};
  // 遍历为组件传递的 props 数据
  for (const key in propsData) {
    if (key in options) {
      // 如果为组件传递的 props 数据在组件自身的 props 选项中有定义,则将其视为合法的 props
      props[key] = propsData[key];
    } else {
      // 否则将其作为 attrs
      attrs[key] = propsData[key];
    }
  }

  // 最后返回 props 与 attrs 数据
  return [props, attrs];
}

props 本质上是父组件的数据,当 props 发生变化时,会触发父组件重新渲染,在更新过程中,渲染器发现父组件的 subTree 包含组件类型的虚拟节点,所以会调用 patchComponent 函数完成子组件的更新

由父组件自更新引起的子组件更新叫作子组件的被动更新。当子组件发生被动更新时,需要:

  1. 检测子组件是否真的需要更新,因为子组件的 props 可能是不变的;
  2. 如果需要更新,则更新子组件的 propsslots 等内容。
js
function patch(n1, n2, container, anchor) {
  if (n1 && n1.type !== n2.type) {
    unmount(n1);
    n1 = null;
  }

  const { type } = n2;

  if (typeof type === "string") {
    // 省略部分代码
  } else if (type === Text) {
    // 省略部分代码
  } else if (type === Fragment) {
    // 省略部分代码
  } else if (typeof type === "object") {
    // vnode.type 的值是选项对象,作为组件来处理
    if (!n1) {
      mountComponent(n2, container, anchor);
    } else {
      // 更新组件
      patchComponent(n1, n2, anchor);
    }
  }
}

function patchComponent(n1, n2, anchor) {
  // 获取组件实例,即 n1.component,同时让新的组件虚拟节点 n2.component 也指向组件实例
  const instance = (n2.component = n1.component);
  // 获取当前的 props 数据
  const { props } = instance;
  // 调用 hasPropsChanged 检测为子组件传递的 props 是否发生变化,如果没有变化,则不需要更新
  if (hasPropsChanged(n1.props, n2.props)) {
    // 调用 resolveProps 函数重新获取 props 数据
    const [nextProps] = resolveProps(n2.type.props, n2.props);
    // 更新 props
    for (const k in nextProps) {
      props[k] = nextProps[k];
    }
    // 删除不存在的 props
    for (const k in props) {
      if (!(k in nextProps)) delete props[k];
    }
  }
}

function hasPropsChanged(prevProps, nextProps) {
  const nextKeys = Object.keys(nextProps);
  // 如果新旧 props 的数量变了,则说明有变化
  if (nextKeys.length !== Object.keys(prevProps).length) {
    return true;
  }

  for (let i = 0; i < nextKeys.length; i++) {
    const key = nextKeys[i];
    // 有不相等的 props,则说明有变化
    if (nextProps[key] !== prevProps[key]) return true;
  }
  return false;
}

12.5   setup 函数的作用与实现

在组件的整个生命周期中,setup 函数只会在被挂载时执行一次,它的返回值可以有两种情况

  1. 返回一个函数,该函数将作为组件的 render 函数
js
const Comp = {
  setup() {
    // setup 函数可以返回一个函数,该函数将作为组件的渲染函数
    return () => {
      return { type: "div", children: "hello" };
    };
  },
};
  1. 返回一个对象,该对象中包含的数据将暴露给模板使用
js
const Comp = {
  setup() {
    const count = ref(0);
    // 返回一个对象,对象中的数据会暴露到渲染函数中
    return {
      count,
    };
  },
  render() {
    // 通过 this 可以访问 setup 暴露出来的响应式数据
    return { type: "div", children: `count is: ${this.count}` };
  },
};
js
function mountComponent(vnode, container, anchor) {
  const componentOptions = vnode.type;
  // 从组件选项中取出 setup 函数
  let { render, data, setup /* 省略其他选项 */ } = componentOptions;

  beforeCreate && beforeCreate();

  const state = data ? reactive(data()) : null;
  const [props, attrs] = resolveProps(propsOption, vnode.props);

  const instance = {
    state,
    props: shallowReactive(props),
    isMounted: false,
    subTree: null,
  };

  // 暂时只要 attrs
  const setupContext = { attrs };
  // 调用 setup 函数,将只读版本的 props 作为第一个参数传递,避免用户意外地修改 props 的值,
  // 将 setupContext 作为第二个参数传递
  const setupResult = setup(shallowReadonly(instance.props), setupContext);
  // setupState 用来存储由 setup 返回的数据
  let setupState = null;
  // 如果 setup 函数的返回值是函数,则将其作为渲染函数
  if (typeof setupResult === "function") {
    // 报告冲突
    if (render) console.error("setup 函数返回渲染函数,render 选项将被忽略");
    // 将 setupResult 作为渲染函数
    render = setupResult;
  } else {
    // 如果 setup 的返回值不是函数,则作为数据状态赋值给 setupState
    setupState = setupResult;
  }

  vnode.component = instance;
  // 创建渲染上下文对象,本质上是组件实例的代理
  const renderContext = new Proxy(instance, {
    get(t, k, r) {
      // 取得组件自身状态与 props 数据
      const { state, props } = t;
      if (state && k in state) {
        return state[k];
      } else if (k in props) {
        return props[k];
      } else if (setupState && k in setupState) {
        // 渲染上下文需要增加对 setupState 的支持
        return setupState[k];
      } else {
        console.error("不存在");
      }
    },
    set(t, k, v, r) {
      const { state, props } = t;
      if (state && k in state) {
        state[k] = v;
      } else if (k in props) {
        console.warn(`Attempting to mutate prop "${k}". Props are readonly.`);
      } else if (setupState && k in setupState) {
        // 渲染上下文需要增加对 setupState 的支持
        setupState[k] = v;
      } else {
        console.error("不存在");
      }
    },
  });

  // 省略部分代码
}

12.6   组件事件与 emit 的实现

html
<MyComponent @change="handler" />
js
const CompVNode = {
  type: MyComponent,
  props: {
    onChange: handler,
  },
};

在具体的实现上,发射自定义事件的本质就是根据事件名称去 props 数据对象中寻找对应的事件处理函数并执行

js
function mountComponent(vnode, container, anchor) {
  // 省略部分代码

  const instance = {
    state,
    props: shallowReactive(props),
    isMounted: false,
    subTree: null,
  };

  // 定义 emit 函数,它接收两个参数
  // event: 事件名称
  // payload: 传递给事件处理函数的参数
  function emit(event, ...payload) {
    // 根据约定对事件名称进行处理,例如 change --> onChange
    const eventName = `on${event[0].toUpperCase() + event.slice(1)}`;
    // 根据处理后的事件名称去 props 中寻找对应的事件处理函数
    const handler = instance.props[eventName];
    if (handler) {
      // 调用事件处理函数并传递参数
      handler(...payload);
    } else {
      console.error("事件不存在");
    }
  }

  // 将 emit 函数添加到 setupContext 中,用户可以通过 setupContext 取得 emit 函数
  const setupContext = { attrs, emit };

  // 省略部分代码
}

由于没有显式地声明为 props 的属性都会存储到 attrs 中。所以在解析 props 数据的时候对事件类型的 props 需要做特殊处理

js
function resolveProps(options, propsData) {
  const props = {};
  const attrs = {};
  for (const key in propsData) {
    // 以字符串 on 开头的 props,无论是否显式地声明,都将其添加到 props 数据中,而不是添加到 attrs 中
    if (key in options || key.startsWith("on")) {
      props[key] = propsData[key];
    } else {
      attrs[key] = propsData[key];
    }
  }

  return [props, attrs];
}

12.7   插槽的工作原理与实现

组件的插槽指组件会预留一个槽位,该槽位具体要渲染的内容由用户插入

MyComponent 组件

html
<template>
  <header><slot name="header" /></header>
  <div>
    <slot name="body" />
  </div>
  <footer><slot name="footer" /></footer>
</template>
js
// MyComponent 组件模板的编译结果
function render() {
  return [
    {
      type: "header",
      children: [this.$slots.header()],
    },
    {
      type: "body",
      children: [this.$slots.body()],
    },
    {
      type: "footer",
      children: [this.$slots.footer()],
    },
  ];
}

父组件模板

html
<MyComponent>
  <template #header>
    <h1>我是标题</h1>
  </template>
  <template #body>
    <section>我是内容</section>
  </template>
  <template #footer>
    <p>我是注脚</p>
  </template>
</MyComponent>
js
// 父组件的渲染函数
function render() {
  return {
    type: MyComponent,
    // 组件的 children 会被编译成一个对象
    children: {
      header() {
        return { type: "h1", children: "我是标题" };
      },
      body() {
        return { type: "section", children: "我是内容" };
      },
      footer() {
        return { type: "p", children: "我是注脚" };
      },
    },
  };
}

在运行时的实现上,插槽则依赖于 setupContext 中的 slots 对象。但为了在 render 函数内和生命周期钩子函数内能够通过 this.$slots 来访问插槽内容,还需要在 renderContext 中特殊对待 $slots 属性

js
function mountComponent(vnode, container, anchor) {
  // 省略部分代码

  // 直接使用编译好的 vnode.children 对象作为 slots 对象即可
  const slots = vnode.children || {};
  // 将 slots 对象添加到 setupContext 中
  const setupContext = { attrs, emit, slots };

  const instance = {
    state,
    props: shallowReactive(props),
    isMounted: false,
    subTree: null,
    // 将插槽添加到组件实例上
    slots,
  };
  // 省略部分代码
  const renderContext = new Proxy(instance, {
    get(t, k, r) {
      const { state, props, slots } = t;
      // 当 k 的值为 $slots 时,直接返回组件实例上的 slots
      if (k === "$slots") return slots;
      // 省略部分代码
    },
    set(t, k, v, r) {
      // 省略部分代码
    },
  });
  // 省略部分代码
}

12.8   注册生命周期

每当初始化组件并执行组件的 setup 函数之前,先将 currentInstance 设置为当前组件实例,再执行组件的 setup 函数,这样就可以通过 currentInstance 来获取当前正在被初始化的组件实例,从而将那些通过 onMounted 函数注册的钩子函数与组件实例进行关联

js
// 全局变量,存储当前正在被初始化的组件实例
let currentInstance = null;
// 该方法接收组件实例作为参数,并将该实例设置为 currentInstance
function setCurrentInstance(instance) {
  currentInstance = instance;
}
js
function mountComponent(vnode, container, anchor) {
  // 省略部分代码

  const instance = {
    state,
    props: shallowReactive(props),
    isMounted: false,
    subTree: null,
    slots,
    // 在组件实例中添加 mounted 数组,用来存储通过 onMounted 函数注册的生命周期钩子函数
    mounted: [],
  };

  // 省略部分代码

  // setup
  const setupContext = { attrs, emit, slots };

  // 在调用 setup 函数之前,设置当前组件实例
  setCurrentInstance(instance);
  // 执行 setup 函数
  const setupResult = setup(shallowReadonly(instance.props), setupContext);
  // 在 setup 函数执行完毕之后,重置当前组件实例
  setCurrentInstance(null);

  // 省略部分代码
}

function onMounted(fn) {
  if (currentInstance) {
    // 将生命周期函数添加到 instance.mounted 数组中
    currentInstance.mounted.push(fn);
  } else {
    console.error("onMounted 函数只能在 setup 中调用");
  }
}

最后在合适的时机调用这些注册到 instance.mounted 数组中的生命周期钩子函数

js
function mountComponent(vnode, container, anchor) {
  // 省略部分代码

  effect(
    () => {
      const subTree = render.call(renderContext, renderContext);
      if (!instance.isMounted) {
        // 省略部分代码

        // 遍历 instance.mounted 数组并逐个执行即可
        instance.mounted &&
          instance.mounted.forEach((hook) => hook.call(renderContext));
      } else {
        // 省略部分代码
      }
      instance.subTree = subTree;
    },
    {
      scheduler: queueJob,
    }
  );
}

上次更新于: