普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月16日首页

现代 CSS 颜色使用指南

作者 冴羽
2026年1月16日 11:21

1. 前言

大多数开发者处理颜色时,就是从设计稿里复制粘贴一个色值,然后就完事了。

但是!CSS 颜色在过去几年里发生了很多变化!

不仅从 Web 十六进制代码演变成了 hsl() 函数,而且就连你熟知的 rgb() 函数也跟以前不同了!

不信我们接着往下看。

2. 现代 CSS 写法

2.1. 你不需要添加 a

以前,我们用 rgb() 填写普通的 RGB 颜色值,要想改变不透明度,就必须使用 rgba()

.red {
  color: rgb(255, 0, 0);
}

.red-50 {
  color: rgba(255, 0, 0, 0.5);
}

现在,你可以直接添加第 4 个通道了:

.red {
  color: rgb(255, 0, 0);
}

.red-50 {
  color: rgb(255, 0, 0, 0.5);
}

而且,不用担心浏览器兼容问题,只要你不用支持 IE。

2.2. 空格分隔语法

除此之外,逗号现在也被视为老语法了,对于新的颜色函数,我们甚至不能再使用逗号,只能使用新的空格语法。

.red {
  color: rgb(255 0 0);
}

.blue {
  color: hsl(226 100% 50%);
}

不过,使用空格分隔语法时要注意:你不能为 alpha 通道添加第四个值。

换句话说,这样写 color: rgb(255 0 0 0.5) 是不可以的。

如果你要添加第 4 个值,你需要在字母值之前使用一个正斜杠:

.red {
  color: rgb(255 0 0);
}

/* 50% 透明 */
.red-50 {
  color: rgb(255 0 0 / 0.5);
}

.hsl-red-50 {
  color: hsl(0 100% 50% / 0.5);
}

为什么要这么做呢?

因为 CSS 新增了很多颜色函数,统一用斜杠可以快速区分“颜色值”和“透明度”,看代码时一目了然。

2.3. hsl() 也变了

单位现在可选了:

.red {
  color: hsl(0deg 100% 50%);
}

.also-red {
  color: hsl(0deg 100 50);
}

.another-red {
  color: hsl(0 100% 50%);
}

.this-is-red-too {
  color: hsl(0 100 50);
}

小提示: 最好还是保留百分号,因为 VS Code 只有带百分号的才会显示颜色预览。

3. 相对颜色

相对颜色是什么?

简单说就是基于现有颜色生成新颜色。

3.1. 基础用法

我们从一个简单的例子开始:

.rgb-red {
  color: rgb(from #ff0000 r g b);
}

这行代码的意思是:

基于 #ff0000 这个颜色,提取它的红(r)、绿(g)、蓝(b)值,然后用这些值创建一个新颜色。

结果其实就是 rgb(255 0 0)

看起来很傻对吧?但重点是下面这个:

.rgb-red {
  color: rgb(from #ff0000 r g b);
}

/* 轻松创建 50% 透明度版本 */
.rgb-red-50 {
  color: rgb(from #ff0000 r g b / 0.5);
}

3.2. 最实用的场景:处理 CSS 变量

以前你想让一个 CSS 变量颜色变透明,得这样:

:root {
  --color-primary: #2563eb;
  --color-primary-transparent: rgba(37, 99, 235, 0.75); /* 手动转换,麻烦! */
}

现在呢,你可以直接这样写:

:root {
  --color-primary: #2563eb;
}

.semi-transparent-primary-background {
  /* 直接基于变量创建透明版本 */
  background-color: hsl(from var(--color-primary) h s l / 0.75);
}

就像你有一张照片,以前想调整透明度要重新处理一遍,现在滤镜一点就搞定。

3.3. 快速生成配色方案

我们可以快速创建基础颜色的浅色和深色版本:

:root {
  --base: hsl(217 73% 50%);
  --base-light: hsl(from var(--base) h s 75%); /* 调亮 */
  --base-dark: hsl(from var(--base) h s 25%); /* 调暗 */
}

比如实现一个 Toast,它可能基于一种基础颜色,然后创建一种较深的颜色用于文本,一种较浅的颜色用于背景,还需要一个不透明度较低的颜色用于阴影。

以前要 4 个值,现在直接 1 个值搞定:

.toast {
  --toast-color: #222;

  /* 深色文字 */
  color: hsl(from var(--toast-color) h s 15%);

  /* 原色边框 */
  border: 2px solid var(--toast-color);

  /* 浅色背景 */
  background: hsl(from var(--toast-color) h s 90%);

  /* 半透明阴影 */
  box-shadow: 0 12px 12px -8px hsl(from var(--toast-color) h s l / 0.325);
}

此时换颜色也超简单:

[data-toast="info"] {
  --toast-color: #0362fc; /* 蓝色 */
}

[data-toast="error"] {
  --toast-color: hsl(0 100% 50%); /* 红色 */
}

一个变量搞定所有颜色变体,优雅!十分优雅!

4. 浅暗主题切换

4.1. 以前的痛苦

不知道你是否实现过网站的浅色和深色主题:

:root {
  /* 默认浅色主题 */
  --text-heading: #000;
  --text-body: #212121;
  --surface: #efefef;

  @media (prefers-color-scheme: dark) {
    /* 暗色主题 - 第一遍 */
    --text-heading: #fff;
    --text-body: #efefef;
    --surface: #212121;
  }
}

.dark-theme {
  /* 暗色主题 - 又写一遍! */
  --text-heading: #fff;
  --text-body: #efefef;
  --surface: #212121;
}

同样的颜色写两遍,一个给媒体查询(自动切换),一个给切换按钮。

改一次要改两个地方,烦死了!

4.2. 现在的解决方案:light-dark()

:root {
  /* 跟随系统偏好 */
  color-scheme: light dark;

  /* 一次定义,自动切换 */
  --text-heading: light-dark(#000, #fff);
  --text-body: light-dark(#212121, #efefef);
  --surface: light-dark(#efefef, #212121);
}

light-dark(浅色, 暗色) 就这么简单!系统是浅色就用第一个,暗色就用第二个。

4.3. 添加手动切换按钮

如果让用户手动切换主题:

:root {
  /* 默认跟随系统 */
  color-scheme: light dark;
  --text-heading: light-dark(#000, #fff);
  --text-body: light-dark(#212121, #efefef);
  --surface: light-dark(#efefef, #212121);
}

/* 用户选了浅色,锁定 */
html[data-theme="light"] {
  color-scheme: light;
}

/* 用户选了暗色,锁定 */
html[data-theme="dark"] {
  color-scheme: dark;
}

4.4. 组件精细控制

如果某个区域必须保持固定颜色,比如某个背景图上必须是白字:

.hero {
  /* 不管全局主题怎么变,这里永远是浅色主题 */
  color-scheme: light;
  background: url("light-background.webp");
}

5. 渐变优化

5.1. 以前的痛苦

让我们直接看例子,这是一个从蓝到红的渐变:

.gradient {
  --color-1: hsl(219 76 41); /* 蓝色 */
  --color-2: hsl(357 68 53); /* 红色 */
  background: linear-gradient(90deg, var(--color-1), var(--color-2));
}

中间会出现灰扑扑的颜色,不好看:

以前你只能手动加个中间色:

.better {
  --middle: hsl(271 52 41); /* 紫色 */
  background: linear-gradient(90deg, var(--color-1), var(--middle), /* 加一个中间色 */ var(--color-2));
}

是不是好看多了:

但是麻烦呀!我甚至可能需要添加两到三个额外的色阶,以确保它与原设计完全相同。

5.2. 现在的解决办法:指定颜色空间

现在你可以轻松解决,只需要指定一个颜色空间:

.better {
  /* 用 oklch 颜色空间插值,中间色更鲜艳 */
  background: linear-gradient(in oklch 90deg, var(--color-1), var(--color-2));
}

就像拍照时选滤镜,不同的颜色空间会产生不同的中间色效果。oklch 在大多数情况下效果最好。

看下效果对比:

唯一的真正问题是,不同的颜色空间可能更适合不同的渐变,所以有时确实需要花点时间摸索。

可选的颜色空间有:

  • oklch / lch
  • oklab / lab
  • hwb
  • xyz
  • 不指定默认是 srgb

5.3. 实现彩虹渐变

以前做彩虹要指定每一个颜色。

现在只需要:

.rainbow {
  /* 从红色绕一整圈再回到红色,走长路径 */
  background: linear-gradient(in hsl longer hue 90deg, red, red);
}

longer hue 的意思是“走远路”,这样就能经过所有颜色了。

实现效果如下:

6. 超宽色域——当客户就要那个色

有时候客户会拿着他们的 Logo 说:“我就要这个色,一模一样的!”

问题是,普通的 hex、rgb()hsl() 用的是 sRGB 色域,能表示的颜色有限。

这就像以前电视只能显示几百种颜色,然而现在的手机屏幕能显示几百万种。

6.1. 解决方案:display-p3

为了满足客户的需求,你可以使用 color颜色函数,并使用 display-p3 色域。

.vibrant-green {
  /* 使用 display-p3 色域,颜色更鲜艳 */
  color: color(display-p3 0 1 0);
}

如果浏览器不支持,会自动回退到能显示的最接近颜色,不会出错。

你可以在 Chrome 的开发者工具查看这种颜色:

点击色块,它会显示你选择的颜色,但同时也会显示使用 sRGB 色域的显示器的色域限制。

建议: 除非客户真的非常在意那个特定颜色,一般用不着。但知道有这个方案总是好的。

7. 总结

现在你可以更轻松地写代码了:

  1. 少打字 - 不用区分 rgbargb,用空格代替逗号
  2. 少定义变量 - 用相对颜色基于一个颜色生成多个变体
  3. 少写重复代码 - light-dark() 一次定义两套主题
  4. 更好的渐变 - 指定颜色空间让中间色更漂亮
  5. 更精确的颜色 - 需要时可以用更宽的色域

最重要的是: 这些特性浏览器支持都很好了(除了 IE,但谁还在乎 IE 呢?)

我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。

欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。

Vue3向全局广播数据变化

作者 Rrvive
2026年1月16日 11:12

✅ 功能点

我们要做到:

  • 🧠 ruleForm 仍然是唯一状态源
  • 🔁 formDataToJson 自动跟随 ruleForm
  • ⚡ 子组件 直接拿“已处理好的 params”
  • 🚫 不在每个组件里重复写转换逻辑

一、升级 useFilterState(关键)

✅ 改造点:加入 params(computed)

useFilterState.ts

import { reactive, provide, watch, computed } from "vue";
import { ElMessage } from "element-plus";

export const FILTER_KEY = Symbol("FILTER_STATE");

export function useFilterState() {
  const ruleForm = reactive({
    time_menu: "day",
    timeRange: [] as any[],
    service_info: [] as any[],
  });

  /** ✅ 派生:接口参数 */
  const params = computed(() => {
    if (ruleForm.time_menu === "custom") {
      if (ruleForm.timeRange.length === 2) {
        const [start_time, end_time] = ruleForm.timeRange;
        return {
          start_time,
          end_time,
          service_info: ruleForm.service_info,
          time_menu: ruleForm.time_menu,
        };
      }
      return null;
    }

    return {
      time_menu: ruleForm.time_menu,
      service_info: ruleForm.service_info,
    };
  });

  /** 可扩展的 change hook */
  const onChangeCbs: Function[] = [];

  watch(
    ruleForm,
    () => {
      onChangeCbs.forEach((cb) => cb(ruleForm));
    },
    { deep: true }
  );

  provide(FILTER_KEY, {
    ruleForm,
    params,
    onFilterChange: (cb: Function) => onChangeCbs.push(cb),
  });

  return {
    ruleForm,
    params,
  };
}

useFilterInject.ts

import { inject } from "vue";
import { FILTER_KEY } from "./useFilterState";

export function useFilterInject() {
  const ctx = inject<any>(FILTER_KEY);
  if (!ctx) {
    throw new Error("useFilterInject 必须在 useFilterState 下使用");
  }
  return ctx;
}


二、页面中用法(更干净了)

<script setup lang="ts">
import { useFilterState } from "@/composables/useFilterState";

const { ruleForm, params } = useFilterState();
</script>

三、子组件中如何用(重点)

任意子组件

<script setup lang="ts">
import { watchEffect } from "vue";
import { useFilterInject } from "@/composables/useFilterInject";
import { getRiskInfo } from "@/api/visualizedOperations";

const { params } = useFilterInject();

async function fetchData() {
  if (!params.value) return; // custom 未选时间
  await getRiskInfo(params.value);
}

watchEffect(fetchData);
</script>

🎯 效果:

  • ruleForm 任意字段变化
  • params 自动重新计算
  • watchEffect 自动触发
  • 所有组件 同步刷新

四、如果你还想保留 ElMessage 提示

不要在 computed 里直接弹消息(副作用)
更优雅的方式是:提交或请求前校验

推荐写法

function validateParams(params: any) {
  if (!params) {
    ElMessage.warning("请选择时间范围");
    return false;
  }
  return true;
}

使用时:

if (!validateParams(params.value)) return;

五、为什么这是「最佳实践」

原来 现在
formDataToJson scattered 单一来源
子组件 自己拼参数 直接用
维护成本
复用
状态一致性 易乱

六、结构总结(你现在的架构)

页面
 ├─ useFilterState()
 │   ├─ ruleForm   ✅ 原始状态
 │   ├─ params     ✅ 派生参数
 │
 ├─ AlarmView
 │   └─ watchEffect(params) → 请求
 │
 └─ DataView
     └─ watchEffect(params) → 请求

七、一句话记住这个模式

状态在 ruleForm,逻辑在 computed,请求在组件

这套模式特别适合:

  • 可视化运营页面
  • 多组件联动筛选
  • 不想上 Pinia / URL

为啥 Array.isArray 判断数组最靠谱?

2026年1月16日 11:12

JavaScript 中,如何判断一个值是否为数组是很常见的需求,一般有以下几种方法。

1、方式一:JSON.stringify

可以通过 JSON.stringify 序列化字符串,然后判断字符串是否以 [" 开头,以 "] 结尾来判断是否为数组。这其实和使用 JSON.stringify(obj) === '{}' 判断一个对象是否是空对象的思路类似。

const arr = [];
console.log(JSON.stringify(arr).startsWith('[') && JSON.stringify(arr).endsWith(']')); // true

优点

  • 兼容性好。

缺点

  • 需要序列化性能较差。
  • 无法处理循环引用的数据,会报错 TypeError: Converting circular structure to JSON
  • 判断代码也较为复杂。
const arr = [];
arr[0] = arr; // 添加循环引用
console.log(JSON.stringify(arr).startsWith('[') && JSON.stringify(arr).endsWith(']')); // 报错 TypeError: Converting circular structure to JSON

2、方式二:instanceof

instanceof 可以判断某个实例是否属于某种类型,它内部会通过原型链往上一级一级查找,直到找到和当前对象的原型相等的原型。

console.log([] instanceof Array);      // true
console.log({} instanceof Array);      // false

但是原型链是可以被修改的,所以 instanceof 判断结果可能不准确。比如我们可以利用 Object.setPrototypeOf(target, newProto) 来修改原型。

const arr = {};
Object.setPrototypeOf(arr, Array.prototype);
console.log(arr instanceof Array); // true

还有一种情况,就是跨 iframe 进行判断时,instanceof 判断结果可能不准确。

<!DOCTYPE html>
<html lang="en">

<body>
  <script>
    const iframe = document.createElement('iframe');
    document.body.appendChild(iframe);
    const iframeArray = iframe.contentWindow.Array;
    const iframeArr = new iframeArray();
    console.log(iframeArr instanceof Array);  // false
  </script>
</body>

</html>

这里其实 iframeArr 是一个数组,但是 instanceof 判断结果却是 false

优点

  • 简单直观。

缺点

  • 通过 Object.setPrototypeOf 修改原型后判断不准确。
  • iframe 失效。

3、方式三:constructor 属性

每个实例对象都有一个 constructor 属性,指向它的构造函数,所以通过 constructor 属性可以判断一个实例是否属于某个类型。

console.log([].constructor === Array);  // true
console.log({}.constructor === Array);  // false

然而和原型一样,constructor 属性也是可以被修改的。

const arr = [];
arr.constructor = Object; // 修改 constructor
console.log(arr.constructor === Array); // false(被修改了导致判断错误)

优点

  • 简单直观。

缺点

  • constructor 可被修改,判断不准确。

4、方式四:Object.prototype.toString.call

Object.prototype.toString.call()JavaScript 中一个非常强大的类型检测方法,它可以获取任意值的内部 [[Class]] 属性,并以字符串形式返回其类型。

const arr = [];
console.log(Object.prototype.toString.call(arr) === '[object Array]'); // true

它是一个检测数据类型的标准方法,它不仅可以检查数组 Array、对象 Object,基本数据类型和其它引用类型像日期 Date正则 regexp 等都可以判断出来,实际开发中通常会把它封装成一个通用的类型检测方法。

// 判断特定类型
function isType(value, type) {
    return Object.prototype.toString.call(value) === `[object ${type}]`;
}

console.log(isType([], 'Array'));     // true
console.log(isType({}, 'Object'));    // true
console.log(isType(new Date(), 'Date')); // true
console.log(isType(/abc/, 'RegExp')); // true

但是在 es6Symbol出现之后,可以通过 Symbol.toStringTag 属性来控制其返回值,也会导致其判断不准确。

const arr = {
  [Symbol.toStringTag]: 'Array'
}
console.log(Object.prototype.toString.call(arr) === '[object Array]'); // true

Symbol.toStringTag 也可以支持设置为任意值。

const obj = {
  [Symbol.toStringTag]: '111' // 可以设置为任意值
}
console.log(Object.prototype.toString.call(obj)); // [object 111]

优点

  • 简单直观。
  • 不仅可以判断数组,基本数据类型和其它引用数据类型 DateRegexp 也可以。

缺点

  • 代码较长,并且可通过 Symbol.toStringTag 修改判断结果。

5、方式五:Array.isArray(最推荐)

Array.isArray()Array 的一个静态方法,可以判断一个值是否为数组。

const arr = [];
console.log(Array.isArray(arr));  // true

优点

  • 最可靠。
  • 简单直观。

缺点

  • 兼容性问题,只支持 IE9+

为什么 Array.isArray 是最靠谱的呢?

1. 通过判断内部存储的数据结构是否符合数组的数据结构定义

数组是一种数据结构:

  • 它是一种线性表数据结构,也就是它里面的数据是排成一条线一样的,它的数据只有前和后两个方向。除了数组,队列、栈、链接也是线性表数据结构。
  • 连续的内存空间。就是说数据中的元素在内存中是连续存储的,正是因为这个特性,所以数据可以实现随机访问,也就是访问数据中的每个元素花的时间就是一样的,时间复杂度都是 O(1),查询数据特别快。

2. 它是一个原生方法,内部 C++ 编码实现,我们从 js 代码层面无法判断数据内部的存储结构,也就无法模拟实现。

小结

判断是否是数组的方法 优点 缺点 推荐度
Array.isArray() 标准、跨环境、最可靠 兼容性问题,只支持IE9+ ⭐⭐⭐⭐⭐
Object.prototype.toString.call() 最兼容、可靠 代码较长,并且可通过 Symbol.toStringTag 修改判断结果 ⭐⭐⭐⭐
instanceof 简单直观 通过 Object.setPrototypeOf 修改原型后判断不准确,跨 iframe 失效 ⭐⭐⭐
constructor 简单 constructor 可被修改 ⭐⭐
JSON.stringify 兼容性好 无法处理循环引用、性能差

在平时实际前端项目开发中,如果需要判断是否是数组,直接用 Array.isArray() 就好了,它内部通过 C++ 编码实现,判断其存储结构是否为数组,是最可靠的判断方法,如果需要封装一个通用的数据类型判断方法,可以采用 Object.prototype.toString.call()

数据连接开发设计文档

作者 爱吃香菜i
2026年1月16日 11:08

数据连接开发设计文档

1. 文档概述

1.1 文档目的

本文档旨在规范前端数据连接模块的开发与集成,确保开发团队能够依据此文档进行统一、高效、安全的数据连接开发。文档包含数据连接架构设计、API接口规范等核心内容。

1.2 功能背景

根据数据接口文档的需求,需要对数据接口相关功能进行前端开发,包括审批流程、动态流程节点、RPA机器人配置和动态表单等模块的数据接口调用和展示。同时,系统需要支持数据连接的管理,包括数据连接的新增、编辑、删除和测试连接等功能。

2. 数据连接架构设计

2.1 系统架构图

graph TD
    A[前端应用] --> B[数据连接管理模块]
    B --> C[DataConnectionModule主容器]
    C --> D[数据连接列表组件]
    C --> E[数据连接表单组件]
    C --> F[数据连接测试组件]
    C --> G[API请求层]
    G --> H[数据连接API]
    H --> I[后端服务]
    I
    I --> J[数据库连接池]
    J --> K[MySQL数据库]

2.2 核心组件结构

DataConnectionModule
├── DataConnectionModule.vue        # 数据连接功能主容器组件
├── components/
│   ├── DataConnectionList.vue      # 数据连接列表组件
│   ├── DataConnectionForm.vue      # 数据连接表单组件
│   ├── DataConnectionTest.vue      # 数据连接测试组件
├── hooks/
│   ├── useDataConnection.ts        # 数据连接逻辑Hook
│   └── useApiRequest.ts            # API请求Hook
├── types/
│   ├── dataConnection.ts           # 数据连接类型定义
│   └── dataInterface.ts            # 数据接口类型定义
└── index.ts                        # 模块入口

2.3 数据流向图

graph TD
    A[用户操作] --> B[DataConnectionModule]
    B --> C{操作类型?}
    C -->|新增| D[数据连接表单组件]
    C -->|编辑| E[数据连接表单组件]
    C -->|删除| F[调用删除API]
    C -->|测试连接| G[数据连接测试组件]
    D --> H[验证表单数据]
    E --> H
    H --> I{验证通过?}
    I -->|是| J[调用保存API]
    I -->|否| K[显示错误信息]
    G --> L[调用测试连接API]
    J --> M[后端处理]
    L --> M
    F --> M
    M --> N[返回响应结果]
    N --> O[更新前端状态]
    O --> P[显示操作结果]

3. API接口规范说明

3.1 数据连接API

接口名称 URL 请求方法 请求参数 返回结果 描述
获取数据连接列表 /api/data-connection/list GET name: string { success: boolean, data: DataConnection[], message: string } 获取完整数据连接列表,全量查询
获取数据连接详情 /api/data-connection/detail GET id: string { success: boolean, data: DataConnection, message: string } 获取数据连接详情
新增数据连接 /api/data-connection/add POST DataConnection { success: boolean, data: { id: string }, message: string } 新增数据连接
修改数据连接 /api/data-connection/update PUT id: string, DataConnection { success: boolean, message: string } 修改数据连接
删除数据连接 /api/data-connection/delete DELETE id: string { success: boolean, message: string } 删除数据连接
测试数据连接 /api/data-connection/test POST DataConnection { success: boolean, message: string } 测试数据连接
获取数据库类型列表 /api/data-connection/types GET - { success: boolean, data: string[], message: string } 获取支持的数据库类型列表,当前仅返回["MySQL"]
获取驱动列表 /api/data-connection/drivers GET dbType: string { success: boolean, data: string[], message: string } 获取指定数据库类型的驱动列表,当前仅支持MySQL

4. DataConnectionModule.vue组件设计

4.1 组件功能

DataConnectionModule.vue作为数据连接功能的主容器组件,负责整合所有相关子组件,实现统一的视图管理和交互逻辑。它包含以下核心功能:

  1. 数据连接列表展示
  2. 新增数据连接
  3. 编辑数据连接
  4. 删除数据连接
  5. 测试数据连接
  6. 数据连接详情查看

4.4 DataConnectionForm.vue组件设计

4.4.1 组件功能

DataConnectionForm.vue是数据连接的核心表单组件,负责数据连接的新增和编辑功能。它提供以下核心功能:

  1. 数据连接表单的渲染
  2. 表单数据的验证(在保存和测试连接前触发)
  3. 表单数据的保存
  4. 与父组件的数据同步
4.4.3 组件交互逻辑
  1. 表单验证触发条件

    • 表单验证仅在特定条件下触发
    • 仅在父组件调用validateFormvalidateAndSave方法时触发完整验证流程
    • 验证触发场景:
      • 点击头部区域的"保存"按钮时
      • 点击头部区域的"测试连接"按钮时
    • 输入过程中不进行实时验证,提升用户体验
  2. 表单数据同步

    • 表单数据与父组件通过props双向绑定
    • 父组件可以实时获取表单数据
    • 外部model变化时,表单数据自动更新
  3. 方法暴露

    • validateForm:触发表单完整验证,返回布尔值表示验证结果
    • validateAndSave:触发表单验证并保存数据,返回布尔值表示操作结果
    • 这两个方法仅由父组件在点击对应按钮时调用
  4. 表单布局

    • 纯表单布局,不包含任何操作按钮
    • 所有操作按钮统一放置在父组件的头部区域
    • 表单样式与现有界面风格保持一致
    • 支持滚动,确保在不同屏幕尺寸下都能完整显示所有字段

4.3 组件交互逻辑

4.3.1 表单验证触发条件
触发场景 触发方式 验证范围 验证结果处理
保存数据连接 点击头部区域的"保存"按钮 完整表单验证 验证通过则保存数据,否则显示错误提示
测试数据连接 点击头部区域的"测试连接"按钮 完整表单验证 验证通过则打开测试对话框,否则显示错误提示
其他交互场景 用户输入、切换字段等 无(仅在表单提交时验证) 不进行验证,提升用户体验
4.3.2 按钮布局调整后的界面结构
  1. 界面整体结构

    • 顶部:系统统一管理系统标题栏
    • 左侧:导航菜单
    • 右侧主内容区:
      • 头部:数据连接管理标题和功能操作区(包含所有操作按钮)
      • 主体:左右分栏布局
        • 左侧:数据连接列表
        • 右侧:数据连接表单/详情
  2. 头部功能操作区结构

    • 左侧:数据连接管理标题
    • 右侧:功能按钮组,包含以下按钮:
      • "设置超时"按钮(始终显示)
      • "新增数据连接"按钮(仅在右侧面板未显示时显示)
      • 右侧面板操作按钮组(仅在右侧面板显示时显示):
        • 表单模式(新增/编辑):
          • "保存"按钮
          • "测试连接"按钮
          • "取消"按钮
        • 查看模式
          • "编辑"按钮
          • "取消"按钮
  3. 按钮布局规则

    • 所有功能按钮统一放置于头部区域,形成功能操作区
    • 按钮按照使用频率和重要性排序
    • 按钮样式保持一致,符合Element Plus设计规范
    • 不同模式下显示不同的按钮组合
    • 按钮状态根据当前上下文动态变化(如禁用状态)
4.3.3 核心交互流程
  1. 新增数据连接

    • 点击头部区域的"新增数据连接"按钮,在界面右侧区域显示完整的新增数据连接表单
    • 填写表单数据
    • 点击头部区域的"保存"按钮:
      • 触发表单完整验证
      • 验证通过后,调用API保存数据连接
      • 保存成功后,系统不关闭右侧表单面板,而是自动将表单从编辑模式切换为查看模式
      • 保留面板显示状态并展示已保存的表单数据
      • 刷新数据连接列表
    • 点击头部区域的"测试连接"按钮:
      • 触发表单完整验证
      • 验证通过后,打开测试连接对话框
      • 调用API测试数据连接,显示测试结果
    • 点击头部区域的"取消"按钮:
      • 关闭右侧表单面板
      • 取消操作不会保存任何修改
  2. 编辑数据连接

    • 在数据连接列表中点击"编辑"按钮,在界面右侧区域显示完整的编辑数据连接表单
    • 修改表单数据
    • 点击头部区域的"保存"按钮,保存修改后的数据
    • 点击头部区域的"测试连接"按钮,测试修改后的连接
    • 点击头部区域的"取消"按钮,取消编辑操作
  3. 查看数据连接详情

    • 在数据连接列表中点击列表项,在右侧面板中显示数据连接详情
    • 查看模式下,点击头部区域的"编辑"按钮,切换到编辑模式
    • 点击头部区域的"取消"按钮,切换到查看模式
  4. 删除数据连接

    • 在数据连接列表中点击"删除"按钮,弹出确认对话框
    • 确认删除后,调用API删除数据连接
    • 删除成功后,刷新数据连接列表
    • 如果当前右侧面板正在查看或编辑该数据连接,则自动关闭右侧面板
  5. 设置超时时间

    • 点击头部区域的"设置超时"按钮,打开设置数据连接超时时间的弹框
    • 弹框中显示一个输入框,默认值为100毫秒,单位明确
    • 用户可以输入超时时间,仅允许非负整数
    • 输入为空表示不限制连接超时时间
    • 点击"确定"按钮,保存超时设置并应用到所有数据连接请求中
    • 点击"取消"按钮,关闭弹框,不保存任何设置
4.3.4 表单交互特点
  1. 无实时验证

    • 表单在输入过程中不进行实时验证
    • 仅在点击"保存"或"测试连接"按钮时触发完整验证
    • 提升用户体验,减少不必要的干扰
  2. 统一按钮布局

    • 所有操作按钮集中在头部区域,便于用户查找和操作
    • 减少用户在界面中的移动距离
    • 保持界面整洁,突出表单内容
  3. 清晰的模式区分

    • 不同模式下显示不同的按钮组合
    • 明确当前操作上下文
    • 避免用户误操作
  4. 流畅的状态切换

    • 模式切换时,按钮状态和表单内容平滑过渡
    • 保存成功后自动切换到查看模式
    • 提供明确的视觉反馈

12. 附录

12.1 数据连接类型定义


// src/types/dataConnection.ts
export interface DataConnection {
  id?: string;
  name: string;
  dbType: string; // 仅支持 "MySQL"
  driver: string;
  databaseName: string;
  host: string;
  port: string;
  url: string;
  username: string;
  password?: string;
  status?: string;
  createTime?: string;
  updateTime?: string;
}

14. 项目开发工时表

14.1 工时分配说明

根据数据连接模块的开发需求和任务复杂度,将总工作量40小时合理分配到UI编写、交互逻辑实现和接口联调三个主要任务中。工时分配遵循软件开发的常规流程和任务复杂度比例,确保资源分配合理高效。

14.2 详细工时表

任务名称 任务描述 预计耗时(小时)
UI编写 数据连接模块的界面设计与实现,包括主容器布局、表单组件、列表组件、对话框组件等UI元素的开发 15
 └ 主容器布局设计与实现 设计并实现数据连接模块的主容器布局结构 3
 └ 数据连接表单组件开发 开发数据连接的新增和编辑表单组件 5
 └ 数据连接列表组件开发 开发数据连接列表展示组件 3
 └ 对话框组件开发(测试连接、超时设置) 开发测试连接和超时设置的对话框组件 2
 └ 样式优化与响应式设计 优化组件样式并实现响应式布局 2
交互逻辑实现 数据连接模块的交互逻辑开发,包括表单验证机制、按钮事件处理、模式切换、状态管理等核心功能实现 18
 └ 表单验证机制设计与实现 设计并实现表单验证逻辑,包括必填项、格式验证等 4
 └ 按钮事件处理逻辑 实现各种按钮的点击事件处理逻辑 3
 └ 模式切换与状态管理 实现编辑、查看、新增等模式的切换和状态管理 5
 └ 组件间通信机制实现 实现组件间的数据传递和通信机制 3
 └ 数据缓存与状态同步 实现数据缓存和组件状态同步机制 3
接口联调 数据连接模块与后端API的接口联调,包括数据连接的增删改查、测试连接、超时设置等接口的调试和验证 7
 └ 数据连接CRUD接口联调 调试数据连接的增删改查接口 3
 └ 测试连接接口联调 调试测试连接功能接口 2
 └ 超时设置接口联调 调试超时设置功能接口 1
 └ 接口异常处理与边界情况测试 测试接口异常处理和边界情况 1
总计 - 40

Async/Await:让异步像同步一样简单

2026年1月16日 10:00

上一期我们学会了用 Promise 链式调用来摆脱回调地狱。
今天我们再往前迈一步,用 async/await 把异步代码写得像同步代码一样直观

async/await 是 ES2017 引入的语法糖,本质上还是 Promise,但极大提升了代码的可读性。

1. 基本写法

// 普通 Promise 写法
function fetchUser() {
  return fetch('/api/user')
    .then(res => res.json())
    .then(data => data.user);
}

// async/await 写法
async function fetchUser() {
  const res = await fetch('/api/user');
  const data = await res.json();
  return data.user;
}

关键点

  • 在函数前加 async,这个函数就自动返回一个 Promise
  • 在 Promise 前面加 await,表示“等到这个 Promise 完成再继续往下走”
  • 代码从上到下顺序执行,像写同步代码一样

2. 错误处理:使用 try…catch

async function loginFlow(username, password) {
  try {
    const token = await login(username, password);
    const user = await getUserInfo(token);
    const products = await getRecommendations(user.level);
    
    renderProducts(products);
    showSuccess("欢迎回来!");
  } catch (error) {
    console.error("登录流程失败:", error);
    showError("出错了,请稍后重试");
  }
}

优点

  • 只需要一个 try…catch,就能捕获整个流程中任意一步的错误
  • 错误位置清晰,栈追踪更有意义
  • 比 Promise 的 .catch() 更接近我们熟悉的同步错误处理方式

3. 常见使用场景

3.1 顺序执行多个异步操作

async function processOrder() {
  const order = await getOrderFromDB(orderId);
  const payment = await processPayment(order.amount);
  const shipping = await createShippingLabel(payment);
  await sendConfirmationEmail(shipping.trackingNumber);
  
  return shipping;
}

3.2 与 Promise.all 结合实现并发

async function loadUserData(userId) {
  const [profile, posts, friends] = await Promise.all([
    fetch(`/api/profile/${userId}`),
    fetch(`/api/posts/${userId}`),
    fetch(`/api/friends/${userId}`)
  ]);
  
  // 三个请求并发发起,最后一起等待完成
  const profileData = await profile.json();
  const postsData = await posts.json();
  const friendsData = await friends.json();
  
  return { profileData, postsData, friendsData };
}

3.3 在循环中使用 await(注意性能)

// 顺序执行(适合需要前一步结果的情况)
async function processItems(items) {
  for (const item of items) {
    const result = await processSingleItem(item);
    saveResult(result);
  }
}

// 如果不需要顺序,可以先 Promise.all 再循环
async function processItemsInParallel(items) {
  const promises = items.map(item => processSingleItem(item));
  const results = await Promise.all(promises);
  results.forEach(saveResult);
}

4. 常见陷阱与注意事项

问题 错误写法 正确写法 说明
在循环里 await 导致串行慢 forEach(item => await fn(item)) for…ofPromise.all forEach 不等待
忘记 await const data = fetch(url) const data = await fetch(url) 否则得到 Promise 对象
try…catch 范围太小 只 catch 单行 包住整个流程 否则错误漏掉
顶层 await 不支持(旧环境) 包在 async 函数里 现代模块支持顶层 await
性能误用 所有操作都 await 能并行的用 Promise.all 避免不必要的等待

5. 真实业务对比

Promise 链式

login(username, pwd)
  .then(token => getUserInfo(token))
  .then(user => getRecommendations(user.level))
  .then(products => render(products))
  .catch(err => showError(err));

async/await

async function start() {
  try {
    const token = await login(username, pwd);
    const user = await getUserInfo(token);
    const products = await getRecommendations(user.level);
    render(products);
  } catch (err) {
    showError(err);
  }
}

大多数开发者认为后者更清晰、更容易维护。

6. 小结:async/await 的核心价值

  • 可读性:代码结构接近日常同步思维
  • 错误处理:统一的 try…catch
  • 调试友好:断点更容易命中预期位置
  • 与 Promise 完全兼容:该并发时用 Promise.all,该顺序时用 await

一句话总结
async/await 让异步代码看起来像同步代码,但依然保留了异步非阻塞的本质。

下一期我们将把学到的异步知识应用到实际网络请求中:
Fetch API 与异步网络请求 —— 现代浏览器中最常用的数据获取方式。

我们下期见~

留言区互动:
你更喜欢 Promise 链式还是 async/await?
有没有在项目中因为 async/await 写法而修复过 bug 的经历?

拒绝废话!前端开发中最常用的 10 个 ES6 特性(附极简代码)

2026年1月16日 09:34

摘要:还在写冗长的传统 JS 代码吗?本文精选了 ES6+ 中最常用的 10 个特性,通过极简的代码对比,助你快速提升代码逼格与开发效率。建议收藏!

标签:#前端 #JavaScript #ES6 #Web开发 #新手入门

1. 变量声明 (let & const)

告别 var 的变量提升困扰,拥抱块级作用域。

  • let: 用于变量,可重新赋值。
  • const: 用于常量,不可重新赋值(引用的对象属性除外)。
// 旧写法
var a = 1;

// ES6
let count = 10;
const API_URL = '[https://api.com](https://api.com)';

// count = 11; // OK
// API_URL = '...'; // Error

2. 模板字符串 (Template Literals)

再也不用痛苦地用 + 号拼接字符串了。

const name = 'Jack';
const age = 18;

// 旧写法
const str = 'My name is ' + name + ' and I am ' + age + ' years old.';

// ES6
const str = `My name is ${name} and I am ${age} years old.`;

3. 箭头函数 (Arrow Functions)

语法更简洁,且自动绑定当前上下文的 this

// 旧写法
var sum = function(a, b) {
  return a + b;
};

// ES6
const sum = (a, b) => a + b; // 单行自动 return

// 配合数组方法更是绝配
[1, 2, 3].map(x => x * 2);

4. 解构赋值 (Destructuring)

从数组或对象中提取值,爽到飞起。

const user = { name: 'Alice', age: 25 };

// 旧写法
var name = user.name;
var age = user.age;

// ES6
const { name, age } = user; // 对象解构
const [first, second] = [10, 20]; // 数组解构

5. 扩展运算符 (Spread Operator ...)

数组合并、对象复制,三个点全搞定。

const arr1 = [1, 2];
const obj1 = { a: 1 };

// 数组合并
const arr2 = [...arr1, 3, 4]; // [1, 2, 3, 4]

// 对象浅拷贝/合并
const obj2 = { ...obj1, b: 2 }; // { a: 1, b: 2 }

6. 数组新方法 (Array Methods)

查找元素从未如此简单,告别 for 循环。

const arr = [1, 2, 3, 4];

// find: 返回第一个匹配的值
const found = arr.find(x => x > 2); // 3

// Array.from: 将类数组对象转为数组
const newArr = Array.from('foo'); // ["f", "o", "o"]

7. 字符串新方法 (String Methods)

判断字符串包含关系,不再需要 indexOf() !== -1

const str = 'Hello World';

// includes: 是否包含
str.includes('Hello'); // true

// startsWith / endsWith: 开头/结尾判断
str.startsWith('He'); // true
str.endsWith('ld');   // true

8. Promise (异步编程)

解决回调地狱(Callback Hell)的神器,异步操作更优雅。

// 模拟异步请求
const getData = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve('Data Loaded'), 1000);
  });
};

// 使用
getData().then(res => console.log(res));

9. 模块化 (Modules - Import/Export)

组件化开发的基础,彻底告别全局变量污染。

// lib.js (导出)
export const pi = 3.14;
export const add = (a, b) => a + b;

// main.js (导入)
import { pi, add } from './lib.js';
console.log(add(pi, 1));

10. 类 (Classes)

虽然 JS 本质是原型继承,但 Class 写法让面向对象编程(OOP)更直观。

// ES6
class Animal {
  constructor(name) {
    this.name = name;
  }
  
  speak() {
    console.log(`${this.name} makes a noise.`);
  }
}

const dog = new Animal('Doggy');
dog.speak();

总结

掌握这 10 个特性,足以应对 90% 的日常前端开发场景。ES6 不仅是语法糖,更是提升代码可读性和维护性的利器。赶紧在项目中用起来吧!

喜欢这篇文章的话,欢迎点赞、收藏、关注!

昨天 — 2026年1月15日首页

网页版时钟

作者 rocky191
2026年1月15日 20:45

之前看到类似的时钟工具,ai coding 一个类似的!浏览器全屏显示后,当成屏保,也不错。

image.png

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>数字时钟</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: 'Arial', sans-serif;
            background-color: #ffffff;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            min-height: 100vh;
            padding: 20px;
            color: #333;
        }

        .container {
            text-align: center;
            max-width: 800px;
        }

        .date {
            font-size: 2.5rem;
            color: #666;
            margin-bottom: 40px;
            font-weight: 300;
        }

        .clock-container {
            display: flex;
            justify-content: center;
            align-items: center;
            gap: 8px;
            margin-bottom: 60px;
        }

        .time-segment {
            position: relative;
            width: 80px;
            height: 120px;
            background-color: #e0e0e0;
            border-radius: 8px;
            overflow: hidden;
            box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
        }

        .time-digit {
            position: absolute;
            width: 100%;
            height: 100%;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 4rem;
            font-weight: bold;
            color: #333;
        }

        .time-separator {
            font-size: 3rem;
            color: #333;
            font-weight: bold;
        }

        .ampm {
            font-size: 2.5rem;
            color: #333;
            margin-left: 15px;
            font-weight: bold;
        }

        .quote {
            font-size: 1.2rem;
            color: #666;
            line-height: 1.6;
            margin-bottom: 20px;
            font-style: italic;
        }

        .author {
            font-size: 1rem;
            color: #888;
            text-align: right;
        }

        @media (max-width: 768px) {
            .date {
                font-size: 2rem;
            }

            .time-segment {
                width: 60px;
                height: 90px;
            }

            .time-digit {
                font-size: 3rem;
            }

            .time-separator {
                font-size: 2.5rem;
            }

            .ampm {
                font-size: 2rem;
            }

            .quote {
                font-size: 1rem;
            }
        }

        @media (max-width: 480px) {
            .date {
                font-size: 1.5rem;
            }

            .time-segment {
                width: 45px;
                height: 70px;
            }

            .time-digit {
                font-size: 2.2rem;
            }

            .time-separator {
                font-size: 2rem;
            }

            .ampm {
                font-size: 1.5rem;
            }

            .quote {
                font-size: 0.9rem;
            }
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="date" id="date">星期二, 九月 23 2025</div>
        
        <div class="clock-container">
            <div class="time-segment">
                <div class="time-digit" id="hour1">1</div>
            </div>
            <div class="time-segment">
                <div class="time-digit" id="hour2">1</div>
            </div>
            <div class="time-separator">:</div>
            <div class="time-segment">
                <div class="time-digit" id="minute1">3</div>
            </div>
            <div class="time-segment">
                <div class="time-digit" id="minute2">0</div>
            </div>
            <div class="time-separator">:</div>
            <div class="time-segment">
                <div class="time-digit" id="second1">3</div>
            </div>
            <div class="time-segment">
                <div class="time-digit" id="second2">8</div>
            </div>
            <div class="ampm" id="ampm">PM</div>
        </div>
        
        <div class="quote">
            "When I get a little money I buy books; and if any is left I buy food and clothes."
        </div>
        <div class="author">Desiderius Erasmus</div>
    </div>

    <script>
        // 星期和月份的中文映射
        const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
        const months = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
        
        function updateClock() {
            const now = new Date();
            
            // 获取中文日期
            const year = now.getFullYear();
            const month = now.getMonth();
            const day = now.getDate();
            const weekday = now.getDay();
            
            // 设置日期(使用中文格式)
            const dateElement = document.getElementById('date');
            dateElement.textContent = `${weekdays[weekday]}, ${months[month]} ${day} ${year}`;
            
            // Get current time
            let hours = now.getHours();
            const minutes = now.getMinutes();
            const seconds = now.getSeconds();
            const ampm = hours >= 12 ? 'PM' : 'AM';
            
            // Convert to 12-hour format
            hours = hours % 12;
            hours = hours ? hours : 12; // the hour '0' should be '12'
            
            // Format time with leading zeros
            const formattedHours = hours.toString().padStart(2, '0');
            const formattedMinutes = minutes.toString().padStart(2, '0');
            const formattedSeconds = seconds.toString().padStart(2, '0');
            
            // Update the clock display
            document.getElementById('hour1').textContent = formattedHours[0];
            document.getElementById('hour2').textContent = formattedHours[1];
            document.getElementById('minute1').textContent = formattedMinutes[0];
            document.getElementById('minute2').textContent = formattedMinutes[1];
            document.getElementById('second1').textContent = formattedSeconds[0];
            document.getElementById('second2').textContent = formattedSeconds[1];
            document.getElementById('ampm').textContent = ampm;
        }
        
        // Update clock immediately and then every second
        updateClock();
        setInterval(updateClock, 1000);
    </script>
</body>
</html>

JS-类型转换:从显式“强制”到隐式“魔法”

2026年1月15日 17:12

前言

在 JavaScript 中,类型转换(Type Coercion)既是它的魅力所在,也是许多 Bug 的温床。为什么 [] == ![] 会等于 true?理解了显示与隐式转换的规则,你就能像编译器一样思考。

一、 显式类型转换 (Explicit Conversion)

显式转换是指开发者通过代码明确地将一种类型转换为另一种类型。

1. 转换为字符串 (String)

  • toString() 方法:大多数值都有此方法。

    注意nullundefined 没有这个方法,直接调用会报错。

  • 字符串拼接:与空字符串相加 val + ""

  • String() 构造函数:万能转换,包括 nullundefined

2. 转换为布尔值 (Boolean)

  • Boolean() 包装:手动转换。

  • 双感叹号 !! :利用逻辑非特性快速转换。

    JavaScript

    console.log(!!'hello'); // true
    console.log(!!0);       // false
    

3. 转换为数字 (Number)

  • Number()

    • null \rightarrow 0
    • undefined \rightarrow NaN
    • true \rightarrow 1, false \rightarrow 0
  • parseInt() / parseFloat()

    • 相比 Number() 更加严格,如果参数是 nullundefinedboolean,统统返回 NaN
    • 常用于从字符串中提取数字:parseInt("12.5px") \rightarrow 12

二、 隐式类型转换 (Implicit Conversion)

当运算符两边的数据类型不统一时,JavaScript 会在后台自动完成转换。

1. 逻辑触发:布尔值转换

在以下逻辑语句中,非布尔值会被隐式转换为布尔值:

  • if (...) / while (...) / for (...)
  • 逻辑非 ! :隐式转为布尔并取反。
  • 逻辑与 && 和 逻辑或 || :先将操作数转为布尔值再判断,但要注意它们返回的是原始操作数,而非布尔值。

2. 算术触发:数字转换

除了加法 + 之外的算术运算符,都会强制将两端转为 Number

  • 运算符-, *, /, %, ++, --
  • 一元正负号+a, -a 会尝试将 a 转为数字。

3. “加法 + ”的特殊规则

+ 运算符具有双重身份(数值加法或字符串拼接):

  • 字符串优先:只要其中一个是字符串,另一个就会转成字符串,然后拼接。
  • 数字优先:如果两个操作数都不是字符串,则都转为数字(或 NaN)进行运算。

三、 对象转基本类型的底层逻辑

当对象参与运算或转换时,JS 引擎会遵循以下流程:

  1. Symbol.toPrimitive:如果对象定义了这个方法,优先调用。
  2. valueOf() :如果没有 toPrimitive,通常先尝试获取原始值。
  3. toString() :如果 valueOf 没能返回基本类型,则调用 toString

JavaScript

// 自定义转换行为
const obj = {
  valueOf: () => 10,
  toString: () => "obj"
};
console.log(obj + 1); // 11 (优先调用 valueOf)

四、 避坑小结:布尔判断中的对象

  • 所有对象(包括空数组 [] 和空对象 {})在转换为布尔值时,结果均为 true
  • 在验证 nullundefined 时,始终建议使用全等 ===,以避免隐式转换带来的干扰。

五、 进阶:经典面试题深度推导

为了验证你是否掌握了前面的知识,我们来看这几个面试高频题:

1. 为什么 [] == ![] 结果是 true

这道题几乎涵盖了所有的隐式转换规则,推导过程如下:

  1. 右侧优先处理![]。由于 [] 是对象,转为布尔值为 true,取反后得到 false

    • 表达式变为:[] == false
  2. 类型不统一:一边是对象,一边是布尔值。根据规则,布尔值先转为数字false 转为 0

    • 表达式变为:[] == 0
  3. 对象转基本类型[] 会尝试调用 valueOf(返回自身)和 toString[].toString() 得到空字符串 ""

    • 表达式变为:"" == 0
  4. 字符串转数字:空字符串 "" 转为数字 0

    • 表达式变为:0 == 0
  5. 结果true

2. 1 + "2" vs 1 - "2"

  • 1 + "2" :遇到 + 且有字符串,触发字符串拼接,结果为 "12"
  • 1 - "2"- 运算符只能用于数值计算,强制将 "2" 转为数字 2,结果为 -1

3. NaN 的奇特逻辑

JavaScript

console.log(NaN == NaN); // false
  • 解析NaN(Not a Number)是 JavaScript 中唯一一个不等于自身的值。
  • 避坑:判断一个值是否是 NaN,请使用 Number.isNaN(),不要直接用 ==

使用Web Worker的经历

作者 刘羡阳
2026年1月15日 15:59

什么是webWorker

  • webworker实现了前端开发的多线程开发,就是把js代码放到后台线程中跑
  • 运行独立线程,不会互相影响
  • 纯消息通知,如果用过websocket的话,使用过程中就会发现这个跟websocket类似的

-初始化webWorker

class DataMonitor {
  private options: DataMonitorOptions;
  private worker: Worker | null;


  constructor(options: DataMonitorOptions) {
    this.options = options;
    this.worker = null;
  }

  // 初始化Web Worker
  initWorker() {
    try {
      // 初始化Web Worker
      this.worker = new Worker(new URL('./data-worker.ts', import.meta.url), { type: 'module' });
      // 监听Worker消息
      this.options.onLog('Worker初始化成功', 'success');
    } catch (error: unknown) {
      this.options.onLog(`Worker初始化失败: ${error instanceof Error ? error.message : String(error)}`, 'error');
    }
  }
}
  • 这段代码就是newWorker的初始化,我在这里面传递了两个参数
    • 第一个参数就是我要进行数据通信的文件
    • 第二个参数中的type为module是为了让worker中使用import/export这种语句,并且可以导入其他的es5模块

进行通信

  1. 通信有两种通信情况
 - 第一种是我在DataMonitor中创建的方法,我会在外部通过事件触发的形式来触发这个方法,
 然后通过里面 this.worker.postMessage({type: '...'})这里的type是自己定义的通信类型
 ,当我触发这个通信的时候就会通知data-worker.ts中的这个通信类型,然后根据这个类型去调用对应的方法
 - 简单示例
     addChat: (data: any) => { //这个是在DataMonitor类中定义的方法
      if (this.worker) {
        this.worker.postMessage({
          type: 'getTopic',
        })
      }
    }
//data-worker.ts
    self.onmessage = function (event) {
      switch (event.data.type) {
        case 'addChat':
          messageHandlers.addChat(event.data);
          break;
      }
    }
- 第二种就是当我调用data-work中定义的方法后,如果成功或者失败如何通知主线程,其实调用方法跟上面的一致
self.postMessage({
    type: 'addFileOrIns',
    data: {
      res: 'error',
      type: 'addFileOrIns',
      id: newId,
      timestamp: new Date().toISOString(),
    }
  })这里的self就是对应上面this.worke
  • 这里有个问题需要注意一下,这里每次通信传递的数据类型,就是data里面带的数据,不能是复杂数据类型的,如果想传复杂数据类型的话可以通过两种形式来传

    • 第一种就是把复杂类型转化成字符串进行传递,JSON.stringify(newItem)这种写法,这种写法方便快捷,但是有个问题就是当数据量过大时传输效率太低了,适合不是很复杂的数据类型
    • 第二种就是通过把数据类型先转换成字符串,在转化成二进制数据流进行传递,这样操作的比较麻烦,但是数据传输速度上面要快很多
  • 然后我就可以在class中和data-worke中定义一一对应的消息通知,只有触发到对应的消息通知,才会调用对应的方法

  • data-worker文件做为初始化的时候调用的文件,其实可以类似于一个入口文件,当他的业务复杂的时候可以把业务抽离成单独的文件,然后在data-worker中进行调用

此处放下全部代码

// 数据监控类,封装所有监控相关的方法



// 监控配置类型
interface MonitorConfig {
  interval: number | string;
  dataSource: string;
  apiUrl?: string | null;
  threshold: number | string;
}

// 状态变化数据类型
interface StatusChangeData {
  isMonitoring?: boolean;
  isPaused?: boolean;
  startTime?: Date;
}

// Worker消息事件类型
interface WorkerMessageEvent {
  data: {
    type: string;
    data?: any;
    timestamp?: number;
  };
}

// 回调函数选项类型
interface DataMonitorOptions {
  config: MonitorConfig;
  onLog: (message: string, level: string, timestamp?: number) => void;
  onStatusChange: (status: StatusChangeData) => void;

  onDataUpdate: (data: any) => void;
  onUptimeUpdate: (uptime: string) => void;
}

export class DataMonitor {
  private options: DataMonitorOptions;
  private worker: Worker | null;
  constructor(options: DataMonitorOptions) {
    this.options = options;
    this.worker = null;
  }

  // 初始化Web Worker
  initWorker() {
    try {
      this.worker = new Worker(new URL('./data-worker.ts', import.meta.url), { type: 'module' });
      // 监听Worker消息
      this.worker.onmessage = this.handleWorkerMessage.bind(this);
      this.worker.onerror = this.handleWorkerError.bind(this);
      this.options.onLog('Worker初始化成功', 'success');
    } catch (error: unknown) {
      this.options.onLog(`Worker初始化失败: ${error instanceof Error ? error.message : String(error)}`, 'error');
    }
  }

  // 处理Worker消息
  handleWorkerMessage(event: WorkerMessageEvent) {
    const { type, data, timestamp } = event.data;
    switch (type) {
      case 'status':
        this.updateStatus(data);
        break;
      case 'data_error':
        this.handleDataError(data);
        break;
      case 'check_result':
        this.handleCheckResult(data);
        break;

      case 'log':
        this.options.onLog(data.message, data.level, timestamp);
        break;

      default:
        this.options.onLog(`未知消息类型: ${type}`, 'warning');
    }
  }

  // 处理Worker错误
  handleWorkerError(error: ErrorEvent) {
    this.options.onLog(`Worker错误: ${error.message}`, 'error');
    this.stopMonitoring();
  }

  // 开始监控
  startMonitoring() {
    if (!this.worker) {
      this.initWorker();
    }
    if (this.worker) {
      this.worker.postMessage({
        type: 'start',
      });
    }
    this.options.onLog(`开始监控 - 间隔: ${this.options.config.interval}ms, 数据源: ${this.options.config.dataSource}`, 'info');
  }

  // 停止监控
  stopMonitoring() {
    if (this.worker) {
      this.worker.postMessage({ type: 'stop' });
    }
    this.options.onLog('监控已停止', 'info');
  }

  // 立即检查一次
  checkNow() {
    if (this.worker) {
      this.worker.postMessage({
        type: 'check_once',
      });
      this.options.onLog('执行单次检查', 'info');
    }
  }

  // 处理数据错误
  handleDataError(data: any) {
    this.options.onLog(`数据错误: ${data.error} (源: ${data.source})`, 'error');
    this.handleError(data);
  }

  // 处理检查结果
  handleCheckResult(data: any) {
    //根据返回的结果去更新对应的数据
    this.updateStatus(data)
  }

  // 更新状态信息
  updateStatus(data: any) {
    this.options.onLog(`更新状态: ${JSON.stringify(data).substring(0, 100)}...`, 'status');
  }

  // 处理错误
  handleError(data: any) {
    this.options.onLog(`执行错误处理: ${data.error}`, 'error');
  }
}
/**
 * 数据监控Worker - 后台定时检查线程
 */


export interface Config {
  interval: number | string;
  dataSource: string;
  apiUrl?: string | null;
  threshold: number | string;
}
let monitoringInterval: number | null = null;
let isActive = false;
let checkCount = 0;


// 监听主线程消息
self.onmessage = function (event) {
  const { type, config: newConfig } = event.data;
  switch (type) {
    case 'start':
      startMonitoring(newConfig);
      break;
    case 'pause':
      pauseMonitoring();
      break;
    case 'stop':
      stopMonitoring();
      break;
    case 'check_once':
      // 执行单次检查

      break
    default:
      sendLog(`未知命令: ${type}`, 'warning');
  }
};

// 开始监控
function startMonitoring(newConfig: Config) {
  // 发送检查结果
  self.postMessage({
    type: 'check_result',
    data: {
      backData: '111',
      timestamp: new Date().toISOString(),
    }
  });
}

// 暂停监控
function pauseMonitoring() {
  if (!isActive) {
    sendLog('监控未运行,无法暂停', 'warning');
    return;
  }

  isActive = false;
  sendStatus('监控已暂停');
  sendLog('监控已暂停', 'warning');
}



// 停止监控
function stopMonitoring() {
  isActive = false;

  if (monitoringInterval) {
    clearInterval(monitoringInterval);
    monitoringInterval = null;
  }

  sendStatus('监控已停止');
  sendLog(`监控已停止,共执行 ${checkCount} 次检查`, 'info');
}

// 执行数据检查


// 获取数据(根据不同的数据源)
async function fetchData(currentConfig) {
  const { dataSource, apiUrl } = currentConfig;

  switch (dataSource) {
    case 'mockApi':
      return fetchMockData();

    case 'localStorage':
      return fetchLocalStorageData();

    case 'websocket':
      return fetchWebSocketData();

    case 'externalApi':
      if (!apiUrl) {
        throw new Error('API地址未配置');
      }
      return fetchExternalApiData(apiUrl);

    default:
      throw new Error(`不支持的数据源: ${dataSource}`);
  }
}

// 模拟API数据
async function fetchMockData() {
  // 模拟API延迟
  await sleep(Math.random() * 1000 + 500);

  // 随机决定是否有数据
  if (Math.random() > 0.3) { // 70%的概率有数据
    return {
      timestamp: new Date().toISOString(),
      data: {
        value: Math.floor(Math.random() * 100),
        items: Array.from({ length: Math.floor(Math.random() * 5) + 1 }, (_, i) => ({
          id: i + 1,
          name: `项目${i + 1}`,
          status: Math.random() > 0.5 ? 'active' : 'inactive'
        })),
        metadata: {
          source: 'mock_api',
          version: '1.0'
        }
      }
    };
  } else {
    // 30%的概率返回空数据
    return null;
  }
}

// 从localStorage获取数据
async function fetchLocalStorageData() {
  await sleep(100); // 模拟微小延迟

  const data = localStorage.getItem('monitor_test_key');

  if (data) {
    return {
      timestamp: new Date().toISOString(),
      data: JSON.parse(data),
      source: 'localStorage'
    };
  }

  return null;
}

// WebSocket数据(模拟)
async function fetchWebSocketData() {
  await sleep(Math.random() * 800 + 200);

  // 模拟WebSocket数据
  const hasData = Math.random() > 0.4;

  if (hasData) {
    return {
      timestamp: new Date().toISOString(),
      data: {
        type: 'ws_update',
        payload: {
          users: Math.floor(Math.random() * 1000),
          messages: Math.floor(Math.random() * 5000),
          connections: Math.floor(Math.random() * 100)
        }
      },
      source: 'websocket'
    };
  }

  return null;
}

// 获取外部API数据
async function fetchExternalApiData(apiUrl: string) {
  // 注意:实际使用中可能会遇到CORS问题
  // 这里添加了模拟实现

  await sleep(Math.random() * 1500 + 500);

  try {
    // 如果是演示,可以模拟响应
    if (apiUrl.includes('example.com')) {
      // 模拟API响应
      return {
        timestamp: new Date().toISOString(),
        data: {
          success: true,
          value: Math.floor(Math.random() * 100),
          message: '数据获取成功'
        },
        source: 'external_api'
      };
    } else {
      // 尝试真实请求(注意CORS)
      const response = await fetch(apiUrl, {
        method: 'GET',
        headers: {
          'Content-Type': 'application/json'
        }
      });

      if (!response.ok) {
        throw new Error(`API请求失败: ${response.status}`);
      }

      const data = await response.json();

      return {
        timestamp: new Date().toISOString(),
        data: data,
        source: 'external_api',
        rawResponse: response
      };
    }
  } catch (error) {
    // 如果真实请求失败,返回模拟数据作为fallback
    sendLog(`外部API请求失败: ${error.message},使用模拟数据`, 'warning');

    return {
      timestamp: new Date().toISOString(),
      data: {
        success: false,
        fallback: true,
        value: Math.floor(Math.random() * 100),
        message: '这是回退数据'
      },
      source: 'external_api_fallback'
    };
  }
}

// 检查数据是否为空
function isDataEmpty(data) {
  if (!data) return true;

  // 检查数据对象是否为空
  if (typeof data === 'object') {
    if (Array.isArray(data) && data.length === 0) return true;
    if (Object.keys(data).length === 0) return true;

    // 检查嵌套的数据字段
    if (data.data === null || data.data === undefined) return true;
    if (typeof data.data === 'object' && Object.keys(data.data).length === 0) return true;
  }

  return false;
}

// 处理找到的数据
function handleDataFound(data, config) {
  sendLog(`发现有效数据 (检查点: ${checkCount})`, 'success');

  // 处理数据验证
  const validatedData = validateData(data, config);

  self.postMessage({
    type: 'data_found',
    data: validatedData,
    timestamp: new Date().toISOString()
  });
}

// 处理空数据
function handleEmptyData(data, config) {
  self.postMessage({
    type: 'data_empty',
    data: {
      checkpoint: checkCount,
      timestamp: new Date().toISOString(),
      reason: '数据为空或无内容',
      config: config.dataSource
    },
    timestamp: new Date().toISOString()
  });
}

// 处理数据错误
function handleDataError(error, config) {
  self.postMessage({
    type: 'data_error',
    data: {
      error: error.message,
      source: config.dataSource,
      checkpoint: checkCount,
      timestamp: new Date().toISOString()
    },
    timestamp: new Date().toISOString()
  });
}

// 验证数据
function validateData(data, config) {
  const validated = { ...data };

  // 添加验证标记
  validated.validated = true;
  validated.validationTime = new Date().toISOString();

  // 简单的数据清洗
  if (validated.data && validated.data.value !== undefined) {
    // 确保数值在合理范围内
    validated.data.value = Math.max(0, Math.min(100, validated.data.value));
  }

  // 添加检查计数
  validated.checkCount = checkCount;

  return validated;
}

// 发送状态更新
function sendStatus(message) {
  self.postMessage({
    type: 'status',
    data: {
      message,
      isActive,
      checkCount,
      timestamp: new Date().toISOString()
    }
  });
}

// 发送日志
function sendLog(message, level = 'info') {
  self.postMessage({
    type: 'log',
    data: {
      message,
      level,
      timestamp: new Date().toISOString()
    }
  });
}

// 工具函数:延迟
function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// Worker初始化完成
sendLog('数据监控Worker已加载', 'success');
sendStatus('准备就绪');

// 错误处理
self.onerror = function (error) {
  sendLog(`Worker发生错误: ${error.message}`, 'error');
};

总结

个人感觉使用webworker可以把业务代码抽离出来,然后通过通信的方式去进行调用,而且此时的业务代码个个是独立的,他们直接互不影响,但是可以通过通信来进行互相调用,而且它不堵塞主线程,这点用着很舒服。

JS-ES6新特性

2026年1月15日 14:42

前言

ES6 (ECMAScript 2015) 的发布是现代 JavaScript 开发的分水岭。它不仅修复了 var 带来的历史遗留问题,还引入了更高效的数据结构。本文将带你系统复习 let/const、解构赋值、Map/Set 以及独一无二的 Symbol

一、 变量声明的“进化”:let 与 const

在 ES6 之前,我们只有 varm但 var 带来的变量提升和全局污染常常让人头疼,ES6则新增了let、const。

1. let 特点

  • 禁止重复声明:同一作用域内不可重复定义同名变量。
  • 块级作用域:仅在 {} 内部有效(如 iffor 块)。
  • 无变量提升:存在“暂时性死区”(TDZ),必须先定义后使用,否则抛出 ReferenceError

2. const 特点

  • 必须赋初值:声明时必须立即初始化。

  • 值不可变:一旦声明,其指向的内存地址不可修改。

    注意: 修改对象或数组内部的属性是允许的,因为这并没有改变引用地址。

  • 具备块级作用域,同样不存在提升。

3. 三者对比速查表

特性 var let const
作用域 函数作用域 块级作用域 块级作用域
变量提升 是 (显示 undefined) 否 (报错) 否 (报错)
重复声明 允许 不允许 不允许
必须赋初值

二、 解构赋值:代码瘦身的艺术

解构赋值允许我们按照一定模式,从数组和对象中提取值。

1. 数组解构

数组解构是位置对应的。

JavaScript

let [a, [b, c]] = [1, [2, 3]]; // 支持嵌套解构
  • 注意: 如果等号右边不是可遍历结构(Iterator),将会报错。

2. 对象解构

对象解构是属性名对应的,不强调顺序。

JavaScript

let obj = { first: 'hello', last: 'world' };

// 别名用法:{ 原属性名: 新变量名 }
let { first: f, last: l } = obj; 

console.log(f); // 'hello'

3. 函数参数解构

这是开发中最常用的场景,通过设定默认值可以增强代码的健壮性。

JavaScript

function connect({ host = '127.0.0.1', port = 3000 } = {}) {
    console.log(host, port);
}

4. 妙用场景

  • 快速交换变量[x, y] = [y, x]
  • 提取 JSON 数据:从复杂的接口返回对象中精准拿取字段。
  • 接收多个返回值:函数返回数组或对象后直接解构。

三、 键值对的新选择:Map

Map 是一组键值对结构,其查找时间复杂度为 O(1)O(1)

1. Map 的常用 API

  • set(key, value):添加元素。
  • get(key):获取元素。
  • has(key):检查是否存在。
  • delete(key):删除指定元素。
  • size:属性,返回元素个数。
  • clear():清空所有。

2. 核心特性

  • Key 的多样性:对象的 key 只能是字符串或 Symbol,而 Map 的 key 可以是任意类型(包括对象、函数)。
  • 覆盖性:同一个 key 放入多个 value,后面的会覆盖前面的。

JavaScript

const m = new Map();
m.set('Bob', 59);
m.forEach((val, key) => {
    console.log(`${key}: ${val}`);
});

四、 唯一值的容器:Set

Set 类似于数组,但其成员的值都是唯一的。

1. 数组去重的神技

在 ES6 中,一行代码即可搞定数组去重:

JavaScript

let arr = [1, 2, 2, 3];
let uniqueArr = Array.from(new Set(arr)); 
// 或者使用扩展运算符
let uniqueArr2 = [...new Set(arr)];
console.log(uniqueArr,uniqueArr2) //[1, 2, 3],[1, 2, 3]

2. 常用操作

  • add(value):添加新成员。
  • delete(value):删除。
  • has(value):判断是否存在。
  • size:获取长度。

3. 遍历演示

JavaScript

let set = new Set([123, 456, 789]);

for (let item of set) {
   console.log(item); 
}

// 过滤小数值
set.forEach(e => {
    if(e < 500) set.delete(e);
});
console.log(set); // Set { 789 }

五、 独一无二的 Symbol

Symbol 是 ES6 引入的一种原始数据类型,表示独一无二的值。

1. 为什么需要 Symbol?

为了防止对象属性名冲突。如果你给一个他人提供的对象添加属性,使用 Symbol 可以确保不会覆盖原有属性。

2. 基本使用

JavaScript

let s1 = Symbol('desc');
let s2 = Symbol('desc');

console.log(s1 === s2); // false (即使描述相同,值也是唯一的)

// 作为对象属性
let obj = {
    [s1]: 'Hello Symbol'
};

注意Symbol 作为属性名时,通过 for...inObject.keys() 是遍历不到的,需要使用 Object.getOwnPropertySymbols()

案例+图解带你遨游 Canvas 2D绘图 Fabric.js🔥🔥(5W+字)

作者 Lsx_
2026年1月15日 17:17

Fabric.js 简介

Fabric.js 是一个功能强大且操作简单的 Javascript HTML5 canvas 工具库。

00.png

『Fabric.js 官网首页』

『Fabric.js Demos』

本文主要讲解 Fabric.js 有基础也有实战,包括:

  • 画布的基本操作
  • 基础图形绘制方法(矩形、圆形、三角形、椭圆、多边形、线段等)
  • 自定义图形
  • 图片的使用
  • 文本和文本框
  • 图形和文本的基础样式
  • 渐变
  • 选中状态
  • 分组和取消分组
  • 动画
  • 设置和获取图形层级
  • 基础事件
  • 禁止水平、垂直移动
  • 缩放和平移画布
  • 视口坐标和画布坐标转换
  • 序列化和反序列化
  • ……

起步

1. 新建页面并引入 Fabric.js

import { fabric } from 'fabric'

2. 创建 canvas 容器

HTML 中创建 <canvas>,并设置容器的 id宽高,width/height

<canvas width="400" height="400" id="c" style="border: 1px solid #ccc;"></canvas>

这里创建了一个 canvas 容器,边框为灰色,id="c" 。指定长宽都为 400px

003.png

3. 使用 fabric 接管容器,并画一个矩形

JS 中实例化 fabric ,之后就可以使用 fabricapi 管理 canvas 了。

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric' // 引入 fabric

function init() {
  const canvas = new fabric.Canvas('c') // 这里传入的是canvas的id

  // 创建一个长方形
  const rect = new fabric.Rect({
    top: 30, // 距离容器顶部 30px
    left: 30, // 距离容器左侧 30px
    width: 100, // 宽 100px
    height: 60, // 高 60px
    fill: 'red' // 填充 红色
  })

  // 在canvas画布中加入矩形(rect)。
  canvas.add(rect)
}

onMounted(() => {
  init()
})
</script>

004.png

画布

Fabric.js 的画布操作性是非常强的。

基础版(可交互)

005.gif

基础版就是“起步”章节所说的那个例子。

<template>
  <canvas width="400" height="400" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas') // 这里传入的是canvas元素的id

  // 创建一个长方形
  const rect = new fabric.Rect({
    top: 100, // 距离容器顶部 100px
    left: 100, // 距离容器左侧 100px
    width: 30, // 矩形宽度 30px
    height: 30, // 矩形高度 30px
    fill: 'red' // 填充 红色
  })

  canvas.add(rect) // 将矩形添加到 canvas 画布里
}

onMounted(() => {
  init()
})
</script>

不可交互

006.gif

<template>
  <canvas width="400" height="400" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  // 使用 StaticCanvas 创建一个不可操作的画布
  const canvas = new fabric.StaticCanvas('canvas') // 这里传入的是canvas元素的id

  // 创建一个长方形
  const rect = new fabric.Rect({
    top: 100, // 距离容器顶部 100px
    left: 100, // 距离容器左侧 100px
    width: 30, // 矩形宽度 30px
    height: 30, // 矩形高度 30px
    fill: 'red' // 填充 红色
  })

  canvas.add(rect)
}

onMounted(() => {
  init()
})
</script>

创建不可交互的画布,其实只需把 new fabric.Canvas 改成 new fabric.StaticCanvas 即可。

在js设定画布参数

007.png

const canvas = new fabric.Canvas('canvas', {
    width: 300, // 画布宽度
    height: 300, // 画布高度
    backgroundColor: '#eee' // 画布背景色
})
</script>

使用背景图

<template>
  <canvas width="400" height="400" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas')

  // 设置背景图
  // 参数1:背景图资源(可以引入本地,也可以使用网络图)
  // 参数2:设置完背景图执行以下重新渲染canvas的操作,这样背景图就会展示出来了
  canvas.setBackgroundImage(
    '图片url',
    canvas.renderAll.bind(canvas)
  )
}

onMounted(() => {
  init()
})
</script>

拉伸背景图

<template>
  <canvas width="400" height="400" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas')

  // fabric.Image.fromURL:加载图片的api
  // 第一个参数:图片地址(可以是本地的,也可以是网络图)
  // 第二个参数:图片加载的回调函数
  fabric.Image.fromURL(
    '图片url',
    (img) => {
      // 设置背景图
      canvas.setBackgroundImage(
        img,
        canvas.renderAll.bind(canvas),
        {
          scaleX: canvas.width / img.width, // 计算出图片要拉伸的宽度
          scaleY: canvas.height / img.height // 计算出图片要拉伸的高度
        }
      )
    }
  )
}

onMounted(() => {
  init()
})
</script>

这个例子使用了 fabric.Image.fromURL 这个 api 来加载图片,第一个参数是图片地址,第二个参数是回调函数。

拿到图片的参数和画布的宽高进行计算,从而使图片充满全屏。

基础图形

矩形

015.png

<template>
  <canvas width="400" height="375" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas')

  const rect = new fabric.Rect({
    top: 100, // 距离容器顶部 100px
    left: 100, // 距离容器左侧 100px
    fill: 'orange', // 填充 橙色
    width: 100, // 宽度 100px
    height: 100 // 高度 100px
  })
  
  // 将矩形添加到画布中
  canvas.add(rect)
}

onMounted(() => {
  init()
})
</script>

使用 new fabric.Rect 创建 矩形

圆角矩形

016.png

<template>
  <canvas width="400" height="375" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas')

  const rect = new fabric.Rect({
    top: 100, // 距离容器顶部 100px
    left: 100, // 距离容器左侧 100px
    fill: 'orange', // 填充 橙色
    width: 100, // 宽度 100px
    height: 100, // 高度 100px
    rx: 20, // x轴的半径
    ry: 20 // y轴的半径
  })
  
  // 将矩形添加到画布中
  canvas.add(rect)
}

onMounted(() => {
  init()
})
</script>

画圆角矩形,需要添加 rxry

圆形

017.png

<template>
  <canvas width="400" height="375" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas')

  const circle = new fabric.Circle({
    top: 100,
    left: 100,
    radius: 50, // 圆的半径 50
    fill: 'green'
  })
  canvas.add(circle)
}

onMounted(() => {
  init()
})
</script>

使用 new fabric.Circle 创建圆形

圆形需要使用 radius 设置半径大小。

椭圆形

018.png

<template>
  <canvas width="400" height="375" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas')

  const ellipse = new fabric.Ellipse({
    top: 20,
    left: 20,
    rx: 70,
    ry: 30,
    fill: 'hotpink'
  })
  canvas.add(ellipse)
}

onMounted(() => {
  init()
})
</script>

需要使用 new fabric.Ellipse 创建 椭圆

和圆形不同,椭圆不需要设置 radius ,但要设置 rxry

  • rx > ry :椭圆是横着的
  • rx < ry:椭圆是竖着的
  • rx = ry: 看上去就是个圆形

三角形

019.png

<template>
  <canvas width="400" height="375" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas')

  const triangle = new fabric.Triangle({
    top: 100,
    left: 100,
    width: 80, // 底边长度
    height: 100, // 底边到对角的距离
    fill: 'blue'
  })
  canvas.add(triangle)
}

onMounted(() => {
  init()
})
</script>

使用 new fabric.Triangle 创建三角形,三角形是需要给定 “底和高” 的。

线段

020.png

<template>
  <canvas width="400" height="375" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas')

  const line = new fabric.Line(
    [
      10, 10, // 起始点坐标
      200, 300 // 结束点坐标
    ],
    {
      stroke: 'red', // 笔触颜色
    }
  )
  canvas.add(line)
}

onMounted(() => {
  init()
})
</script>

使用 new fabric.Line 创建线段。

new fabric.Line 需要传入2个参数。

  • 第一个参数是 数组 ,数组需要传4个值,前2个值是起始坐标的x和y,后2个值是结束坐标的x和y

  • 第二个参数是 线段的样式,要设置线段的颜色,需要使用 stroke

折线

021.png

<template>
  <canvas width="400" height="375" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas')

  const polyline = new fabric.Polyline([
    {x: 30, y: 30},
    {x: 150, y: 140},
    {x: 240, y: 150},
    {x: 100, y: 30}
  ], {
    fill: 'transparent', // 如果画折线,需要填充透明
    stroke: '#6639a6', // 线段颜色:紫色
    strokeWidth: 5 // 线段粗细 5
  })
  canvas.add(polyline)
}

onMounted(() => {
  init()
})
</script>

使用 new fabric.Polyline 创建线段

new fabric.Polyline 需要传入2个参数。

  • 第一个参数是数组,描述线段的每一个点
  • 第二个参数用来描述线段样式

需要注意的是, fill 设置成透明才会显示成线段,如果不设置,会默认填充黑色,如下图所示:

022.png

你也可以填充自己喜欢的颜色,new fabric.Polyline 是不会自动把 起始点结束点 自动闭合起来的。

多边形

023.png

<template>
  <canvas width="400" height="375" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas')

  const polygon = new fabric.Polygon([
    {x: 30, y: 30},
    {x: 150, y: 140},
    {x: 240, y: 150},
    {x: 100, y: 30}
  ], {
    fill: '#ffd3b6', // 填充色
    stroke: '#6639a6', // 线段颜色:紫色
    strokeWidth: 5 // 线段粗细 5
  })
  canvas.add(polygon)
}

onMounted(() => {
  init()
})
</script>

使用 new fabric.Polygon 绘制多边形,用法和 new fabric.Polyline 差不多,但最大的不同点是 new fabric.Polygon 会自动把 起始点结束点 连接起来。

自定义图形

在Fabric.js中,几乎所有的2d图形直接或间接继承自 Object 类,那么如果我们不用其自带的2d图形,而是自建图形,要怎么应用 Fabric.js 中的方法呢?

Fabric.js 提供了 fabric.util.createClass 方法解决这个问题

一个自定义子类的结构:

      // 创建一个自定义子类
      const customClass = fabric.util.createClass(fabric.Object, {
        type: "customClass",
        initialize: function (options) {
          options || (options = {});
          this.callSuper("initialize", options);
          // 自定义属性
        },

        toObject: function () {
          return fabric.util.object.extend(this.callSuper("toObject"), {
            // 将自定义属性添加到序列化对象中
          });
        },

        _render: function (ctx) {
          this.callSuper("_render", ctx);
          // 自定义渲染逻辑
        },
      });

一个简单的自定义类主要要修改3个地方,分别是:

  1. initialize : 添加的自定义属性方法放这
  2. toObject: 将自定义属性添加到序列化对象中,方便canvas记录
  3. _render: 处理自定义渲染逻辑

此处举一个简单的例子,写一个自定义表格图形:

新增绘制网格图的方法 initMap:

      // 绘制表格图形
      function initTable(options, ctx) {
        const { gridNumX, gridNumY, width, height, fill, left, top } = options;
        ctx.save();
        ctx.translate(-width / 2, -height / 2)
        // 开始路径并绘制线条
        ctx.beginPath();
        // 设置线条样式
        ctx.lineWidth = 1;
        ctx.strokeStyle = fill;
        // 开始绘制横线
        for (let i = 0; i < gridNumY + 1; i++) {
          // 注意要算线的宽度,也就是后面那个+i
          ctx.moveTo(0, height / gridNumY * i);
          ctx.lineTo(width, height / gridNumY * i);
          ctx.stroke();
        }
        // 开始绘制竖线
        for (let i = 0; i < gridNumX + 1; i++) {
          ctx.moveTo(width / gridNumX * i, 0);
          ctx.lineTo(width / gridNumX * i, height);
          ctx.stroke();
        }
        ctx.restore();
      }

创建 Table 子类:

      // 创建一个自定义子类
      const Map = fabric.util.createClass(fabric.Object, {
        type: "Table",
        initialize: function (options) {
          options || (options = {});
          this.callSuper("initialize", options);
          this.set("gridNumX", options.gridNumX || "");
          this.set("gridNumY", options.gridNumY || "");
        },

        toObject: function () {
          return fabric.util.object.extend(this.callSuper("toObject"), {
            gridNumX: this.get("gridNumX"),
            gridNumY: this.get("gridNumY"),
          });
        },

        _render: function (ctx) {
          this.callSuper("_render", ctx);
          initTable({
            ...this
          }, ctx)
        },
      });

新建 Table 实例并添加到canvas:

      const table = new Table({
        left: 100,
        top: 100,
        label: "test",
        fill: "#faa",
        width: 100,
        height: 100,
        gridNumX: 4,
        gridNumY: 3
      });

      const table2 = new Table({
        left: 300,
        top: 100,
        label: "test",
        fill: "green",
        width: 200,
        height: 300,
        gridNumX: 2,
        gridNumY: 5
      });
      // 将所有图形添加到 canvas 中
      canvas.add(table, table2);

如图所示,成功创建了可复用的自定义图形,而且能够使用 Object 类的功能。

image.png

文本

Fabric.js 有3类跟文本相关的 api

  • 普通文本
  • 可编辑文本
  • 文本框

普通文本 Text

<template>
  <canvas width="400" height="400" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas')

  const text = new fabric.Text('hello')
  canvas.add(text)
}

onMounted(() => {
  init()
})
</script>

使用 new fabric.Text 创建文本,传入第一个参数就是文本内容。

new fabric.Text 还支持第二个参数,可以设置文本样式。

可编辑文本 IText

<template>
  <canvas width="400" height="400" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas')

  const itext = new fabric.IText('hello')
  canvas.add(itext)
}

onMounted(() => {
  init()
})
</script>

使用 new fabric.IText 可以创建可编辑文本,用法和 new fabric.Text 一样。

文本框 Textbox

<template>
  <canvas width="400" height="400" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas')

  const textbox = new fabric.Textbox('What are you doing?', {
    width: 250
  })
  canvas.add(textbox)
}

onMounted(() => {
  init()
})
</script>

使用 new fabric.Textbox 可以创建文本框。

new fabric.Textbox 第二个参数是对象,使用 width 可以设定了文本框的宽度,文本内容超过设定的宽度会自动换行。

new fabric.Textbox 的内容同样是可编辑的。

基础样式

图形常用样式

<template>
  <canvas width="400" height="400" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas')

  const circle = new fabric.Circle({
    top: 100,
    left: 100,
    radius: 50, // 半径:50px
    backgroundColor: 'green', // 背景色:绿色
    fill: 'orange', // 填充色:橙色
    stroke: '#f6416c', // 边框颜色:粉色
    strokeWidth: 5, // 边框粗细:5px
    strokeDashArray: [20, 5, 14], // 边框虚线规则:填充20px 空5px 填充14px 空20px 填充5px ……
    shadow: '10px 20px 6px rgba(10, 20, 30, 0.4)', // 投影:向右偏移10px,向下偏移20px,羽化6px,投影颜色及透明度
    transparentCorners: false, // 选中时,角是被填充了。true 空心;false 实心
    borderColor: '#16f1fc', // 选中时,边框颜色:天蓝
    borderScaleFactor: 5, // 选中时,边的粗细:5px
    borderDashArray: [20, 5, 10, 7], // 选中时,虚线边的规则
    cornerColor: "#a1de93", // 选中时,角的颜色是 青色
    cornerStrokeColor: 'pink', // 选中时,角的边框的颜色是 粉色
    cornerStyle: 'circle', // 选中时,叫的属性。默认rect 矩形;circle 圆形
    cornerSize: 20, // 选中时,角的大小为20
    cornerDashArray: [10, 2, 6], // 选中时,虚线角的规则
    selectionBackgroundColor: '#7f1300', // 选中时,选框的背景色:朱红
    padding: 40, // 选中时,选择框离元素的内边距:40px
    borderOpacityWhenMoving: 0.6, // 当对象活动和移动时,对象控制边界的不透明度  
  })

  canvas.add(circle)
}

onMounted(() => {
  init()
})
</script>

文本常用样式

<template>
  <canvas width="400" height="400" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas')

  const text = new fabric.Text('hello', {
    top: 40,
    left: 40,
    fontSize: 120,
    backgroundColor: 'green', // 背景色:绿色
    fill: 'orange', // 填充色:橙色
    stroke: '#f6416c', // 边框颜色:粉色
    strokeWidth: 3, // 边框粗细:3px
    strokeDashArray: [20, 5, 14], // 边框虚线规则:填充20px 空5px 填充14px 空20px 填充5px ……
    shadow: '10px 20px 6px rgba(10, 20, 30, 0.4)', // 投影:向右偏移10px,向下偏移20px,羽化6px,投影颜色及透明度
    transparentCorners: false, // 选中时,角是被填充了。true 空心;false 实心
    borderColor: '#16f1fc', // 选中时,边框颜色:天蓝
    borderScaleFactor: 5, // 选中时,边的粗细:5px
    borderDashArray: [20, 5, 10, 7], // 选中时,虚线边的规则
    cornerColor: "#a1de93", // 选中时,角的颜色是 青色
    cornerStrokeColor: 'pink', // 选中时,角的边框的颜色是 粉色
    cornerStyle: 'circle', // 选中时,叫的属性。默认rect 矩形;circle 圆形
    cornerSize: 20, // 选中时,角的大小为20
    cornerDashArray: [10, 2, 6], // 选中时,虚线角的规则
    selectionBackgroundColor: '#7f1300', // 选中时,选框的背景色:朱红
    padding: 40, // 选中时,选择框离元素的内边距:40px
    borderOpacityWhenMoving: 0.6, // 当对象活动和移动时,对象控制边界的不透明度  
  })

  canvas.add(text)
}

onMounted(() => {
  init()
})
</script>

除此之外,还可以配置 上划线下划线删除线左对齐右对齐居中对齐行距 等。

030.png

<template>
  <canvas width="600" height="400" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas')

  // 上划线
  const overline = new fabric.Text('上划线', {
    top: 30,
    left: 10,
    fontSize: 20,
    overline: true, // 上划线
  })

  // 下划线
  const underline = new fabric.Text('下划线', {
    top: 30,
    left: 100,
    fontSize: 20,
    underline: true, // 下划线
  })

  // 删除线
  const linethrough = new fabric.Text('删除线', {
    top: 30,
    left: 200,
    fontSize: 20,
    linethrough: true, // 删除线
  })

  // 左对齐
  const msg1 = '左\n左左\n左对齐'
  const left = new fabric.Text(msg1, {
    top: 100,
    left: 10,
    fontSize: 16,
    textAlign: 'left', // 左对齐
  })

  // 居中对齐
  const msg2 = '中\n中中\n居中对齐'
  const center = new fabric.Text(msg2, {
    top: 100,
    left: 100,
    fontSize: 16,
    textAlign: 'center',// 居中对齐
  })

  // 右对齐
  const msg3 = '右\n右右\n右对齐'
  const right = new fabric.Text(msg3, {
    top: 100,
    left: 200,
    fontSize: 16,
    textAlign: 'right', // 右对齐
  })

  // 文本内容
  const msg4 = "What are you doing,\nWhat are you doing,\nWhat are you doing\What are you doing"
  
  const lineHeight1 = new fabric.Text(msg4, {
    top: 250,
    left: 10,
    fontSize: 16,
    lineHeight: 1, // 行高
  })

  const lineHeight2 = new fabric.Text(msg4, {
    top: 250,
    left: 300,
    fontSize: 16,
    lineHeight: 2, // 行高
  })

  canvas.add(
    overline,
    underline,
    linethrough,
    left,
    center,
    right,
    lineHeight1,
    lineHeight2
  )

}

onMounted(() => {
  init()
})
</script>

渐变

线性渐变

031.png

<template>
  <canvas width="600" height="600" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  let canvas = new fabric.Canvas('canvas')

  // 圆
  let circle = new fabric.Circle({
    left: 100,
    top: 100,
    radius: 50,
  })

  // 线性渐变
  let gradient = new fabric.Gradient({
    type: 'linear', // linear or radial
    gradientUnits: 'pixels', // pixels or pencentage 像素 或者 百分比
    coords: { x1: 0, y1: 0, x2: circle.width, y2: 0 }, // 至少2个坐标对(x1,y1和x2,y2)将定义渐变在对象上的扩展方式
    colorStops:[ // 定义渐变颜色的数组
      { offset: 0, color: 'red' },
      { offset: 0.2, color: 'orange' },
      { offset: 0.4, color: 'yellow' },
      { offset: 0.6, color: 'green' },
      { offset: 0.8, color: 'blue' },
      { offset: 1, color: 'purple' },
    ]
  })
  circle.set('fill', gradient);
  canvas.add(circle)
}

onMounted(() => {
  init()
})
</script>

径向渐变

032.png

<template>
  <canvas width="600" height="600" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  let canvas = new fabric.Canvas('canvas')

  // 圆
  let circle = new fabric.Circle({
    left: 100,
    top: 100,
    radius: 50,
  })

  let gradient = new fabric.Gradient({
    type: 'radial',
    coords: {
      r1: 50, // 该属性仅径向渐变可用,外圆半径
      r2: 0, // 该属性仅径向渐变可用,外圆半径  
      x1: 50, // 焦点的x坐标
      y1: 50, // 焦点的y坐标
      x2: 50, // 中心点的x坐标
      y2: 50, // 中心点的y坐标
    },
    colorStops: [
      { offset: 0, color: '#fee140' },
      { offset: 1, color: '#fa709a' }
    ]
  })

  circle.set('fill', gradient);
  canvas.add(circle)
}

onMounted(() => {
  init()
})
</script>

选中状态

Fabric.js 创建出来的元素(图形、图片、组等)默认是可以被选中的。

禁止选中

055.gif

<template>
  <canvas width="400" height="400" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas')

  const rect = new fabric.Rect({
    top: 100,
    left: 100,
    width: 200,
    height: 100,
    fill: 'red'
  })

  // 元素禁止选中
  rect.selectable = false

  canvas.add(rect)
}

onMounted(() => {
  init()
})
</script>

框选样式

<template>
  <canvas width="200" height="200" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas')

  // 圆形
  const circle = new fabric.Circle({
    radius: 30,
    fill: '#f55',
    top: 70,
    left: 70
  })

  canvas.add(circle)

  canvas.selection = true // 画布是否可选中。默认true;false 不可选中
  canvas.selectionColor = 'rgba(106, 101, 216, 0.3)' // 画布鼠标框选时的背景色
  canvas.selectionBorderColor = "#1d2786" // 画布鼠标框选时的边框颜色
  canvas.selectionLineWidth = 6 // 画布鼠标框选时的边框厚度
  canvas.selectionDashArray = [30, 4, 10] // 画布鼠标框选时边框虚线规则
  canvas.selectionFullyContained = true // 只选择完全包含在拖动选择矩形中的形状
}

onMounted(() => {
  init()
})
</script>

自定义边和控制角样式

058.png

<template>
  <canvas width="200" height="200" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas')

  // 圆形
  const circle = new fabric.Circle({
    radius: 30,
    fill: '#f55',
    top: 70,
    left: 70
  })

  circle.set({
    borderColor: 'red', // 边框颜色
    cornerColor: 'green', // 控制角颜色
    cornerSize: 10, // 控制角大小
    transparentCorners: false // 控制角填充色不透明
  })

  canvas.add(circle)

  canvas.setActiveObject(circle) // 选中圆
}

onMounted(() => {
  init()
})
</script>

没有控制角

没有控制角将意味着无法用鼠标直接操作缩放和旋转,只允许移动操作。

062.png

<template>
  <canvas width="200" height="200" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas')

  // 圆形
  const circle = new fabric.Circle({
    radius: 30,
    fill: '#f55',
    top: 70,
    left: 70
  })

  circle.hasControls = false // 禁止控制角

  canvas.add(circle)

  canvas.setActiveObject(circle) // 选中第一项
}

onMounted(() => {
  init()
})
</script>

不允许框选

不允许从画布框选,但允许选中元素。

065.gif

<template>
  <canvas width="200" height="200" id="canvas" style="border: 1px solid #ccc;"></canvas>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas')

  // 圆形
  const circle = new fabric.Circle({
    radius: 30,
    fill: '#f55',
    top: 70,
    left: 70
  })

  canvas.add(circle)
  canvas.selection = false // 不允许直接从画布框选
}

onMounted(() => {
  init()
})
</script>

分组

建组

039.gif

<template>
  <div>
    <canvas width="400" height="400" id="canvas" style="border: 1px solid #ccc;"></canvas>
  </div>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas')

  // 椭圆
  const ellipse = new fabric.Ellipse({
    top: 20,
    left: 20,
    rx: 100,
    ry: 50,
    fill: '#ddd',
    originX: 'center', // 旋转x轴:left, right, center
    originY: 'center' // 旋转y轴:top, bottom, center
  })

  // 文本
  const text = new fabric.Text('Hello World', {
    top: 40,
    left: 20,
    fontSize: 20,
    originX: "center",
    originY: "center"
  })

  // 建组
  const group = new fabric.Group([ellipse, text], {
    top: 50, // 整组距离顶部100
    left: 100, // 整组距离左侧100
    angle: -10, // 整组旋转-10deg
  })

  canvas.add(group)
}

onMounted(() => {
  init()
})
</script>

new fabric.Group 可以创建一个组,把多个图层放在同一个组内,实现同步的操作,比如拖拽、缩放等。

操作组

Fabric.js 的组提供了很多方法,这里列一些常用的:

  • getObjects() 返回一组中所有对象的数组

  • size() 所有对象的数量

  • contains() 检查特定对象是否在 group

  • item() 组中元素

  • forEachObject() 遍历组中对象

  • add() 添加元素对象

  • remove() 删除元素对象

  • fabric.util.object.clone() 克隆

<template>
  <div>
    <canvas width="400" height="400" id="canvas" style="border: 1px solid #ccc;"></canvas>
  </div>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const canvas = new fabric.Canvas('canvas')

  // 椭圆
  const ellipse = new fabric.Ellipse({
    top: 20,
    left: 20,
    rx: 100,
    ry: 50,
    fill: '#ddd',
    originX: 'center', // 旋转x轴:left, right, center
    originY: 'center' // 旋转y轴:top, bottom, center
  })

  // 文本
  const text = new fabric.Text('Hello World', {
    top: 40,
    left: 20,
    fontSize: 20,
    originX: "center",
    originY: "center"
  })

  // 建组
  const group = new fabric.Group([ellipse, text], {
    top: 50, // 整组距离顶部100
    left: 100, // 整组距离左侧100
    angle: -10, // 整组旋转-10deg
  })

  // 控制第一个元素(椭圆)
  group.item(0).set('fill', '#ea5455')

  // 控制第二个元素(文本)
  group.item(1).set({
    text: '雷猴,世界',
    fill: '#fff'
  })

  canvas.add(group)
}

onMounted(() => {
  init()
})
</script>

取消分组

041.gif

<template>
  <div>
    <button @click="ungroup">取消组</button>
    <canvas width="400" height="400" id="canvas" style="border: 1px solid #ccc;"></canvas>
  </div>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

let canvas = null

// 初始化
function init() {
  canvas = new fabric.Canvas('canvas')

  // 椭圆
  const ellipse = new fabric.Ellipse({
    top: 20,
    left: 20,
    rx: 100,
    ry: 50,
    fill: '#ddd',
    originX: 'center', // 旋转x轴:left, right, center
    originY: 'center' // 旋转y轴:top, bottom, center
  })

  // 文本
  const text = new fabric.Text('Hello World', {
    top: 40,
    left: 20,
    fontSize: 20,
    originX: "center",
    originY: "center"
  })

  // 建组
  const group = new fabric.Group([ellipse, text], {
    top: 50, // 整组距离顶部100
    left: 100, // 整组距离左侧100
    angle: -10, // 整组旋转-10deg
  })

  canvas.add(group)
}

// 取消组
function ungroup() {
  // 判断当前有没有选中元素,如果没有就不执行任何操作
  if (!canvas.getActiveObject()) {
    return
  }

  // 判断当前是否选中组,如果不是,就不执行任何操作
  if (canvas.getActiveObject().type !== 'group') {
    return
  }

  // 先获取当前选中的对象,然后打散
  canvas.getActiveObject().toActiveSelection()
}

onMounted(() => {
  init()
})
</script>

动画

绝对值动画

042.gif

<template>
  <div>
    <canvas width="400" height="400" id="canvas" style="border: 1px solid #ccc;"></canvas>
  </div>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

// 初始化
function init() {
  const canvas = new fabric.Canvas('canvas')

  const rect = new fabric.Rect({
    left: 100,
    top: 100,
    width: 100,
    height: 100,
    fill: 'red'
  })

  // 设置矩形动画
  rect.animate('angle', "-50", {
    onChange:canvas.renderAll.bind(canvas), // 每次刷新的时候都会执行
  })

  canvas.add(rect)
}

onMounted(() => {
  init()
})
</script>

每个 Fabric 对象都有一个 animate 方法,该方法可以动画化该对象。

用法:animate(动画属性, 动画的结束值, [画的详细信息])

第一个参数是要设置动画的属性。

第二个参数是动画的结束值。

第三个参数是一个对象,包括:

{

   rom:允许指定可设置动画的属性的起始值(如果我们不希望使用当前值)。

   duration:默认为500(ms)。可用于更改动画的持续时间。

   onComplete:在动画结束时调用的回调。

   easing:缓动功能。

}

相对值动画

043.gif

<template>
  <div>
    <canvas width="400" height="400" id="canvas" style="border: 1px solid #ccc;"></canvas>
  </div>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

// 初始化
function init() {
  const canvas = new fabric.Canvas('canvas')
  const rect = new fabric.Rect({
    left: 100,
    top: 100,
    width: 100,
    height: 100,
    fill: 'red'
  })

  // 请注意第二个参数:+=360
  rect.animate('angle', '+=360', {
    onChange:canvas.renderAll.bind(canvas), // 每次刷新的时候都会执行
    duration: 2000, // 执行时间
    easing: fabric.util.ease.easeOutBounce, // 缓冲效果
  })

  canvas.add(rect)
}

onMounted(() => {
  init()
})
</script>

绝对值动画相对值动画 的用法是差不多的,只是 第二个参数 用法不同。

相对值动画 是把 animate 改成带上运算符的值,这样就会在原基础上做计算。

设置图形层级

  • Canvas对象层级操作方法

    • canvas.bringToFront(object): 将指定对象移到最前面。
    • canvas.sendToBack(object): 将指定对象移到最后面。
    • canvas.bringForward(object): 将指定对象向前移动一个层级。
    • canvas.sendBackwards(object): 将指定对象向后移动一个层级。
    • canvas.moveTo(object, index): 将指定对象移动到指定的层级索引。
  • Object对象层级操作方法

    • object.bringToFront(): 将当前对象移到最前面。
    • object.sendToBack(): 将当前对象移到最后面。
    • object.bringForward(intersecting): 将当前对象向前移动一个层级,若intersecting为true则会跳过所有交叉的对象。
    • object.sendBackwards(intersecting): 将当前对象向后移动一个层级,若intersecting为true则会跳过所有交叉的对象。
    • object.moveTo(index): 将当前对象移动到指定的层级索引。

想要获取具体图形的层级一般使用 canvas.getObjects().indexOf(xxx)

显然,这个有点麻烦,我们自己加一个 level 方法让其直接显示对象的层级。

// 新增 level 方法
fabric.Object.prototype.getLevel = function() {
  return this.canvas.getObjects().indexOf(this);
}
// 添加到画布
canvas.add(rect, circle, triangle);
// 调用level方法
console.log(rect.getLevel()); // 0
console.log(triangle.getLevel()); // 1

事件

Fabric.js 提供了一套很方便的事件系统,可以用 on 方法可以初始化事件监听器,用 off 方法将其删除。

045.gif

<template>
  <div>
    <canvas width="400" height="400" id="canvas" style="border: 1px solid #ccc;"></canvas>
    <button @click="addClickEvent">添加画布点击事件</button>
    <button @click="removeClickEvent">移除画布点击事件</button>
  </div>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

let canvas = null

// 初始化画布
function init() {
  canvas = new fabric.Canvas('canvas')

  const rect = new fabric.Rect({
    top: 20,
    left: 20,
    width: 100,
    height: 50,
    fill: '#9896f1'
  })

  // 给矩形添加一个选中事件
  rect.on('selected', options => {
    console.log('选中矩形啦', options)
  })
  canvas.add(rect)

  addClickEvent()
}

// 移除画布点击事件
function removeClickEvent() {
  canvas.off('mouse:down')
}

// 添加画布点击事件
function addClickEvent() {
  removeClickEvent() // 在添加事件之前先把该事件清除掉,以免重复添加
  canvas.on('mouse:down', options => {
    console.log(`x轴坐标: ${options.e.clientX};    y轴坐标: ${options.e.clientY}`)
  })
}

onMounted(() => {
  init()
})
</script>

禁止操作框的部分功能

禁止水平移动

047.gif

<template>
  <div>
    <canvas id="canvas" width="400" height="400" style="border: 1px solid #ccc;"></canvas>
  </div>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

// 初始化画布
function init() {
  const canvas = new fabric.Canvas('canvas')

  const rect = new fabric.Rect({
    top: 100,
    left: 100,
    width: 100,
    height: 50,
    fill: '#ffde7d'
  })

  // 不允许水平移动
  rect.lockMovementX = true

  canvas.add(rect)
}

onMounted(() => {
  init()
})
</script>

禁止垂直移动

048.gif

<template>
  <div>
    <canvas id="canvas" width="400" height="400" style="border: 1px solid #ccc;"></canvas>
  </div>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

// 初始化画布
function init() {
  const canvas = new fabric.Canvas('canvas')

  const rect = new fabric.Rect({
    top: 100,
    left: 100,
    width: 100,
    height: 50,
    fill: '#f6416c'
  })

  // 不允许垂直移动
  rect.lockMovementY = true

  canvas.add(rect)
}

onMounted(() => {
  init()
})
</script>

缩放和平移画布

缩放画布

以原点为基准缩放画布

需要监听鼠标的滚轮事件:mouse:wheel

052.gif

<template>
  <div>
    <canvas id="canvas" width="400" height="400" style="border: 1px solid #ccc;"></canvas>
  </div>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  // 初始化画布
  const canvas = new fabric.Canvas('canvas')

  // 矩形
  const rect = new fabric.Rect({
    top: 10,
    left: 10,
    width: 40,
    height: 40,
    fill: 'orange'
  })

  // 圆形
  const circle = new fabric.Circle({
    top: 30,
    left: 30,
    radius: 50,
    fill: 'green'
  })
  canvas.add(rect, circle) // 将矩形和圆形添加到画布中

  // 监听鼠标滚轮事件
  canvas.on('mouse:wheel', opt => {
    let delta = opt.e.deltaY // 滚轮向上滚一下是 -100,向下滚一下是 100
    let zoom = canvas.getZoom() // 获取画布当前缩放值

    // 控制缩放范围在 0.01~20 的区间内
    zoom *= 0.999 ** delta
    if (zoom > 20) zoom = 20
    if (zoom < 0.01) zoom = 0.01

    // 设置画布缩放比例
    canvas.setZoom(zoom)
  })
}

onMounted(() => {
  init()
})
</script>

以鼠标指针为基准缩放画布

053.gif

<template>
  <div>
    <canvas id="canvas" width="400" height="400" style="border: 1px solid #ccc;"></canvas>
  </div>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  // 初始化画布
  const canvas = new fabric.Canvas('canvas')

  // 矩形
  const rect = new fabric.Rect({
    top: 130,
    left: 130,
    width: 40,
    height: 40,
    fill: 'orange'
  })

  // 圆形
  const circle = new fabric.Circle({
    top: 150,
    left: 150,
    radius: 50,
    fill: 'green'
  })
  canvas.add(rect, circle) // 将矩形和圆形添加到画布中

  // 监听鼠标滚轮事件
  canvas.on('mouse:wheel', opt => {
    let delta = opt.e.deltaY // 滚轮向上滚一下是 -100,向下滚一下是 100
    let zoom = canvas.getZoom() // 获取画布当前缩放值

    // 控制缩放范围在 0.01~20 的区间内
    zoom *= 0.999 ** delta
    if (zoom > 20) zoom = 20
    if (zoom < 0.01) zoom = 0.01

    // 设置画布缩放比例
    // 关键点!!!
    // 参数1:将画布的所放点设置成鼠标当前位置
    // 参数2:传入缩放值
    canvas.zoomToPoint(
      {
        x: opt.e.offsetX, // 鼠标x轴坐标
        y: opt.e.offsetY  // 鼠标y轴坐标
      },
      zoom // 最后要缩放的值
    )
  })
}

onMounted(() => {
  init()
})
</script>

平移画布

<template>
  <div>
    <canvas id="canvas" width="400" height="400" style="border: 1px solid #ccc;"></canvas>
  </div>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  // 初始化画布
  const canvas = new fabric.Canvas('canvas')

  // 矩形
  const rect = new fabric.Rect({
    top: 130,
    left: 130,
    width: 40,
    height: 40,
    fill: 'orange'
  })

  // 圆形
  const circle = new fabric.Circle({
    top: 150,
    left: 150,
    radius: 50,
    fill: 'green'
  })
  canvas.add(rect, circle) // 将矩形和圆形添加到画布中

  canvas.on('mouse:down', opt => { // 鼠标按下时触发
    let evt = opt.e
    if (evt.altKey === true) { // 是否按住alt
      canvas.isDragging = true // isDragging 是自定义的,开启移动状态
      canvas.lastPosX = evt.clientX // lastPosX 是自定义的
      canvas.lastPosY = evt.clientY // lastPosY 是自定义的
    }
  })

  canvas.on('mouse:move', opt => { // 鼠标移动时触发
    if (canvas.isDragging) {
      let evt = opt.e
      let vpt = canvas.viewportTransform // 聚焦视图的转换
      vpt[4] += evt.clientX - canvas.lastPosX
      vpt[5] += evt.clientY - canvas.lastPosY
      canvas.requestRenderAll() // 重新渲染
      canvas.lastPosX  = evt.clientX
      canvas.lastPosY  = evt.clientY
    }
  })

  canvas.on('mouse:up', opt => { // 鼠标松开时触发
    canvas.setViewportTransform(canvas.viewportTransform) // 设置此画布实例的视口转换  
    canvas.isDragging = false // 关闭移动状态
  })
}

onMounted(() => {
  init()
})
</script>

获取真实的转换坐标

在图像处理的过程中,我们经常会用到坐标点信息,以便于进行一些交互操作。

此处举一个简单的例子,当鼠标点击时,在鼠标的位置创建一个方块对象:

      // 当鼠标按下时
      canvas.on('mouse:down', function(option) {
        const evt = option.e;
        // 创建一个小方块
        this.add(new fabric.Rect({
          left: evt.offsetX,
          top: evt.offsetY,
          width: 50,
          height: 50,
          fill: 'yellow'
        }))
        this.renderAll();
      });

recording.gif

从上图可见,当canvas未平移或缩放时,可以很简单的获取相应点位置,但是一但平移或者缩放后,鼠标点的位置就全乱了。Fabric.js 提供了 transformPoint 方法解决这一问题。

  • fabric.util.transformPoint(Point, transform) :

    • 将Canvas坐标点转换为视口坐标点
    • 例如:fabric.util.transformPoint(new fabric.Point(100, 100), canvas.viewportTransform) ,将视口的(100,100)坐标点转化为平移缩放后的坐标点。
  • Canvas.getPointer(event) :

    • 用于获取事件(如鼠标或触摸事件)发生时相对于画布的坐标。它考虑了当前视口的变换(包括平移和缩放),因此可以正确地将鼠标或触摸事件的屏幕坐标转换为画布坐标。

修改代码:

      // 当鼠标按下时
      canvas.on('mouse:down', function(option) {
        const evt = option.e;
        // 用transformPoint创建一个小方块
        // 注意 transformPoint 作用是将一个坐标从一个坐标系转换到另一个坐标系
        // 由于这里的将按下的视口坐标转换成 canvas画布坐标系,所以需要用 invertTransform 反转变换
        this.add(new fabric.Rect({
          left: fabric.util.transformPoint({ x: evt.offsetX, y: evt.offsetY },  fabric.util.invertTransform(canvas.viewportTransform)).x,
          top: fabric.util.transformPoint({ x: evt.offsetX, y: evt.offsetY }, fabric.util.invertTransform(canvas.viewportTransform)).y,
          width: 50,
          height: 50,
          fill: 'red'
        }))
        // 用getPointer创建一个小方块
        const pointer = canvas.getPointer(evt);
        console.log('potint, ', pointer)
        this.add(new fabric.Rect({
          left: pointer.x,
          top: pointer.y,
          width: 50,
          height: 50,
          fill: 'blue'
        }))
        this.renderAll();
      });

注意 transformPoint 作用是将一个坐标从一个坐标系转换到另一个坐标系,由于这里的将按下的视口坐标转换成 canvas画布坐标系,所以需要用 invertTransform 反转变换。

3C730B8B-C74E-4BD5-B5DA-B66851E96B6C.gif

序列化

输出JSON

<template>
  <div>
    <canvas id="canvas" width="200" height="200" style="border: 1px solid #ccc;"></canvas>
  </div>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  // 初始化画布
  const canvas = new fabric.Canvas('canvas')

  console.log('canvas stringify ', JSON.stringify(canvas))
  console.log('canvas toJSON', canvas.toJSON())
  console.log('canvas toObject', canvas.toObject())
}

onMounted(() => {
  init()
})
</script>

打开控制台可以看到输出。

输出base64

<template>
  <div>
    <canvas id="canvas" width="200" height="200" style="border: 1px solid #ccc;"></canvas>
  </div>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  // 初始化画布
  const canvas = new fabric.Canvas('canvas', {
    backgroundColor: '#a5dee5'
  })

  const rect = new fabric.Rect({
    left: 50,
    top: 50,
    height: 20,
    width: 20,
    fill: 'green'
  })

  const circle = new fabric.Circle({
    left: 80,
    top: 80,
    radius: 40,
    fill: "red"
  })

  canvas.add(rect, circle)

  console.log('toPng', canvas.toDataURL('png')) // 在控制台输出 png(base64)
  canvas.requestRenderAll()
}

onMounted(() => {
  init()
})
</script>

输出 SVG

<template>
  <div>
    <canvas id="canvas" width="200" height="200" style="border: 1px solid #ccc;"></canvas>
  </div>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  // 初始化画布
  const canvas = new fabric.Canvas('canvas', {
    backgroundColor: '#a5dee5'
  })

  const rect = new fabric.Rect({
    left: 50,
    top: 50,
    height: 20,
    width: 20,
    fill: 'green'
  })

  const circle = new fabric.Circle({
    left: 80,
    top: 80,
    radius: 40,
    fill: "red"
  })

  canvas.add(rect, circle)

  console.log(canvas.toSVG()) // 输出 SVG
}

onMounted(() => {
  init()
})
</script>

反序列化

反序列化就是把 JSON 数据渲染到画布上。

通常把从后台请求回来的数据渲染到画布上。

<template>
  <div>
    <canvas id="canvas" width="200" height="200" style="border: 1px solid #ccc;"></canvas>
  </div>
</template>

<script setup>
import { onMounted } from 'vue'
import { fabric } from 'fabric'

function init() {
  const jsonStr = ''

  // 初始化画布
  const canvas = new fabric.Canvas('canvas')

  // 反序列化
  canvas.loadFromJSON(jsonStr)
}

onMounted(() => {
  init()
})
</script>

总结

写到这里,我们已经把 Fabric.js 常见功能都过了一遍。如果你坚持看到这里,恭喜你!你已经具备构建一个简单画板/海报编辑器/可视化工具的基础能力。

Fabric.js 是一个非常强大的前端 Canvas 库,随着你不断实践,你会发现它能做的事情远不止本文展示的这些。

如果你有什么想法、问题或希望我继续写的方向,欢迎在评论区告诉我,我会持续更新更多相关内容。

深入理解MessageChannel:JS双向通信的高效解决方案

作者 大知闲闲i
2026年1月15日 15:02

===

在前端开发中,跨执行环境的通信是常见需求——比如主线程与Web Worker间的数据交互、iframe与父页面的消息传递等。传统的全局事件监听方式虽然简单,但在复杂场景下容易出现消息冲突、性能低下等问题。MessageChannel作为JavaScript原生提供的双向通信API,为这类场景提供了轻量、高效的解决方案。本文将深入解析MessageChannel的工作原理、核心优势及实际应用。

一、MessageChannel核心概念:建立专属通信通道

什么是MessageChannel?

MessageChannel是浏览器提供的原生API,用于在两个独立的JavaScript执行环境之间建立专属的双向通信通道。每个通道包含两个互相关联的端口(port1port2),形成一个完整的通信闭环。

核心特性解析

  • 双向通信:两个端口均可发送和接收消息,实现真正的双向对话

  • 独立通道:每个通道都是隔离的,不同通道互不干扰,避免消息污染

  • 跨环境支持:可在主线程、Web Worker、iframe、SharedWorker等任意组合间建立连接

  • 异步无阻塞:基于事件机制,不会阻塞主线程执行

  • 所有权转移:端口可以安全地转移给其他执行环境

基础工作流程

// 1. 创建通道实例
const channel = new MessageChannel();
const { port1, port2 } = channel;

// 2. 将一个端口传递给目标环境
target.postMessage('init', '*', [port2]);

// 3. 监听端口消息
port1.onmessage = (event) => {
  console.log('收到消息:', event.data);
};

// 4. 发送消息
port1.postMessage('Hello from port1');

// 5. 关闭端口(可选)
// port1.close();

二、MessageChannel典型使用场景

1. 主线程与Web Worker的高效通信

Web Worker常用于处理计算密集型任务,但传统的worker.postMessage()方式存在性能瓶颈。每次通信都需要对数据进行序列化/反序列化(结构化克隆),频繁通信时开销明显。

MessageChannel解决方案

  • 建立专属通道,减少序列化开销

  • 实现精准的"一对一"通信

  • 支持复杂的数据传输(如ArrayBuffer、ImageBitmap等可转移对象)

2. 父页面与iframe的安全通信

window.postMessage虽然能实现跨域通信,但存在安全隐患:

  • 全局监听可能被恶意页面劫持

  • 多iframe场景下消息来源难以区分

  • 缺乏消息确认机制

MessageChannel优势

// 为每个iframe创建独立通道
const iframeChannels = new Map();

function connectToIframe(iframeId) {
  const channel = new MessageChannel();
  iframeChannels.set(iframeId, channel.port1);
  // 仅目标iframe能接收到端口
  document.getElementById(iframeId).contentWindow
    .postMessage('channel-init', '*', [channel.port2]);
}

3. SharedWorker多页面通信管理

当多个页面共享同一个Worker时,MessageChannel能为每个页面建立独立的通信链路,避免消息广播带来的混乱。

4. 异步任务解耦与封装

在微前端、插件化架构中,可通过MessageChannel将独立模块封装在隔离环境中:

// 创建数据处理专用Worker
class DataProcessor {
  constructor() {
    this.channel = new MessageChannel();
    this.worker = new Worker('processor.js');
    this.setupChannel();
  }
  
  async process(data) {
    return new Promise((resolve) => {
      this.channel.port1.onmessage = (e) => resolve(e.data);
      this.channel.port1.postMessage(data);
    });
  }
}

5. 跨标签页通信优化

结合BroadcastChannel实现跨标签页的高效通信:

  1. 使用BroadcastChannel广播通道建立请求

  2. 通过MessageChannel建立专属数据通道

  3. 进行高频或大数据量的传输

三、实战示例:核心场景代码实现

示例1:主线程与Web Worker的双向通信

主线程代码(main.js)

class WorkerManager {
  constructor(workerUrl) {
    this.worker = new Worker(workerUrl);
    this.channel = new MessageChannel();
    this.initChannel();
  }
  
  initChannel() {
    // 将port2传递给Worker
    this.worker.postMessage(
      { type: 'INIT_CHANNEL' },
      [this.channel.port2]
    );
    
    // 设置消息监听
    this.channel.port1.onmessage = this.handleMessage.bind(this);
    this.channel.port1.onmessageerror = this.handleError.bind(this);
  }
  
  handleMessage(event) {
    const { type, data, id } = event.data;
    
    if (type === 'RESULT') {
      // 处理Worker返回的结果
      this.pendingRequests.get(id)?.resolve(data);
      this.pendingRequests.delete(id);
    }
  }
  
  async sendTask(taskData) {
    const taskId = Date.now() + Math.random();
    
    return new Promise((resolve, reject) => {
      this.pendingRequests.set(taskId, { resolve, reject });
      
      this.channel.port1.postMessage({
        type: 'EXECUTE_TASK',
        id: taskId,
        data: taskData
      });
      
      // 设置超时
      setTimeout(() => {
        if (this.pendingRequests.has(taskId)) {
          reject(new Error('Worker timeout'));
          this.pendingRequests.delete(taskId);
        }
      }, 5000);
    });
  }
}

Worker代码(worker.js)

let communicationPort = null;

// 监听主线程初始化
self.onmessage = function(event) {
  const { type, ports } = event.data;
  
  if (type === 'INIT_CHANNEL' && ports[0]) {
    communicationPort = ports[0];
    
    communicationPort.onmessage = async function(event) {
      const { type, id, data } = event.data;
      
      if (type === 'EXECUTE_TASK') {
        try {
          // 执行计算密集型任务
          const result = await processData(data);
          
          // 返回结果
          communicationPort.postMessage({
            type: 'RESULT',
            id,
            data: result
          });
        } catch (error) {
          communicationPort.postMessage({
            type: 'ERROR',
            id,
            error: error.message
          });
        }
      }
    };
  }
};

// 数据处理函数
async function processData(data) {
  // 模拟复杂计算
  await new Promise(resolve => setTimeout(resolve, 100));
  
  return {
    processed: true,
    timestamp: Date.now(),
    summary: `Processed ${data.length} items`
  };
}

示例2:安全的iframe通信架构

父页面控制器

class IframeCommunicator {
  constructor() {
    this.channels = new Map();
    this.messageHandlers = new Map();
  }
  
  registerIframe(iframeElement, allowedOrigins) {
    const channel = new MessageChannel();
    const iframeId = iframeElement.id;
    
    // 存储通道引用
    this.channels.set(iframeId, {
      port: channel.port1,
      iframe: iframeElement,
      allowedOrigins
    });
    
    // 设置消息监听
    channel.port1.onmessage = (event) => {
      this.handleIncomingMessage(iframeId, event);
    };
    
    // 等待iframe加载完成后发送端口
    iframeElement.addEventListener('load', () => {
      iframeElement.contentWindow.postMessage(
        {
          type: 'CHANNEL_INIT',
          iframeId
        },
        '*',
        [channel.port2]
      );
    });
    
    return {
      send: (type, data) => this.sendToIframe(iframeId, type, data),
      on: (type, handler) => this.registerHandler(iframeId, type, handler)
    };
  }
  
  sendToIframe(iframeId, type, data) {
    const channel = this.channels.get(iframeId);
    if (channel && channel.port) {
      channel.port.postMessage({ type, data });
    }
  }
}

iframe端适配器

class IframeBridge {
  constructor() {
    this.parentPort = null;
    this.handlers = new Map();
    
    window.addEventListener('message', (event) => {
      if (event.data.type === 'CHANNEL_INIT' && event.ports[0]) {
        this.parentPort = event.ports[0];
        
        this.parentPort.onmessage = (messageEvent) => {
          const { type, data } = messageEvent.data;
          this.dispatchMessage(type, data);
        };
        
        // 通知父页面连接就绪
        this.send('READY', { status: 'connected' });
      }
    });
  }
  
  send(type, data) {
    if (this.parentPort) {
      this.parentPort.postMessage({ type, data });
    }
  }
  
  on(type, handler) {
    if (!this.handlers.has(type)) {
      this.handlers.set(type, []);
    }
    this.handlers.get(type).push(handler);
  }
}

四、高级应用与最佳实践

1. 错误处理与重连机制

class RobustMessageChannel {
  constructor(target, options = {}) {
    this.target = target;
    this.maxRetries = options.maxRetries || 3;
    this.reconnectDelay = options.reconnectDelay || 1000;
    this.retryCount = 0;
    
    this.setupChannel();
  }
  
  setupChannel() {
    try {
      this.channel = new MessageChannel();
      this.setupEventListeners();
      
      // 发送端口到目标
      this.target.postMessage('INIT', '*', [this.channel.port2]);
      
      // 设置连接超时
      this.connectionTimeout = setTimeout(() => {
        this.handleDisconnection();
      }, 5000);
      
    } catch (error) {
      this.handleError(error);
    }
  }
  
  handleDisconnection() {
    if (this.retryCount < this.maxRetries) {
      this.retryCount++;
      setTimeout(() => this.setupChannel(), this.reconnectDelay);
    }
  }
}

2. 消息序列化与性能优化

// 使用Transferable对象提升性能
function sendLargeBuffer(port, buffer) {
  // 标记为可转移对象,避免复制
  port.postMessage(
    { type: 'LARGE_BUFFER', buffer },
    [buffer]
  );
}

// 批量消息处理
class MessageBatcher {
  constructor(port, batchSize = 10) {
    this.port = port;
    this.batchSize = batchSize;
    this.queue = [];
    this.flushTimeout = null;
  }
  
  send(type, data) {
    this.queue.push({ type, data, timestamp: Date.now() });
    
    if (this.queue.length >= this.batchSize) {
      this.flush();
    } else if (!this.flushTimeout) {
      this.flushTimeout = setTimeout(() => this.flush(), 50);
    }
  }
  
  flush() {
    if (this.queue.length > 0) {
      this.port.postMessage({
        type: 'BATCH',
        messages: this.queue
      });
      this.queue = [];
    }
    clearTimeout(this.flushTimeout);
    this.flushTimeout = null;
  }
}

3. 类型安全的消息通信

// 使用TypeScript或JSDoc增强类型安全
/**
 * @typedef {Object} MessageProtocol
 * @property {'TASK' | 'RESULT' | 'ERROR'} type
 * @property {string} id
 * @property {any} [data]
 * @property {string} [error]
 */

class TypedMessageChannel {
  /**
   * @param {MessagePort} port 
   */
  constructor(port) {
    this.port = port;
  }
  
  /**
   * @param {'TASK' | 'RESULT' | 'ERROR'} type
   * @param {any} data
   * @returns {Promise<any>}
   */
  send(type, data) {
    return new Promise((resolve, reject) => {
      const messageId = this.generateId();
      
      const handler = (event) => {
        const response = /** @type {MessageProtocol} */ (event.data);
        if (response.id === messageId) {
          this.port.removeEventListener('message', handler);
          if (response.type === 'ERROR') {
            reject(new Error(response.error));
          } else {
            resolve(response.data);
          }
        }
      };
      
      this.port.addEventListener('message', handler);
      this.port.postMessage({ type, id: messageId, data });
    });
  }
}

五、使用注意事项与兼容性

关键注意事项

  1. 端口所有权转移:传递端口时必须在postMessage的第二个参数中声明

    // 正确:声明转移
    target.postMessage('init', '*', [port2]);
    
    // 错误:端口将被冻结
    target.postMessage({ port: port2 }, '*');
    
  2. 内存管理:及时关闭不再使用的端口

    // 通信结束时清理
    port.close();
    channel = null;
    
  3. 数据类型限制:结构化克隆算法不支持函数、DOM节点等

    • 支持:对象、数组、Blob、ArrayBuffer、ImageBitmap等

    • 不支持:函数、Symbol、DOM节点、原型链

  4. 安全考虑

    • 验证消息来源

    • 设置消息超时

    • 实施速率限制

兼容性处理

function createCommunicationChannel(target) {
  // 检测MessageChannel支持
  if (typeof MessageChannel !== 'undefined') {
    const channel = new MessageChannel();
    target.postMessage('init', '*', [channel.port2]);
    return channel.port1;
  } else {
    // 降级方案:使用postMessage + 消息ID
    return new LegacyChannel(target);
  }
}

class LegacyChannel {
  constructor(target) {
    this.target = target;
    this.listeners = new Map();
    window.addEventListener('message', this.handleMessage.bind(this));
  }
  
  postMessage(data) {
    this.target.postMessage({
      _legacyChannel: true,
      data
    }, '*');
  }
}

六、性能对比与选型建议

MessageChannel vs postMessage

选型建议

  • 选择MessageChannel当

    1. 需要高频双向通信

    2. 要求通信隔离和安全性

    3. 传输大量或敏感数据

    4. 需要精准的"请求-响应"模式

  • 使用postMessage当

    1. 简单的单向通知

    2. 广播消息到多个目标

    3. 兼容旧版浏览器

    4. 轻量级通信需求

七、总结

MessageChannel是现代前端架构中不可或缺的通信工具,它解决了跨执行环境通信的关键问题:

核心价值

  1. 性能卓越:专用通道避免全局事件竞争,提升通信效率

  2. 安全可靠:端口隔离机制防止消息泄露和污染

  3. 架构清晰:明确的"端口对"模型简化了复杂通信逻辑

  4. 功能强大:支持可转移对象、双向通信、错误处理等高级特性

适用场景总结

  • ✅ Web Worker与主线程的高频数据交换

  • ✅ 微前端架构中的模块通信

  • ✅ 复杂iframe应用的父子页面交互

  • ✅ 需要严格隔离的插件系统

  • ✅ 实时数据处理管道

最佳实践要点

  1. 始终实现错误处理和重连逻辑

  2. 通信结束后及时清理端口资源

  3. 对于大型数据传输使用Transferable对象

  4. 在生产环境中添加监控和日志记录

  5. 考虑降级方案以保证兼容性

随着Web应用越来越复杂,对执行环境隔离和高效通信的需求日益增长。MessageChannel提供的专属、双向、高性能通信能力,使其成为构建现代化、模块化前端应用的基石技术。掌握MessageChannel不仅能够解决具体的通信问题,更能帮助你设计出更清晰、更可维护的前端架构。

手写简易Vue响应式:基于Proxy + effect的核心实现

作者 boooooooom
2026年1月15日 14:02

Vue的响应式系统是其核心特性之一,从Vue2到Vue3,响应式的实现方案从Object.defineProperty演进为Proxy。相比前者,Proxy能原生支持数组、对象新增属性等场景,且对对象的拦截更全面。本文将从核心原理出发,手把手教你实现一个基于Proxy + effect的简易Vue响应式系统,帮你彻底搞懂响应式的底层逻辑。

一、响应式的核心原理是什么?

响应式的本质是“数据变化驱动视图更新”,其核心逻辑可拆解为三个关键步骤:

  1. 依赖收集:当组件渲染(或effect执行)时,会访问响应式数据,此时记录“数据-依赖(effect)”的映射关系;
  2. 数据拦截:通过Proxy拦截响应式数据的读取(get)和修改(set)操作——读取时触发依赖收集,修改时触发依赖更新;
  3. 依赖触发:当响应式数据被修改时,找到之前收集的所有依赖(effect),并重新执行这些依赖,从而实现视图更新或其他副作用触发。

其中,Proxy负责“数据拦截”,effect负责封装“依赖(副作用函数)”,再配合一个“依赖映射表”完成整个响应式闭环。

二、核心模块拆解与实现

我们将分三步实现简易响应式系统:先实现effect模块封装副作用,再实现reactive模块基于Proxy拦截数据,最后通过依赖映射表关联两者,完成依赖收集与触发。

1. 第一步:实现effect——副作用函数封装

effect的作用是包裹需要响应式触发的副作用函数(比如组件渲染函数、watch回调等)。当effect执行时,会主动触发响应式数据的get操作,进而触发依赖收集;当数据变化时,effect会被重新执行。

核心逻辑:

  • 定义一个全局变量(activeEffect),用于标记当前正在执行的effect;
  • effect函数接收一个副作用函数(fn),执行fn前将其赋值给activeEffect,执行后清空activeEffect(避免非响应式数据访问时误收集依赖)。

代码实现:

// 全局变量:标记当前活跃的effect(正在执行的副作用函数)
let activeEffect = null;

/**
 * 副作用函数封装
 * @param {Function} fn - 需要响应式触发的副作用函数
 */
function effect(fn) {
  // 定义一个包装函数,便于后续扩展(如错误处理、调度执行等)
  const effectFn = () => {
    // 执行副作用函数前,先标记当前活跃的effect
    activeEffect = effectFn;
    // 执行副作用函数(此时会访问响应式数据,触发get拦截,进而收集依赖)
    fn();
    // 执行完成后,清空标记(避免后续非响应式数据访问时误收集)
    activeEffect = null;
  };

  // 立即执行一次副作用函数,触发初始的依赖收集
  effectFn();
}

2. 第二步:实现依赖映射表——track与trigger

我们需要一个数据结构来存储“数据-属性-effect”的映射关系,这里采用WeakMap(数据)→ Map(属性)→ Set(effect)的结构:

  • WeakMap:key为响应式对象(target),value为Map(属性映射表),弱引用特性可避免内存泄漏;
  • Map:key为对象的属性名(key),value为Set(存储该属性对应的所有effect);
  • Set:存储effect,保证effect不重复(避免多次执行同一副作用)。

基于这个结构,实现两个核心函数:

  • track:在响应式数据被读取时调用,收集依赖(将activeEffect存入映射表);
  • trigger:在响应式数据被修改时调用,触发依赖(从映射表中取出effect并执行)。

代码实现:

// 依赖映射表:WeakMap(target) → Map(key) → Set(effect)
const targetMap = new WeakMap();

/**
 * 收集依赖(响应式数据读取时触发)
 * @param {Object} target - 响应式对象
 * @param {string} key - 被读取的属性名
 */
function track(target, key) {
  // 1. 若当前无活跃的effect,无需收集依赖,直接返回
  if (!activeEffect) return;

  // 2. 从targetMap中获取当前对象的属性映射表(Map)
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    // 若不存在,创建新的Map并存入targetMap
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }

  // 3. 从depsMap中获取当前属性的effect集合(Set)
  let deps = depsMap.get(key);
  if (!deps) {
    // 若不存在,创建新的Set并存入depsMap
    deps = new Set();
    depsMap.set(key, deps);
  }

  // 4. 将当前活跃的effect存入Set(保证不重复)
  deps.add(activeEffect);
}

/**
 * 触发依赖(响应式数据修改时触发)
 * @param {Object} target - 响应式对象
 * @param {string} key - 被修改的属性名
 */
function trigger(target, key) {
  // 1. 从targetMap中获取当前对象的属性映射表
  const depsMap = targetMap.get(target);
  if (!depsMap) return; // 若没有收集过依赖,直接返回

  // 2. 从depsMap中获取当前属性的effect集合
  const deps = depsMap.get(key);
  if (deps) {
    // 3. 遍历effect集合,执行每个effect(触发副作用更新)
    deps.forEach(effect => effect());
  }
}

3. 第三步:实现reactive——基于Proxy的响应式数据拦截

reactive函数的作用是将普通对象转为响应式对象,核心是通过Proxy拦截对象的get(读取)和set(修改)操作:

  • get拦截:当读取响应式对象的属性时,调用track函数收集依赖;
  • set拦截:当修改响应式对象的属性时,先更新属性值,再调用trigger函数触发依赖。

代码实现:

/**
 * 将普通对象转为响应式对象(基于Proxy)
 * @param {Object} target - 普通对象
 * @returns {Proxy} 响应式对象
 */
function reactive(target) {
  return new Proxy(target, {
    // 拦截属性读取操作
    get(target, key) {
      // 1. 读取原始属性值
      const value = Reflect.get(target, key);
      // 2. 收集依赖(关联target、key和当前activeEffect)
      track(target, key);
      // 3. 返回属性值(若value是对象,可递归转为响应式,这里简化实现)
      return value;
    },

    // 拦截属性修改操作
    set(target, key, value) {
      // 1. 修改原始属性值
      const result = Reflect.set(target, key, value);
      // 2. 触发依赖(执行该属性关联的所有effect)
      trigger(target, key);
      // 3. 返回修改结果(符合Proxy规范)
      return result;
    }
  });
}

这里使用Reflect而非直接操作target,是为了保证操作的规范性(比如Reflect.set会返回布尔值表示修改成功,而直接赋值不会),同时与Proxy的拦截行为更匹配。

三、完整测试:验证响应式效果

我们已经实现了effect、track、trigger、reactive四个核心模块,现在编写测试代码验证响应式是否生效:

// 1. 创建普通对象并转为响应式对象
const user = reactive({ name: "张三", age: 20 });

// 2. 定义副作用函数(模拟组件渲染:依赖user.name和user.age)
effect(() => {
  console.log(`姓名:${user.name},年龄:${user.age}`);
});

// 3. 修改响应式数据,观察副作用是否触发
user.name = "李四"; // 输出:姓名:李四,年龄:20(触发effect重新执行)
user.age = 21;      // 输出:姓名:李四,年龄:21(再次触发effect)
user.gender = "男"; // 新增属性(Proxy天然支持,若有依赖该属性的effect也会触发)

运行结果:

  • effect首次执行时,输出“姓名:张三,年龄:20”(初始渲染);
  • 修改user.name时,触发set拦截→trigger→effect重新执行,输出更新后的内容;
  • 修改user.age时,同样触发effect更新;
  • 新增user.gender时,若后续有effect依赖该属性,修改时也会触发更新(本测试中无依赖,故无输出)。

四、核心细节补充与简化点说明

上面的实现是简化版响应式,Vue3的真实响应式系统更复杂,这里补充几个关键细节和简化点:

1. 简化点:未处理嵌套对象

当前reactive函数仅对顶层对象进行Proxy拦截,若对象属性是嵌套对象(如user = { info: { age: 20 } }),修改user.info.age不会触发响应式。解决方法是在get拦截时,对返回的value进行判断,若为对象则递归调用reactive:

// 优化reactive的get拦截
get(target, key) {
  const value = Reflect.get(target, key);
  track(target, key);
  // 递归处理嵌套对象
  return typeof value === 'object' && value !== null ? reactive(value) : value;
}

2. 简化点:未处理数组

Proxy天然支持数组拦截,比如修改数组的push、splice、索引等操作。只需在set拦截时,对数组的特殊操作(如push会新增索引)进行处理,确保trigger能正确触发。当前简化实现已支持数组的索引修改,比如:

const list = reactive([1, 2, 3]);
effect(() => {
  console.log("数组:", list.join(','));
});
list[0] = 10; // 输出:数组:10,2,3(触发effect)
list.push(4); // 输出:数组:10,2,3,4(触发effect)

3. 真实Vue3的扩展:调度执行、computed、watch等

我们的实现仅覆盖了核心响应式逻辑,Vue3还在此基础上扩展了:

  • 调度执行:effect支持传入scheduler选项,实现副作用的延迟执行、防抖、节流等;
  • computed:基于effect实现缓存机制,只有依赖变化时才重新计算;
  • watch:监听响应式数据变化,触发回调函数(支持立即执行、深度监听等);
  • Ref:处理基本类型的响应式(通过封装对象实现,核心还是Proxy)。

五、总结

本文通过“effect封装副作用 → track/trigger管理依赖 → reactive基于Proxy拦截数据”的步骤,实现了一个简易的Vue响应式系统。核心逻辑可概括为:

effect执行时标记活跃状态,访问响应式数据触发get拦截,通过track收集“数据-属性-effect”依赖;修改数据触发set拦截,通过trigger找到对应依赖并重新执行effect,最终实现响应式更新。

理解这个核心逻辑后,再去学习Vue3的computed、watch等API的实现原理,就会变得非常轻松。建议你动手敲一遍代码,尝试修改和扩展(比如添加嵌套对象支持、调度执行),加深对响应式原理的理解。

前端开发里最常用的5种本地存储

2026年1月15日 13:23

最近项目里又在反复纠结本地数据怎么存最合适,就想把前端日常最常碰的几种存储方式捋一遍。

为什么前端需要本地存储?
简单说:提升性能、支持离线、记住用户偏好、减少服务器压力。
比如列表页用户翻了好几页,下次进来还想看到上次位置和筛选;PWA没网也能看点内容;主题暗黑模式、字体大小这些小东西,不想每次请求接口。
服务器传太慢,内存关页就丢,本地存储就成了日常标配。

优秀的前端本地存储该有哪些特性?(参考后端思路,我觉得前端也差不多)

  • 容量够用(别5MB就爆)
  • 持久性(关浏览器还能剩多少)
  • 性能(读写快不快,同步/异步)
  • 易用性(API友好不)
  • 跨标签共享(多标签能不能同步)
  • 结构化支持(对象、数组、Blob、大文件行不行)
  • 安全性(同源、隐私模式)

基于这些,2026年现在前端项目里,我最常用来存的其实就这5种(从简单到复杂排):

1. localStorage

最基础、最常用,几乎每个项目都逃不开。

怎么用(大家都知道,但还是贴代码):

// 存
localStorage.setItem('userTheme', 'dark');

// 取
const theme = localStorage.getItem('userTheme');

// 删
localStorage.removeItem('userTheme');

// 清空
localStorage.clear();

容量:一般5MB左右(Chrome、Safari、Firefox差不多)
持久性:永久,除非用户手动清浏览器缓存。
优缺点

  • 优点:API极简,同步操作,跨标签共享(改一个,其他标签能通过storage事件监听到)
  • 缺点:只能存字符串(对象要JSON.stringify),同步大点数据会卡主线程,隐私模式下可能被清,iOS Safari有时莫名其妙丢数据

真实场景:用户设置(主题、侧边栏状态)、token应急存、简单购物车草稿

2. sessionStorage

跟localStorage几乎一样,但会话级。

代码:把localStorage换成sessionStorage就行,用法一模一样。
容量:5MB左右
持久性:标签页关了就没了(同窗口新tab共享)
优缺点

  • 优点:临时数据不污染长期,安全性稍好(自动销毁)
  • 缺点:不能跨标签持久共享

3. IndexedDB

大容量、结构化数据的王者,PWA/离线必备,现在中大型项目越来越多往这儿搬。

基本用法(原生API啰嗦,实际我基本用dexie.js或idb封装,这里先贴原生):

// 打开数据库
const request = indexedDB.open('myAppDB', 1);

request.onupgradeneeded = (event) => {
  const db = event.target.result;
  db.createObjectStore('todos', { keyPath: 'id', autoIncrement: true });
};

request.onsuccess = (event) => {
  const db = event.target.result;
  // 操作...
};

// 存数据(事务)
function addTodo(todo) {
  const tx = db.transaction('todos', 'readwrite');
  const store = tx.objectStore('todos');
  store.add(todo);
}

容量:很大,通常几百MB到GB(视磁盘空间,浏览器配额管理,远超5MB)
持久性:永久,但空间紧张时浏览器可能清(比localStorage难清)
优缺点

  • 优点:异步不卡线程、支持复杂对象/Blob/文件、索引查询、事务
  • 缺点:原生API回调地狱,学习曲线陡(推荐封装)

4. Cache API(配合Service Worker)

资源缓存神器,PWA性能核心。

基本用法(必须在sw.js里):

// sw.js install事件
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('my-app-v1').then(cache => {
      return cache.addAll([
        '/styles.css',
        '/app.js',
        '/images/logo.png'
      ]);
    })
  );
});

// fetch拦截
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then(response => {
      return response || fetch(event.request);
    })
  );
});

容量:跟IndexedDB一样,受配额管理,很大
持久性:持久,但可被浏览器清理
优缺点

  • 优点:专为资源设计(html/css/js/img),拦截请求方便,支持离线秒开
  • 缺点:只能存Request/Response,不适合业务JSON;依赖Service Worker(https或localhost)

真实场景:PWA壳子、静态资源离线、图片懒加载备用、H5页面缓存

5. 内存缓存(JS对象/Map + 简单LRU)

页面内最快,临时数据首选。

简单实现(项目里常用mini LRU):

class SimpleLRU {
  constructor(max = 100) {
    this.max = max;
    this.cache = new Map();
  }
  get(key) {
    if (!this.cache.has(key)) return null;
    const value = this.cache.get(key);
    this.cache.delete(key);
    this.cache.set(key, value);
    return value;
  }
  set(key, value) {
    if (this.cache.has(key)) this.cache.delete(key);
    this.cache.set(key, value);
    if (this.cache.size > this.max) {
      const oldest = this.cache.keys().next().value;
      this.cache.delete(oldest);
    }
  }
}

// 用法
const pageCache = new SimpleLRU(50);
pageCache.set('userListPage1', data);

容量:内存大小,取决于JS堆
持久性:页面刷新/关闭就没了
优缺点

  • 优点:纳秒级、无序列化、最快
  • 缺点:不持久、内存泄漏风险

整体对比一图概览

维度 localStorage sessionStorage IndexedDB Cache API 内存缓存
易用性 ★★★★★ ★★★★★ ★★☆☆☆ ★★★☆☆ ★★★★☆
容量 ★★☆☆☆ (5MB) ★★☆☆☆ ★★★★★ ★★★★★ ★☆☆☆☆
持久性 ★★★★★ ★☆☆☆☆ ★★★★★ ★★★★☆ ★☆☆☆☆
性能 ★★★☆☆ ★★★☆☆ ★★★★☆ ★★★★★ ★★★★★
结构化支持 ★☆☆☆☆ ★☆☆☆☆ ★★★★★ ★★☆☆☆ ★★★★☆
适用规模 小配置 临时会话 中大型离线 资源/PWA 页面内临时

最后选型建议(我自己的经验)

  • 简单配置、偏好 → localStorage
  • 临时表单/状态 → sessionStorage
  • 大量业务数据、离线 → IndexedDB(强烈推dexie.js)
  • PWA/静态资源 → Cache API + Service Worker
  • 页面内高速复用 → 内存LRU

项目里我现在基本这么分层:小配置local,临时session,大数据IndexedDB,资源Cache,内存加速。
覆盖了95%的场景,不会再纠结了。

2025 年 HTML 年度调查报告亮点速览!

作者 冴羽
2026年1月15日 10:28

1. 前言

近日「State of HTML 2025」年度调查报告公布。

这份报告收集了全球数万名开发者的真实使用经验和反馈,堪称是 Web 开发领域的“年度风向标”。

上篇文章我们盘点了使用最多的功能 Top 5,本篇我们盘点下这份报告的亮点部分。

注:目前 State of JS 2025 还未公布,欢迎关注公众号:冴羽,第一时间获取报告结果。

2. 延迟加载最常用

使用过延迟加载的受访者比例高达 70%,是“新可用(Newly Available)”功能中最常用的功能。

所谓延迟加载,指的是 loading="lazy"属性,该属性可以指定仅在需要时加载资源。

<img src="picture.jpg" loading="lazy" /> <iframe src="supplementary.html" loading="lazy"></iframe>

3. 内容安全策略(CSP)使用量增长最多

内容安全策略的使用量同比增长最大。

但同时,内容安全策略也是最令人失望的功能榜首 😂。

所谓内容安全策略,指的是网站向浏览器发出的一组指令,用于帮助检测和缓解 XSS 攻击。

<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src https://*; child-src 'none';" />

4. <input type="color"/>表单使用最广泛

<input type="color"/> 是 2025 年使用最广泛的表单相关功能,41.8% 的受访者表示使用过该功能。

5. JPEG XL 最受好评

JPEG XLctx.drawElement() 是图形方面两项最受好评的新功能。

关于这两项功能:

JPEG XL 是一种新型图像编码格式,它结合了现有的 JPEG 和 WebP 编码技术,旨在提供更好的压缩性能、更高的图像质量和更好的适用性,支持有损和无损压缩。它旨在超越现有的位图格式,并成为它们的通用替代。

ctx.drawElement() 使开发者可以在 HTML 元素上绘制 <canvas>

<canvas id="canvas" layoutsubtree="true">
  <p>Hello world!</p>
</canvas>
<script type="module">
  const ctx = canvas.getContext("2d");
  const text = canvas.querySelector("p");
  ctx.drawElement(text, 30, 0);
</script>

6. hidden="until-found" 好多人从没听说过

你知道浏览器现在可以玩捉迷藏了吗?

这个 hidden="until-found" 属性可以让你隐藏一个元素,直到用户触发它,例如点击指向它的锚链接。

目前这项功能显然还处于“隐藏”状态,79.4% 的受访者甚至从未听说过它。

而且浏览器支持仍然有限(说的就是你,Safari!)。

但一旦互操作性得到改善,它或许会成为你工具箱中不可或缺的工具。

7. Sanitizer API 最受欢迎

最受欢迎和最不受欢迎的功能都与安全有关:

Sanitizer API 获得了最多的正面评价,而内容安全策略则位列最令人失望的功能榜首。

Sanitizer API 指的是 element.setHTML()以及 Document.parseHTML() API,通过清理 HTML 中不受信任的字符串来防止 XSS 攻击。

8. popover 可以开始用了

Popover API 今年正式上线,这意味着它现在已被四大主流浏览器全面支持。

所以现在正是学习该 API 的最佳时机。

其实 Popover API 学起来也很简单,它主要用于实现弹出窗口,例如覆盖层、弹窗、菜单等。

<button popovertarget="foo">Toggle the popover</button>
<div id="foo" popover>Popover content</div>

值得一提的是,Popover API 还是开发者投诉 “浏览器不支持” 最多的功能 —— 不是浏览器没跟上,是我们还没反应过来 “这个功能已经能用了”。

9. blocking="render" 知道的人多了起来

顾名思义,这个属性可以让<link><script><style>标签阻塞页面渲染,直到它们完全加载完毕。

不过浏览器支持尚未完全到位,但一旦得到广泛支持,它肯定会使网页加载用户体验更加流畅。

10. ElementInternals 可以开始用了

如果你编写过 Web 组件,那么你一定经常需要指定自定义伪类、默认 ARIA 参数,或者让组件的行为像常规表单元素一样。

ElementInternals 不仅能做到这些,还能做得更多!

而且它应用广泛,自 2023 年以来就受到所有浏览器的支持!

11. PaymentRequestAPI 值得密切关注

广告似乎已成为网络世界中不可避免的一部分,PaymentRequest API 可能是实现浏览器集成微支付的第一步。仅凭这一点,就值得我们密切关注。

目前支持度欠佳:

12. <search>可以开始用了

<search> 元素属于那种只需稍加努力就能轻松添加到最佳实践列表中的实用技巧。

用于封装搜索用户界面的语义元素:

<search>
  <form action="search.php">
    <label>Find: <input name="q" type="search" /></label>
    <button>Go!</button>
  </form>
</search>

现在它已被四大主流浏览器支持,没有理由不使用它了。

我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。

欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。

浏览器硬导航优化:提升用户体验的关键

2026年1月15日 10:24

一、前言

在现代Web应用中,用户体验(User Experience, UX)的重要性日益凸显。一个流畅、响应迅速的网站,能够显著提升用户的满意度和留存率。然而,在追求极致性能的道路上,我们常常会遇到一个棘手的挑战——硬导航(Hard Navigation)。当用户点击一个链接,浏览器需要完全卸载当前页面,再重新加载新页面时,这种“硬”切换往往伴随着延迟和短暂的空白,极大地损害了用户体验。本文将深入探讨硬导航的本质,并介绍如何通过现代浏览器技术,有效地优化这一过程,让你的网站在用户眼中更加“丝滑”。

二、软导航与硬导航:理解网页切换的两种模式

要理解硬导航的优化,我们首先需要区分两种主要的网页导航方式:软导航和硬导航。

1. 软导航(Soft Navigation)

软导航,顾名思义,是一种“柔和”的页面切换方式。它主要出现在**单页应用(Single Page Application, SPA)**中。当用户在SPA内部进行页面跳转时,浏览器并不会重新加载整个文档。相反,应用程序会动态地替换页面中的主要内容区域,同时按需加载新的JavaScript和CSS资源。这种方式的优势在于:

  • 速度快:避免了整个页面的重新解析和渲染。
  • 体验流畅:页面不会出现闪烁或空白,用户感觉像是在同一个应用内切换视图。
  • 资源高效:只加载增量资源,减少了不必要的网络请求。

2. 硬导航(Hard Navigation)

与软导航相对的,便是硬导航。当用户点击的链接指向一个完全不同的应用、不同的域名,或者需要浏览器完全重新加载整个文档时,就会发生硬导航。例如,从你的网站跳转到外部电商平台,或者从SPA内部跳转到一个非SPA的传统页面。硬导航的特点是:

  • 完全重载:浏览器会卸载当前文档及其所有相关资源(如JavaScript、CSS、图片等)。
  • 重新加载:然后,浏览器会从头开始加载新文档及其所有子资源。
  • 用户感知明显:由于需要重新建立连接、下载资源、解析和渲染,用户通常会感受到明显的延迟,甚至看到短暂的空白页面(俗称“白屏”)。

3. 为什么硬导航需要优化?

硬导航带来的延迟和不流畅体验,是影响用户留存率和转化率的重要因素。想象一下,用户每次点击链接都要等待几秒钟,甚至看到页面闪烁,这无疑会让他们感到沮丧。尤其是在网络环境不佳的情况下,硬导航的负面影响会被进一步放大。因此,优化硬导航,使其尽可能接近软导航的体验,是提升网站整体性能和用户满意度的关键一环。

三、优化的核心思路:预加载与预渲染

面对硬导航带来的挑战,现代浏览器提供了一系列机制来缓解其负面影响。核心思路在于一个“”字——预先加载(Prefetching)预先渲染(Prerendering)。这两种技术的目标都是在用户实际访问页面之前,提前做好准备工作,从而缩短用户等待时间,提升导航的感知速度。

1. 预取(Prefetching)

预取是指浏览器在用户点击链接之前,提前下载新页面所需的文档和部分子资源。这就像是你在出门前,提前把可能需要的东西准备好,这样等你真正需要的时候,就能立刻拿到。当用户最终点击链接时,由于部分资源已经缓存,浏览器可以更快地呈现新页面,从而减少了等待时间。

2. 预渲染(Prerendering)

预渲染则更为激进。它不仅预取了新页面的内容,还会像用户已经访问过一样,在后台一个不可见的标签页中完整地渲染新页面。当用户点击链接时,浏览器可以直接将这个已经渲染好的页面切换到前台,实现即时导航(Instant Navigation)。这种体验几乎与软导航无异,但代价是会消耗更多的网络和计算资源。预渲染就像是你在看电影之前,电影院已经把电影播放到某个暂停点,等你一进去就能立刻接着看。

四、Speculation Rules API:现代浏览器的利器

为了更精细地控制预取和预渲染的行为,现代浏览器引入了Speculation Rules API [1]。这是一个强大的新API,允许开发者通过声明式的方式,定义哪些链接应该被预取或预渲染,以及在何种条件下进行。它通过在HTML中插入一个<script type="speculationrules">标签来实现,或者通过HTTP响应头来配置。

1. 基本语法

Speculation Rules API 的核心是一个JSON对象,它定义了预取和预渲染的规则。例如:

<script type="speculationrules">
  {
    "prerender": [
      {
        "where": {
          "and": [
            { "href_matches": "/*" },
            { "not": { "href_matches": "/logout" } },
            { "not": { "selector_matches": ".no-prerender" } }
          ]
        }
      }
    ],
    "prefetch": [
      {
        "urls": ["/next-page.html", "/another-page.html"],
        "requires": ["anonymous-client-ip-when-cross-origin"],
        "referrer_policy": "no-referrer"
      }
    ]
  }
</script>

这段代码定义了两组规则:一组用于预渲染,另一组用于预取。prerenderprefetch数组中的每个对象都代表一个规则集。

2. 匹配规则

Speculation Rules API 提供了灵活的匹配机制,让你可以精确控制哪些链接触发预加载行为:

  • href_matches:通过URL模式匹配链接。例如,"href_matches": "/*"表示匹配所有内部链接。
  • selector_matches:通过CSS选择器匹配链接。例如,"selector_matches": ".no-prerender"可以排除带有特定CSS类的链接。
  • where:结合andornot等逻辑操作符,构建复杂的匹配条件。
  • urls:直接指定要预取或预渲染的URL列表。

这些规则使得开发者能够根据业务逻辑和用户行为,智能地选择需要优化的导航路径。

五、触发时机(Eagerness):何时启动预加载?

Speculation Rules API 允许我们定义预加载的触发时机(Eagerness),这对于平衡性能提升和资源消耗至关重要。不同的触发时机决定了浏览器何时开始预取或预渲染。

  • immediate:页面加载完成后立即启动预加载。这会消耗较多资源,但能最大程度地提升用户体验。
  • hover:当用户鼠标悬停在链接上时启动预加载。这是一种较为保守的策略,只在用户表现出兴趣时才进行预加载。
  • conservative:一种平衡策略,通常在用户有较高概率点击链接时触发,例如在视口内可见的链接。
  • moderate:介于conservativeimmediate之间,比conservative更积极,但比immediate更节省资源。

通过合理配置eagerness属性,我们可以在提供流畅体验的同时,避免不必要的资源浪费。

六、传统方案与兼容性:link rel="prefetch"

尽管 Speculation Rules API 功能强大,但它目前主要由基于Chromium的浏览器(如Chrome、Edge、Opera)支持。为了兼容不支持新API的浏览器,我们仍然可以使用传统的link标签进行预取。

1. link rel="prefetch" 的用法

link rel="prefetch" 是一种较早的预取机制,它告诉浏览器在空闲时下载指定资源,以便将来使用。它只能用于预取资源,不支持预渲染。

<link rel="prefetch" href="/link-to-other-application" />

当浏览器解析到这个标签时,它会在后台悄悄地下载/link-to-other-application这个页面及其相关资源。当用户真正访问这个链接时,页面加载速度会显著加快。

2. 两种方案的结合使用

在实际应用中,我们可以将 Speculation Rules API 和 link rel="prefetch" 结合使用,以实现最佳的兼容性和性能:

  • 对于支持 Speculation Rules API 的浏览器,优先使用该API进行精细化的预取和预渲染。
  • 对于不支持的浏览器,则回退到 link rel="prefetch",至少提供基础的预取能力。

这种渐进增强的策略,确保了所有用户都能从硬导航优化中受益。

七、在 Next.js/React 中的实践

对于使用 Next.js 或 React 等现代前端框架的开发者来说,将这些优化技术集成到应用中变得更加便捷。框架通常会提供抽象层,简化API的使用。

1. 封装 SpeculationRules 组件

在 React 中,我们可以创建一个 SpeculationRules 组件来动态地插入 Speculation Rules:

import Script from 'next/script';

export function SpeculationRules({
  prefetchPathsOnHover,
  prerenderPathsOnHover,
}) {
  const speculationRules = {
    prefetch: [
      {
        urls: prefetchPathsOnHover,
        eagerness: 'moderate',
      },
    ],
    prerender: [
      {
        urls: prerenderPathsOnHover,
        eagerness: 'conservative',
      },
    ],
  };

  return (
    <Script
      dangerouslySetInnerHTML={{
        __html: `${JSON.stringify(speculationRules)}`,
      }}
      type="speculationrules"
    />
  );
}

这个组件接收 prefetchPathsOnHoverprerenderPathsOnHover 等属性,允许开发者通过组件的props来配置预取和预渲染的URL和触发时机,从而实现声明式的优化。

2. 自动预取的实现逻辑

对于 link rel="prefetch",我们也可以在 React 中封装一个自定义的 Link 组件,实现鼠标悬停自动预取的功能。这通常涉及到创建一个上下文(Context)来跟踪已经预取过的链接,并利用 onMouseOver 事件来触发预取。

import { forwardRef, useContext } from 'react';

// 假设 PrefetchLinksContext 已经定义并提供了 prefetchHref 方法
import { PrefetchLinksContext } from './PrefetchLinksContext'; 

export const Link = ({ children, ...props }) => {
    const { prefetchHref } = useContext(PrefetchLinksContext);
    function onHoverPrefetch(): void {
      if (!props.href) {
        return;
      }
      prefetchHref(props.href);
    }

    return (
      <a
        {...props}
        onMouseOver={props.prefetch !== false ? onHoverPrefetch : undefined}
      >
        {children}
      </a>
    );
};

通过这种方式,开发者可以轻松地在应用中集成预取逻辑,而无需手动管理每个 link 标签。当用户鼠标悬停在自定义的 Link 组件上时,相关的页面资源就会在后台开始下载,为用户下一次点击做好准备。

八、参考链接

  1. Speculation Rules API - MDN Web Docs
  2. Link prefetching - MDN Web Docs

一文搞懂 JavaScript 数据类型转换(显式 & 隐式全解析)

作者 zhEng
2026年1月14日 22:41

1、为什么 JS 会有数据类型转换?

先从一个最本质的特点说起 👇

(1) JavaScript 是一门「动态类型语言」

var i = 1;
i = "zhangsan";
console.log(i);

在 JS 中:

  • 变量没有固定类型
  • 类型是在运行时才确定的

这和 Java / TypeScript 完全不同。

(2) 运算符「有类型预期」

虽然变量没有类型,但运算符是有要求的。虽然是两个字符串相减,但是依然得到数值 1,原因就在于 JavaScript 将运算子自动转为了数值。

所以接下来就来看一下 JavaScript 中如何进行数据类型转换。

'4' - '3' // 1

减法运算符期望的是「数字」,于是 JS 会在背后偷偷做一件事:

'4'4
'3'3

这就是数据类型转换存在的根本原因


2、显式转换(你自己动手)

强制转换主要指使用Number()String()Boolean()三个函数,手动将各种类型的值,分别转换成数字、字符串或者布尔值。

2.1 Number():把一切变成数字

(1)原始类型

原始类型值的转换规则如下:

Number('')        // 0
Number('123')     // 123
Number('zhangsan')  // NaN
Number('123?')    // NaN

Number(true)      // 1
Number(false)     // 0

Number(undefined) // NaN
Number(null)      // 0

⚠️ 注意:Number() 非常严格parseInt能转多少转多少,Number不纯就直接 NaN

parseInt('123abc') // 123
Number('123abc')   // NaN

(2)对象转换规则(重点)

简单的规则是,Number方法的参数是对象时,将返回NaN,除非是包含单个数值的数组。

Number({n: 1}) // NaN
Number([1, 2, 3]) // NaN
Number([6]) // 6

内部其实走了三步规则

  1. 先调用对象自身的 valueOf()方法。如果返回原始类型的值,则直接对该值使用Number函数,不在进行后续操作。
  2. 如果valueOf方法返回的还是对象,则改为调用对象的toString方法。如果toString方法返回原始类型的值,则对该值使用Number函数,不再进行后续操作。
  3. 如果toString方法返回的仍是对象,就报错。
var obj = { name: 'zhangsan' };
obj.valueOf();   // { name: 'zhangsan' }
obj.toString();  // "[object Object]"
Number(obj);     // NaN

// 等价于
if(typeof obj.valueOf() === 'object') {
   Number(obj.toString());
} else {
   Number(obj.valueOf());
}

上述代码,Number函数将obj对象转为数值,首先调用obj.valueOf方法, 结果返回对象本身;于是,继续调用obj.toString方法,这时返回字符串[object Object],对这个字符串使用Number函数,得到NaN

如果toString方法返回的仍不是原始类型的值,结果就会报错

const obj = {
  valueOf() {
    return {}
  },
  toString() {
    return {}
  }
}
Number(obj);
// TypeError: Cannot convert object to primitive value

从上述可以看出,valueOftoString方法,是可以自定义的。

// 验证会调用valueOf
const obj = {
  valueOf() {
    return 10;
  }
}
Number(obj); // 10

// 验证会调用toString
const obj2 = {
  toString() {
    return 20
  }
}
Number(obj2); // 20

// 验证valueOf方法先于toString方法
const obj3 = {
  valueOf() {
    return 10
  },
  toString() {
    return 20
  }
}
Number(obj3); // 10

数组为什么不一样?

Number([5])    // 5
Number([1,2])  // NaN

原因拆解:

[5].valueOf()  // [5]
[5].toString()   // "5"
Number("5")      // 5

[1,2].toString() // "1,2"
Number("1,2")    // NaN

执行顺序永远是:

valueOf方法 优先于 toString方法


2.2 String():转成字符串

(1)原始类型值

String(123);        // "123"
String(true);       // "true"
String('test');     // "test"
String(undefined);  // "undefined"
String(null);       // "null"

对象规则

String方法的参数如果是对象,返回一个类型字符串;如果是数组,返回该数组的字符串形式。

String({a:1}) // "[object Object]"
String([1,2]) // "1,2"

String方法背后的转换规则,与Number方法基本相同,只是互换了valueOf方法和toString方法的执行顺序。

  1. 先调用自身toString方法,如果返回原始类型的值,则对该值使用String函数,不再进行后续操作。
  2. 如果toString方法返回的还是对象,则调用对象的valueOf方法,如果valueOf方法返回原始类型的值,则对该值使用String函数,不再进行后续操作。
  3. 如果valueOf方法返回的仍是对象,就报错。

例如:

String({ name:'zhangsan' }); // "[object Object]"
// 等价于
String({ name:'zhangsan' }.toString()) // "[object Object]"

如果toStringvalueOf方法,返回的都是对象就报错。

const obj = {
  toString() {
    console.log('1');
    return {}
  },

  valueOf() {
    console.log('2');
    return {}
  }
};

String(obj) // TypeError: Cannot convert object to primitxive value

从上述可以看出,toStringvalueOf方法,是可以自定义的。

const obj = {
  toString() {
    console.log('1');
    // return {}
    return 10;
  },

  valueOf() {
    console.log('2');
    return 20;
  }
};
// toString方法如果返回10则输出'10'
// toString返回如果返回{},则继续执行valueOf方法,输出'20'
String(obj);

2.3 Boolean():最简单,但最容易踩坑

Boolean()函数可以将任意类型的值转为布尔值。它的转换规则比较简单,只有 5 个值是 false,其他的值全部为true

false
0
''
null
undefined
NaN
Boolean({})   // true
Boolean([])   // true
Boolean(false) // false

⚠️ 所有对象的布尔值,永远是 true

4、隐式转换

(1) 自动转布尔(if 判断)

if ('abc') {
  console.log('Hello');
}

等价于:

if (Boolean('abc')) {}

快速写法:

!!'abc' // true

(2)自动转字符串(+ 号)

'5' + 1        // "51"
'5' + true     // "5true"
'5' + {}       // "5[object Object]"

核心规则:

只要有字符串,+ 就变成拼接


(3)自动转数字(- * /)

'5' - '2'     // 3
'5' * []      // 0
'5' * [2]     // 10
'5' * [1,2]   // NaN

拆解 [] 的例子:

[].toString()  // ""
Number("")     // 0

(4)一元运算符 +

+'abc'   // NaN
+true    // 1
+false   // 0

JavaScript 的类型转换分为 显式转换隐式转换

  • 显式转换:是开发者主动调用 NumberStringBoolean 等方法;
  • 隐式转换:发生在运算或条件判断中,由 JS 根据上下文自动完成;
  • 转换本质:根据运算符的预期类型,调用对应的转换规则。
❌
❌