阅读视图

发现新文章,点击刷新页面。

10个 ES2025 新特性速览!🚀🚀🚀

前言

最近翻了下最新的 ES2025 语法提案,发现又上新了很多有用的新语法。今天让我们一起来看看这些变化吧!

往期精彩推荐

正文

ECMAScript 2025 已经是是 JavaScript 标准的第 16 版,强调解决开发者痛点,如错误处理、集合运算和模块加载优化。这些特性通过提案过程逐步落地,帮助大家写出更简洁、高效的代码。

以下是 ES2025 的主要新语法提案和特性解析。

1. Promise.try() - 统一同步/异步错误处理

背景:同步函数抛出的错误无法直接被 Promise.catch 捕获,开发者通常需要额外的 try/catch 包裹,增加了代码冗余。

提案内容Promise.try() 提供了一种统一处理同步和异步错误的机制,简化错误捕获逻辑。

代码示例

function mightThrow() {
  if (Math.random() > 0.5) throw new Error('Oops');
  return 'Success';
}

Promise.try(mightThrow)
  .then(console.log) // 成功时输出 'Success'
  .catch(console.error); // 捕获同步/异步错误

优势:统一错误处理逻辑,减少嵌套,提升代码简洁性,特别适用于混合同步/异步操作的场景,如图形渲染或数据库查询。

2. Set 方法扩展 - 集合运算

背景:JavaScript 的 Set 对象长期缺乏数学集合运算(如交集、并集、差集),开发者需手动实现或依赖第三方库(如 Lodash)。

提案内容:ES2025 新增了 7 个集合方法,包括 intersection(交集)、union(并集)、difference(差集)等,基于哈希表实现,性能高效。

代码示例

const userTags = new Set(['js', 'node', 'web']);
const hotTags = new Set(['web', 'react', 'ts']);

// 交集:共同标签
const common = userTags.intersection(hotTags); // Set {'web'}

// 差集:推荐标签
const recommended = hotTags.difference(userTags); // Set {'react', 'ts'}

// 并集:所有标签
const all = userTags.union(hotTags); // Set {'js', 'node', 'web', 'react', 'ts'}

优势:操作返回新 Set,避免原集合污染,时间复杂度为 O(min(n, m)),适合标签系统、权限管理等场景。

3. JSON 模块原生支持 - Import Attributes

背景:传统导入 JSON 文件需依赖 fetchJSON.parse 或 Webpack 等工具,存在语法冗余和兼容性问题。

提案内容:通过 with { type: 'json' } 语法,ES2025 原生支持静态和动态导入 JSON 模块。

代码示例

// 静态导入 JSON
import config from './config.json' with { type: 'json' };

// 动态导入 JSON
const data = await import('./data.json', { with: { type: 'json' } });

优势:无需第三方工具,简化 JSON 文件处理流程,适合配置文件加载或静态数据管理。

4. RegExp.escape() - 正则表达式转义

背景:动态构建正则表达式时,用户输入的特殊字符(如 *$)可能引发语法错误,需手动转义。

提案内容:新增 RegExp.escape() 方法,自动转义特殊字符,确保正则表达式安全。

代码示例

const userInput = 'Hello (World)';
const safeInput = RegExp.escape(userInput);
const safeRegex = new RegExp(safeInput);
console.log(safeRegex.test('Hello (World)')); // true

优势:提升正则表达式的安全性,减少手动转义的工作量,适用于用户输入验证场景。

5. 正则表达式增强 - 组名统一与部分忽略大小写

背景:正则表达式的组匹配和大小写处理在复杂场景下不够灵活。

提案内容

  • 统一组名:支持多模式正则表达式的组名统一提取,简化解构逻辑。
  • 部分忽略大小写:通过 (?i:...) 语法支持局部忽略大小写匹配。

代码示例

// 统一组名
const DATE_REGEX = /^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})|(?<month>\d{2})\/(?<day>\d{2})\/(?<year>\d{4})$/;
const match = '2025-09-08'.match(DATE_REGEX);
const { year, month, day } = match.groups; // 自动提取匹配组

// 部分忽略大小写
const re = /HELLO(?i:World)/;
console.log(re.test('HELLO world')); // true

优势:简化正则表达式处理,提升匹配灵活性,适合日期解析或多语言文本处理。

6. Deferred Module Evaluation - 延迟模块加载

背景:大型应用的模块加载可能导致启动性能瓶颈,传统动态导入仍需执行模块代码。

提案内容:通过 defer import 语法,模块声明时并行加载但延迟执行,直到首次访问导出成员。

代码示例

defer import heavyModule from './heavy.js';
button.onclick = async () => {
  await heavyModule.run(); // 点击时才执行模块代码
};

优势:优化启动性能,适合按需加载重型模块,如大型库或复杂组件。

7. Float16Array - 16 位浮点数组

背景:高性能计算(如机器学习、图形渲染)需要更高效的内存使用。

提案内容:新增 Float16Array,提供 16 位浮点数存储,内存占用仅为 Float32Array 的 50%。

代码示例

const float16Arr = new Float16Array([1.5, 2.5, 3.5]);
console.log(float16Arr.byteLength); // 6(3 元素 × 2 字节/元素)

优势:降低内存占用,适合大规模数据处理场景,如张量计算或图像处理。

8. Temporal API(部分落地)

背景:JavaScript 原生的 Date 对象因设计缺陷(如月份从 0 开始计数)饱受诟病。

提案内容Temporal API 提供不可变的日期和时间操作接口,预计 2025 年全面落地,目前已进入提案 Stage 3。

代码示例

const olympics = Temporal.PlainDate.from('2024-07-26');
console.log(olympics.daysInYear); // 输出 366(闰年)

优势:简化日期处理逻辑,提供更直观和可靠的 API,适合日历应用或国际化场景。

9. 管道操作符(|>)(实验性支持)

背景:嵌套函数调用导致代码可读性下降,特别是在数据处理流程中。

提案内容:管道操作符 |> 允许将前一个函数的输出直接作为下一个函数的输入,已在 Babel 中实现实验性支持。

代码示例

const result = 42
  |> (x => x * 2)
  |> (x => x + 10)
  |> (x => x.toString());
console.log(result); // "94"

优势:支持链式调用,提升数据处理代码的可读性和简洁性,适合函数式编程场景。

10. Record 和 Tuple - 不可变数据结构

背景:JavaScript 缺乏原生的不可变数据结构,导致数据安全性问题。

提案内容:引入 Record(不可变对象)和 Tuple(不可变数组),提供数据不可变性保证。

代码示例

const user = #{ name: 'Bob', roles: #['admin', 'user'] };
// user.name = 'Charlie'; // 抛出错误
console.log(user.name); // 'Bob'

优势:提升数据安全性,适合需要严格状态管理的场景,如 Redux 或 React 状态管理。

最后

ECMAScript 2025 从 Promise.try() 的错误统一到 Record 的不可变性,这些提案让开发更高效、更可靠。快来在项目中试用这些特性,感受 ES2025 的魅力吧!

今天的分享就这些了,感谢大家的阅读!如果文章中存在错误的地方欢迎指正!

往期精彩推荐

http缓存

概述

浏览器资源请求的时候,必不可少肯定会对资源进行缓存,这是对性能的一种必不可少的策略,为的就是带给用户更好的用户体验。

缓存

为什么缓存?

减少网络请求(网络请求不稳定性),让页面渲染更快

哪些资源可以被缓存?

静态资源(js css img)webpack/vite打包加contenthash根据内容生成hash

http缓存策略

强缓存

服务端在Response Headers中返回给客户端缓存标识字段(Cache-ControlExpires

Cache-Control的值取值

  • max-age:(常用)缓存的内容将在max-age秒后失效
  • no-cache:(常用)不要本地强制缓存,正常向服务端请求(只要服务端最新的内容)。需要使用协商缓存来验证缓存数据(Etag Last-Modified)
  • no-store: 不要本地强制缓存,也不要服务端做缓存,所有内容都不会缓存,强制缓存和协商缓存都不会触发
  • public: 所有内容都将被缓存(客户端和代理服务器都可缓存)
  • private: 所有内容只有客户端可以缓存

Expires

  • Expires:Thu, 31 Dec 2037 23:55:55 GMT(过期时间)
  • 已被Cache-Control代替

强制缓存的流程

  • 浏览器第一次请求资源,服务器返回资源和Cache-Control Expires
  • 浏览器第二次请求资源,会带上Cache-Control Expires,服务器根据这两个值判断是否命中强制缓存
  • 命中强制缓存,直接从缓存中读取资源,返回给浏览器
  • 未命中强制缓存,会带上If-Modified-Since If-None-Match,服务器根据这两个值判断是否命中协商缓存
  • 命中协商缓存,返回304,浏览器直接从缓存中读取资源
  • 未命中协商缓存,返回200,浏览器重新请求资源

流程图

协商缓存

属于服务端缓存策略,服务端判断客户端资源,是否和服务端资源一样如果判断一致则返回304(走缓存),否则返回200和最新资源, 服务端在Response Headers中返回给客户端缓存标识字段(Last-ModifiedEtag

  • Last-Modified和Etag会优先使用Etag,Last-Modified只能精确到秒级,如果资源被重复生成而内容不变,则Etag更准确
  • Last-Modified 服务端返回的资源的最后修改时间
  • If-Modified-Since 客户端请求时,携带的资源的最后修改时间(即Last-Modified的值)

协商缓存流程

image.png

image.png

示例

  • 通过Etag或Last-Modified命中缓存,没有返回资源,返回304,体积非常小

概览图

image.png

注意

强制缓存的优先级高于协商缓存

使用 github workflow 的 actions/setup-node 工作流,安装 pnpm 失败的 bug

使用 github workflow 的 actions/setup-node 工作流,安装 pnpm 失败的 bug

在 github workflow 中,我们经常用 actions/setup-nodepnpm/action-setup 这两个工作流,来完成流水线安装 node 环境,并准备包管理器的需求。

我的工作流写法如下:

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: 检出分支
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: 安装pnpm
        uses: pnpm/action-setup@v4
        with:
          run_install: true

      - name: 安装node
        uses: actions/setup-node@v4
        with:
          node-version: 22.14.0
          cache: "pnpm"

这段写法产生了以下故障,这里只截取一小部分:

/home/runner/setup-pnpm/node_modules/.bin/pnpm store path --silent
/home/runner/setup-pnpm/node_modules/.bin/store/v10
Error: Dependencies lock file is not found in /home/runner/work/*** . Supported file patterns: pnpm-lock.yaml

问题起因

因为 actions/setup-node 工作流会默认检索项目内已经存在的包依赖锁文件,所以在查询包锁文件时,找不到就报错了。

如图所示:

2025-09-08-17-40-53

具体缘由见以下文档所述:

按照官网文档,我应该提交 pnpm-lock.yaml 锁文件。

个人开发习惯导致必须另辟蹊径

由于我的开发习惯,是不上传任何包锁文件的。我不喜欢上传巨大的,频繁变更的,自动生成的文件到 git 仓库,所以在工作流场景下,自然是无法提供任何包锁文件的。

类似的情况还有在 vercel 流水线部署项目时,vercel 会根据是否存在包锁文件来决定包管理器的版本号。比如我的项目在根目录内不提供任何 pnpm-lock.yamlvercel 就使用了低版本的 pnpm6,而不是我在 packageManager 内配置的最新版 pnpm。不提供 pnpm-lock.yaml 锁文件确实容易给流水线环境带来误导。

但是我的场景下,肯定不能提交锁文件。在使用 taze 实现高强度依赖升级的情况下,依赖锁文件会频繁更新,其提交到仓库的意义不大。

临时性解决方案

在设置 pnpm 缓存前,先安装依赖,生成 pnpm-lock.yaml ,避免 actions/setup-node 出现识别故障。

更新后的工作流文件如下:

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: 检出分支
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: 安装pnpm
        uses: pnpm/action-setup@v4
        with:
          run_install: true

      - name: 安装依赖(尝试生成lockfile)
        run: pnpm install --frozen-lockfile

      - name: 安装node
        uses: actions/setup-node@v4
        with:
          node-version: 22.14.0
          cache: "pnpm"

大话设计模式——观察者模式和发布/订阅模式的区别

观察者模式和发布/订阅模式,有相当多的程序员,尤其是前端,完全分不清他们之间的区别,甚至认为这两个设计模式是同一个。

其实这两个设计模式用一句话就可以区分其差别:观察者模式观察的是被观察者本身,而发布订阅模式订阅的是主题

从API设计到数据流转

image.png

观察者模式

观察者模式通常有两个核心api:

  • observable: 将数据包装为可观察对象
  • observer:观察可观察者
const obj$ = observable({name:'a'})
observer(obj$,(snapshot?)=>{console.log('change')})
obj$.name = 'b';// change

一般情况下,observer只需要知道该对象发生变化即可,如果需要知道变化内容,也仅提供快照或部分快照

发布订阅模式

发布订阅模式通常有三个api:

  • dispath(或emit)
  • subscribe(或on)
  • unsubscribe(或off)
const unsubscribe = messageCenter.subscribe('weather',(info)=>{console.log('天气',info)})
messageCenter.dispatch('weather','Sunny');// 天气: sunny
unsubscribe()//取消订阅

订阅的内容往往是一个主题,所以响应内容中必定带上该主题的内容。虽然一般都是订阅后再接收该主题内容,但也可以订阅后立即获取该主题历史内容,甚至设置推送频率、内容分组和过滤等。

概念陷阱

这里最容易产生理解偏差的地方就是发布订阅模式中的发布者和订阅者是谁?

上文代码中,表面上看,messageCenter自己订阅,自己发布,又是订阅者,又是发布者。但实际上,我们应该将调用订阅方法的地方看成是订阅者,将调用发布方法的地方,看成发布者。比如class A的某个方法中进行了订阅,则该class A的实例就是订阅者,class B的某个方法中调用了发布,则class B的实例,就是发布者。从这个角度看,发布者与订阅者是解耦的,两者互不关心对方的存在,都和messageCenter单向联系,因此,这里的messageCenter往往也叫做事件总线(EventBus),相当于用事件/消息这根线串起多个互不关联对象。

而观察者模式,观察者对被观察者有直接依赖,对被观察者的响应或通知,并不需要进行主题区分和内容响应,因为被观察者本身就是观察的内容,并且观察者本身就拥有或能直接获取到被观察者对象

Vue3 实现 B站 视差 动画

粘贴代码就能用了👇🏻:

效果图.gif

目标:制作一个多图层的响应式 banner,每层可以是图片或视频,能根据鼠标在 banner 上的横向移动控制 animationProgress,进而驱动每层的 translate/rotate/scale/blur/opacity 动画。

实现要点:

  1. 数据结构(layers.js)描述每层的资源和动画参数。
  2. 把资源(img/video)预处理成 DOM 元素并存 .el,便于后续统一操控。
  3. 每层放到一个 .layer 容器里,统一用 requestAnimationFrame 渲染变换。
  4. 鼠标事件只绑定在 banner(或 pointer 事件),并做复位动画。
  5. 响应式处理:窗口 resize 要重算尺寸。
  6. 必须在组件卸载时移除监听并取消动画,避免内存泄漏。

分步骤实现(每步:怎么想 → 怎么写 → 为什么)

步骤 0:准备(如果你还没建项目)

怎么想:先有个能跑 Vue 3 的项目(Vite 最简单)。 怎么写(命令,任选其一):

# 推荐:Vite + Vue
npm create vite@latest my-app -- --template vue
cd my-app
npm install
npm run dev

为什么:有了运行环境你才能在浏览器实时调试组件。


步骤 1:设计数据结构(layers.js

怎么想:要把每一层需要的参数(资源列表、初始缩放、偏移量、模糊/不透明等)都写成 JSON/JS 对象,方便组件读取并驱动动画。 怎么写(示例):

// src/assets/layers.js
export default [
  {
    name: '背景',
    scale: { initial: 1.0, offset: 0.05 },
    translate: { initial: [0, 0], offset: [10, 0] },
    rotate: { initial: 0, offset: 2 },
    blur: { initial: 0, offset: 2, wrap: 'clamp' },
    opacity: { initial: 1, offset: -0.2, wrap: 'clamp' },
    resources: [{ src: '/images/bg.jpg' }],
  },
  {
    name: '前景',
    scale: { initial: 1.1, offset: -0.02 },
    translate: { initial: [0, 0], offset: [-15, 0] },
    resources: [{ src: '/images/fg.png' }],
  },
];

为什么:把参数和资源分离,便于调试和 A/B 调整,也利于复用/热替换。


步骤 2:静态 DOM 与样式结构(先做最简单的静态展示)

怎么想:先把 HTML/CSS 做好,确保能显示出图片/视频,再加入 JS 控制。 怎么写(template + 最简样式):

<template>
  <div class="header-banner">
    <div ref="bannerRef" class="animated-banner"></div>
  </div>
</template>

<style>
.header-banner { position: relative; min-height: 155px; height: 9.375vw; max-height: 240px; }
.animated-banner { position: absolute; inset: 0; overflow: hidden; }
.layer { position: absolute; inset: 0; display:flex; align-items:center; justify-content:center; }
img, video { max-width:100%; height:auto; }
</style>

为什么:把容器准备好,后面 JS 只需要向 .animated-banner 插入每个 .layer 的子元素即可。


步骤 3:资源预处理(图片和视频分别处理)

怎么想:图片要等 load 获取 naturalWidth/naturalHeight;视频要等 loadedmetadata 获取尺寸。把这些尺寸存到 dataset 里,方便响应式 resize。 怎么写(示例核心):

// 伪代码
if (isImage) {
  const img = new Image();
  img.src = resource.src;
  img.addEventListener('load', () => {
    img.dataset.width = img.naturalWidth;
    img.dataset.height = img.naturalHeight;
    // 根据 banner 高度计算初始缩放尺寸并设置 width/height
  });
  resource.el = img;
} else { // video
  const video = document.createElement('video');
  video.src = resource.src; video.muted = true; video.loop = true; video.autoplay = true;
  video.addEventListener('loadedmetadata', () => {
    video.dataset.width = video.videoWidth;
    video.dataset.height = video.videoHeight;
  });
  resource.el = video;
}

为什么:提前知道原始像素尺寸能精确按比例缩放,避免失真或拉伸,同时视频的元数据有时是异步的,必须等待。


步骤 4:建立每个层的容器和初始状态(layerStates)

怎么想:每层需要一个 DOM 容器和一份「初始状态」来记录最开始的 scale/rotate/translate/opacity/blur。动画时基于这个初始状态叠加偏移量。 怎么写(示例):

const layerContainers = layers.map(() => {
  const el = document.createElement('div');
  el.classList.add('layer');
  bannerElement.appendChild(el);
  return el;
});

const layerStates = layers.map(layer => ({
  scale: layer.scale?.initial ?? 1,
  rotate: layer.rotate?.initial ?? 0,
  translate: layer.translate?.initial ?? [0,0],
  blur: layer.blur?.initial ?? 0,
  opacity: layer.opacity?.initial ?? 1,
}));

为什么:把状态分开保存,方便“基线值 + 动态偏移”这种组合逻辑,并且更易于调试。


步骤 5:渲染/变换逻辑(把 animationProgress 映射到 transform/filter)

怎么想:根据 animationProgress(一般 -1..1,也可以不限制)计算每层最终的 translate/rotate/scale/blur/opacity,然后把它们写到 style.transform / style.filter / style.opacity。 怎么写(简化版):

const applyTransforms = (index, progress) => {
  const base = layerStates[index];
  const cfg = layers[index];
  // scale
  const scale = base.scale + (cfg.scale?.offset ?? 0) * progress;
  // rotate
  const rotate = base.rotate + (cfg.rotate?.offset ?? 0) * progress;
  // translate
  const translateOffset = (cfg.translate?.offset ?? [0,0]).map(v => v * progress);
  const translate = base.translate.map((v,i) => v + translateOffset[i]);
  element.style.transform = `translate(${translate[0]}px, ${translate[1]}px) rotate(${rotate}deg) scale(${scale})`;
};

为什么:拆成很多小步(先算 scale,再算 rotate,再算 translate),逻辑清晰,方便单独调试某个属性。


步骤 6:动画循环(为什么用 requestAnimationFrame)

怎么想:用 requestAnimationFrame 逐帧更新 DOM,浏览器会把动画和渲染周期对齐,保证流畅并节省 CPU。不要直接用 setInterval。 怎么写(核心):

let rafId = 0;
const frameLoop = () => {
  // 对所有层调用 applyTransforms(...)
  rafId = requestAnimationFrame(frameLoop);
};
rafId = requestAnimationFrame(frameLoop);

为什么:requestAnimationFrame 在页面不可见时会暂停,从而节省性能;并且帧率与屏幕刷新同步,动画更流畅。


步骤 7:鼠标交互(只绑在 banner 上,做复位动画)

怎么想:不要把 mousemove 绑到 window 上(影响全页面性能)。绑定到 banner 或 使用 pointermove,并且当鼠标离开时做一个平滑的回退动画(200ms)。 怎么写(关键逻辑):

const pointerMoveHandler = (event) => {
  if (!bannerRect) return;
  // 记录起始位置 lastMouseX,在后续的移动中根据当前位置 - 起始位置 得到 progress
  animationProgress = (event.clientX - lastMouseX) / bannerWidth;
};

const pointerLeaveHandler = () => {
  // 用 requestAnimationFrame 做线性插值回到 0
};
bannerElement.addEventListener('pointermove', pointerMoveHandler);
bannerElement.addEventListener('pointerleave', pointerLeaveHandler);

为什么:把事件限制在 banner 区域能显著降低事件触发频率,pointer 系列事件也能同时兼容鼠标与触控。


步骤 8:响应式 resize 与清理(最重要的工程细节)

怎么想:当窗口大小改变,banner 的高度/宽度以及基准缩放 baseRatio 需要重新计算,图片/视频的 width/height 也要重设。同时,组件卸载时必须 removeEventListener & cancelAnimationFrame。 怎么写(示例):

const resizeHandler = () => {
  const newHeight = bannerElement.clientHeight;
  const newBaseRatio = newHeight / 155;
  layers.forEach(layer => {
    layer.resources.forEach(res => {
      const el = res.el;
      const w = Number(el.dataset.width || el.width || 0);
      const h = Number(el.dataset.height || el.height || 0);
      const newW = w * newBaseRatio * (layer.scale?.initial ?? 1);
      const newH = h * newBaseRatio * (layer.scale?.initial ?? 1);
      el.style.width = `${newW}px`; el.style.height = `${newH}px`;
    });
  });
};

window.addEventListener('resize', resizeHandler);

// 清理
onBeforeUnmount(() => {
  bannerElement.removeEventListener('pointermove', pointerMoveHandler);
  bannerElement.removeEventListener('pointerleave', pointerLeaveHandler);
  window.removeEventListener('resize', resizeHandler);
  cancelAnimationFrame(rafId);
});

为什么:不清理会导致内存泄漏、在组件再次 mount 时重复绑定监听器与动画。


代码:

<template>
  <div class="header-banner">
    <div ref="bannerRef" class="animated-banner"></div>
  </div>
</template>

<script setup>
import { onMounted, ref } from "vue";
import layers from "@/assets/layers.js";

const bannerRef = ref(null);

onMounted(() => {
  const bannerElement = bannerRef.value;

  let animationProgress = 0; // 原 k
  let animationFrameId = 0; // 原 w
  let lastMouseX = 0; // 原 C

  // 初始化元素
  layers.map((layer) => {
    layer.resources.map((resource, resourceKey) => {
      if (!/\.(webm|mp4)$/.test(resource.src)) {
        const imgElement = document.createElement("img");
        imgElement.src = resource.src;

        imgElement.addEventListener("load", function () {
          imgElement.dataset.height = imgElement.naturalHeight.toString();
          imgElement.dataset.width = imgElement.naturalWidth.toString();

          const baseRatio = bannerElement.clientHeight / 155;
          const scaleInitial = layer.scale?.initial ?? 1;
          const scaledHeight =
            Number(imgElement.dataset.height) * baseRatio * scaleInitial;
          const scaledWidth =
            Number(imgElement.dataset.width) * baseRatio * scaleInitial;

          imgElement.height = scaledHeight;
          imgElement.width = scaledWidth;
          imgElement.style.height = scaledHeight + "px";
          imgElement.style.width = scaledWidth + "px";
        });

        layer.resources[resourceKey].el = imgElement;
      } else {
        const videoElement = document.createElement("video");
        videoElement.muted = true;
        videoElement.loop = true;
        videoElement.autoplay = true;
        videoElement.playsInline = true;
        videoElement.src = resource.src;
        videoElement.style.objectFit = "cover";
        layer.resources[resourceKey].el = videoElement;
      }
    });
  });

  const bannerHeight = bannerElement.clientHeight;
  const bannerWidth = bannerElement.clientWidth;
  const baseRatio = bannerHeight / 155;

  // 每个图层容器
  const layerContainerElements = layers.map(() => {
    const divElement = document.createElement("div");
    divElement.classList.add("layer");
    bannerElement.appendChild(divElement);
    return divElement;
  });

  // 每个图层初始状态
  const layerStates = layers.map((layer) => {
    return {
      scale: 1,
      rotate: layer.rotate?.initial || 0,
      translate: layer.translate?.initial || [0, 0],
      blur: layer.blur?.initial || 0,
      opacity: layer.opacity?.initial ?? 1,
    };
  });

  // 动画更新函数
  const animationFrameFn = () => {
    try {
      layerContainerElements.map((layerContainer, index) => {
        const currentLayer = layers[index];
        const resourceElement = layerContainer.firstChild;

        const transformState = {
          scale: layerStates[index].scale,
          rotate: layerStates[index].rotate,
          translate: layerStates[index].translate,
        };

        // scale
        if (currentLayer.scale) {
          const offset = currentLayer.scale.offset || 0;
          const delta = offset * animationProgress;
          transformState.scale = layerStates[index].scale + delta;
        }

        // rotate
        if (currentLayer.rotate) {
          const offset = currentLayer.rotate.offset || 0;
          const delta = offset * animationProgress;
          transformState.rotate = layerStates[index].rotate + delta;
        }

        // translate
        if (currentLayer.translate) {
          const offset = currentLayer.translate.offset || [0, 0];
          const delta = offset.map((val) => animationProgress * val);
          const newTranslate = layerStates[index].translate.map(
            (val, subIndex) => {
              return (
                (val + delta[subIndex]) *
                baseRatio *
                (currentLayer.scale?.initial || 1)
              );
            }
          );
          transformState.translate = newTranslate;
        }

        resourceElement.style.transform = `translate(${transformState.translate[0]}px, ${transformState.translate[1]}px) rotate(${transformState.rotate}deg) scale(${transformState.scale})`;

        // blur
        if (currentLayer.blur) {
          const offset = currentLayer.blur.offset || 0;
          const delta = offset * animationProgress;
          let blurValue = 0;

          if (!currentLayer.blur.wrap || currentLayer.blur.wrap === "clamp") {
            blurValue = Math.max(0, layerStates[index].blur + delta);
          } else if (currentLayer.blur.wrap === "alternate") {
            blurValue = Math.abs(layerStates[index].blur + delta);
          }

          resourceElement.style.filter =
            blurValue < 1e-4 ? "" : `blur(${blurValue}px)`;
        }

        // opacity
        if (currentLayer.opacity) {
          const offset = currentLayer.opacity.offset || 0;
          const delta = offset * animationProgress;
          const baseOpacity = layerStates[index].opacity;

          if (
            !currentLayer.opacity.wrap ||
            currentLayer.opacity.wrap === "clamp"
          ) {
            resourceElement.style.opacity = Math.max(
              0,
              Math.min(1, baseOpacity + delta)
            ).toString();
          } else if (currentLayer.opacity.wrap === "alternate") {
            const total = baseOpacity + delta;
            let finalOpacity = Math.abs(total % 1);
            if (Math.abs(total % 2) >= 1) finalOpacity = 1 - finalOpacity;
            resourceElement.style.opacity = finalOpacity.toString();
          }
        }
      });
    } catch (err) {
      console.log("animation error", err);
    }
  };

  // 初始化每个 layer 的 DOM
  layers.map((layer, index) => {
    const firstResourceElement = layer.resources[0].el;
    layerContainerElements[index].appendChild(firstResourceElement);
    requestAnimationFrame(animationFrameFn);
  });

  // 鼠标离开后复位动画
  const resetAnimation = () => {
    const startTime = performance.now();
    const duration = 200;
    const startProgress = animationProgress;

    cancelAnimationFrame(animationFrameId);

    const step = (now) => {
      if (now - startTime < duration) {
        animationProgress =
          startProgress * (1 - (now - startTime) / duration);
        animationFrameFn();
        requestAnimationFrame(step);
      } else {
        animationProgress = 0;
        animationFrameFn();
      }
    };
    animationFrameId = requestAnimationFrame(step);
  };

  const mouseActiveState = { value: false };

  // 鼠标事件
  const mouseLeaveFn = () => {
    mouseActiveState.value = false;
    resetAnimation();
  };

  const mouseMoveFn = (event) => {
    if (
      document.documentElement.scrollTop + event.clientY <
      bannerHeight
    ) {
      if (!mouseActiveState.value) {
        mouseActiveState.value = true;
        lastMouseX = event.clientX;
      }
      animationProgress = (event.clientX - lastMouseX) / bannerWidth;
      cancelAnimationFrame(animationFrameId);
      animationFrameId = requestAnimationFrame(animationFrameFn);
    } else if (mouseActiveState.value) {
      mouseActiveState.value = false;
      resetAnimation();
    }
  };

  // 窗口缩放时重新计算尺寸
  const resizeFn = () => {
    const newHeight = bannerElement.clientHeight;
    const newWidth = bannerElement.clientWidth;
    const newRatio = newHeight / 155;

    layers.forEach((layer) => {
      layer.resources.forEach((resource) => {
        const resourceElement = resource.el;
        const newWidthScaled =
          Number(resourceElement.dataset.width) *
          newRatio *
          (layer.scale?.initial || 1);
        const newHeightScaled =
          Number(resourceElement.dataset.height) *
          newRatio *
          (layer.scale?.initial || 1);

        resourceElement.height = newHeightScaled;
        resourceElement.width = newWidthScaled;
        resourceElement.style.height = `${newHeightScaled}px`;
        resourceElement.style.width = `${newWidthScaled}px`;
      });
    });

    cancelAnimationFrame(animationFrameId);
    animationFrameId = requestAnimationFrame(animationFrameFn);
  };

  document.addEventListener("mouseleave", mouseLeaveFn);
  window.addEventListener("mousemove", mouseMoveFn);
  window.addEventListener("resize", resizeFn);
});
</script>

<style>
body {
  margin: 0;
  padding: 0;
}

.header-banner {
  position: relative;
  z-index: 0;
  min-height: 155px;
  height: 9.375vw;
  max-height: 240px;
  background-color: #e3e5e7;
}

.animated-banner {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  overflow: hidden;
}

.layer {
  position: absolute;
  left: 0;
  top: 0;
  height: 100%;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}

img {
  width: auto;
  height: auto;
}
</style>

拿B站的数据和素材,如下:

const layers = [
  {
    resources: [
      {
        src: "./static_13/90240f707cb4a015bbf8bbd13e018b3f664087ce.png",
        id: 0,
      },
    ],
    scale: {
      initial: 0.47,
      offset: 0.02,
    },
    rotate: {},
    translate: {
      offset: [40, 10],
    },
    blur: {},
    opacity: {
      wrap: "clamp",
    },
    id: 0,
    name: "21天空",
  },
  {
    resources: [
      {
        src: "./static_13/cd4194a7be89655450147d2384a162b966cf2ec2.png",
        id: 0,
      },
    ],
    scale: {
      initial: 0.47,
    },
    rotate: {},
    translate: {
      offset: [5, 0],
    },
    blur: {},
    opacity: {
      wrap: "clamp",
    },
    id: 1,
    name: "20远景色",
  },
  {
    resources: [
      {
        src: "./static_13/ab2379f7c80b225020ee42289db11b84b57c2766.png",
        id: 0,
      },
    ],
    scale: {
      initial: 0.47,
      offset: -0.02,
    },
    rotate: {},
    translate: {
      offset: [5, 10],
    },
    blur: {},
    opacity: {
      wrap: "clamp",
    },
    id: 2,
    name: "19月亮",
  },
  {
    resources: [
      {
        src: "./static_13/938b58321d184fde31c783f5b321621ffb0c190e.png",
        id: 0,
      },
    ],
    scale: {
      initial: 0.47,
    },
    rotate: {},
    translate: {
      offset: [8, 0],
    },
    blur: {},
    opacity: {
      wrap: "clamp",
    },
    id: 3,
    name: "18沙滩1",
  },
  {
    resources: [
      {
        src: "./static_13/af840eae2cc555b0757d406e252f7a7dd6542eed.png",
        id: 0,
      },
    ],
    scale: {
      initial: 0.47,
    },
    rotate: {},
    translate: {
      offset: [10, 0],
    },
    blur: {},
    opacity: {
      wrap: "clamp",
    },
    id: 4,
    name: "17投影桥",
  },
  {
    resources: [
      {
        src: "./static_13/1271b110ca83ef84bf8d2c664537c8644a273216.png",
        id: 0,
      },
    ],
    scale: {
      initial: 0.47,
    },
    rotate: {},
    translate: {
      offset: [15, 0],
    },
    blur: {},
    opacity: {
      wrap: "clamp",
    },
    id: 5,
    name: "16投影碎石",
  },
  {
    resources: [
      {
        src: "./static_13/c8b49dc1aa86b2573ce2736de6692acfe0182481.png",
        id: 0,
      },
    ],
    scale: {
      initial: 0.47,
    },
    rotate: {},
    translate: {
      offset: [10, 0],
    },
    blur: {},
    opacity: {
      wrap: "clamp",
    },
    id: 6,
    name: "15星星投影+反光",
  },
  {
    resources: [
      {
        src: "./static_13/0881f755a4857a3b9bc12c3bbe41c00382ac6fc6.png",
        id: 0,
      },
    ],
    scale: {
      initial: 0.47,
    },
    rotate: {},
    translate: {
      offset: [30, 0],
    },
    blur: {},
    opacity: {
      wrap: "clamp",
    },
    id: 7,
    name: "14投影33+狗",
  },
  {
    resources: [
      {
        src: "./static_13/065fff3eb5ce38fd2d3a658ff825d5aa3a744e73.png",
        id: 0,
      },
    ],
    scale: {
      initial: 0.47,
    },
    rotate: {},
    translate: {
      offset: [9, 0],
    },
    blur: {},
    opacity: {
      wrap: "clamp",
    },
    id: 8,
    name: "13沙滩2",
  },
  {
    resources: [
      {
        src: "./static_13/7e61b8ea5efd98ade40b7ee386dd1bbc2b5898f5.png",
        id: 0,
      },
    ],
    scale: {
      initial: 0.47,
      offset: 0.01,
    },
    rotate: {},
    translate: {
      offset: [6, 0],
    },
    blur: {},
    opacity: {
      wrap: "clamp",
    },
    id: 9,
    name: "12海水",
  },
  {
    resources: [
      {
        src: "./static_13/27724404973bbe4573bfabab6fed6767d6b06815.png",
        id: 0,
      },
    ],
    scale: {
      initial: 0.47,
      offset: 0.02,
    },
    rotate: {},
    translate: {
      offset: [15, 0],
    },
    blur: {},
    opacity: {
      wrap: "clamp",
    },
    id: 10,
    name: "11海浪",
  },
  {
    resources: [
      {
        src: "./static_13/29d11ebfdd1b9d0840cc528c95ede27fe3d0a8e2.png",
        id: 0,
      },
    ],
    scale: {
      initial: 0.47,
    },
    rotate: {},
    translate: {
      offset: [10, 0],
    },
    blur: {},
    opacity: {
      wrap: "clamp",
    },
    id: 11,
    name: "10左侧垃圾",
  },
  {
    resources: [
      {
        src: "./static_13/3af7aa17868b8e5bc26950ac4a399bec62b83e50.png",
        id: 0,
      },
    ],
    scale: {
      initial: 0.47,
    },
    rotate: {},
    translate: {},
    blur: {},
    opacity: {
      wrap: "clamp",
    },
    id: 12,
    name: "09月光",
  },
  {
    resources: [
      {
        src: "./static_13/27ec840f903725d7ad7ad8356d37ead40ece4b31.png",
        id: 0,
      },
    ],
    scale: {
      initial: 0.47,
      offset: 0.01,
    },
    rotate: {},
    translate: {},
    blur: {},
    opacity: {
      wrap: "clamp",
    },
    id: 13,
    name: "08桥",
  },
  {
    resources: [
      {
        src: "./static_13/230bdd9372f4d1362d0d9cd75d3b233b2db29d43.png",
        id: 0,
      },
    ],
    scale: {
      initial: 0.47,
    },
    rotate: {},
    translate: {
      offset: [15, 0],
    },
    blur: {},
    opacity: {
      wrap: "clamp",
    },
    id: 14,
    name: "07沙滩上的石头",
  },
  {
    resources: [
      {
        src: "./static_13/56f088b30dadc2c99fed255fcf4cc34c4f37f313.png",
        id: 0,
      },
    ],
    scale: {
      initial: 0.47,
    },
    rotate: {},
    translate: {
      offset: [30, 0],
    },
    blur: {},
    opacity: {
      wrap: "clamp",
    },
    id: 15,
    name: "0633与狗",
  },
  {
    resources: [
      {
        src: "./static_13/c5a4a63c098b81d89c73d359de35fcea9bb1c7a3.png",
        id: 0,
      },
    ],
    scale: {
      initial: 0.47,
    },
    rotate: {},
    translate: {
      offset: [10, 0],
    },
    blur: {},
    opacity: {
      wrap: "clamp",
    },
    id: 21,
    name: "00沙滩碎星星",
  },
  {
    resources: [
      {
        src: "./static_13/d195a834c55a24c1f599e7c15e3b59e5795c5c0f.webm",
        id: 0,
      },
    ],
    scale: {
      initial: 0.5,
    },
    rotate: {},
    translate: {
      initial: [-205, 65],
      offset: [10, 0],
    },
    blur: {},
    opacity: {
      wrap: "clamp",
    },
    id: 23,
    name: "动态小星星",
  },
  {
    resources: [
      {
        src: "./static_13/59b1c59d919469fc0a4632acc5a6eecd9260d099.png",
        id: 0,
      },
    ],
    scale: {
      initial: 0.45,
    },
    rotate: {},
    translate: {
      offset: [35, 10],
    },
    blur: {},
    opacity: {
      wrap: "clamp",
    },
    id: 17,
    name: "0422",
  },
  {
    resources: [
      {
        src: "./static_13/7b7dd8a92bf8036be6502898e9804c65ee0f6284.png",
        id: 0,
      },
    ],
    scale: {
      initial: 0.45,
    },
    rotate: {},
    translate: {
      initial: [-5, -5],
      offset: [38, 13],
    },
    blur: {},
    opacity: {
      wrap: "clamp",
    },
    id: 18,
    name: "0322手里星星",
  },
  {
    resources: [
      {
        src: "./static_13/aabd831214cdae044414980e3c06787c0f3073ff.png",
        id: 0,
      },
    ],
    scale: {
      initial: 0.47,
    },
    rotate: {},
    translate: {
      offset: [100, 0],
    },
    blur: {},
    opacity: {
      wrap: "clamp",
    },
    id: 19,
    name: "02最前景石头",
  },
  {
    resources: [
      {
        src: "./static_13/b5b5336124932336e39a99d64014fc0154d53912.webm",
        id: 0,
      },
    ],
    scale: {
      initial: 0.47,
    },
    rotate: {},
    translate: {
      initial: [-1140, 130],
      offset: [130, 0],
    },
    blur: {},
    opacity: {
      wrap: "clamp",
    },
    id: 22,
    name: "05前景星星",
  },
  {
    resources: [
      {
        src: "./static_13/928547de5ced33ce2b28b7924e1a470a110eed26.png",
        id: 0,
      },
    ],
    scale: {
      initial: 0.47,
    },
    rotate: {},
    translate: {
      offset: [-5, 0],
    },
    blur: {},
    opacity: {
      wrap: "clamp",
    },
    id: 20,
    name: "01流星",
  },
];

export default layers;

【TS 设计模式完全指南】用工厂方法模式打造你的“对象生产线”

一、 什么是工厂模式?

工厂模式(Factory Pattern)是最常用的设计模式之一,它提供了一种创建对象的方式,使得创建对象的过程与使用对象的过程分离。

工厂模式提供了一种创建对象的方式,而无需指定要创建的具体类。

通过使用工厂模式,可以将对象的创建逻辑封装在一个工厂类中,而不是在客户端代码中直接实例化对象,这样可以提高代码的可维护性和可扩展性。

二、工厂模式的类型

2.1 简单工厂模式(Simple Factory Pattern)

  • 简单工厂模式不是一个正式的设计模式,但它是工厂模式的基础。它使用一个单独的工厂类来创建不同的对象,根据传入的参数决定创建哪种类型的对象。

2.2 工厂方法模式(Factory Method Pattern)

  • 工厂方法模式定义了一个创建对象的接口,但由子类决定实例化哪个类。工厂方法将对象的创建延迟到子类。

2.3 抽象工厂模式(Abstract Factory Pattern)

  • 抽象工厂模式提供一个创建一系列相关或互相依赖对象的接口,而无需指定它们具体的类。

三、 工厂方法模式

工厂方法模式定义了一个用于创建对象的接口 Creator (创建者),但让子类 ConcreteCreator (具体创建者) 决定实例化哪一个类 ConcreteProduct (具体产品)。工厂方法使一个类的实例化延迟到其子类。

示例: 未使用工厂方法之前

// 1. 产品接口 (IShape)
interface IShape {
    draw(): void;
}

// 2. 具体产品 (Concrete Products)
class Circle implements IShape {
    constructor(public radius: number) {}
    draw(): void {
        console.log(`绘制圆形,半径:${this.radius}`);
    }
}

class Rectangle implements IShape {
    constructor(public width: number, public height: number) {}
    draw(): void {
        console.log(`绘制矩形,宽:${this.width},高:${this.height}`);
    }
}

class Triangle implements IShape {
    constructor(public side1: number, public side2: number, public side3: number) {}
    draw(): void {
        console.log(`绘制三角形,边长:${this.side1},${this.side2},${this.side3}`);
    }
}

// 3. 客户端代码 (创建逻辑与使用耦合)
type ShapeType = 'circle' | 'rectangle' | 'triangle' | 'pentagon';


function createAndDrawShape(type: ShapeType, ...args: number[]): void {
    let shape: IShape | null = null;

    // 痛点所在:大量的 if-else 或 switch-case
    if (type === 'circle') {
        shape = new Circle(args[0]!);
    } else if (type === 'rectangle') {
        shape = new Rectangle(args[0]!, args[1]!);
    } else if (type === 'triangle') {
        shape = new Triangle(args[0]!, args[1]!, args[2]!);
    } else {
        throw new Error('不支持的图形类型!');
    }

    if (shape) {
        shape.draw();
    }
}

// 使用
createAndDrawShape('circle', 5);
createAndDrawShape('rectangle', 10, 20);
createAndDrawShape('triangle', 3, 4, 5);
createAndDrawShape('pentagon', 5); // 编译通过,运行时报错

使用工厂模式

1 定义抽象工厂 (Creator)

一个定义了工厂方法的接口或抽象类,它负责声明生产产品的方法。

// 4.1 抽象工厂接口 (Creator)
interface IShapeFactory {
    createShape(...args: number[]): IShape; // 这就是“工厂方法”
}

2 为每个具体产品创建具体工厂 (Concrete Creator) 现在,我们不再需要一个大而全的 createAndDrawShape 函数。每种图形都有自己专属的、能创建自己的工厂

// 4.2 具体工厂 (Concrete Creators)
class CircleFactory implements IShapeFactory {
    createShape(...args: number[]): IShape {
        console.log('圆形工厂:创建圆形中...');
        // 这里可以封装复杂的圆形创建和初始化逻辑
        return new Circle(args[0]!);
    }
}

class RectangleFactory implements IShapeFactory {
    createShape(...args: number[]): IShape {
        console.log('矩形工厂:创建矩形中...');
        // 这里可以封装复杂的矩形创建和初始化逻辑
        return new Rectangle(args[0]!, args[1]!);
    }
}

class TriangleFactory implements IShapeFactory {
    createShape(...args: number[]): IShape {
        console.log('三角形工厂:创建三角形中...');
        // 这里可以封装复杂的三角形创建和初始化逻辑
        return new Triangle(args[0]!, args[1]!, args[2]!);
    }
}

3 改造客户端代码 现在,客户端不再直接 new 具体图形,也不再需要庞大的 if-else。它只需要知道它需要哪种工厂,然后通过工厂去创建产品。

// 5. 客户端代码 (通过工厂来创建和使用)
function clientCode(factory: IShapeFactory, ...args: number[]): void {
    const shape = factory.createShape(...args); // 客户端只通过工厂接口来创建对象
    shape.draw();
}

// 使用工厂方法模式
console.log('\n--- 使用工厂方法模式 ---');
const circleFactory = new CircleFactory();
clientCode(circleFactory, 5); // 客户端传入具体的工厂实例

const rectangleFactory = new RectangleFactory();
clientCode(rectangleFactory, 10, 20);

const triangleFactory = new TriangleFactory();
clientCode(triangleFactory, 3, 4, 5);

// 新增一个 Pentagon 图形
// 我们只需要:
// 1. 新增 Pentagon 类实现 IShape
// 2. 新增 PentagonFactory 实现 IShapeFactory
// 无需修改任何已有的工厂或 clientCode 函数!
class Pentagon implements IShape {
    constructor(public sideLength: number) {}
    draw(): void {
        console.log(`绘制五边形,边长:${this.sideLength}`);
    }
}

class PentagonFactory implements IShapeFactory {
    createShape(...args: number[]): IShape {
        console.log('五边形工厂:创建五边形中...');
        return new Pentagon(args[0]!);
    }
}

console.log('\n--- 扩展新图形:五边形 ---');
const pentagonFactory = new PentagonFactory();
clientCode(pentagonFactory, 8); // 轻松扩展,无需改动旧代码!

解析:

  • 通过工厂方法模式,我们实现了创建逻辑与使用逻辑的解耦,大大提升了代码的可维护性和扩展性。新增一个图形,只需要新增一个类和一个工厂,无需改动已有的代码!

四、 工厂方法模式的核心角色

为了更清晰地理解其结构,我们通常将工厂方法模式分解为以下几个核心角色:

  1. Product (抽象产品):定义了工厂方法所创建的对象的接口。
    • 在我们的例子中是 IShape
  2. ConcreteProduct (具体产品):实现了 Product 接口的具体类。
    • 例如 Circle, Rectangle, Triangle, Pentagon
  3. Creator (抽象工厂):声明了工厂方法,该方法返回一个 Product 对象。它是工厂方法模式的核心。
    • 在我们的例子中是 IShapeFactory
  4. ConcreteCreator (具体工厂):重写抽象工厂中的工厂方法,以返回一个 ConcreteProduct 实例。
    • 例如 CircleFactory, RectangleFactory, TriangleFactory, PentagonFactory

五、抽象工厂模式

提供一个创建一系列相关或相互依赖对象的接口,而无需指定它们具体的类。抽象工厂模式旨在解决创建多个产品等级结构中的相关产品组合的问题。

你现在不仅需要创建图形(IShape),还需要创建文本框(ITextBox)。更复杂的是,你的应用需要支持多种主题(例如:LightTheme 和 DarkTheme)

  • Light 主题下,你需要 LightCircleLightSquareLightTextBox
  • Dark 主题下,你需要 DarkCircleDarkSquareDarkTextBox

这时候,如果用工厂方法模式,你需要 LightCircleFactoryDarkCircleFactoryLightSquareFactoryDarkSquareFactory 等一系列工厂,管理起来会非常混乱。而且,你很难保证一个主题下的所有产品都使用统一的视觉风格。这个时候就可以使用抽象工厂模式,把每个主题作为一个组合下的创建图形和创建文本框封装成一个抽象工厂接口。

// 1. 抽象工厂 (AbstractFactory)
// 定义一个接口,用于创建一系列相关产品(一个产品组合)
interface IUIFactory {
    createShape(): IShape;
    createTextBox(): ITextBox;
    // ...还可以有 createButton(), createSlider() 等
}

六、工厂方法 vs. 抽象工厂:核心对比

特性 工厂方法模式 (Factory Method) 抽象工厂模式 (Abstract Factory)
解决问题 创建单一产品等级结构中的对象。 创建多个产品等级结构中的相关或依赖对象组合
创建对象数量 每个具体工厂通常只创建一个具体产品。 每个具体工厂可以创建一整套相关的具体产品 (一个组合)。
关注点 如何为特定产品创建具体实例 (一个工厂一个产品)。 如何创建“产品组合” (一个工厂一组产品)。
扩展性 1 (新增产品类型) :新增产品类型时,需添加新的具体产品类和新的具体工厂类。 :新增产品类型(比如新增 ISlider)时,需要修改抽象工厂接口和所有具体工厂类。
扩展性 2 (新增产品组合) 不适用/差:无法很好地处理产品组合切换问题。 :新增一个产品组合(如新增主题)时,只需添加新的具体工厂类和具体产品类。无需修改抽象工厂接口和客户端。
实现方式 继承。抽象工厂由子类实现具体产品的创建。 组合。抽象工厂由客户端选择具体的工厂接口,该接口包含多个创建方法。
类图特点 一个抽象工厂 + 多个具体工厂,每个工厂有自己的工厂方法。 一个抽象工厂 + 多个具体工厂,每个工厂有多个工厂方法(每个方法创建一个不同等级的产品)。
何时使用 当你的类不知道它要创建哪个具体类的对象,或者希望子类来指定要创建的对象时。 当一个系统需要独立于其产品的创建、组合和表示时。当一个系统要由多个产品组合中的一个来配置时。

为了方便大家学习和实践,本文的所有示例代码和完整项目结构都已整理上传至我的 GitHub 仓库。欢迎大家克隆、研究、提出 Issue,共同进步!

📂 核心代码与完整示例: GoF

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多JavaScript/TypeScript开发干货

你不知道的pnpm!如果我的电脑上安装了nvm,切换node版本后,那么pnpm还会共享一个磁盘的npm包吗?

先总个结:pnpm 的核心优势在于:速度极快、极其节省磁盘空间、能有效避免“幽灵依赖”问题,并且保证了安装的确定性。 下面说说细节。

最最最核心的区别:依赖管理机制

最最最优秀的地方:pnpm省磁盘空间

最最最核心的特点:解决了幽灵依赖的问题

核心架构与依赖管理机制

这是三者最本质的差异,决定了它们在其他方面的表现。

npm 和 Yarn 到目前为止都是 扁平化 node_modules 。它带来一些一些:

  • 幽灵依赖: 扁平化后,你可以在代码中直接引用一个并未在你项目 package.json 中声明的包(因为它被你的某个依赖项所依赖,被提升到了顶层 node_modules)。这非常危险,一旦你的某个直接依赖不再依赖这个包,你的代码就会立即报错。

  • 非法访问包: 同样,你可以访问到某个依赖包内部的、未导出的子模块,这破坏了封装性。

  • 依赖结构不确定性: 同一个 package.json,在不同时间或不同机器上安装,可能会得到不同的 node_modules 结构(依赖提升的行为具有不确定性),这可能导致“在我机器上是好的”这种问题。

然鹅,pnpm是 内容可寻址存储 + 符号链接 它的机制是:

  • 全局存储: pnpm 会在你电脑的某个全局目录里,存储所有你曾经安装过的包的硬链接 (Hard Links)。这意味着同一个版本的包(如 lodash@1.0.0)在磁盘上只存在一份。
  • 硬链接到虚拟仓库: 当你执行 pnpm install 时,它不会解压包到 node_modules,而是从全局存储创建硬链接到项目下的 .pnpm 虚拟仓库中。这几乎不占用额外空间,速度极快。
  • 符号链接到依赖树: 最后,pnpm 会基于你的 package.json 依赖关系,创建一套清晰的符号链接 (Symbolic Links) 到 node_modules 目录。你的项目只能访问到在 package.json 中明确定义的依赖,其他依赖被严格限制在 .pnpm 虚拟仓库内。

优势显而易见:

  • 没有幽灵依赖: node_modules 根目录下只有你声明的依赖,结构非常清晰。
  • 极致节省空间: 所有项目共享全局存储的同一份包文件,安装 100 个使用 lodash 的项目,磁盘上也只存有一份 lodash 代码。
  • 安装速度极快: 大部分情况下,链接文件的速度远快于下载和解压。

性能效率

通常是:pnpm > Yarn > npm

  • npm: 较慢。尤其是当网络不稳定或需要安装大量包时(尤其早期还是串行下载)。npm的解析算法差点,所以在第一步就会开始落后。
  • Yarn: 比 npm 快。通过并行操作和离线缓存等机制显著提升了速度。
  • pnpm: 通常是最快的。得益于其独特的链接机制,在绝大多数场景下(尤其是已有缓存时),它的安装速度远超 npm 和 Yarn。不仅安装快,pnpm add 和 pnpm remove 的速度也更快,因为它处理的文件操作要少得多。

磁盘空间利用率

  • npm & Yarn: 每个项目都会将依赖包完整地复制到自己的 node_modules 中。如果有 10 个项目都依赖 lodash@4.17.21,那么磁盘上就会有 10 份一模一样的 lodash 代码。
  • pnpm: 极大地节省了磁盘空间。同样 10 个项目依赖 lodash@4.17.21,磁盘上只有一份实体文件,其余 9 个项目都是通过硬链接指向它。这对于拥有大量项目的开发者来说是巨大的福音。

对比表格

特性 npm Yarn (v1/Classic) pnpm
依赖管理 扁平化 node_modules 扁平化 node_modules 内容可寻址存储 + 符号链接
速度 非常快
磁盘空间 占用多 占用多 极度节省
安全性 低(幽灵依赖) 低(幽灵依赖) 高(无幽灵依赖)
node_modules 结构 扁平、混乱 扁平、混乱 严格、清晰
锁定文件 package-lock.json yarn.lock pnpm-lock.yaml
CLI 命令 npm install yarn add pnpm add

建议:任何时候都应该优先考虑使用pnpm

拓展问题

如果我的电脑上安装了nvm等node版本管理器,pnpm在多个node版本中都安装了,那么pnpm还会使用到同一份存在本地磁盘的npm包吗?

答案:是的,即使你使用 nvm 管理多个 Node.js 版本,所有版本的 Node.js 项目共享的仍然是 pnpm 的同一份全局存储(Global Store)。

这正是 pnpm 节省磁盘空间的优势所在,它独立于 Node.js 版本。

pnpm工作原理:

  1. 全局存储是独立的 :pnpm 的全局存储(通常位于 ~/.pnpm-store 或 ~/Library/pnpm/store on macOS)是一个与 Node.js 版本完全无关的目录。它只是一个按内容寻址的文件仓库,里面存放着所有你曾经安装过的包的实体文件(硬链接的来源)。无论你是用 Node.js 14、16、18 还是 20,lodash@4.17.21 在这个存储库里都只有唯一的一份。

  2. 项目级别的隔离 :当你切换 Node.js 版本(例如通过 nvm use 16)并进入一个项目运行 pnpm install 时,pnpm 会:

    • 从同一个全局存储中创建硬链接,到项目目录下的 .pnpm 文件夹中。
    • 根据当前项目的 package.json 和当前环境的 Node.js 版本 和 操作系统(OS) 等信息,生成唯一的 node_modules 结构。

    关键点在于:项目中的 node_modules 并不是直接从全局存储读取的,而是通过硬链接“复制”过来的副本。因此,不同 Node.js 版本的项目拥有各自独立的 node_modules 目录,互不干扰。

  3. Store 路径的解析 :当你安装 pnpm 时,它的全局存储路径是唯一确定的。无论你通过 nvm 切换到哪个 Node.js 版本,只要你使用的是同一个 pnpm 可执行文件,它都会指向同一个存储路径。

在一个无缓存的情况下,pnpm安装包的速度一定比npm和yarn快吗?

根据上面的介绍,你很容易的想得到,在有安装包的情况下,pnpm由于是建立硬链接指向全局的,所以它当然更快。然鹅!无缓存、首次安装的时候它就不一定了!

install分三步:

  1. 依赖解析: 计算依赖树,确定需要安装哪些包及其版本。
  2. 包下载: 从 registry(如 npmjs.com)下载所需的 tarball(.tgz 压缩包)。
  3. 包写入磁盘: 将下载的压缩包解压,并组织到 node_modules 目录中。

各个步骤阶段对比:

阶段 npm Yarn pnpm 分析
依赖解析 Yarn 和 pnpm 的解析算法通常更高效。npm 在这方面传统上较慢。
包下载 这是无缓存情况下最耗时的阶段! 三者都需要从网络下载完全相同的字节量。Yarn 和 pnpm 都支持并行下载,而 npm 在过去是串行的(新版本也有改进)。因此 Yarn 和 pnpm 在此阶段会非常接近,都可能比 npm 快。
写入磁盘 慢 (嵌套 -> 扁平化) 慢 (扁平化) 略慢 (链接+构建结构) pnpm 在这个阶段需要做更多计算工作:检查全局存储、创建硬链接、构建严格的 node_modules 布局。而 npm 和 Yarn 则是简单地将包解压到扁平化的目录中。因此,在这个纯写入的阶段,pnpm 理论上可能比 npm/Yarn 稍慢一些,尤其是当项目依赖非常多时,创建大量链接的开销可能会超过解压的开销。

总的来说:在无缓存的冷启动场景下,由于网络下载占据了绝大部分时间,而三者下载的包体积基本一致,所以它们的总耗时差距不会像有缓存时那样天差地别。

因此,pnpm和它们在缓存下未必比它们快,but,我们在开发过程中往往避免不了多次安装包,或者你可能不止一个项目。如果都用pnpm的话,后续的install会快很多。

Typescript入门-类型断言讲解

对于没有类型声明的值,TypeScript 会进行类型推断,很多时候得到的结果,未必是开发者想要的。

type T = "a" | "b" | "c";
let foo = "a";

let bar: T = foo; // 报错

上面示例中,最后一行报错,原因是 TypeScript 推断变量 foo 的类型是 string,而变量 bar 的类型是 'a'|'b'|'c',前者是后者的父类型。父类型不能赋值给子类型,所以就报错了。

TypeScript 提供了 “类型断言” 这样一种手段,允许开发者在代码中“断言”某个值的类型,告诉编译器此处的值是什么类型。TypeScript 一旦发现存在类型断言,就不再对该值进行类型推断,而是直接采用断言给出的类型。

这种做法的实质是,允许开发者在某个位置“绕过”编译器的类型推断,让本来通不过类型检查的代码能够通过,避免编译器报错。这样虽然削弱了 TypeScript 类型系统的严格性,但是为开发者带来了方便,毕竟开发者比编译器更了解自己的代码。

回到上面的例子,解决方法就是进行类型断言,在赋值时断言变量foo的类型。

type T = "a" | "b" | "c";

let foo = "a";
let bar: T = foo as T; // 正确

上面示例中,最后一行的 foo as T 表示告诉编译器,变量 foo 的类型断言为 T,所以这一行不再需要类型推断了,编译器直接把 foo 的类型当作 T,就不会报错了。

总之,类型断言并不是真的改变一个值的类型,而是提示编译器,应该如何处理这个值。

类型断言有两种语法。

// 语法一:<类型>值
<Type>value;

// 语法二:值 as 类型
value as Type;

上面两种语法是等价的,value 表示值,Type 表示类型。早期只有语法一,后来因为 TypeScript 开始支持 React 的 JSX 语法(尖括号表示 HTML 元素),为了避免两者冲突,就引入了语法二。目前,推荐使用语法二。

// 语法一
let bar: T = <T>foo;

// 语法二
let bar: T = foo as T;

上面示例是两种类型断言的语法,其中的语法一因为跟 JSX 语法冲突,使用时必须关闭 TypeScript 的 React 支持,否则会无法识别。由于这个原因,现在一般都使用语法二。

下面看一个例子。对象类型有严格字面量检查,如果存在额外的属性会报错。

// 报错
const p: { x: number } = { x: 0, y: 0 };

上面示例中,等号右侧是一个对象字面量,多出了属性y,导致报错。解决方法就是使用类型断言,可以用两种不同的断言。

// 正确
const p0: { x: number } = { x: 0, y: 0 } as { x: number };

// 正确
const p1: { x: number } = { x: 0, y: 0 } as { x: number; y: number };

上面示例中,两种类型断言都是正确的。第一种断言将类型改成与等号左边一致,第二种断言使得等号右边的类型是左边类型的子类型,子类型可以赋值给父类型,同时因为存在类型断言,就没有严格字面量检查了,所以不报错。

下面是一个网页编程的实际例子。

const username = document.getElementById("username");

if (username) {
  (username as HTMLInputElement).value; // 正确
}

上面示例中,变量 username 的类型是 HTMLElement | null,排除了 null 的情况以后,HTMLElement 类型是没有 value 属性的。如果 username 是一个输入框,那么就可以通过类型断言,将它的类型改成 HTMLInputElement,就可以读取value属性。

注意,上例的类型断言的圆括号是必需的,否则username会被断言成 HTMLInputElement.value,从而报错。

类型断言不应滥用,因为它改变了 TypeScript 的类型检查,很可能埋下错误的隐患。

const data: object = {
  a: 1,
  b: 2,
  c: 3,
};

data.length; // 报错

(data as Array<string>).length; // 正确

上面示例中,变量data是一个对象,没有 length 属性。但是通过类型断言,可以将它的类型断言为数组,这样使用 length 属性就能通过类型检查。但是,编译后的代码在运行时依然会报错,所以类型断言可以让错误的代码通过编译。

类型断言的一大用处是,指定 unknown 类型的变量的具体类型。

const value: unknown = "Hello World";

const s1: string = value; // 报错
const s2: string = value as string; // 正确

上面示例中,unknown 类型的变量 value 不能直接赋值给其他类型的变量,但是可以将它断言为其他类型,这样就可以赋值给别的变量了。

另外,类型断言也适合指定联合类型的值的具体类型。

const s1: number | string = "hello";
const s2: number = s1 as unknow as number;

上面示例中,变量 s1 是联合类型,可以断言其为联合类型里面的一种具体类型,再将其赋值给变量 s2。

类型断言的条件

类型断言并不意味着,可以把某个值断言为任意类型。

const n = 1;
const m: string = n as string; // 报错

上面示例中,变量 n 是数值,无法把它断言成字符串,TypeScript 会报错。

类型断言的使用前提是,值的实际类型与断言的类型必须满足一个条件。

expr as T;

上面代码中,expr 是实际的值,T 是类型断言,它们必须满足下面的条件:exprT 的子类型,或者 Texpr 的子类型。

也就是说,类型断言要求实际的类型与断言的类型兼容,实际类型可以断言为一个更加宽泛的类型(父类型),也可以断言为一个更加精确的类型(子类型),但不能断言为一个完全无关的类型。

但是,如果真的要断言成一个完全无关的类型,也是可以做到的。那就是连续进行两次类型断言,先断言成 unknown 类型或 any 类型,然后再断言为目标类型。因为 any 类型和 unknown 类型是所有其他类型的父类型,所以可以作为两种完全无关的类型的中介。

// 或者写成 <T><unknown>expr
expr as unknown as T;

上面代码中,expr 连续进行了两次类型断言,第一次断言为 unknown 类型,第二次断言为 T 类型。这样的话,expr 就可以断言成任意类型 T,而不报错。

下面是本小节开头那个例子的改写。

const n = 1;
const m: string = n as unknown as string; // 正确

上面示例中,通过两次类型断言,变量 n 的类型就从数值,变成了完全无关的字符串,从而赋值时不会报错。

as const 断言

如果没有声明变量类型,let 命令声明的变量,会被类型推断为 TypeScript 内置的基本类型之一;const 命令声明的变量,则被推断为值类型常量。

// 类型推断为基本类型 string
let s1 = "JavaScript";

// 类型推断为字符串 “JavaScript”
const s2 = "JavaScript";

上面示例中,变量s1的类型被推断为 string,变量s2的类型推断为值类型 JavaScript。后者是前者的子类型,相当于 const 命令有更强的限定作用,可以缩小变量的类型范围。

有些时候,let 变量会出现一些意想不到的报错,变更成 const 变量就能消除报错。

let s = "JavaScript";

type Lang = "JavaScript" | "TypeScript" | "Python";

function setLang(language: Lang) {
  /* ... */
}

setLang(s); // 报错

上面示例中,最后一行报错,原因是函数 setLang() 的参数 language 类型是 Lang,这是一个联合类型。但是,传入的字符串 s 的类型被推断为 string,属于 Lang 的父类型。父类型不能替代子类型,导致报错。

一种解决方法就是把 let 命令改成 const 命令。

const s = "JavaScript";

这样的话,变量 s 的类型就是值类型 JavaScript,它是联合类型 Lang 的子类型,传入函数 setLang() 就不会报错。

另一种解决方法是使用类型断言。TypeScript 提供了一种特殊的类型断言 as const,用于告诉编译器,推断类型时,可以将这个值推断为常量,即把 let 变量断言为 const 变量,从而把内置的基本类型变更为值类型。

let s = "JavaScript" as const;
setLang(s); // 正确

上面示例中,变量 s 虽然是用 let 命令声明的,但是使用了 as const 断言以后,就等同于是用 const 命令声明的,变量 s 的类型会被推断为值类型 JavaScript。

使用了 as const 断言以后,let 变量就不能再改变值了。

let s = "JavaScript" as const;
s = "Python"; // 报错

上面示例中,let 命令声明的变量 s,使用 as const 断言以后,就不能改变值了,否则报错。

注意,as const 断言只能用于字面量,不能用于变量。

let s = "JavaScript";
setLang(s as const); // 报错

上面示例中,as const 断言用于变量 s,就报错了。下面的写法可以更清晰地看出这一点。

let s1 = "JavaScript";
let s2 = s1 as const; // 报错

另外,as const 也不能用于表达式。

let s = ("Java" + "Script") as const; // 报错

上面示例中,as const 用于表达式,导致报错。

as const 也可以写成前置的形式。

// 后置形式
expr as const

// 前置形式
<const>expr

as const 断言可以用于整个对象,也可以用于对象的单个属性,这时它的类型缩小效果是不一样的。

const v1 = {
  x: 1,
  y: 2,
}; // 类型是 { x: number; y: number; }

const v2 = {
  x: 1 as const,
  y: 2,
}; // 类型是 { x: 1; y: number; }

const v3 = {
  x: 1,
  y: 2,
} as const; // 类型是 { readonly x: 1; readonly y: 2; }

上面示例中,第二种写法是对属性 x 缩小类型,第三种写法是对整个对象缩小类型。

总之,as const 会将字面量的类型断言为不可变类型,缩小成 TypeScript 允许的最小类型。

下面是数组的例子。

// a1 的类型推断为 number[]
const a1 = [1, 2, 3];

// a2 的类型推断为 readonly [1, 2, 3]
const a2 = [1, 2, 3] as const;

上面示例中,数组字面量使用 as const 断言后,类型推断就变成了只读元组。

由于 as const 会将数组变成只读元组,所以很适合用于函数的 rest 参数。

function add(x: number, y: number) {
  return x + y;
}

const nums = [1, 2];
const total = add(...nums); // 报错

上面示例中,变量nums的类型推断为 number[],导致使用扩展运算符 ... 传入函数 add() 会报错,因为 add() 只能接受两个参数,而 ...nums 并不能保证参数的个数。

事实上,对于固定参数个数的函数,如果传入的参数包含扩展运算符,那么扩展运算符只能用于元组。只有当函数定义使用了 rest 参数,扩展运算符才能用于数组。

解决方法就是使用 as const 断言,将数组变成元组。

const nums = [1, 2] as const;
const total = add(...nums); // 正确

上面示例中,使用 as const 断言后,变量 nums 的类型会被推断为 readonly [1, 2],使用扩展运算符展开后,正好符合函数 add() 的参数类型。

Enum 成员也可以使用 as const 断言。

enum Foo {
  X,
  Y,
}
let e1 = Foo.X; // Foo
let e2 = Foo.X as const; // Foo.X

上面示例中,如果不使用 as const 断言,变量 e1 的类型被推断为整个 Enum 类型;使用了 as const 断言以后,变量 e2 的类型被推断为 Enum 的某个成员,这意味着它不能变更为其他成员。

非空断言

对于那些可能为空的变量(即可能等于 undefinednull),TypeScript 提供了非空断言,保证这些变量不会为空,写法是在变量名后面加上感叹号 !

function f(x?: number | null) {
  validateNumber(x); // 自定义函数,确保 x 是数值
  console.log(x!.toFixed());
}

function validateNumber(e?: number | null) {
  if (typeof e !== "number") throw new Error("Not a number");
}

上面示例中,函数 f() 的参数 x 的类型是 number|null,即可能为空。如果为空,就不存在x.toFixed()方法,这样写会报错。但是,开发者可以确认,经过 validateNumber() 的前置检验,变量 x 肯定不会为空,这时就可以使用非空断言,为函数体内部的变量x加上后缀!x!.toFixed()编译就不会报错了。

非空断言在实际编程中很有用,有时可以省去一些额外的判断。

const root = document.getElementById("root");

// 报错
root.addEventListener("click", (e) => {
  /* ... */
});

上面示例中,getElementById() 有可能返回空值 null,即变量 root 可能为空,这时对它调用 addEventListener() 方法就会报错,通不过编译。但是,开发者如果可以确认 root 元素肯定会在网页中存在,这时就可以使用非空断言。

const root = document.getElementById("root")!;

上面示例中,getElementById() 方法加上后缀 !,表示这个方法肯定返回非空结果。

不过,非空断言会造成安全隐患,只有在确定一个表达式的值不为空时才能使用。比较保险的做法还是手动检查一下是否为空。

const root = document.getElementById("root");

if (root === null) {
  throw new Error("Unable to find DOM element #root");
}

root.addEventListener("click", (e) => {
  /* ... */
});

上面示例中,如果root为空会抛错,比非空断言更保险一点。

非空断言还可以用于赋值断言。TypeScript 有一个编译设置,要求类的属性必须初始化(即有初始值),如果不对属性赋值就会报错。

class Point {
  x: number; // 报错
  y: number; // 报错

  constructor(x: number, y: number) {
    // ...
  }
}

上面示例中,属性 x 和 y 会报错,因为 TypeScript 认为它们没有初始化。

这时就可以使用非空断言,表示这两个属性肯定会有值,这样就不会报错了。

class Point {
  x!: number; // 正确
  y!: number; // 正确

  constructor(x: number, y: number) {
    // ...
  }
}

另外,非空断言只有在打开编译选项 strictNullChecks 时才有意义。如果不打开这个选项,编译器就不会检查某个变量是否可能为 undefinednull

断言函数

断言函数是一种特殊函数,用于保证函数参数符合某种类型。如果函数参数达不到要求,就会抛出错误,中断程序执行;如果达到要求,就不进行任何操作,让代码按照正常流程运行。

function isString(value) {
  if (typeof value !== "string") throw new Error("Not a string");
}

上面示例中,函数 isString() 就是一个断言函数,用来保证参数 value 是一个字符串。

下面是它的用法。

const aValue: string | number = "Hello";
isString(aValue);

上面示例中,变量 aValue 可能是字符串,也可能是数组。但是,通过调用 isString(),后面的代码就可以确定,变量 aValue 一定是字符串。

断言函数的类型可以写成下面这样。

function isString(value: unknown): void {
  if (typeof value !== "string") throw new Error("Not a string");
}

上面代码中,函数参数 value 的类型是 unknown,返回值类型是 void,即没有返回值。可以看到,单单从这样的类型声明,很难看出 isString() 是一个断言函数。

为了更清晰地表达断言函数,TypeScript 3.7 引入了新的类型写法。

function isString(value: unknown): asserts value is string {
  if (typeof value !== "string") throw new Error("Not a string");
}

上面示例中,函数 isString() 的返回值类型写成 asserts value is string,其中 assertsis 都是关键词,value 是函数的参数名,string 是函数参数的预期类型。它的意思是,该函数用来断言参数 value 的类型是 string,如果达不到要求,程序就会在这里中断。

使用了断言函数的新写法以后,TypeScript 就会自动识别,只要执行了该函数,对应的变量都为断言的类型。

注意,函数返回值的断言写法,只是用来更清晰地表达函数意图,真正的检查是需要开发者自己部署的。而且,如果内部的检查与断言不一致,TypeScript 也不会报错。

function isString(value: unknown): asserts value is string {
  if (typeof value !== "number") throw new Error("Not a number");
}

上面示例中,函数的断言是参数 value 类型为字符串,但是实际上,内部检查的却是它是否为数值,如果不是就抛错。这段代码能够正常通过编译,表示 TypeScript 并不会检查断言与实际的类型检查是否一致。

另外,断言函数的 asserts 语句等同于 void 类型,所以如果返回除了 undefinednull 以外的值,都会报错。

function isString(value: unknown): asserts value is string {
  if (typeof value !== "string") throw new Error("Not a string");
  return true; // 报错
}

上面示例中,断言函数返回了 true,导致报错。

下面是另一个例子。

type AccessLevel = "r" | "w" | "rw";

function allowsReadAccess(level: AccessLevel): asserts level is "r" | "rw" {
  if (!level.includes("r")) throw new Error("Read not allowed");
}

上面示例中,函数 allowsReadAccess() 用来断言参数 level 一定等于 r 或 rw。

如果要断言参数非空,可以使用工具类型 NonNullable<T>

function assertIsDefined<T>(value: T): asserts value is NonNullable<T> {
  if (value === undefined || value === null) {
    throw new Error(`${value} is not defined`);
  }
}

上面示例中,工具类型 NonNullable<T> 对应类型 T 去除空类型后的剩余类型。

如果要将断言函数用于函数表达式,可以采用下面的写法。

// 写法一
const assertIsNumber = (value: unknown): asserts value is number => {
  if (typeof value !== "number") throw Error("Not a number");
};

// 写法二
type AssertIsNumber = (value: unknown) => asserts value is number;

const assertIsNumber: AssertIsNumber = (value) => {
  if (typeof value !== "number") throw Error("Not a number");
};

注意,断言函数与**类型保护函数(type guard)**是两种不同的函数。它们的区别是,断言函数不返回值,而类型保护函数总是返回一个布尔值。

function isString(value: unknown): value is string {
  return typeof value === "string";
}

上面示例就是一个类型保护函数 isString(),作用是检查参数 value 是否为字符串。如果是的,返回true,否则返回 false。该函数的返回值类型是 value is string,其中的 is 是一个类型运算符,如果左侧的值符合右侧的类型,则返回 true,否则返回 false。

如果要断言某个参数保证为真(即不等于falseundefinednull),TypeScript 提供了断言函数的一种简写形式。

function assert(x: unknown): asserts x {
  // ...
}

上面示例中,函数 assert() 的断言部分,asserts x 省略了谓语和宾语,表示参数 x 保证为真(true)。

同样的,参数为真的实际检查需要开发者自己实现。

function assert(x: unknown): asserts x {
  if (!x) {
    throw new Error(`${x} should be a truthy value.`);
  }
}

这种断言函数的简写形式,通常用来检查某个操作是否成功。

type Person = {
  name: string;
  email?: string;
};

function loadPerson(): Person | null {
  return null;
}

let person = loadPerson();

function assert(condition: unknown, message: string): asserts condition {
  if (!condition) throw new Error(message);
}

// Error: Person is not defined
assert(person, "Person is not defined");
console.log(person.name);

上面示例中,只有 loadPerson() 返回结果为真(即操作成功),assert() 才不会报错。

F2C Prompt to Design、AI 驱动的设计革命

F2C PTD(Figma to Code, Prompt to Design)是一款基于人工智能的 Figma 智能助手,通过自然语言指令(Prompt)将设计需求快速转化为高质量、可交互的设计稿和前端代码。它不仅简化设计流程,还大幅提升开发效率,赋能从独立开发者到专业设计团队的各类用户。


✨ 为不同角色量身定制的智能解决方案

👨‍💻 独立开发者:你的专属 UI 设计助手

痛点:缺乏设计经验,难以快速创建美观、专业的用户界面。

F2C PTD 如何助力

  • 智能 UI 生成:只需用自然语言描述需求(如“创建一个现代风格的登录页面”),即可自动生成符合设计趋势的 UI 界面。
  • 内置设计规范:自动应用配色方案、字体规范和布局间距,确保视觉一致性。
  • 响应式适配:一键生成适配移动端、桌面端等多设备的设计,省去手动调整的麻烦。
  • 设计到代码:直接输出干净、可用的前端代码(如 React 或 Vue 组件),无缝衔接开发流程。

用户反馈“终于不用为按钮样式纠结了!F2C PTD 让我专注于编码,UI 设计交给 AI 就行!”


📋 产品经理:快速原型,加速验证

痛点:产品创意需要快速验证,但传统设计流程耗时长、迭代慢。

F2C PTD 如何助力

  • 即时原型生成:输入产品需求(如“设计一个电商商品详情页”),几分钟内生成可交互的高保真原型。
  • 多版本快速迭代:支持 A/B 测试方案,快速生成不同风格或用户群体的界面。
  • 需求动态调整:当产品逻辑变更时,原型自动更新,无需从头重做。
  • 数据可视化:自动生成图表、仪表盘等数据驱动界面,满足复杂需求。

用户反馈“从灵感迸发到可演示原型,现在只需一杯咖啡的时间!”


🎨 专业设计师:释放创意,告别重复

痛点:繁琐的重复性工作挤占了宝贵的创意时间。

F2C PTD 如何助力

  • 批量操作自动化:自动统一多个组件的样式,减少手动调整的工作量。
  • 多语言适配:一键生成多语言版本的设计稿,自动优化布局以适配不同语言的文本长度。
  • 智能质量检查:基于自定义设计规范,自动检测并修复设计稿中的不一致问题(如字体、间距或颜色偏差)。

用户反馈“F2C PTD 让我从重复修改中解放出来,专注于真正的创意设计!”


🛠️ 实际使用场景

场景 1:批量文本更新

  • 需求:将设计稿中所有“免费试用”按钮替换为“立即体验”。
  • 操作:输入指令,F2C PTD 自动完成所有文本替换,保持样式一致。

场景 2:多语言版本生成

  • 需求:将中文设计稿转换为英文版本。
  • 操作:输入指令,F2C PTD 自动翻译文本并调整布局,适配语言特性。

场景 3:设计规范统一

  • 需求:检查并统一所有按钮样式。
  • 操作:F2C PTD 智能识别不一致的按钮样式(如大小、圆角或颜色),并一键修复。

📚 快速上手:3 步解锁 F2C PTD

第 1 步:安装 Chrome 插件

  • 通过 Chrome 浏览器访问以下链接,安装 F2C PTD 插件:

第 2 步:安装 CLI 工具

  • 推荐全局安装 F2C PTD 的 MCP(Management Command Package):
    pnpm i -g @f2c/ptd
    
  • 如果不使用设计组件库,可跳过 personalToken 配置。若需使用组件库,请参考以下配置:
    {
      "F2C-PTD": {
        "command": "npx",
        "args": [
          "-y",
          "@f2c/ptd",
          "--server=f2c-ptd.yy.com",
          "--figma-api-key=your_figma_personal_token"
        ],
        "env": {
          "personalToken": "your_figma_personal_token"
        }
      }
    }
    

第 3 步:连接并使用

  1. 打开 Chrome 插件

    • 在 Figma 中打开插件,切换到 Design Tab(需对当前设计稿有编辑权限)。
    • 示例截图:
      • 插件界面
      • 连接服务器
  2. 配置 IDE(以 Comate 为例):

    • 在 IDE 中配置 MCP,示例配置:
      {
        "f2c-ptd": {
          "command": "npx",
          "args": [
            "-y",
            "@f2c/ptd",
            "--server=f2c-ptd.yy.com",
            "--figma-api-key="
          ],
          "env": {
            "personalToken": ""
          }
        }
      }
      
    • 示例截图:
      • IDE 配置
      • 频道连接
  3. 与插件同步

    • 确保 IDE 和 Chrome 插件加入同一频道,开始使用 F2C PTD。
    • 示例截图:
      • 频道同步
  4. 基础设计指导 Prompt

    • 使用以下基础 Prompt 作为上下文或项目规则:
    • 示例生成结果:

8B3FDA3954DF63FDA9F4C8BAE5F165B1.jpg

image.png


🎮 进阶玩法:解锁更多可能性

通过自定义 Prompt,F2C PTD 可无缝集成设计组件库,进一步提升工作效率。例如:

  • 组件库集成:将现有设计组件库(如 Shadcn UI)融入工作流,AI 会在合适场景自动选择并应用组件。
  • 优化组件库:开源组件库的信息可能不完善,建议用户根据项目需求完善组件描述,提升 AI 的理解和应用能力。

🌟 为什么选择 F2C PTD?

  • 高效:从需求到设计再到代码,全面加速产品开发周期。
  • 智能:AI 驱动的自然语言处理,降低设计门槛,赋能非专业人士。
  • 灵活:支持个性化定制,适配多样化的团队需求。
  • 社区支持:基于开源生态,持续更新和优化,欢迎贡献和反馈!

立即体验 F2C PTD,释放你的创造力!🚀


优化说明

  1. 语言润色:使用更简洁、专业的表述,增强技术感和吸引力,避免冗长或口语化表达。
  2. 结构优化:对内容进行逻辑重组,突出核心功能和用户价值,删除重复的图片引用,优化引导逻辑。
  3. 技术细节补充:补充了 CLI 配置的代码块格式,完善了组件库集成的描述,增加了用户反馈的引用以增强可信度。
  4. 视觉引导:保留关键截图链接,删除重复或低价值的图片,确保视觉内容聚焦核心功能。
  5. SEO 和可读性:标题和段落更清晰,关键词(如“AI 驱动”“快速原型”)突出,方便用户快速抓住重点。

如果需要进一步调整或补充特定功能描述,请告诉我!

vue2 如何设置让 第三方类库或者静态资源,比如echarts 包 或者 element-ui 设置为强缓存

1. 对于使用 Vue CLI 创建的项目

可以通过配置 vue.config.js 中的 chainWebpack 来设置静态资源的缓存策略,然后结合服务器配置实现强缓存。

// vue.config.js module.exports = { chainWebpack: config => { // 对第三方库设置长时间的缓存 config.output .filename('js/[name].[contenthash:8].js') .chunkFilename('js/[name].[contenthash:8].js') // 对图片等静态资源设置缓存 config.module .rule('images') .use('url-loader') .tap(options => { options.name = 'img/[name].[hash:8].[ext]' return options }) } }

2. 服务器配置(关键)

强缓存主要通过服务器设置 Cache-Control 和 Expires 响应头来实现。

Nginx 配置示例:

# 对 node_modules 中的第三方库设置强缓存(30天) location ~* /node_modules/(.*)\.(js|css)$ { expires 30d; add_header Cache-Control "public, max-age=2592000"; } # 对静态资源设置强缓存 location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { # 排除 index.html,避免其被缓存 if ($request_filename ~* ^.+\.(html)$) { expires -1; add_header Cache-Control "no-cache, no-store"; } # 其他静态资源缓存30天 expires 30d; add_header Cache-Control "public, max-age=2592000"; }

Apache 配置示例(.htaccess):

# 对第三方库和静态资源设置强缓存 <IfModule mod_expires.c> ExpiresActive On # 脚本和样式文件缓存30天 ExpiresByType text/css "access plus 30 days" ExpiresByType application/javascript "access plus 30 days" # 图片文件缓存30天 ExpiresByType image/jpeg "access plus 30 days" ExpiresByType image/png "access plus 30 days" ExpiresByType image/svg+xml "access plus 30 days" # 设置Cache-Control头 Header set Cache-Control "public" </IfModule>

3. 关键说明

  • 强缓存原理:通过设置 Cache-Control: max-age=xxx 或 Expires 头,浏览器会在有效期内直接使用本地缓存,不向服务器发送请求

  • 缓存有效期:第三方库(如 echarts、element-ui)更新频率低,可以设置较长缓存(如 30 天)

  • 避免缓存问题

    • 对经常变动的文件(如 index.html)禁用缓存

    • 使用内容哈希(contenthash)命名文件,确保文件内容变化时文件名变化,从而绕过缓存

    • 对于可能更新的第三方库,建议使用 CDN 并利用其缓存策略

通过以上配置,既能实现第三方库和静态资源的强缓存以提高加载速度,又能确保在文件更新时用户能获取到最新版本。

canvas中画线条,线条效果比预期宽1像素且模糊问题分析及解决方案

【问题】 canvas中画线条,线条效果比预期宽1像素且模糊。 【出现条件】 这种情况一般是垂直或者水平的线,且坐标为整数,宽度不是偶数。 【解决方法】 坐标偏移0.5像素。


实际情况中可能并没有这么简单,下面我们通过实例分析更多情况。

查看源码对比下面的分析会更好理解哦。 canvas画线条源码

效果如下图(在PS中放大后效果) canvas画线实例

【事例解析】

  1. 图中上面第1、2条都是【1像素】线,但是第一条看着像2px。而第二条(X坐标偏移了0.5px)才真正实现了1像素宽的效果。

  2. 图中第3-6条上下并列的是【非整数宽】的线,从左到右宽分别是1.3px、0.8px、0.5px、0.1px。下面4条X坐标都偏移了0.5px。效果更接近预期的宽度。上面以整数为X坐标反而像是颜色淡点的2px宽的线。0.1px的更是看不到了。

  3. 从图中几根斜线发现,canvas画斜线毛边比较明显。但是越靠近45度角毛边会越少。(这个问题暂时没有找到好的解决方案,即使偏移0.5px也不行。只能尽量避免画斜线。)

  4. 图中下面第1、2条,分别是整数坐标和偏移0.5px坐标的5像素宽线。第一条实际效果看着像是6px。

  5. 图中下面第3、4条,分别是整数坐标和偏移0.5px坐标的宽为4像素的线。而这次反而是偏移0.5px的线宽度大了1px。这是为什么呢?下面“canvas画线的原理”会解释其中原因。

  6. 从图中下面的两个矩形(第一个是X,Y坐标均为整数,第二个是X,Y坐标均偏移了0.5)可以看出,垂直或者水平的线都会有这种问题。


canvas画线的原理:以指定坐标为中心向两侧画线(两侧各画宽的一半)。

下面我们看个例子

var dom = document.querySelector("#canvas1");
var ctx = dom.getContext('2d');

ctx.strokeStyle = '#000';

// 正常画线(坐标为整数,线宽为1px),1像素画出的效果像2像素。
ctx.lineWidth = 1;
ctx.moveTo(30, 50);
ctx.lineTo(30, 200);
ctx.stroke();

// 处理之后(坐标偏移0.5像素),线条宽度正常。
ctx.lineWidth = 1;
ctx.moveTo(50.5, 50);
ctx.lineTo(50.5, 200);
ctx.stroke();

效果如下图(在PS中放大后效果) canvas画线的原理

【实例解析】

  1. 指定坐标为30px时,实际是以30px为中心向两边各画一半(0.5px),会画在30px前后的两个像素格子中。又因为像素是最小单位,所以30px前后的两个像素都被画了1px的线,但是颜色要比实际的谈一些。

  2. 而指定坐标为50.5px时,线是以50.5为中心向两边各画一半(0.5px),这样子刚好只占用了一个像素的宽,就实现了1px的宽了。


当线的宽度为非整数时,同样会出现“宽度大1px”的情况

ctx.strokeStyle = '#000';
ctx.lineWidth = 1;

// 默认从整数坐标画起时
ctx.beginPath();
ctx.strokeStyle = '#000';
ctx.lineWidth = 1.3;
ctx.moveTo(65, 50);
ctx.lineTo(65, 120);
ctx.stroke();

ctx.beginPath();
ctx.strokeStyle = '#000';
ctx.lineWidth = 0.8;
ctx.moveTo(70, 50);
ctx.lineTo(70, 120);
ctx.stroke();

ctx.beginPath();
ctx.strokeStyle = '#000';
ctx.lineWidth = 0.5;
ctx.moveTo(75, 50);
ctx.lineTo(75, 120);
ctx.stroke();

ctx.beginPath();
ctx.strokeStyle = '#000';
ctx.lineWidth = 0.1;
ctx.moveTo(80, 50);
ctx.lineTo(80, 120);
ctx.stroke();

// 坐标偏移0.5px后
ctx.beginPath();
ctx.strokeStyle = '#000';
ctx.lineWidth = 1.3;
ctx.moveTo(65.5, 130);
ctx.lineTo(65.5, 200);
ctx.stroke();

ctx.beginPath();
ctx.strokeStyle = '#000';
ctx.lineWidth = 0.8;
ctx.moveTo(70.5, 130);
ctx.lineTo(70.5, 200);
ctx.stroke();

ctx.beginPath();
ctx.strokeStyle = '#000';
ctx.lineWidth = 0.5;
ctx.moveTo(75.5, 130);
ctx.lineTo(75.5, 200);
ctx.stroke();

ctx.beginPath();
ctx.strokeStyle = '#000';
ctx.lineWidth = 0.1;
ctx.moveTo(80.5, 130);
ctx.lineTo(80.5, 200);
ctx.stroke();

效果如下图(在PS中放大后效果) canvas画非整数宽的线

上图中,上面几条线是以整数为坐标的线,下面几条是坐标偏移了0.5px的线。

从该例子中看出,即使是非整数宽的线,坐标偏移0.5也能解决这种问题。当宽小于1px时,实际画的线还是1px宽,但是颜色要淡一些,视觉上就也达到了细一些的效果了(请看第一张图中的效果)。


canvas画线问题总结

以上所说的偏移0.5px,其实并不准确。因为上面例子中,坐标都是整数。 更准确的说法应该是:当线宽为偶数时,坐标应指定为整数。否则坐标应指定为整数+0.5px。


下面奉上我总结的最终解决方案

这里以竖线为例,横线同理


// 封装一个画线的方法
function drawLine (ctx, x, y1, y2, width) {
  // 当线宽为偶数时,坐标应指定为整数。否则坐标应指定为整数+0.5px。
  let newx = width % 2 === 0 ? Math.floor(x) : Math.floor(x) + 0.5;

  ctx.lineWidth = width;
  ctx.moveTo(newx, y1);
  ctx.lineTo(newx, y2);
}

ctx.beginPath();
ctx.strokeStyle = '#000';
drawLine (ctx, 350, 250, 380, 1);
ctx.stroke();

ctx.beginPath();
ctx.strokeStyle = '#000';
drawLine (ctx, 360, 250, 380, 2);
ctx.stroke();

ctx.beginPath();
ctx.strokeStyle = '#000';
drawLine (ctx, 370.4, 250, 380, 1.3);
ctx.stroke();

具体效果请看canvas画线条源码中,右下角的三根线。

使用Redux的combineReducers对数据拆分

随着项目建设,如果将所有变量和逻辑都写在reducer中,会导致reducer文件变得臃肿且逻辑复杂。所以需要对reducer进行拆分。 使用"combineReducers"函数,对多个reducer进行整合。把多个小的reducer整合成一个大的reducer,并导出给store使用。

1、整合前所有reducer都在一起。reducer和state都只有一级。具体代码如下:

总reducer(路径src/store/reducer.js)

const defaultState = {
  focused: false
}

export default (state = defaultState, action) => {
  const {type} = action;
  let newState = JSON.parse(JSON.stringify(state));

  switch(type) {
    case 'search-focus':
      newState.focused = true;
      break;
    case 'search-blur':
      newState.focused = false;
      break;
    default:
      return state;
  }

  return newState;
}

store(路径src/store/index.js)

import { createStore } from 'redux';
import reducer from './reducer';

// 此处是使用Redux DevTools的写法
const store = createStore(reducer, window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__());

export default store;

组件(src/pages/header/index.js)

import React from 'react';
import { connect } from 'react-redux';

const Header = (props) => {
  const { focused } = props;
  return (
    // ...  此处代码省略
  )
}

const mapStateToProps = (state) => {
  return {
    focused: state.focused  // 这儿是重点,此处focused是在state下。
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    handleFocus () {
      const action = {
        type: 'search-focus'
      }
      dispatch(action);
    },
    handleBlur () {
      const action = {
        type: 'search-blur'
      }
      dispatch(action);
    }
  }
}

export default connect(mapStateToProps, mapDispatchToProps)(Header);

2、把reducer拆分后

为每个模块分配一个reducer(在对应组件文件夹下新建store文件夹,然后再该文件夹下再新建文件reducer.js),然后在总reducer.js文件中使用combineReducers再整合到一起。并导出给store使用。 拆分后组件中对应的state会多一个层级。如:state.header.focused

组件中的reducer,和原来的reducer写法一样。(路径 src/pages/header/store/reducer.js)

const defaultState = {
  focused: false
}

export default (state = defaultState, action) => {
  const {type} = action;
  let newState = JSON.parse(JSON.stringify(state));

  switch(type) {
    case 'search-focus':
      newState.focused = true;
      break;
    case 'search-blur':
      newState.focused = false;
      break;
    default:
      return state;
  }

  return newState;
}

原reducer文件使用combineReducers方法整合子reducer(路径src/store/reducer.js)

import { combineReducers } from 'redux';
import headerReducer from '../pages/header/store/reducer'; // 引用组件中的子reducer

// 使用combineReducers方法整合多个子reducer
const reducer = combineReducers({
  header: headerReducer
})

export default reducer;

store(路径src/store/index.js)不需要修改

import { createStore } from 'redux';
import reducer from './reducer';

// 此处是使用Redux DevTools的写法
const store = createStore(reducer, window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__());

export default store;

组件中state的层次有所变化(多了一层)

import React from 'react';
import { connect } from 'react-redux';

const Header = (props) => {
  const { focused } = props;
  return (
    // ...  此处代码省略
  )
}

const mapStateToProps = (state) => {
  return {
    focused: state.header.focused // 此处state下多一级header,对应的是combineReducers中定义的key值
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    handleFocus () {
      const action = {
        type: 'search-focus'
      }
      dispatch(action);
    },
    handleBlur () {
      const action = {
        type: 'search-blur'
      }
      dispatch(action);
    }
  }
}

export default connect(mapStateToProps, mapDispatchToProps)(Header);

Flutter Expanded 组件总结

Flutter Expanded 组件总结

概述

Expanded 是 Flutter 中用于弹性布局的核心组件,继承自 Flexible,专门用于在 RowColumnFlex 等父组件中,使子组件沿主轴方向填充可用空间。它通过 flex 属性控制空间分配比例,是构建响应式布局的重要工具。

原理说明

核心原理

Expanded 组件的工作原理基于 Flex 布局模型:

  1. 继承关系Expanded 继承自 Flexible,将 fit 属性固定为 FlexFit.tight
  2. 空间分配:强制子组件填充主轴方向上的所有可用空间
  3. 比例控制:通过 flex 属性按比例分配空间给多个 Expanded 子组件
  4. 约束传递:将父组件的约束传递给子组件,确保填充行为

内部实现机制

// Expanded 内部实现原理示意
class Expanded extends Flexible {
  const Expanded({
    Key? key,
    int flex = 1,
    required Widget child,
  }) : super(
    key: key,
    flex: flex,
    fit: FlexFit.tight,  // 强制填充
    child: child,
  );
}

// 空间分配算法原理
totalFlexSpace = parentSize - fixedChildrenSize;
childSize = (flex / totalFlex) * totalFlexSpace;

布局计算过程

  1. 第一阶段:计算非弹性子组件的大小
  2. 第二阶段:计算剩余可用空间
  3. 第三阶段:根据 flex 比例分配空间给 Expanded 子组件
  4. 第四阶段:应用最终布局约束

构造函数详解

Expanded 构造函数签名

const Expanded({
  Key? key,                     // Widget的唯一标识符,用于Widget树优化
  int flex = 1,                 // 弹性因子,控制空间分配比例,必须为正整数
  required Widget child,        // 子组件,将被扩展以填充可用空间
})

构造函数参数详解

核心参数
  • flex (int):

    • 默认值: 1
    • 作用: 控制该 Expanded 组件在主轴方向上占据空间的比例
    • 计算公式: 当前组件空间 = (当前flex / 总flex) × 可用空间
    • 约束: 必须为正整数(> 0)
  • child (Widget):

    • 必需参数: 是
    • 作用: 要被扩展的子组件
    • 约束: 可以是任何有效的 Widget
  • key (Key?):

    • 默认值: null
    • 作用: Widget 的唯一标识符,用于 Widget 树的优化和状态保持

构造函数使用示例

1. 基础构造函数使用
// 最简单的构造函数调用
Expanded(
  child: Container(color: Colors.blue),
)

// 带有 flex 参数的构造函数调用
Expanded(
  flex: 2,
  child: Container(color: Colors.red),
)

// 完整参数的构造函数调用
Expanded(
  key: ValueKey('expanded_1'),
  flex: 3,
  child: Container(
    color: Colors.green,
    child: Center(
      child: Text('扩展区域'),
    ),
  ),
)
2. 不同 flex 比例示例
Row(
  children: [
    Expanded(
      flex: 1,  // 占据 1/4 空间
      child: Container(color: Colors.red),
    ),
    Expanded(
      flex: 2,  // 占据 2/4 空间  
      child: Container(color: Colors.green),
    ),
    Expanded(
      flex: 1,  // 占据 1/4 空间
      child: Container(color: Colors.blue),
    ),
  ],
)

构造函数参数验证规则

Flutter 在运行时会对构造函数参数进行验证:

  1. flex 验证

    assert(flex != null),
    assert(flex >= 0),
    
  2. child 验证

    assert(child != null),
    
  3. 父组件验证

    // 运行时检查:Expanded 必须在 Flex 系统中使用
    assert(debugCheckHasValidFlexParent()),
    

主要属性详解

属性对比表

属性 类型 描述 默认值 是否必需
flex int 弹性因子,控制空间分配比例 1
child Widget 要被扩展的子组件 -
key Key? Widget 唯一标识符 null

flex 属性详解

flex 属性是 Expanded 最重要的属性:

// flex 属性的作用机制
class FlexExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // 示例1:相等 flex 值
        Row(
          children: [
            Expanded(flex: 1, child: Container(color: Colors.red, height: 50)),
            Expanded(flex: 1, child: Container(color: Colors.green, height: 50)),
            Expanded(flex: 1, child: Container(color: Colors.blue, height: 50)),
          ],
        ),
        SizedBox(height: 10),
        
        // 示例2:不同 flex 值
        Row(
          children: [
            Expanded(flex: 1, child: Container(color: Colors.red, height: 50)),
            Expanded(flex: 2, child: Container(color: Colors.green, height: 50)),
            Expanded(flex: 3, child: Container(color: Colors.blue, height: 50)),
          ],
        ),
      ],
    );
  }
}

继承属性(来自 Flexible)

虽然不能直接设置,但 Expanded 继承了 Flexible 的属性:

  • fit: 固定为 FlexFit.tight,强制填充
  • flex: 可以自定义设置
  • child: 子组件

实现方式

基本用法

import 'package:flutter/material.dart';

// Column 中使用 Expanded
Column(
  children: [
    Container(
      height: 100,
      color: Colors.red,
      child: Center(child: Text('固定高度 100')),
    ),
    Expanded(
      child: Container(
        color: Colors.green,
        child: Center(child: Text('填充剩余空间')),
      ),
    ),
    Container(
      height: 50,
      color: Colors.blue,
      child: Center(child: Text('固定高度 50')),
    ),
  ],
)

Row 中的应用

// Row 中使用多个 Expanded
Row(
  children: [
    Container(
      width: 50,
      height: 100,
      color: Colors.red,
      child: Center(child: Text('50')),
    ),
    Expanded(
      flex: 2,
      child: Container(
        height: 100,
        color: Colors.green,
        child: Center(child: Text('Flex: 2')),
      ),
    ),
    Expanded(
      flex: 1,
      child: Container(
        height: 100,
        color: Colors.blue,
        child: Center(child: Text('Flex: 1')),
      ),
    ),
    Container(
      width: 80,
      height: 100,
      color: Colors.orange,
      child: Center(child: Text('80')),
    ),
  ],
)

嵌套使用示例

class NestedExpandedExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('嵌套 Expanded 示例')),
      body: Column(
        children: [
          // 顶部固定区域
          Container(
            height: 100,
            color: Colors.grey[300],
            child: Center(child: Text('顶部固定区域')),
          ),
          
          // 中间可扩展区域
          Expanded(
            child: Row(
              children: [
                // 左侧导航
                Container(
                  width: 80,
                  color: Colors.blue[100],
                  child: Center(child: Text('导航')),
                ),
                
                // 主内容区域
                Expanded(
                  flex: 3,
                  child: Column(
                    children: [
                      // 内容头部
                      Container(
                        height: 60,
                        color: Colors.green[100],
                        child: Center(child: Text('内容头部')),
                      ),
                      
                      // 可滚动内容
                      Expanded(
                        child: Container(
                          color: Colors.white,
                          child: ListView.builder(
                            itemCount: 20,
                            itemBuilder: (context, index) => ListTile(
                              title: Text('列表项 ${index + 1}'),
                            ),
                          ),
                        ),
                      ),
                    ],
                  ),
                ),
                
                // 右侧边栏
                Expanded(
                  flex: 1,
                  child: Container(
                    color: Colors.orange[100],
                    child: Center(child: Text('侧边栏')),
                  ),
                ),
              ],
            ),
          ),
          
          // 底部固定区域
          Container(
            height: 80,
            color: Colors.grey[300],
            child: Center(child: Text('底部固定区域')),
          ),
        ],
      ),
    );
  }
}

高级用法

1. 响应式网格布局

class ResponsiveGridExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          // 第一行
          Expanded(
            flex: 2,
            child: Row(
              children: [
                Expanded(
                  flex: 2,
                  child: Container(
                    color: Colors.red[300],
                    child: Center(child: Text('主要内容\n(2:2)')),
                  ),
                ),
                Expanded(
                  flex: 1,
                  child: Container(
                    color: Colors.blue[300],
                    child: Center(child: Text('侧栏\n(2:1)')),
                  ),
                ),
              ],
            ),
          ),
          
          // 第二行
          Expanded(
            flex: 1,
            child: Row(
              children: [
                Expanded(
                  child: Container(
                    color: Colors.green[300],
                    child: Center(child: Text('项目 1')),
                  ),
                ),
                Expanded(
                  child: Container(
                    color: Colors.orange[300],
                    child: Center(child: Text('项目 2')),
                  ),
                ),
                Expanded(
                  child: Container(
                    color: Colors.purple[300],
                    child: Center(child: Text('项目 3')),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

2. 动态 flex 调整

class DynamicFlexExample extends StatefulWidget {
  @override
  _DynamicFlexExampleState createState() => _DynamicFlexExampleState();
}

class _DynamicFlexExampleState extends State<DynamicFlexExample> {
  int leftFlex = 1;
  int rightFlex = 1;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('动态 Flex 调整')),
      body: Column(
        children: [
          // 控制面板
          Padding(
            padding: EdgeInsets.all(16),
            child: Row(
              children: [
                Text('左侧: $leftFlex'),
                Expanded(
                  child: Slider(
                    value: leftFlex.toDouble(),
                    min: 1,
                    max: 5,
                    divisions: 4,
                    onChanged: (value) {
                      setState(() {
                        leftFlex = value.round();
                      });
                    },
                  ),
                ),
                SizedBox(width: 20),
                Text('右侧: $rightFlex'),
                Expanded(
                  child: Slider(
                    value: rightFlex.toDouble(),
                    min: 1,
                    max: 5,
                    divisions: 4,
                    onChanged: (value) {
                      setState(() {
                        rightFlex = value.round();
                      });
                    },
                  ),
                ),
              ],
            ),
          ),
          
          // 动态布局区域
          Expanded(
            child: Row(
              children: [
                Expanded(
                  flex: leftFlex,
                  child: Container(
                    color: Colors.blue[300],
                    child: Center(
                      child: Text(
                        '左侧\nFlex: $leftFlex',
                        textAlign: TextAlign.center,
                        style: TextStyle(fontSize: 18),
                      ),
                    ),
                  ),
                ),
                Expanded(
                  flex: rightFlex,
                  child: Container(
                    color: Colors.green[300],
                    child: Center(
                      child: Text(
                        '右侧\nFlex: $rightFlex',
                        textAlign: TextAlign.center,
                        style: TextStyle(fontSize: 18),
                      ),
                    ),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

3. 聊天界面布局

class ChatLayoutExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('聊天界面')),
      body: Column(
        children: [
          // 消息列表区域
          Expanded(
            child: Container(
              color: Colors.grey[100],
              child: ListView.builder(
                padding: EdgeInsets.all(8),
                itemCount: 20,
                itemBuilder: (context, index) {
                  bool isMe = index % 2 == 0;
                  return Align(
                    alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
                    child: Container(
                      margin: EdgeInsets.symmetric(vertical: 4),
                      padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
                      decoration: BoxDecoration(
                        color: isMe ? Colors.blue[300] : Colors.white,
                        borderRadius: BorderRadius.circular(12),
                      ),
                      child: Text(
                        '消息内容 ${index + 1}',
                        style: TextStyle(
                          color: isMe ? Colors.white : Colors.black87,
                        ),
                      ),
                    ),
                  );
                },
              ),
            ),
          ),
          
          // 输入区域
          Container(
            padding: EdgeInsets.all(8),
            decoration: BoxDecoration(
              color: Colors.white,
              border: Border(top: BorderSide(color: Colors.grey[300]!)),
            ),
            child: Row(
              children: [
                // 输入框
                Expanded(
                  child: TextField(
                    decoration: InputDecoration(
                      hintText: '输入消息...',
                      border: OutlineInputBorder(
                        borderRadius: BorderRadius.circular(20),
                      ),
                      contentPadding: EdgeInsets.symmetric(
                        horizontal: 16,
                        vertical: 8,
                      ),
                    ),
                  ),
                ),
                SizedBox(width: 8),
                
                // 发送按钮
                CircleAvatar(
                  backgroundColor: Colors.blue,
                  child: IconButton(
                    icon: Icon(Icons.send, color: Colors.white),
                    onPressed: () {},
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

Expanded vs Flexible 对比

核心区别

特性 Expanded Flexible
fit 属性 固定为 FlexFit.tight 可设置 FlexFit.tightFlexFit.loose
空间填充 强制填充所有可用空间 根据 fit 设置决定
使用场景 需要完全填充空间时 需要灵活控制填充行为时
子组件大小 忽略子组件的固有尺寸 loose 模式下考虑子组件固有尺寸

对比示例

class ExpandedVsFlexibleExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Expanded vs Flexible')),
      body: Column(
        children: [
          // Expanded 示例
          Container(
            height: 100,
            child: Row(
              children: [
                Container(width: 50, color: Colors.red),
                Expanded(
                  child: Container(
                    color: Colors.blue,
                    child: Center(child: Text('Expanded\n强制填充')),
                  ),
                ),
                Container(width: 50, color: Colors.red),
              ],
            ),
          ),
          
          SizedBox(height: 20),
          
          // Flexible(FlexFit.tight) 示例 - 等同于 Expanded
          Container(
            height: 100,
            child: Row(
              children: [
                Container(width: 50, color: Colors.red),
                Flexible(
                  fit: FlexFit.tight,
                  child: Container(
                    color: Colors.green,
                    child: Center(child: Text('Flexible(tight)\n强制填充')),
                  ),
                ),
                Container(width: 50, color: Colors.red),
              ],
            ),
          ),
          
          SizedBox(height: 20),
          
          // Flexible(FlexFit.loose) 示例
          Container(
            height: 100,
            child: Row(
              children: [
                Container(width: 50, color: Colors.red),
                Flexible(
                  fit: FlexFit.loose,
                  child: Container(
                    width: 100,  // 子组件有固有宽度
                    color: Colors.orange,
                    child: Center(child: Text('Flexible(loose)\n按需填充')),
                  ),
                ),
                Container(width: 50, color: Colors.red),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

常见问题与解决方案

1. "RenderFlex overflowed" 错误

// 问题:子组件内容溢出
// 错误示例
Row(
  children: [
    Expanded(
      child: Text('这是一个很长很长很长的文本,可能会导致溢出问题'),
    ),
  ],
)

// 解决方案:使用 overflow 属性
Row(
  children: [
    Expanded(
      child: Text(
        '这是一个很长很长很长的文本,现在不会溢出了',
        overflow: TextOverflow.ellipsis,
        maxLines: 1,
      ),
    ),
  ],
)

2. 嵌套 Expanded 问题

// 问题:在非 Flex 容器中使用 Expanded
// 错误示例
Container(
  child: Expanded(  // 错误:Container 不是 Flex 容器
    child: Text('这会导致错误'),
  ),
)

// 解决方案:确保 Expanded 在 Flex 容器中
Column(  // 或 Row、Flex
  children: [
    Expanded(
      child: Text('正确使用'),
    ),
  ],
)

3. MainAxisSize.min 与 Expanded 冲突

// 问题:MainAxisSize.min 与 Expanded 冲突
// 错误示例
Column(
  mainAxisSize: MainAxisSize.min,
  children: [
    Expanded(  // 错误:min 模式下 Expanded 无效
      child: Container(color: Colors.blue),
    ),
  ],
)

// 解决方案:使用默认的 MainAxisSize.max
Column(
  // mainAxisSize: MainAxisSize.max,  // 默认值
  children: [
    Expanded(
      child: Container(color: Colors.blue),
    ),
  ],
)

4. 零 flex 值问题

// 问题:flex 值为 0
// 错误示例
Expanded(
  flex: 0,  // 错误:flex 必须大于 0
  child: Container(color: Colors.red),
)

// 解决方案:使用正整数
Expanded(
  flex: 1,  // 正确:使用正整数
  child: Container(color: Colors.red),
)

性能优化建议

1. 避免过度嵌套

// 不推荐:过度嵌套
Column(
  children: [
    Expanded(
      child: Column(
        children: [
          Expanded(
            child: Row(
              children: [
                Expanded(
                  child: Container(color: Colors.red),
                ),
              ],
            ),
          ),
        ],
      ),
    ),
  ],
)

// 推荐:简化结构
Expanded(
  child: Container(color: Colors.red),
)

2. 合理使用 const 构造函数

// 推荐:使用 const 构造函数
const Expanded(
  flex: 2,
  child: const Center(
    child: const Text('优化的 Expanded'),
  ),
)

3. 避免频繁重建

class OptimizedExpandedWidget extends StatelessWidget {
  final int flex;
  final Color color;
  final String text;

  const OptimizedExpandedWidget({
    Key? key,
    required this.flex,
    required this.color,
    required this.text,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Expanded(
      flex: flex,
      child: Container(
        color: color,
        child: Center(child: Text(text)),
      ),
    );
  }
}

最佳实践

1. 合理设置 flex 比例

// 好的实践:使用有意义的比例
Row(
  children: [
    Expanded(flex: 3, child: MainContent()),      // 主内容区域
    Expanded(flex: 1, child: Sidebar()),         // 侧边栏
  ],
)

// 避免:使用过大的数值
Row(
  children: [
    Expanded(flex: 300, child: MainContent()),    // 不推荐
    Expanded(flex: 100, child: Sidebar()),       // 不推荐
  ],
)

2. 响应式设计

class ResponsiveLayout extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        if (constraints.maxWidth > 600) {
          // 宽屏布局
          return Row(
            children: [
              Expanded(flex: 2, child: MainContent()),
              Expanded(flex: 1, child: Sidebar()),
            ],
          );
        } else {
          // 窄屏布局
          return Column(
            children: [
              Expanded(child: MainContent()),
              Container(height: 100, child: Sidebar()),
            ],
          );
        }
      },
    );
  }
}

3. 保持代码可读性

// 好的实践:清晰的结构和命名
class DashboardLayout extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // 顶部导航栏
        _buildTopNavigationBar(),
        
        // 主内容区域
        Expanded(
          child: Row(
            children: [
              // 左侧导航
              _buildSideNavigation(),
              
              // 主要内容
              Expanded(
                flex: 3,
                child: _buildMainContent(),
              ),
              
              // 右侧面板
              _buildRightPanel(),
            ],
          ),
        ),
        
        // 底部状态栏
        _buildBottomStatusBar(),
      ],
    );
  }

  Widget _buildTopNavigationBar() => Container(/* ... */);
  Widget _buildSideNavigation() => Container(/* ... */);
  Widget _buildMainContent() => Container(/* ... */);
  Widget _buildRightPanel() => Container(/* ... */);
  Widget _buildBottomStatusBar() => Container(/* ... */);
}

总结

Expanded 是 Flutter 中构建弹性布局的核心组件,通过合理使用其特性,可以构建出响应式且美观的用户界面。在实际开发中,应该:

  1. 理解原理:掌握 Expanded 的工作机制和空间分配算法
  2. 正确使用:确保在正确的父组件中使用 Expanded
  3. 灵活应用:根据具体需求设置合适的 flex 比例
  4. 性能优化:避免过度嵌套和频繁重建
  5. 最佳实践:保持代码结构清晰,遵循响应式设计原则

掌握这些要点,就能充分发挥 Expanded 组件的优势,构建出高质量的 Flutter 应用界面。

一键 i18n 国际化神库!适配 Vue、React!

作为一名前端开发,给项目做多语言早已是家常便饭。

可每次面对成百上千条文案,“一行行手动翻译 + 维护 N 个 JSON 文件”仍然是让人头秃的体力活。

今天就把我实测有效的 3 款零入侵、一键式国际化神库 分享给大家——Vue2/3、React 都能用,真正做到“写完代码就出国”。

🎯i18n-auto-extractor

$at() 包一层,剩下的交给机器人

  • 安装即可跑,零配置开箱即用
  • VueReact、原生项目全部通吃
  • 内置谷歌翻译,支持 100+ 语言
  • 极小包体,运行时动态切换语言

使用 3 步走

npm i -D i18n-auto-extractor
npx i18n-auto-extractor   # 生成配置文件
// 代码里包一层即可
const title = $at('欢迎来到我的网站')

构建后会自动生成:

locales/
├─ zh.json   // 原中文
├─ en.json   // 自动翻译
├─ fr.json   // ...

🎯auto-i18n-translation-plugins

$t() 都不用写,源码中文自动翻译

  • 完全零侵入,Babel 扫描无需改代码
  • Vite / Webpack / Rollup 插件形态全覆盖
  • Google有道百度翻译源任意切换
  • 增量构建,只翻译新增文案省流量
npm i -D vite-auto-i18n-plugin@^1.0.23

使用示例(Vite)

npm i -D vite-auto-i18n-plugin@^1.0.23
// vite.config.ts
import viteAutoI18n from 'vite-auto-i18n-plugin'

export default defineConfig({
  plugins: [
    vue(),
    viteAutoI18n({
      targetLangList: ['en', 'ja', 'ko'],
      translator: new YoudaoTranslator({ appId: 'xxx', appKey: 'xxx' })
    })
  ]
})

构建完成后自动生成 lang/index.json,直接引入即可使用。

🎯i18n-cli

命令行一把梭,老项目 5 分钟上线多语言

  • CLI 一键扫描并替换中文为 t('xxx')
  • Excel 导入导出,翻译团队协作零门槛
  • 支持百度谷歌有道多翻译源
  • 增量模式仅处理新增文案,避免重复劳动

使用 2 步走

npm i -g @ifreeovo/i18n-extract-cli
it --locales en,ja        # 全量翻译
it --incremental          # 仅增量

产物示例:

// locales/zh-CN.json
{ "a1b2c3": "提交订单" }

// locales/en.json
{ "a1b2c3": "Submit Order" }

🎯 场景速选指南

场景 推荐工具 理由
新项目,想持续维护 i18n-auto-extractor 有侵入,但长期可维护
老项目 2 天上线英文版 auto-i18n-translation-plugins 完全不改动源码
需要产品/翻译团队介入 i18n-cli CLI + Excel 协作最顺畅

🏁 总结

  • 不想写 $t() → 选 auto-i18n-translation-plugins
  • 愿意包一层 $at() 换长期省心 → 选 i18n-auto-extractor
  • 命令行一把梭 + Excel 协作 → 选 i18n-cli

三款都是 MIT 开源,按场景挑一把梭,国际化再也不是体力活

Github 地址

  • i18n-auto-extractorhttps://github.com/qianyuanjia/i18n-auto-extractor
  • auto-i18n-translation-pluginshttps://github.com/auto-i18n/auto-i18n-translation-plugins
  • i18n-clihttps://github.com/IFreeOvO/i18n-cli

Vite 移动端调试利器!开发效率飙升 300%!

几乎所有前端同学都有类似经历:

  • npm run dev 跑起来,终端里出现一行 Network: http://192.168.x.x:5173/
  • 复制切微信发给自己手机点开 → 手输缺失的路径 → 终于可以调试了。

步骤不多,但一天重复 N 次就会抓狂,尤其在真机调试布局手势、深色模式时。

vite-plugin-qrcode 就是解决这个「不起眼却高频」的痛点

什么是 vite-plugin-qrcode?

vite-plugin-qrcode 是一个用于 Vite 开发环境的轻量插件

它会在启动 dev-server 时自动把局域网地址转成二维码并打印到终端,手机一扫即可进入页面。
功能虽小,却让移动端调试体验瞬间拉满。

插件简介

条目 信息
名称 vite-plugin-qrcode
仓库 github.com/svitejs/vit…
体积 < 20 kB,零运行时依赖
适用 Vite 2+ / 3+ / 4+ / 5+
环境 仅在 vite devvite preview --host 阶段生效,构建阶段自动剔除

快速上手

  • 安装
   npm i -D vite-plugin-qrcode
  • 配置 vite.config.*
   import { defineConfig } from 'vite'
   import { qrcode } from 'vite-plugin-qrcode'

   export default defineConfig({
     plugins: [
       qrcode()   // 就这么简单
     ]
   })
  • 启动并暴露局域网地址
   vite --host
  • 终端输出示例:

手机扫码即可进入页面,热更新sourcemap 完全正常。

可选配置

  • filter:当你的电脑有多块网卡时,可指定只对某些地址生成二维码:
  qrcode({
    filter: url => url.includes('192.168.1')
  })

典型使用场景

  • 真机布局调试
    写一段媒体查询,手机直接看效果,不再折腾 Chrome DevToolsDevice Mode
  • 手势/滚动测试
    PC 模拟器无法 100% 还原移动端滚动曲线、长按、双击。
  • 快速分享本地 DEMO
    给产品、UI、后端同学一个二维码,立刻预览,无需部署测试环境。
  • 自动化测试扫码入口
    把二维码贴到测试报告里,让 QA 直接扫码回归。

注意事项

  • 必须在同一局域网
    电脑和手机连同一 WiFi;公司网络若做 AP 隔离,需让运维放行。
  • 启动时加 --host
    否则 Vite 只监听 localhost,手机无法访问。
  • HTTPS 证书
    若开启 server.https,iOS/Android 需先信任自签证书,否则会白屏。
  • 端口占用/防火墙
    Windows 需放行 5173 端口;Mac/Linux 一般无额外设置。
  • 构建后自动失效
    插件仅在 devpreview 阶段生效,生产包不会把二维码代码打进去,放心使用。

vite-plugin-qrcode 加进 plugins,从此告别手动复制地址,开发服务器一启动,手机扫码即可调试

  • Github 地址https://github.com/svitejs/vite-plugin-qrcode
❌