前端框架设计

发布时间:2024年01月05日

框架设计

框架设计的权衡

框架设计里面到处体现了权衡的艺术。

在框架设计之初,我们的最初的构想往往是“既要…又要…”,但是往往现实是非常残酷的, 因此我们需要处处作出权衡。

  • 框架的设计应该将其设计为命令式还是声明式 ?
  • 框架需要设计成纯运行时还是纯编译时,还是设计为运行时 + 编译时 ?

这里只是举了一部分例子,但是从这些例子也可以看出,处处都需要权衡。

范式的权衡

从编程范式来看,可以分为两大编程范式:

  • 命令式编程

    • 强调的是 How (强调结果)
    • 入门门槛是比较低,这也是为什么最初流行都是命令式的编程语言
    // 计算数组中偶数的和
    const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
    let total = 0;
    for (let i = 0; i < arr.length; i++) {
      if (arr[i] % 2 === 0) {
        total += arr[i];
      }
    }
    console.log(total);
    
    • 随着项目规模的扩大,开发者使用命令式编程是非常痛苦
    /*
     * 需求:
     * 获取 id 为 app 的 div
     * 它的文本内容为 hello world
     * 为其绑定点击事件
     * 当点击时弹出提示 ok
     */
    const div = document.querySelector("#app");
    div.innerText = "hello world";
    div.addEventListener("click", () => alert("ok"));
    
    // 这样的编程范式,思路倒是非常清晰
    // 但是项目一大,使用起来非常痛苦
    
    • React 官网有一张关于命令式编程的一张非常形象的图

    image-20231211094001538

  • 声明式编程

    • 强调的是 What
    • 入门门槛比较高
    // 计算数组中偶数的和
    const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
    console.log(arr.filter((i) => i % 2 === 0).reduce((a, b) => a + b));
    
    • 声明式虽然门槛比较高,但是一旦将声明背后的代码搭建好,开发者使用起来就非常的爽
    <div @click="()=>alert('ok')">hello world</div>
    
    • React 官网针对这种声明式也有一张图

    image-20231211094049358

目前看上去声明式相比命令式要好得多,但是命令式真的就是一无是处么 ?

非也。

声明式代码的性能不可能比命令式更高的,声明式的背后仍然是命令式。

举个具体的例子:我们要将 div 的文本内容修改为 hello vue3,命令式操作如下:

div.textContent = "hello vue3";

在命令式中,明确的知道了哪些地方发生变化,要做的事情仅仅是做必要的修改即可。

但是声明式就做不到这一点,因为声明描述的是结果:

<!-- 修改前 -->
<div @click="()=>alert('ok')">hello world</div>
<!-- 修改后 -->
<div @click="()=>alert('ok')">hello vue3</div>

既然描述的结果,因此对于框架来讲,需要去找到发生变化,然后最终变化的改变仍然是上面的那一句命令式代码。

假设直接修改性能消耗为 A,找差异的性能消耗为 B:

  • 命令式更新的性能消耗 = A
  • 声明式更新的性能消耗 = B + A

因此,最终就落在了框架开发者需要作出权衡。

目前,在现代前端框架中,最终,大家都选择了声明式。因为项目的规模越来越大,声明式相比命令式更加好维护。假设采用的是命令式,需要维护的是整个实现目标的过程,声明式需要维护的最终想要的结果。

运行时和编译时的权衡

在进行框架设计的时候,究竟是设计成纯运行时,还是设计成纯编译时,还是设计成运行时+编译时,也需要框架设计者进行权衡。

纯运行时

假设我们设计了一个框架,里面有一个 Render 函数,其他框架的使用者可以调用这个 Render 函数,在调用的时候,可以传入一个树形的数据对象,然后 Render 函数就会根据开发者传入的数据对象进行 DOM 渲染。

假设用户(开发者、框架使用者)提供的数据对象如下:

const obj = {
  tag: "div",
  children: [
    {
      tag: "span",
      children: "hello world",
    },
  ],
};

我们的框架提供的 Render 函数长这样:

function Render(obj, root) {
  const el = document.createElement(obj.tag);
  if (typeof obj.children === "string") {
    const text = document.createTextNode(obj.children);
    el.appendChild(text);
  } else if (obj.children) {
    // 数组,递归调用 Render,使用 el 作为 root 参数
    obj.children.forEach((child) => Render(child, el));
  }

  // 将元素添加到 root
  root.appendChild(el);
}
Render(obj, document.body);

此时,我们所设计的这个框架,就是一个纯运行时框架,里面没有涉及到任何的编译操作,开发者所写好的代码,直接扔给我们的框架运行即可。

运行时 + 编译时

对于框架使用者来讲,通过数据对象来描述 UI,当 UI 规模一大,简直是一场灾难,我们需要提供一种类 HTML 的形式

<div>
  <span>hello world</span>
</div>

上面的这种 UI 描述方式,对于框架使用者来讲很爽,但是我们的 Render 不认识这种方式,因此这里涉及到了编译。

所谓编译,就是将 A 语言翻译成 B 语言。

因此我们需要在我们的框架里面,设计一个编译器,假设叫做 Compiler

const html = `
  <div>
    <span>hello world</span>
  </div>
`;
// 先编译
const obj = Compiler(html);
// 再运行
Render(obj, document.body);

此时,我们的框架就变成了运行时 + 编译时的框架。

纯编译时

上面的步骤是先编译成数据对象,然后再交给 Render 运行。也许我们可以一步到位

<div>
  <span>hello world</span>
</div>

针对上面的模板,直接进行编译:

const div = document.createElement("div");
const span = document.createElement("span");
span.innerText = "hello world";
div.appendChild(span);
document.body.appendChild(div);

此时我们的框架就只需要 Compiler 这个方法进行编译,不再需要 Render 方法,那么此时我们的框架就变成了一个纯编译时框架。

  • Vue:运行时 + 编译时
    • 编译时:将模板编译成 vnode
    • 运行时:将 vnode 交给渲染器进行一个渲染
  • Svelte:纯编译时
    • 当初的一个噱头就是无虚拟 DOM

框架设计相关要素

下面是关于框架设计时一些核心要素。

给用户良好的反馈

我们用 Vue3 举一个例子:

create(App).mount("#non-exist");

如果传入的节点并不存在,那么 Vue 会给我们一个警告:

[Vue warn]: Failed to mount app: mount target selector "#non-exist" returned null

这条错误实际上就是 Vue 内部所给出的错误警告,这里对失败的原因进行了详细了说明,那么用户也能够快速定位问题所在。

假设 Vue 内部针对这个错误没有任何的处理,那么最终用户那边得到的就是:

TypeError: Cannot read property 'xxx' of null

这样对于用户而言,很难定位错误。

控制框架代码的体积

一般来讲,要给用户提供良好的开发体验,那么自然框架内部的代码就会越多,但是这里又要控制代码的体积,感觉有点冲突

实际上,可以通过一些打包工具的特性来很好的做到一个平衡。

例如在 Vue 或者 React 源码中,当调用 warn 函数来输出一个警告的时候,通常会配合一个 __DEV__ 常量:

if (__DEV__ && !res) {
  warn(
    `Failed to mount app: mount target selector "${container}" returned null`
  );
}

__DEV__ 实际上就是看你是否处于开发环境,最终在进行构建的时候

如果是开发环境,那么 __DEV__ 会被设置为 true,那么最终构建的开发环境资源就会包含这部分代码

如果是生产环境,那么 __DEV__ 就会被设置为 false,那么最终的构建产物就不包含这部分代码

这样我们就做到了 在开发环境为用户提供友好的警告信息,但是在生产环境能够缩小体积

良好的 Tree-shaking

Tree-shaking 这个概念最早是 rollup 所提出的,简单来讲就是消除 dead code,目前无论是 rollup 还是 webpack 都支持 Tree-shaking。

构建产物

我们所设计的框架,往往用户所使用的场景是多种多样的。

一个 Vue 既可以通过 CDN 的方式来引入:

<body>
  <script src="path/to/vue.js"></script>
  <script>
    const { createApp } = Vue;
    // ...
  </script>
</body>

也可以通过 ESM 的方式来引入:

<script src="path/to/vue.esm-brower.js" type="module"></script>

甚至还有的用户需要通过 require 的方式来引入:

const Vue = require("vue");

因此,这就要求我们在对框架进行构建的时候,需要输出不同的包。幸亏有现代现代打包工具,无论是 rollup 还是 webpack,都能很轻松的输出不同格式的包,还可以一次性指定多种格式:

// rollup.config.js
import somePlugin from "some-rollup-plugin"; // 例子中的插件

export default [
  // IIFE 配置
  {
    input: "src/index.js",
    output: {
      file: "dist/bundle.iife.js",
      format: "iife",
      name: "MyBundle",
    },
    plugins: [somePlugin()],
  },
  // ESM 配置
  {
    input: "src/index.js",
    output: {
      file: "dist/bundle.esm.js",
      format: "esm",
    },
    plugins: [somePlugin()],
  },
  // CJS 配置
  {
    input: "src/index.js",
    output: {
      file: "dist/bundle.cjs.js",
      format: "cjs",
    },
    plugins: [somePlugin()],
  },
];

错误处理

这个也是衡量一个框架是否成熟的一个重要标志。框架的错误处理机制的好坏直接决定了用户应用程序的健壮性,以及用户在处理错误时的一个心智负担。

举个例子,假设我们的框架提供一个工具模块:

export default {
  foo(fn) {
    // 其他逻辑...
    fn && fn();
  },
};

用户在使用这个工具的时候,大概率就是这样使用的:

import utils from "utils";
utils.foo(() => {
  // 用户的逻辑
});

接下来思考一个问题 🤔

假设用户所提供的回调函数在执行的时候,报错了,该怎么办 ?

方案一

既然是用户的回调出错了,用户自己处理,执行 try…catch

import utils from "utils";
utils.foo(() => {
  try {
    // ...
  } catch (e) {
    // ...
  }
});

我们的方案一,用户需要自己手动的添加 try…catch 来捕获错误,这样实际上会增加用户的心智负担。

方案二

由我们来代替用户提供统一的错误处理方案:

export default {
  foo(fn) {
    // 其他逻辑...
    try {
      fn && fn();
    } catch (e) {
      // ...
    }
  },
  bar(fn) {
    // 其他逻辑...
    try {
      fn && fn();
    } catch (e) {
      // ...
    }
  },
};

我们可以针对上面的方案做一个改进:

export default {
  foo(fn) {
    // 其他逻辑...
    callWithErrorHandler(fn);
  },
  bar(fn) {
    // 其他逻辑...
    callWithErrorHandler(fn);
  },
};

function callWithErrorHandler(fn) {
  try {
    fn && fn();
  } catch (e) {
    // ...
  }
}

方案三

还可以继续优化,目前是我们框架内部提供了统一的错误处理机制,但是有些时候,用户想要自定义错误处理,所以我们可以再次改造:

let handleError = null;
export default {
  foo(fn) {
    // 其他逻辑...
    callWithErrorHandler(fn);
  },
  bar(fn) {
    // 其他逻辑...
    callWithErrorHandler(fn);
  },
  // 多提供一个方法
  registerErrorHandler(fn) {
    // fn 就是用户所提供的自定义错误处理机制
    handleError = fn;
  },
};
function callWithErrorHandler(fn) {
  try {
    fn && fn();
  } catch (e) {
    if (handleError) {
      // 说明用户是自定义了错误处理机制的
      handleError(e);
      return;
    }
    // ... 使用框架内部的错误处理机制
  }
}

那么此时,用户在使用我们的框架的时候,就可以自定义错误处理:

import utils from "utils";
utils.registerErrorHandler(function () {
  // 用户自定义的错误处理机制
});
utils.foo(() => {
  // 用户的逻辑
});

实际上,在 Vue 的内部,也提供了类似了的机制,让用户可以自己来注册统一的错误处理函数:

import App from "App.vue";
const app = createApp(App);
app.config.errorHandler = () => {
  // 错误处理程序
};

还要很多其他的点:

  • 良好的 TS 支持
  • 源码管理方式
  • 是否提供脚手架
    • 实际上,脚手架的原理非常的简单,脚手架的本质是一个命令行工具,在这个命令行工具里面,可以读取用户的选择,之后根据用户的选择,从远程仓库克隆对应类型的项目到你本地。
文章来源:https://blog.csdn.net/CSDNWuZhiChun/article/details/135414459
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。