likes
comments
collection
share

手把手带你实现一个自己的简易版 Vue3(五)

作者站长头像
站长
· 阅读数 3

👉 项目 Github 地址:github.com/XC0703/VueS…

(希望各位看官给本菜鸡的项目点个 star,不胜感激。)

7、runtime-dom 的实现

7-1 runtime-dom 的作用

Vue3runtime-dom 模块是 Vue3 中的一个核心模块,它的作用是提供了一些运行时的功能来实现组件的创建、更新和销毁等操作:

  1. 组件的创建:在 Vue3 中,组件被创建为一个渲染函数,当使用该组件时,runtime-dom 模块会根据渲染函数生成组件的虚拟节点(VNode),并将其添加到组件实例中。
  2. 组件的更新:当组件的状态发生变化时,runtime-dom 模块会根据新的状态重新执行渲染函数,并生成新的虚拟节点。然后,它会使用 diff 算法比较新旧虚拟节点的差异,并将差异应用到实际的 DOM 上,以更新页面的显示。
  3. 组件的销毁:当组件不再需要时,runtime-dom 模块会调用相应的钩子函数来执行组件的销毁操作,包括解绑事件、清除定时器等。
<!-- weak-vue\packages\examples\7.createApp.html -->
<div id="app"></div>
<script src="../../runtime-dom/dist/runtime-dom.global.js"></script>
<script>
  let { createApp, reactive, h } = VueRuntimeDOM; // createApp和h这两个函数都是Vue3中runtime-dom模块的方法,用于处理组件渲染(创建组件、渲染dom)

  //  创建组件(composition组合式api)
  let App = {
    // data和methods是Vue2.0选项式api的写法,Vue3虽然兼容但是不建议在Vue3中这样写
    // data() {
    //   return {
    //     a: 1,
    //   };
    // },
    // methods: {
    //   xx: () => {},
    // },

    // Vue3组件的入口函数,默认执行一次,相当于vue2中的beforeCreate、created,可以返回代理的一些响应式属性以供渲染函数使用,也可以直接返回渲染函数
    setup(props, context) {
      // 参数、上下文对象(包含了父组件传递下来的非 prop 属性attrs、可以用来触发父组件中绑定的事件函数emit、一个指向当前组件实例的引用root、用来获取插槽内容的函数slot等)
      console.log();
      let state = reactive({ age: 2 });
      const fn = () => {
        state.age++;
      };

      // 返回代理的响应式属性以供渲染函数使用
      return {
        state,
      };

      // 直接返回渲染函数
      return () => {
        console.log(state);
        return h(
          // h是渲染函数
          "div",
          { style: { color: "red" }, onClick: fn },
          `hello ${state.age}`
        );
      };
    },

    // Vue3组件的渲染函数,其实proxy参数将上面定义的所有属性合并了,等效于在setup入口函数里面返回一个函数(和上面等效,上面还不用用proxy.来获取属性)
    render(proxy) {
      console(proxy.state.age);
      return h(
        "div",
        { style: { color: "red" }, onClick: fn },
        `hello ${proxy.state.age}`
      );
    },
  };
</script>

7-2 节点与属性的操作

7-2-1 包初始化

weak-vue\packages 目录下新建两个子目录 runtime-coreruntime-dom,并分别创建自己的 src\index.ts 和进行 npm init 初始化并配置 package.json 文件。

// weak-vue\packages\runtime-core\package.json
{
  "name": "@vue/runtime-dom",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "buildOptions": {
    "name": "VueRuntimeDom",
    "formats": ["esm-bundler", "global"]
  }
}
// weak-vue\packages\runtime-dom\package.json
{
  "name": "@vue/runtime-core",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "buildOptions": {
    "name": "VueRuntimeCore",
    "formats": ["global"]
  }
}

7-2-2 方法的定义

runtime-dom 是用于操作 dom(节点、属性)的模块。创建两个文件,分别用于处理节点(nodeOps.ts) 与属性 (patchProp.ts):(内容不用背,了解大概原理即可)

// weak-vue\packages\runtime-dom\src\nodeOps.ts
// 操作节点(增删改查)
export const nodeOps = {
  // 对节点的一些操作
  // 创建元素,createElement(runtime-dom本质是运行时操作dom,但因为每个平台操作dom的方法不同,vue的runtime-dom模块的createElement方法是针对浏览器的)
  createElement: (tagName) => document.createElement(tagName),
  // 删除元素
  remove: (child) => {
    const parent = child.parentNode;
    if (parent) {
      parent.removeChild(child);
    }
  },
  // 插入元素
  insert: (child, parent, ancher = null) => {
    parent.insertBefore(child, ancher); // ancher为空相当于appendchild
  },
  // 选择节点
  querySelector: (select) => document.querySelector(select),
  // 设置节点的文本
  setElementText: (el, text) => {
    el.textContent = text;
  },

  // 对文本的一些操作
  createText: (text) => document.createTextNode(text),
  setText: (node, text) => (node.nodeValue = text),
};

使用策略模式操作属性:(即包装一下,传入参数返回对应的处理函数)

// weak-vue\packages\runtime-dom\src\patchProp.ts
// 操作属性(增删改查)
import { patchClass } from "./modules/class";
import { patchStyle } from "./modules/style";
import { patchAttr } from "./modules/attrt";
import { patchEvent } from "./modules/event";
export const patchProps = (el, key, prevValue, nextValue) => {
  switch (key) {
    case "class":
      patchClass(el, nextValue); // 只用传节点和新的class值
      break;
    case "style":
      patchStyle(el, prevValue, nextValue);
      break;
    default:
      // 事件要另外处理(事件的特征:@、onclick等==>正则匹配,如以on开头,后面跟小写字母,这里简化判断,知道思想即可)
      if (/^on[^a-z]/.test(key)) {
        patchEvent(el, key, nextValue);
      } else {
        patchAttr(el, key, nextValue);
      }
  }
};

其中关于类名 class、样式 style、自定义属性 attribute、事件 event 的处理如下:

// weak-vue\packages\runtime-dom\src\modules\class.ts
// 处理class
export const patchClass = (el, value) => {
  // 对这个标签的class赋值(如果没有赋值为空,如果有则直接打点获取属性后覆盖)
  if (value === null) {
    value = "";
  }
  el.className = value;
};
// weak-vue\packages\runtime-dom\src\modules\style.ts
// 处理style
// 已经渲染到页面上{style:{color:'red'}}=>当前(新的)样式{style:{background:'green',font-size:20px}}
export const patchStyle = (el, prev, next) => {
  const style = el.style;

  // 说明样式删除
  if (next === null) {
    el.removeAttribute("style");
  } else {
    // 如果是已经渲染的样式有某样式,但是新的样式没有,则要清除老的样式
    if (prev) {
      for (const key in prev) {
        if (next[key] === null) {
          style[key] = "";
        }
      }
    }
    // 如果是新的有,老的没有,则直接打点获取属性后覆盖
    for (const key in next) {
      style[key] = next[key];
    }
  }
};
// weak-vue\packages\runtime-dom\src\modules\attrt.ts
// 处理一些自定义的属性
export const patchAttr = (el, key, value) => {
  if (value === null) {
    el.removeAttribute(key);
  } else {
    el.setAttribute(key,value);
  }
};

注意:对事件的处理比较特殊,因为事件和样式类名自定义属性不一样,绑定不同的事件不能直接覆盖,如@click="fn1"@click = "fn2"。因为 addEventListener 重复添加事件监听时,不能替换之前的监听,导致有多个监听同时存在。所以这里借助一个 map 结构存储所有的事件映射,然后 addEventListener 监听对应的映射值,然后重复绑定时直接改变映射值即可(相当于改变引用)。

// weak-vue\packages\runtime-dom\src\modules\event.ts
// 对事件的处理(注意,事件和样式类名自定义属性不一样,绑定不同的事件不能进行覆盖,如@click="fn1"、@click = "fn2",因为addEventListener重复添加事件监听时,不能替换之前的监听,导致有多个监听同时存在
// 源码对这个处理使用了缓存,用一个map结构存储元素key上面绑定的元素
// 例子:假如当前要处理的元素el,已经绑定了@click="fn1",现在可能要添加@click = "fn2"(情况1),也可能添加@hover = "fn3"(情况2),也可能添加@click = ""(情况3)

// el为元素,key是触发事件的方法,即事件名(如click),value为绑定的函数方法
export const patchEvent = (el, key, value) => {
  const invokers = el._vei || (el._vei = {}); // el._vei相当于一个元素的事件map缓存结构,可能为空{}。拿上面的例子来说的话,此时应该是{"click":{value:fn1}}
  const exists = invokers[key]; // 拿上面的例子来说的话,此时应该是 {value:fn1}
  if (exists && value) {
    // 不能进行覆盖(情况1)==>改变缓存中的value指向最新的事件即可,相当于改变exists的fn引用
    exists.value = value;
  } else {
    // 如果该触发方式还未绑定事件或者传入的函数为空,可能是新的绑定,也可能是清除事件
    const eventName = key.slice(2).toLowerCase();
    if (value) {
      //  新的事件绑定,且将该绑定放入缓存器(情况2)
      let invoker = (invokers[eventName] = createInvoker(value)); // 返回一个包装后的函数
      el.addEventListener(eventName, invoker);
    } else {
      //  移除事件(情况3)
      el.removeEventListener(eventName, exists);
      invokers[eventName] = null;
    }
  }
};

function createInvoker(value) {
  const invoker = (e) => {
    invoker.value(e);
  };
  invoker.value = value;
  return invoker;
}

此时将我们实现的方法导出,并测试:

// weak-vue\packages\runtime-dom\src\index.ts
import { extend } from "@vue/shared";
// runtime-dom是用于操作dom(节点、属性)的模块
// 创建两个文件,分别用于处理节点nodeOps.ts与属性patchProp.ts
import { nodeOps } from "./nodeOps";
import { patchProps } from "./patchProp";

// Vue3的全部dom操作
const VueRuntimeDom = extend({ patchProps }, nodeOps);

export { VueRuntimeDom };
<!-- weak-vue\packages\examples\7.createApp.html -->
<div id="id"></div>
<script src="../runtime-dom/dist/runtime-dom.global.js"></script>
<script>
  console.log(VueRuntimeDom);
</script>

可以看到打印成功(patchProps 使用了策略模式,实际会执行对应情况下的处理方法): 手把手带你实现一个自己的简易版 Vue3(五)


自此,我们关于 runtime-dom 的讲解便已结束,到这里的源码请看提交记录:7、runtime-dom 的实现

8、runtime-core 的实现

8-1 定义渲染的 createRender 方法

上面我们实现了 runtime-dom 模块来定义了一些操作 dom 的方法,这一节主要讲组件的渲染(createApp方法)。


注意,之所以这里将上面实现的 renderOptionDom 作为参数传递进 createRender 方法,这是因为我们前面实现的 renderOptionDom 只是在浏览器平台对于 dom 的一些操作,在其它平台中对于 dom 的操作可能不同,作为参数传递进去增加了代码的灵活度。

// weak-vue\packages\runtime-dom\src\index.ts
// Vue3的全部dom操作
const renderOptionDom = extend({ patchProps }, nodeOps);

export const createApp = (rootComponent, rootProps) => {
  // 创建一个渲染的容器
  let app = createRender(renderOptionDom).createApp(rootComponent, rootProps); // createRender返回的是一个具有createApp属性方法的对象,打点执行该createApp方法后返回一个app对象,里面有一个mount属性方法
  let { mount } = app;
  app.mount = function (container) {
    // 挂载组件之前要清空原来的内容
    container = nodeOps.querySelector(container);
    container.innerHTML = "";
    // 渲染新的内容(挂载dom)
    mount(container);
  };
  return app;
};

// 实现渲染--最后会放在runtime-core模块中
function createRender(renderOptionDom) {
  // 返回一个对象
  return {
    // createApp方法用于指明渲染的组件以及上面的属性
    createApp(rootComponent, rootProps) {
      let app = {
        mount(container) {
          // 挂载的位置
          console.log(renderOptionDom, rootComponent, rootProps, container);
        },
      };
      return app;
    },
  };
}

此时可以看到我们的打印结果: 手把手带你实现一个自己的简易版 Vue3(五)

8-2 创建虚拟 dom

runtime-core 目录下新建一个 render.ts 文件,存放我们上面定义的 createRender 方法并在 index.ts 中导出。然后在 weak-vue\packages\runtime-dom\src\index.ts 文件中通过@vue 方式引入:(注意,引入前要执行 npm run buildyarn install,因为采用 monorepo 结构,此时相当于用新的包的东西了)

// weak-vue\packages\runtime-dom\src\index.ts
import { createRender } from "@vue/runtime-core";

此时具体去执行我们的 mount 渲染方法。Vue 组件的渲染流程为:组件==>vnode==>render。所以先写一个负责创建虚拟 dom 的函数(返回一个对象,对象具有 mount 挂载方法,该挂载方法做了两件事:1、生成 vnode;2、render 渲染 vnode):

// weak-vue\packages\runtime-core\src\apiCreateApp.ts
import { createVNode } from "./vnode";

// apiCreateApp是起到将组件变成虚拟dom的作用(返回一个对象,对象具有mount挂载方法,该挂载方法做了两件事:1、生成vnode;2、render渲染vnode)
export function apiCreateApp(render) {
  // createApp方法用于指明渲染的组件以及上面的属性
  return function createApp(rootComponent, rootProps) {
    let app = {
      // 添加相关的属性
      _components: rootComponent,
      _props: rootProps,
      _container: null,
      mount(container) {
        // 挂载的位置
        // console.log(renderOptionDom, rootComponent, rootProps, container);
        // 1、创建虚拟dom vnode
        let vnode = createVNode(rootComponent, rootProps);
        // 2、将虚拟dom渲染到实际的位置
        render(vnode, container);
        app._container = container;
      },
    };
    return app;
  };
}

其中渲染的 render 方式是通过参数传入的:

// weak-vue\packages\runtime-core\src\render.ts
import { apiCreateApp } from "./apiCreateApp";

// 实现渲染Vue3组件==>vnode==>render
export function createRender(renderOptionDom) {
  // 真正实现渲染的函数(渲染vnode)
  let render = (vnode, containers) => {};

  // 返回一个具有createApp方法的对象,其中createApp负责生成一个具有mount挂载方法的app对象(包含属性、方法等),进而实现1、生成vnode;2、render渲染vnode
  return {
    createApp: apiCreateApp(render),
  };
}

其中,创建虚拟 dom 的 createVNode 方法定义如下:

// weak-vue\packages\runtime-core\src\vnode.ts
// 生成vnode
export const createVNode = (rootComponent, rootProps) => {
  console.log(rootComponent, rootProps);
};

此时执行 npm run build 后运行我们上面的测试用例,可以看到结果被正确打印了出来,说明目前的链路是正确的:

手把手带你实现一个自己的简易版 Vue3(五)


8-3 区分是元素还是数组

注意,我们前面在测试用例使用过一个 h 函数来进行组件的渲染。实际上它也进行了创建虚拟 dom 的操作,本质也是调用了 createVNode 方法:

// h函数的基本使用
h("div", { style: { color: "red" }, onClick: fn }, `hello ${proxy.state.age}`);

可以看到,第一个参数不一定为根组件而是元素,第二个参数是包含一些属性的对象,第三个参数为渲染的子内容(可能是文本/元素/自内容数组),因此我们的 createVNode 方法在参数上可以更改一下:

// weak-vue\packages\runtime-core\src\vnode.ts
export const createVNode = (type, props, children = null) => {};

上面说到,第一个参数 type 不一定为根组件也可能是元素,生成的虚拟 dom 也要据此做出区分。至于怎么区分,源码里面为了精确地获取节点的特性信息的同时提高渲染性能,借助了枚举,每个枚举值都是一个二进制位掩码(至于为什么用二进制源码表示,这是因为经过大量的实践证明,二进制表示、位运算可以节省内存空间的同时大大优化对比性能,同时也可以方便组合、提高代码简洁度),可以用于标记虚拟节点的具体类型和特性:

// weak-vue\packages\shared\src\shapeFlags.ts
export enum ShapeFlags {
  ELEMENT = 1, // 表示该虚拟节点是一个普通的 HTML 元素节点
  FUNCTIONAL_COMPONENT = 1 << 1, // 表示该虚拟节点是一个函数式组件节点
  STATEFUL_COMPONENT = 1 << 2, // 表示该虚拟节点是一个有状态的组件节点
  TEXT_CHILDREN = 1 << 3, // 表示该虚拟节点包含纯文本子节点
  ARRAY_CHILDREN = 1 << 4, // 表示该虚拟节点包含数组形式的子节点
  SLOTS_CHILDREN = 1 << 5, // 表示该虚拟节点包含插槽形式的子节点
  TELEPORT = 1 << 6, // 表示该虚拟节点是一个传送门(Teleport)节点
  SUSPENSE = 1 << 7, // 表示该虚拟节点是一个异步加载(Suspense)节点
  COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8, // 表示该虚拟节点的组件应该被缓存而不是销毁
  COMPONENT_KEPT_ALIVE = 1 << 9, // 表示该虚拟节点的组件已被缓存
  COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT, // 表示该虚拟节点是一个组件节点,可以是函数式组件或者有状态的组件
}

此时 createVNode 更加完善了:

// weak-vue\packages\runtime-core\src\vnode.ts
import { isArray, isObject, isString, ShapeFlags } from "@vue/shared";
// 生成vnode(本质是一个对象)
export const createVNode = (type, props, children = null) => {
  // console.log(rootComponent, rootProps);

  // 区分是组件的虚拟dom还是元素的虚拟dom
  // 如果是字符串,说明是是一个普通的 HTML 元素节点;如果不是字符串且是一个对象,说明是一个组件(这里简化处理,直接默认有状态组件)
  let shapeFlag = isString(type)
    ? ShapeFlags.ELEMENT
    : isObject(type)
    ? ShapeFlags.STATEFUL_COMPONENT
    : 0;
  const vnode = {
    _v_isVNode: true, //表示是一个虚拟dom
    type,
    props,
    children,
    key: props && props.key, // 后面的diff算法会用到
    el: null, // 虚拟dom对应的真实dom
    shapeFlag,
  };

  // 儿子标识
  normalizeChildren(vnode, children);
  return vnode;
};

function normalizeChildren(vnode, children) {
  let type = 0;
  if (children === null) {
  } else if (isArray(children)) {
    // 说明该虚拟节点包含数组形式的子节点
    type = ShapeFlags.ARRAY_CHILDREN;
  } else {
    // 简化处理,表示该虚拟节点包含纯文本子节点
    type = ShapeFlags.TEXT_CHILDREN;
  }
  vnode.shapeFlag = vnode.shapeFlag | type; // 可能标识会受儿子影响
}

此时,去我们的 weak-vue\packages\runtime-core\src\apiCreateApp.ts 文件打印我们的 vnode,打包后执行测试用例:

// weak-vue\packages\runtime-core\src\apiCreateApp.ts
let vnode = createVNode(rootComponent, rootProps);
console.log(vnode);
<!-- weak-vue\packages\examples\7.createApp.html -->
<div id="app">111111111111</div>
<script src="../runtime-dom/dist/runtime-dom.global.js"></script>
<script>
  let { createApp } = VueRuntimeDom;

  let App = {
    render() {
      console.log(100);
    },
  };
  createApp(App, { name: "张三", age: 10 }).mount("#app");
</script>

可以看到打印出预期的 vnode 结果:手把手带你实现一个自己的简易版 Vue3(五)后面的工作便是实现我们 weak-vue\packages\runtime-core\src\render.ts 文件里面的真正实现渲染的函数 render渲染 vnode)了。


8-4 总结

我们可以围绕我们的测试用例去总结我们上面讲的关于组件渲染的知识点。

首先知道 runtime-domruntime-core 是负责组件渲染的,在 runtime-dom 负责定义一些对真实 dom 进行的操作方法,在 runtime-core 定义一些核心方法(如虚拟 dom 的生成及渲染等)。

创建一个组件核心方法是 createApp 方法(用于创建一个渲染的容器),即一个具有挂载方法的对象,其中挂载的过程可以简化为:组件==>vnode(本次重点)==>render。可以根据这个主线对上面的内容重新过一遍。


自此,我们关于 runtime-core 的讲解基本结束。到此处的源码请看提交记录:8、runtime-core 的实现

转载自:https://juejin.cn/post/7357885064505999398
评论
请登录