Skip to content

第十二章:组件的实现原理

渲染组件

组件就是特殊类型的虚拟 DOM,其 vnode 的 type 为对象。

javascript
function patch(n1, n2, container) {
  if (n1 && n1.type !== n2.type) {
    unmount(n1);
    n1 = null;
  }

  const { type } = n2;
  if (type === 'string') {
    /**处理普通元素 */
  } else if (type === Text) {
    /**处理文本节点 */
  } else if (type === Fragment) {
    /**处理片段 */
  } else if (type === 'object') {
    // 作为组件去处理
    if (!n1) {
      // 挂载组件
      mountComponent(n2, container, anchor);
    } else {
      // 更新组件
      patchComponent(n1, n2, anchor);
    }
  }
}

function mountComponent(vnode, container, anchor) {
  const options = vnode.type;
  const { render } = options;
  const subTree = render();
  patch(null, subTree, container, anchor);
}

TIP

每个组件都是对自身页面内容的封装,用来描述页面内容的一部分。因此一个组件必须包含一个 render 函数,并且 render 函数的返回值必须是一个 vnode。

组件状态与自更新

javascript
function mountComponent(vnode, container, anchor) {
  const queue = new Set();
  const p = Promise.resolve();
  let isFlushing = false;

  function queueJob(job) {
    queue.add(job);
    if (!isFlushing) {
      isFlushing = true;
      p.then(() => {
        try {
          queue.forEach(job => job());
        } finally {
          isFlushing = false;
          queue.clear();
        }
      });
    }
  }

  const options = vnode.type;
  const { render, data } = options;
  const state = reactive(data()); // 将数据响应式化

  // 将 render 函数添加到副作用函数中,响应式数据变化后会触发组件更新
  effect(
    () => {
      const subTree = render.call(state, state); // 改变 this,使能够通过 this.xx 访问 data 的数据
      patch(null, subTree, container, anchor);
    },
    {
      // 每一个响应式数据变化都会触发组件更新,因此需要自定义调度器,使全部数据变化后再执行副作用函数。
      scheduler: queueJob
    }
  );
}

组件实例与生命周期

上面的例子每次都是 patch(null, vnode),即每次都是重新构建,这是不合理的,因此要记录上一次的构建结果进行 patch

javascript
function mountComponent(vnode, container, anchor) {
  const options = vnode.type;
  const {
    render,
    data,
    beforeCreate,
    created,
    beforeMount,
    mounted,
    beforeUpdate,
    updated
  } = options;

  beforeCreate && beforeCreate();
  const state = reactive(data()); // 将数据响应式化
  const instance = {
    state,
    isMounted: false,
    subTree: null
  };
  vnode.component = instance;
  created && created.call(state);

  // 将 render 函数添加到副作用函数中,响应式数据变化后会触发组件更新
  effect(
    () => {
      const subTree = render.call(state, state);
      // 第一次渲染
      if (!instance.isMounted) {
        beforeMount && beforeMount.call(state);
        patch(null, subTree, container, anchor);
        instance.isMounted = true;
        mounted && mounted.call(state);
      } else {
        beforeUpdate && beforeUpdate.call(state);
        // 如果不是第一次就执行 patch,传入上一次的 subTree
        patch(instance.subTree, subTree, container, anchor);
        updated && updated.call(state);
      }
      instance.subTree = subTree;
    },
    {
      // 每一个响应式数据变化都会触发组件更新,因此需要自定义调度器,使全部数据变化后再执行副作用函数。
      scheduler: queueJob
    }
  );
}

Props 的传递

组件的 props 需要在组件内显示定义,否则将会认为是 attrs

javascript
function mountComponent(vnode, container, anchor) {
  const options = vnode.type;
  const {
    render,
    data,
    props: propsOption
    /**... */
  } = options;

  const [props, attrs] = resolveProps(propsOption, vnode.props);

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

  // 传递的 props 如果在组件内声明了,就认为是 props,否则认为是 attrs
  function resolveProps(options, propData) {
    const props = {};
    const attrs = {};

    for (const key in propData) {
      if (key in options) {
        props[key] = propData[key];
      } else {
        attrs[key] = propData[key];
      }
    }
    return [props, attrs];
  }
}

为什么 Props 使用的是 shallowReactive?

https://www.zhihu.com/question/465863106

Props 引发的被动更新

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

javascript
function patchComponent(n1, n2, container) {
  // 传递 instance,将新的(n2)设置 component,否则下次更新时将无法获得实例对象
  const instance = (n2.component = n1.component);
  const { props } = instance;
  if (hasPropsChanged(n1.props, n2.props)) {
    const [nextProps] = resolveProps(n2.type.props, n2.props);
    // 更新
    for (const k in nextProps) {
      props[k] = nextProps[k];
    }
    // 删除不存在的
    for (const k in props) {
      if (!(k in nextProps)) delete props[k];
    }
  }
}

/**
 * 长度不同 or 任意一个值不想等,则认为更新了
 */
function hasPropsChanged(prev, next) {
  const nextKeys = Object.keys(next);
  if (nextKeys.length !== Object.keys(prev).length) return true;

  for (const key in nextKeys) {
    if (next[key] !== prev[key]) return true;
  }
  return false;
}

让 this 访问到 Props

由于 Props 和组件自身的数据都要传递到渲染函数,并可以通过 this 访问,因此我们需要封装一个渲染上下文。

javascript
const renderContext = new Proxy(instance, {
  get(t, k, r) {
    const { state, props } = instance;
    if (k in state) {
      return state[k];
    } else if (k in props) {
      return props[k];
    } else {
      console.error('不存在');
    }
  },
  set(t, k, v, r) {
    const { state, props } = instance;
    if (k in state) {
      state[k] = v;
    } else if (k in props) {
      console.error('props 上的数据不允许修改');
    } else {
      console.error('不存在');
    }
  }
});

const subTree = render.call(renderContext, renderContext);

setup 函数的作用与实现

setup 主要配合 Vue3 中的组合式 API,为用户提供一个地方,用于创建组合逻辑、创建响应式数据、创建通用函数、注册生命钩子等能力

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

  • 返回一个函数,组件会将其当作自己的 render 函数。(这种方式常用于组件而非模版,模版也会产生 render 函数,二者会冲突)
  • 返回一个对象,该对象包含的数据将暴露给模版使用。

setup 函数接收两个参数:

javascript
const Comp = {
  props: { foo: String },
  setup(props, setupContext) {
    props.foo; // 访问组件接收的 props
    const { slots, emit, attrs, expos } = setupContext;
  }
};

简单实现一下 setup:

javascript
function mountComponent(vnode, container, anchor) {
  const options = vnode.type;
  const {
    setup
    /**... */
  } = options;

  const [props, attrs] = resolveProps(propsOption, vnode.props);

  const instance = {
    state,
    props: shallowReactive(props),
    isMounted: false,
    subTree: null
  };
  // 用 attrs 做例子,应该还有 emit、slots 等
  const setupContext = { attrs };
  const setupRes = setup(shallowReadonly(instance.props), setupContext);
  // setup 返回的数据
  const setupState = null;
  if (typeof setupRes === 'function') {
    if (render) {
      console.error('setup 返回 render 函数,与模版冲突');
    }
    render = setupRes;
  } else {
    setupState = setupRes;
  }
}

renderContext 中增加 setupState

javascript
const renderContext = new Proxy(instance, {
  get(t, k, r) {
    const { state, props } = instance;
    if (k in state) {
      return state[k];
    } else if (k in props) {
      return props[k];
    } else if (k in setupState) {
      return setupState[k]; // 新增
    } else {
      console.error('不存在');
    }
  },
  set(t, k, v, r) {
    const { state, props } = instance;
    if (k in state) {
      state[k] = v;
    } else if (k in props) {
      console.error('props 上的数据不允许修改');
    } else if (k in setupState) {
      setupState[k] = v; // 新增
    } else {
      console.error('不存在');
    }
  }
});

组件事件与 emit 的实现

vue
<template>
  <MyComp @change="handler" />
</template>

上面的模版对应的虚拟 DOM 为:

javascript
const node = {
  props: {
    onChange: handler
  }
  /** ... */
};

可以看到,自定义事件 change 被编译称为 onChange,并存储到了 props 中,这其实是一个约定。

javascript
function mountComponent(vnode, container, anchor) {
  const options = vnode.type;
  const {
    setup
    /**... */
  } = options;
  const [props, attrs] = resolveProps(propsOption, vnode.props);
  const instance = {
    state,
    props: shallowReactive(props),
    isMounted: false,
    subTree: null
  };
  /**
   * event: 事件名
   * payload: 传递给事件的参数
   */
  function emit(event, ...payload) {
    const eventName = `on${event[0].toUpperCase() + event.slice(0)}`;
    const handler = instance.props[eventName];
    if (handler) {
      handler(...payload);
    } else {
      console.error('未找到事件');
    }
  }

  // 用 attrs 做例子,应该还有 emit、slots 等
  const setupContext = { attrs, emit };

  function resolveProps(options, propData) {
    const props = {};
    const attrs = {};

    for (const key in propData) {
      // 以 on 开头的 props,无论是否显示的声明,都会添加到 props,因为可能是 emit
      if (key in options || key.startsWith('on')) {
        props[key] = propData[key];
      } else {
        attrs[key] = propData[key];
      }
    }
    return [props, attrs];
  }
}

插槽的工作原理及实现

vue
<!-- MyComponent -->
<template>
  <header><slot name="header" /></header>
  <div>
    <slot name="body" />
  </div>
</template>

父组件调用时

vue
<template>
  <MyComponent>
    <template #header>
      <h1>我是标题</h1>
    </template>
    <template #body>
      <div>我是内容</div>
    </template>
  </MyComponent>
</template>

父组件模版会编译成:

javascript
function render() {
  return {
    type: MyComponent,
    // 组件的 children 会被编译成一个对象
    children: {
      header() {
        return { type: 'h1', children: '我是标题' };
      },
      body() {
        return { type: 'div', children: '我是内容' };
      }
    }
  };
}

MyComponent 组件会被编译成:

javascript
function render() {
  return [
    {
      type: 'header',
      children: [this.$slots.header()]
    },
    {
      type: 'body',
      children: [this.$slots.body()]
    }
  ];
}

在运行时的实现上,插槽依赖 setupContext 上的 slots 对象

javascript
function mountComponent(vnode, container, anchor) {
  /** ... */
  const slots = vnode.children || {};
  const instance = {
    state,
    props: shallowReactive(props),
    isMounted: false,
    subTree: null,
    slots
  };
  const setupContext = { attrs, emit, slots };

  const renderContext = new Proxy(instance, {
    get(t, k, r) {
      const { state, props, slots } = instance;
      // 通过劫持 $slots,使 this 可以访问到插槽对象
      if (k === '$slots') return slots;
      if (k in state) {
        return state[k];
      } else if (k in props) {
        return props[k];
      } else {
        console.error('不存在');
      }
    }
  });
}

注册生命周期

Vue3 中有 onMounted 这种注册生命周期的方式,那么它是怎么注册到当前实例的呢?

javascript
import { onMounted } from 'vue';
const Comp = {
  setup() {
    onMounted(() => {
      console.log('onMounted1');
    });
    // 注册多次
    onMounted(() => {
      console.log('onMounted2');
    });
  }
};

Vue3 中定义了一个全局实例 currentInstance, 每次调用 setup 前设置值。

javascript
let currentInstance = null;
function setCurrentInstance(instance) {
  currentInstance = instance;
}
javascript
function mountComponent(vnode, container, anchor) {
  /** ... */
  const instance = {
    state,
    props: shallowReactive(props),
    isMounted: false,
    subTree: null,
    slots,
    // 在实例中创建数组,用来存储通过 onMounted 函数注册的回调
    mounted: []
  };
  const setupContext = { attrs, emit, slots };

  setCurrentInstance(instance);
  const setupRes = setup(shallowReadonly(instance.props), setupContext);
  setCurrentInstance(null);
  /** ... */

  effect(() => {
    // 第一次渲染
    if (!instance.isMounted) {
      /** ... */
      instance.isMounted = true;
      // 执行 vnode 上的钩子
      mounted && mounted.call(state);
      // 执行 onMounted 注册的回调
      instance.mounted.forEach(fn => fn.call(renderContext));
    }
    /**... */
  });
}

onMounted 的实现:

javascript
function onMounted(cb) {
  if (currentInstance) {
    currentInstance.mounted.push(cb);
  }
}