createApp之后发生了什么?

328 阅读7分钟

前言

Vue 项目是从挂载app开始。通常main.js文件,

createApp(App).mount("#app");

这里是使用了createApp方法接受一个组件App,然后基于这个方法返回的一个方法mount将这个应用挂载到idappdiv上。

App可以是一个.vue文件,也可以是一个渲染函数。我们以渲染函数的方式初始化component主流程,因为.vue文件最终也是通过编译生成渲染函数的。

实践

使用渲染函数生成一个组件进行挂载。

main.js

import { createApp } from "vue";
import { App } from "./App.js";
createApp(App).mount("#app");

App.js

import { h } from "vue";

export const App = {
  render() {
    return h("div", "Hello, " + this.msg);
  },
  setup() {
    return {
      msg: "World",
    };
  },
};

最终页面上呈现Hello World

流程思维导图

确定需求

根目录下新建example文件夹,其中新建helloworld文件夹,

新建index.html

<!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="app"></div>
    <script type="module" src="./main.js"></script>
  </body>
</html>

新建main.js

import { App } from "./App.js";
createApp(App).mount("#app");

新建App.js

export const App = {
  render() {
    return h("div", "hello, " + this.msg);
  },
  setup() {
    return {
      msg: "world",
    };
  },
};

最终需要通过我们自己实现的createApph等方法,让Hello World出现在页面上。

实现runtime-core

src下新建文件夹runtime-core,新建index.ts作为方法导出出口。接下来按照需求依次实现。

createApp

新建createApp.ts

export function createApp(rootComponent) {
  return {
    mount(rootContainer) {
      const vnode = createVNode(rootComponent);
    },
  };
}

上面代码中,createApp方法接受一个根组件参数,这个方法返回一个方法mountmount方法接受一个参数,也就是根容器#app。Vue 中组件都是先转换成虚拟节点vnode,在基于vnode进行一系列处理后渲染到页面上。

因此我们需要一个createVNode方法创建虚拟节点。

createVNode

新建vnode.ts

export function createVNode(type, props?, children?) {
  const vnode = {
    type,
    props,
    children,
  };
  return vnode;
}

上面代码createVNode方法创建了虚拟节点,虚拟节点存在typepropschildren三个属性,其中propschildren不是必须的。这里简单的返回vnode还没未涉及到其他更多的逻辑,后续作为扩展。

其中h函数就是对CreateVNode的二次封装,方便用户调用。新建文件h.ts

import { createVNode } from "./vnode";

export function h(type, props?, children?) {
  return createVNode(type, props, children);
}

再回到createApp中,虚拟节点已经创建完成了,接下来就是渲染render

export function createApp(rootComponent) {
  return {
    mount(rootContainer) {
      const vnode = createVNode(rootComponent);
      render(vnode, rootContainer);
    },
  };
}

render

新建renderer.ts

export function render(vnode, container) {
  patch(vnode, container);
}

render函数中主要就是做了patch,而patch中要做的就是处理组件。根据思维导图,这里会调用processComponent方法。

function patch(vnode, container) {
  processComponent(vnode, container);
}

processComponent中会进行组件挂载mountComponent

function processComponent(vnode, container) {
  mountComponent(vnode, container);
}

再来回顾一下思维导图,mountComponent做了什么

function mountComponent(vnode, container) {
  const instance = createComponentInstance(vnode);
}

首先是创建了组件实例,组件相关逻辑可以单独放在component.ts中。

component

新建component.ts

export function createComponentInstance(vnode) {
  const component = {
    vnode,
    type: vnode.type,
  };
  return component;
}

创建完实例,就需要调用setupComponent方法基于实例instance做一系列的初始化动作。

function mountComponent(vnode, container) {
  const instance = createComponentInstance(vnode);
  setupComponent(instance);
}

setupComponent方法还是和组件逻辑相关的,将这个方法放在component文件中,代码如下:

export function setupComponent(instance) {
  // initProps
  // initSlots

  setupStatefulComponent(instance);
}

这里暂留位置,后续可以处理对于props的初始化和slots的初始化。setupStatefulComponent就是处理有状态的组件,Vue 中组件其实可分为有状态组件和无状态组件,我们常写的组件就是有状态组件,函数组件为无状态组件。

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

  const { setup } = Component;
  if (setup) {
    const setupResult = setup();
    handleSetupResult(instance, setupResult);
  }
}

上面代码中,type就是App.js中导出的App配置对象options,因为在createVNode中传入的type参数就是rootComponent,也就是App。既然是options配置对象,就是获取其中setup,然后就是执行setup获取结果,这结果进行处理,准备后续render中需要渲染setup中返回的数据,例如msg

function handleSetupResult(instance, setupResult) {
  if (typeof setupResult === "object") {
    instance.setupResult = setupResult;
  }
  finishComponentSetup(instance);
}

上面代码,setupResult可能是object类型也可能是function类型,针对我们需求中示例代码,暂时只考虑object类型。将setupResult挂载到实例上,调用finishComponentSetup确保render存在可以进行渲染。

function finishComponentSetup(instance) {
  const Component = instance.type;
  if (Component.render) {
    instance.render = Component.render;
  }
}

上面代码,当render存在时也将其挂载到实例上。

setupComponent组件相关逻辑结束了,再次回到renderer.ts,开始执行render

render

还是在mountComponent方法中,

function mountComponent(vnode, container) {
  const instance = createComponentInstance(vnode);
  setupComponent(instance);
  setupRenderEffect(instance, container);
}

setupComponent调用之后,实例instance上已经挂载了setupResultrender

function setupRenderEffect(instance, container) {
  const subTree = instance.render();
  patch(subTree, container);
}

上面代码,render返回值h("div", "hello, " + this.msg)h函数是createVNode的封装,其实返回的就是虚拟节点。这里就会涉及到element的相关逻辑,将虚拟节点转换成真实的element并挂载,实现的地方还是在patch方法中。

element

那就需要来完善一下patch方法,根据传入的type数据类型区分,如果是一个对象还是沿用之前processComponent的逻辑,如果是字符串,例如div,就需要走element逻辑。

function patch(vnode, container) {
  if (typeof vnode.type === "string") {
    processElement(vnode, container);
  } else if (isObject(vnode.type)) {
    processComponent(vnode, container);
  }
}

processElement中进行初始化工作,

function processElement(vnode, container) {
  mountElement(vnode, container);
}

初始化element传入虚拟节点vnode,要挂载的容器container。这里为了丰富一下我们的判断逻辑,修改需求中App.jsrender,添加props并将children改成数组。

export const App = {
  render() {
    return h(
      "div",
      {
        id: "root",
        class: ["main", "content"],
      },
      [
        h("p", { class: "red" }, "hello"),
        h( "p", { class: "blue" }, "world"),
      ]
    );
  },
  setup() {
    return {
      msg: "world",
    };
  },
};

这样需求就变成了,期待页面中最终呈现出两个p标签的文本,分别是helloworld,它们被一个div包裹,这个div又是挂载在根节点app下的。

function mountElement(vnode, container) {
  const { type, children } = vnode;
  let el = document.createElement(type);

  if (typeof children === "string") {
    el.textContent = children;
  } else if (Array.isArray(children)) {
    children.forEach((v) => {
      patch(v, el);
    });
  }

  const { props } = vnode;
  for (const key in props) {
    const value = props[key];
    el.setAttribute(key, value);
  }

  container.append(el);
}

上面代码,初始化elementvnode中解构出type,针对于上面的需求例子中也就是div;判断children是数组类型时,循环每一项再调用patch,因为每一项又是h函数调用返回的虚拟节点,需要走patch逻辑再次判断是component还是element,此时子节点挂载的容器就是当前的父节点elprops属性的设置就是通过setAttribute;最终整个div是挂载在传入的container参数,也就是rootContainer

打包测试

在需求示例中,打开index.html在浏览器中验证。但是createApph函数还没有导入,此时应该是没有效果的。

那直接从runtime-core中导入也是无效的,我们需要借助rollup打包工具,将需要的方法暴露接口,打包成一个单独的js文件,进行引用。

导出方法

runtime-coreindex.ts中,导出createApph方法,

export { createApp } from "./createApp";
export { h } from "./h";

srcindex.ts,作为最外层导出口,

export * from "./runtime-core/index";

安装rollup

终端执行安装命令,

# setup rollup
yarn add rollup --dev

# setup @rollup/plugin-typescript
yarn add @rollup/plugin-typescript --dev

# setup tslib
yarn add tslib --dev

根目录下新建rollup配置文件rollup.config.js

import typescript from "@rollup/plugin-typescript";

export default {
  input: "./src/index.ts",
  output: [
    {
      format: "cjs",
      file: "lib/zwd-mini-vue.cjs.js",
    },
    {
      format: "es",
      file: "lib/zwd-mini-vue.esm.js",
    },
  ],
  plugins: [typescript()],
};

input指定输入文件地址,也就是需要打包的文件入口;output指定输出的打包文件路径,这里分为两种文件格式输出,commonjsesm;同时需要安装ts的插件,因为rollup并不认识ts

同时需要修改tsconfig.json"module": "ESNext"

引入文件

package.json中添加打包命令,

"build": "rollup -c rollup.config.js"

执行打包,yarn build。打包成功完成就会在根目录下看到lib文件夹。

然后在需求示例中代码修改如下,

index.html添加类名,

<style>
  .red {
    color: red;
  }
  .blue {
    color: blue;
  }
</style>

main.js

import { createApp } from "../../lib/zwd-mini-vue.esm.js";
import { App } from "./App.js";

const rootContainer = document.querySelector("#app");
createApp(App).mount(rootContainer);

App.js

import { h } from "../../lib/zwd-mini-vue.esm.js";
export const App = {
  render() {
    return h(
      "div",
      {
        id: "root",
        class: ["main", "content"],
      },
      [
        h("p", { class: "red" }, "hello"),
        h( "p", { class: "blue" }, "world"),
      ]
    );
  },
  setup() {
    return {
      msg: "world",
    };
  },
};

验证

根目录下执行http-server本地启动一个服务,如果没有这个命令的话,需要全局安装一下。

npm install http-server -g

在浏览器中可以查看到页面,地址 http://127.0.0.1:8080/example/helloworld/

总结

初始化的主流程的方法名全部是按照 Vue3 源码相同命名创建的。可以再次回顾一下流程思维导图,一共是分为两部分展开,componentelement

首先是component,所有组件都会先转变成虚拟节点vnode,通过vnode进行一系列操作再渲染,组件挂载部分会先创建实例,将APPoptionssetup执行结果和render函数都挂载到组件实例上。

然后就是patchelement的处理,当获取到虚拟节点中type属性是字符串表示此时处理的是最小单元的标签,挂载element就是创建标签,丰富它的属性和子节点。

最终通过rollup打包,生成我们可以运行的js文件。