Vue3 → 设计思路浅析

84 阅读7分钟

框架设置的权衡

命令式和声明式(可结合)

命令式

早期的JQuery就是典型的命令式框架,命令式框架的一大特点就是关注过程

声明式

声明式框架更关注结果,至于实现该结果的具体过程由对应的框架实现,即框架为开发者封装了过程,因此如vue内部实现是命令式的,暴露给用户的是声明式的

对比
  • 性能方面
    • 声明式代码的性能不优于命令式代码的性能
      • 相对于声明式代码来说,需要在基础的更新性能消耗上再加入寻找最小化差异的性能消耗,因此性能方面弱于命令式
      • 后续的虚拟DOM就是为了尽量减少寻找最小化差异的性能消耗,从而拉低与命令式代码之间的性能差异,但是也只是理论上尽量减小差异(原生操作是最屌的)
  • 可维护性方面
    • 声明式代码优于命令式代码的可维护性
      • 命令式代码需要维护实现目标的整个过程,包括DOM的创、增、删、改等,而声明式直接关注到结果上,不用关心具体实现细节

纯运行时和纯编译时(可结合)

运行时

运行时是指代码实际执行时的阶段,可以理解成代码可以直接在运行环境中直接执行然后产生期望的结果; 一般运行时框架在定义时需要使用者定义好指定数据结构的对象数据,然后调用框架的指定方法就可以实现逻辑产出;

在运行时,前端框架会利用编译时生成的代码来动态地更新和渲染页面,处理事件和数据的变化等。

编译时

编译时一般指将源代码转换成可执行代码的阶段,如将ES6编译成浏览器可执行的ES5语法;如运行时中的DOM对应的对象结构的数据,可以通过编译将易理解的html串编译成运行时需要的对象结构,方便使用者尽快接手该框架;

在编译时,前端框架的编译器会对代码进行语法分析、优化和转换,以生成可在浏览器中运行的代码。编译时的任务包括语法检查、模块打包、代码压缩等

对比

纯运行时,由于没有编译过程,就没办法分析用户提供的内容,从而不能做对应的优化,一般编译器中我们可以实现语法分析,优化和转换,可以执行语法错误检查,模块打包和代码压缩等任务;
如果加入编译步骤,可能就大不一样了,我们可以分析用户提供的内容,看看哪些内容未来可能会改变,哪些内容永远不会改变,这样我们就可以在编译的时候提取这些信息,然后将其传递给 Render 函数,Render 函数得到这些信息之后,就可以做进一步的优化了

框架设计需要考虑的因素

  • 框架应该为用户提供哪些构建的产物,产物的模块化格式应该如何
  • 开发版本的构建和生产环境的构建区别
    • 例如为了减小最终包体积大小,vue中通过rollup.js来对项目进行构建,其中最常见的warn函数的调用就通过rollup进行区分生产环境和开发环境,从而实现代码的优化;
    if(__DEV__ && !res) {
      warn(`Failed to mount app: mount target selector "${container}" returned null.`)
    }
    

    当为生产环境构建代码时,__DEV__会为false,此时包裹的代码永远都不会执行(称为dead Code),同时此代码也不会出现在最终的产物中,在构建资源时就会被移除

  • 框架有多个模块功能时是否支持用户的Tree-Shaking从而减小引用包的体积大小
    • Tree-Shaking即清除dead code,要实现Tree-Shaking必须满足一个条件,即模块必须是ESM,因为TRee-Shaking依赖ESM的静态结构;
    • 为了防止副作用带来的不能Tree-Shaking的问题,如rollup这样的工具都会提供/*#__PURE__*/的代码选项,当确定该逻辑不会产生副作用时,就可以在执行或声明时加入该语句,表明该逻辑可以进行Tree-Shaking;一般是在顶级作用域中进行该逻辑的注入的;

声明式描述UI

Vue是一个声明式的框架、声明式的好处在于直接描述结果,用户不需要关注过程

描述UI的方式

模板式
  • 在Vue中,所有与前端页面元素相关联的属性标签等都会有与之对应的描述方式
    • 动态的属性 - :或者v-bind
    • 事件 @或者v-on
JS对象形式

本质上就是虚拟DOM

  • 需要以指定的格式进行描述,如tag-表签名、props-标签属性、children-子节点
const title = {
  // 标签名称
  tag: "h1",
  // 标签属性
  props: {
    onClick: handler,
  },
  // 子节点
  children: [{ tag: "span" }],
};
// sodo
<h1 @click="handler"><span></span></h1>
  • 使用JS对象形式描述UI更加的灵活

如需要根据传入的level值进行动态的展示h1~h6对应的标签,常规模板式就需要一堆v-if,而js对象式就简单多了

// h 标签的级别
let level = 3
const title = {
  tag: `h${level}`, // h3 标签
}
  • Vue中的应用 - render h()
    • h函数本质上就是一个虚拟DOM,其返回值就是一个对象;
    • h函数就是一个辅助创建虚拟DOM的工具函数
import { h } from "vue";
export default {
  render() {
    return h("h1", { onClick: handler }); // 虚拟 DOM
  },
};

// JS对象形式的实现 h 函数
export default {
  render() {
    return {
      tag: "h1",
      props: {
        onClick: handler,
      },
    };
  },
};
  • 渲染函数
    • 一个Vue组件的内容是通过渲染函数来进行描述的(即上述的render函数),Vue的render函数会根据拿到的虚拟DOM进行组件的渲染

渲染器

初识渲染器

渲染器的作用就是将虚拟DOM渲染为真实的DOM
渲染器的另一个作用就是寻找并更新变化的内容

工作原理

通过原生JS对应的API进行DOM的创建、DOM属性事件的绑定、子元素的渲染等;
工作原理是:递归的遍历虚拟DOM对象,并调用原生DOM API来完成真实DOM的创建;
精髓:在于后续的更新,通过调用Diff算法找出变更点,以达到只变更需要更新的内容;
在Vue3中,渲染器除了常规的传统的Diff算法外,还有效的利用了编译器提供的信息,独创了快捷路径的更新方式,极大的提高了更新性能;

function renderer(vnode, container) {
  // container 是一个真实的DOM元素 作为虚拟DOM的挂载点进行配置
  // 使用 vnode.tag 作为标签名称创建 DOM 元素
  const el = document.createElement(vnode.tag);
  // 遍历 vnode.props,将属性、事件添加到 DOM 元素
  for (const key in vnode.props) {
    if (/^on/.test(key)) {
      // 如果 key 以 on 开头,说明它是事件
      el.addEventListener(
        key.substr(2).toLowerCase(), // 事件名称 onClick ---> click
        vnode.props[key] // 事件处理函数
      );
    }
  }

  // 处理 children
  if (typeof vnode.children === "string") {
    // 如果 children 是字符串,说明它是元素的文本子节点
    el.appendChild(document.createTextNode(vnode.children));
  } else if (Array.isArray(vnode.children)) {
    // 递归地调用 renderer 函数渲染子节点,使用当前元素 el 作为挂载点
    vnode.children.forEach((child) => renderer(child, el));
    // 需要注意的是子节点的挂载点需要设置为刚创建的父节点的真实DOM(即已创建的el)
  }

  // 将元素添加到挂载点下
  container.appendChild(el);
}
render(vnode, document.body)

虚拟DOM和渲染器渲染Vue组件

组件的本质就是一组DOM元素的封装

渲染原理

渲染器在渲染真实DOM时会使用tag进行描述标签名,而组件是真实DOM的封装,没有对应的标签进行tag表示,但是可以通过优化render函数进行将组件实例化渲染为虚拟DOM

  • 组件的函数式描述和对象描述 组件的对象描述时,该对象下必须要有一个函数用来产出组件需要渲染的虚拟DOM
// MyComponent 是一个函数
const MyComponent = function () {
  return {
    tag: "div",
    props: {
      onClick: () => alert("hello"),
    },
    children: "click me",
  };
};

// MyComponent 是一个对象
const MyComponent = {
  render() {
    return {
      tag: 'div',
      props: {
        onClick: () => alert('hello')
      },
      children: 'click me'
    }
  }
}
  • 组件的虚拟DOM式描述
const vNode = {
  tag: MyComponent, // 组件函数式描述名
}

const vNode = {
  tag: MyComponent, // 组件对象式描述名
}
  • 优化render函数 - 兼容组件的虚拟DOM描述
function renderer(vnode, container) {
  if (typeof vnode.tag === "string") {
    // 说明 vnode 描述的是标签元素 - 与上述逻辑一致
    mountElement(vnode, container);
  } else if (typeof vnode.tag === "function") {
    // 说明 vnode 描述的是组件 - 单独提取处理组件式描述
    mountFnComponent(vnode, container);
  } else if(typeof vnode.tag === "object"){
    mountObjComponent(vnode, container);
  }
}

// mountFnComponent 实现 递归调用tag进行循环判断
function mountFnComponent(vnode, container) {
  // 调用组件函数,获取组件要渲染的内容(虚拟 DOM)
  const subtree = vnode.tag();
  // 递归地调用 renderer 渲染 subtree
  renderer(subtree, container);
}

// mountObjComponent 实现 调用对象内的render进行获取组件内的真实DOM
function mountObjComponent(vnode, container) {
  // vnode.tag 是一个对象,调用该对象的render函数得到组价需要渲染的真实DOM
  const subtree = vnode.tag.render();
  // 递归地调用 renderer 渲染 subtree
  renderer(subtree, container);
}

编译器

工作原理

编译器的工作是将模板编译为渲染函数

  • 模板的工作原理

无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的,然后渲染器再把渲染函数返回的虚拟DOM渲染为真实DOM

综合关联

组件的实现依赖于渲染器、模板的编译依赖于编译器,并且编译后产生的代码是根据渲染器和虚拟DOM的设计决定的

  • 模块间的相互关联和制约
    • 对于常规的Vue渲染器,是需要实时捕获到响应式数据变化的对应的虚拟DOM的,并且定点更新,但是在某种方面来说编译器更适合这个工作,编译器有能力分析动态内容,可以在编译阶段就将这些信息提取出来,然后交给渲染器,避免渲染器花费大量的时间去寻找变更点
    • 编译器在编译模板时是可以知道哪些是静态属性、哪些是动态属性的