阅读视图

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

实现一个文字擦除效果

前言

动画实际上就是一个视觉欺骗功能,一个前端如果不会视觉欺骗,那么针对很多效果将会手无足措,实际上学习动画的过程中,也能更好理解视觉欺骗,实际开发一些场景也会有更多实现方案

这里的擦除效果,就是利用视觉欺诈实现一个文字擦除效果(前面写到的进度条也有视觉欺骗在里面)

核心原理,就是在文本上方放置同样的一个文本内容,设置颜色将其盖住,通过css属性动画逐步放开遮挡区间,这样看起来就像是文本逐渐展示了(实现是不是和看起来效果有点不太一样,这就是视觉欺骗了)

实现

import "./App.css";

//基础文本
const text = `
“再也不能骂人了!”近日,一条以“骂人也违法了
最高可判三年”为关键词的话题登上热搜,迅速引发公众热议。这背后,是自2026年1月1日起正式施行的新修订《治安管理处罚法》,言语威胁、辱骂他人可能构成违法!公然辱骂他人或捏造事实,造成重伤或死亡的还涉嫌犯罪。请谨言慎行,莫因“口无遮拦”承担法律责任。
法律依据 《中华人民共和国治安管理处罚法》(2026),第五十条规定:
有下列行为之一的,处五日以下拘留或者一千元以下罚款;情节较重的,处五日以上十日以下拘留,可以并处一千元以下罚款:
(一)写恐吓信或者以其他方法威胁他人人身安全的;
(二)公然侮辱他人或者捏造事实诽谤他人的;
(三)捏造事实诬告陷害他人,企图使他人受到刑事追究或受到治安管理处罚的;
(四)对证人及其近亲属进行威胁、侮辱、殴打或者打击报复的;
(五)多次发送淫秽、侮辱、恐吓等信息或者采取滋扰、纠缠、跟踪等方法,干扰他人正常生活的;
(六)偷窥、偷拍、窃听、散布他人隐私的。
有前款第五项规定的滋扰、纠缠、跟踪行为的,除依照前款规定给予处罚外,经公安机关负责人批准,可以责令其一定期限内禁止接触被侵害人。对违反禁止接触规定的,处五日以上十日以下拘留,可以并处一千元以下罚款。
《中华人民共和国刑法》第二百四十六条:
以暴力或者其他方法公然侮辱他人或者捏造事实诽谤他人,情节严重的,处三年以下有期徒刑、拘役、管制或者剥夺政治权利。
`;

function App() {
  return (
    //使用两个一摸一样的文本效果,这样更好盖住
    //为了能重合使用绝对定位来处理
    <p className="App">
      <p className="box">
        {text}
      </p>
      <p className="box">
        //不适用匿名盒子,是为了更好操作该行盒
        <span className="earser">{text}</span>
      </p>
    </p>
  );
}

css实现,除了使用行盒子实现背景的逐行变化,这里借助css自定义属性,来实现动画(毕竟动画只能应用css内置属性,不能直接应用变量)

ps:当然使用 background-position属性,就不用自定义属性了,容我装一下哈😄

.App {
  margin: 0;
  color: black;
  position: relative;
  display: block;
  color: #000;
}

.box {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
}

@property --per {
  syntax: '<percentage>';
  inherits: false;
  initial-value: 5%;
}

//使用行盒子设置背景,才能达到我们想要的效果
.earser {
  --per: 5%;
  color: transparent;
  background: linear-gradient(to right, transparent var(--per), #fff calc(var(--per) + 100px));
  animation
  : earser 5s linear forwards;
}
//关键帧动画
@keyframes earser {
  to {
    --per: 100%;
  }
}

这是一个简易的部分擦除效果(gif懒得生成)

image.png

最后

思考一下,如果文本区域存在一个背景图的话,那么这种遮盖方式可能就不行了,这种情况怎么实现文字擦除效果么,不妨思考一下

手写 Promise.all

前言

Promise 是 JavaScript 中非常重要的一个功能,他可以让我们异步的去处理一些事情,尤其是对于一些比较耗时的操作,其非常重要,当然一些不耗时的操作,有事就没必要使用它了,毕竟会带来额外的性能开销

Promise.all

Promise.all 是 js 中个一个静态方法,尤其是写业务对接后端的时候,会经常见到,不管是写后端异步签名,还是前端接口请求,其主要用来聚合一组成功的结果

相比于 promise 其他系列,这个算是比较简单了(当然 race 更简单就不介绍了)

下面简单陈述 Promise.all 手写的几个关键单

  1. all 是一个静态方法,因此不能像写对象的原型链 prototype一样写
  2. promise 状态从 pending 变更为 fulfilled 或者 rejected 只能发生一次,因此无需考虑多次回调 reject 的问题
  3. 遍历 promises 数组的时候,遍历的是所有可迭代对象,因此 for...of 非常适合

tips: 可以通过 promise 函数回调赋值的方式减少嵌套层级

//声明一个静态方法
Promise.myAll = function(promises) {
    let resolve, reject;
    //构造一个新的promise,赋值可以减少嵌套层级
    const completePromise = new Promise((res, rej) => {
        resolve = res;
        reject = rej;
    })
    const results = [];
    const length = promises.length;
    let completedCount = 0;
    let idx = 0
    //tips: promise 状态从 pending 变更为 fulfilled 或者 rejected 只能发生一次
    for (const item of promises) {
        //使用一个临时变量接收 idx 避免补货到外部同一个索引
        const currentIndex = idx;
        //item可能不是promise,所以用Promise.resolve包一下
        Promise.resolve(item).then(value => {
            results[currentIndex] = value;
            completedCount++;
            //全部成功标记成功
            if (completedCount === length) {
                resolve(results);
            }
        }).catch(err => {
            //有一个返回err就行了,promise状态只会变更一次
            reject(err);
        })
        idx++;
    }
    return completePromise;
}

下面测试一下结果,非常正确


const test = () => {
    const p1 = new Promise((resolve) => {
        setTimeout(() => {
            resolve(1)
        }, 1000)
    });
    const p2 = new Promise((resolve) => {
        setTimeout(() => {
            resolve(2)
        }, 2000)
    });
    const p3 = new Promise((resolve, reject) => {
        setTimeout(() => {
            // reject('error 3')
            resolve(3)
        }, 1500)
    });
    Promise.myAll([p1, p2, p3]).then(res => {
        console.log('res', res)
    }).catch(err => {
        console.log('err', err)
    })
}

test();

这篇文章就讲到这里了,由于内容相对简单,就不多介绍了

写一个简易的数字转中文功能

前言

今天我们写一个一个简易的数字转中文的效果,虽然是功能看起来很简单,但是要是没有写过这类功能的话,那么可能会无从下手,下面就简单讲一下写这类逻辑的过程

思路

实现简易思路:

  1. 大单位是以万、亿、兆等为单位的,4位小数一组,因此需要对数字进行四位一组分组操作,在后面追加单位即可
  2. 除了大单位,中间的小单位则是个十百千,基本都是一致,因此使用同一种统一处理方案,其中个位没有单位
  3. 除了单位还有每一位数字的翻译工作,即:0~9 => 零~九
  4. 淡出处理中文语法中特有的情况,例如:开头不显示一十而是十,零中间作为间隔只能有一个,连续的零没有大单位,以及各位为零的处理等
  5. 入参处理(这里简单处理一下)

代码实现

//数字转中文(要支持小数,可以在扩展,小数就比较简单了)
//入参为数字,因此不考虑开头为零的情
const convertChineseNumber = (number) => {
  if (isNaN(number)) return "NAN";

  const units = ["", "万", "亿", "兆", "京", "垓"];
  const baseUnits = ["", "十", "百", "千"];
  const cNumbers = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九"];
  
  //分割四位一组,反转数组,从后往前加单位
  const numGroup = String(number)
    .replace(/\B(?=(\d{4})+$)/g, " ")  //使用前瞻运算符来处理四位一体
    .split(" ")
    .reverse();
  let result = "";
  //遍历从后往前加单位
  numGroup.forEach((numStrings, idx) => {
    //处理全零的情况,不应该带单位,由于需要间隔0的情况,保留一个零
    if (numStrings === "0000") {
      result = '零' + result
      return;
    }
    //有数的先添加大单位
    result = units[idx] + result;
    //分割四位一组的小数字,仍然是反转,从后往前加单位
    numStrings
      .split("")
      .reverse()
      .forEach((item, idx) => {
        //处理每个后面的单位,其中个位没有单位,0也没有单位
        if (idx !== 0 && item !== "0") {
          result = baseUnits[idx] + result;
        }
        //数字翻译
        result = cNumbers[Number(item)] + result;
      });
  });
  //处理结果中可能存在的中文读写问题,例如不会存在连续的零,开头不会一十
  //零后面不应该有单位,非零整数结尾不会读零
  return result
    .replace(/零+/g, "零")
    .replace(/^(一十)/, "十")
    .replace(new RegExp(`零([${units.join("")}]{1})`, "g"), "$1") //小单位处理了大单位也要处理
    .replace(/(.+)零$/, "$1")
};

测试案例

//我们试验几个案例
const numbers = [
  convertChineseNumber(0),
  convertChineseNumber(1234567899),
  convertChineseNumber(101234567899),
  convertChineseNumber(100234567899),
  convertChineseNumber(101034567899),
  convertChineseNumber(101030067899),
  convertChineseNumber(101030067890),
  convertChineseNumber(101030007899),
  convertChineseNumber(100000007899),
  convertChineseNumber(100070007899),
  convertChineseNumber(100007007899),
];
console.log(numbers);

//目前来看返回结果是对的,如果感觉哪里不对,可以在填填补补就行了
[
  '零',
  '十二亿三千四百五十六万七千八百九十九',
  '一千零一十二亿三千四百五十六万七千八百九十九',
  '一千零二亿三千四百五十六万七千八百九十九',
  '一千零一十亿三千四百五十六万七千八百九十九',
  '一千零一十亿三千零六万七千八百九十九',
  '一千零一十亿三千零六万七千八百九十',
  '一千零一十亿三千万七千八百九十九',
  '一千亿零七千八百九十九',
  '一千亿七千万七千八百九十九',
  '一千亿零七百万七千八百九十九'
]

最后

上面的整体思路还是可以得,实际还是有所欠缺的,例如:不支持小数,对于大数字的单位支持也不够多,甚至一些读法的场景可能考虑还不够完善

需要完善的话,实际按照上面思路补充一下就行了

本篇就介绍到这里了

script标签有哪些常用属性,作用分别是啥

前言

前端开发中,离不开的就是script脚本的执行,html中嵌入script标签,我们的js脚本才能正常执行,正是因为有了它,前端的页面变得更加灵活了(不是纯静态页面了)

简介

常用属性:

src:脚本引用资源地址,可以是远端,也可以是本地路径的资源

<script src="https://example.com/script.js"></script>

<script src="./common.js"></script>

type: script类型,HTML5 默认为 text/javascript,也基本上很少主动设置了,为了兼容性可以设置

crossorigin:跨域属性 "anonymous"(匿名)、"use-credentials"(使用身份令牌验证)

<script src="https://example.com/script.js" crossorigin="anonymous"></script>

async: 异步(延迟)执行脚本

  • 脚本下载时不阻塞 HTML 解析

  • 下载完成后立即执行

  • 执行顺序不保证

defer: 异步(延迟)执行脚本

  • 脚本下载时不阻塞 HTML 解析

  • HTML 解析完成后才执行

  • 按顺序执行多个 defer 脚本

integrity:子资源完整性验证,防止恶意更改文件来执行潜在攻击

例如:script可能来自cdn提速,但是为了避免被人篡改过,于是使用算法加密,提前设定好脚本加密后的结果,浏览器下载后会将资源加密后进行对比,一致时执行,可以减少被攻击的可能

常用算法有 `sha256``sha384``sha512`(不推荐 md5/sha1,安全性低)。

<script 
    src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js" 
    integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" 
    crossorigin="anonymous"
    />
    

生成sha256哈希值

cat script.js | openssl dgst -sha256 -binary | base64 # 生成sha384哈希值

最后

实际常用的就这么多,且前几个最常用(老代码中比较常见)

使用 && 整合事件,简化代码

前言

js 中 && 和 || 使用好了,有时候能够将我们的代码写的更舒服,甚至逻辑更加清晰,合理的利用他们,能让我们的代码看起来含金量更高一些

ps:当然不一定是真的含金量高很多,如果对于用习惯的来说,确实逻辑更紧密了,个人减少了很多代码和多余判断😄

&& 是返回符合条件的最后一项或不符合的第一项 || 是返回符合条件的第一项或不符合的最后一项

这里就使用 && 举个案例, || 相信遇到合适场景,自己就知道咋回事了

案例

我有一个弹窗,点击确定后,将选中内容后回调给外部,否则提示请选择数据

让ai帮我写这一块代码,是这样的,简单粗暴易懂

//pageInfo 和 onOk 已经定义好了
const onComplete = () => {
  const { dataSource, key } = pageInfo;
  if (dataSource && dataSource.length > 0 && key) {
    return Modal.info({
      title: '信息',
      content: '请选择一条数据',
    });
  }
  const record = dataSource.find(item => item.key === key);
  if (!record) {
    return Modal.info({
      title: '信息',
      content: '请选择一条数据',
    });
  }
  onOk && onOk(record);
}

下面我是这么写的,不想多写提示了,合理利用 && 会返回符合条件最后一项特性来实现,看起来似乎好了一点点

//pageInfo 和 onOk 已经定义好了
const onComplete = () => {
  const { dataSource, key } = pageInfo;
  const hasSelectedData =
    dataSource &&
    dataSource.length > 0 &&
    key &&
    dataSource.find((item) => item.key === key);  
  if (!hasSelectedData) {
    return Modal.info({
      title: "信息",
      content: "请选择一条数据",
    });
  }
  onOk && onOk(ifs.pop());
};

最后

普通的代码蕴含着我们对于代码的一些追求,你觉得什么样的代码好呢,反正我的ai感觉我的代码是屎,给我改了😄

我尝试将TinyPro集成TinyEngine低代码设计器了

本文由TinyPro贡献者宋子文原创。

TinyProTinyEngine 是 OpenTiny 开源生态的重要组成部分:

  • TinyPro 提供企业级后台系统模板
  • TinyEngine 提供灵活强大的低代码引擎

本项目在 TinyPro 中深度集成了基于 TinyEngine 的低代码设计器,通过 插件化架构 构建出可扩展的低代码开发平台。

借助它,你只需在可视化设计器中完成页面设计,就能一键导入 TinyPro,并自动生成菜单、权限及国际化配置,实现真正的 “所见即所得” 式开发体验。

整体架构

lowcode-designer/
├── src/
│   ├── main.js              # 应用入口
│   ├── composable/          # 可组合逻辑
│   ├── configurators/       # 配置器
├── registry.js              # 插件注册表
├── engine.config.js         # 引擎配置
└── vite.config.js          # 构建配置

image.png

核心组成部分

  1. TinyEngine 核心:提供低代码设计器的基础能力
  2. 插件系统:通过插件扩展功能
  3. 注册表机制:统一管理插件和服务
  4. 配置器系统:自定义组件属性配置

核心特性

  • 智能代码生成:基于可视化设计自动生成符合 TinyPro 规范的 Vue 3 + TypeScript 代码
  • 🔐 自动认证管理:智能获取和管理 API Token,支持多种认证方式
  • 🎯 一键集成:自动创建菜单、配置权限、添加国际化词条
  • 🛠️ 代码转换:将 TinyEngine 生成的代码自动转换为 TinyPro 项目兼容格式
  • 💾 本地保存:支持将生成的文件保存到本地文件系统
  • 🎨 可视化配置:提供友好的 UI 界面进行菜单和路由配置

快速开始

安装

使用 TinyCli 可以快速初始化 TinyPro 模版

tiny init pro 

image 1.png

启动低代码设计器

cd lowcode-designer
pnpm install
pnpm dev

启动前端与后端

cd web
pnpm install
pnpm start

cd nestJs
pnpm install
pnpm start

启动完成后,访问 👉 http://localhost:8090 即可体验低代码设计器。

使用流程

image 2.png

设计页面:在 TinyEngine 可视化编辑器中设计页面

image 3.png

点击出码按钮:点击工具栏中的”出码”按钮

image 4.png

配置菜单信息:在弹出的对话框中填写菜单配置信息

生成预览:点击”生成预览”查看将要生成的文件

image 5.png

完成集成:点击”完成集成”自动创建菜单、分配权限并保存文件

image 6.png

接下来我们就可以直接去 TinyPro 直接看到页面效果

image 7.png

TinyPro Generate Code 插件解析

插件目录结构

generate-code-tinypro/
├── package.json              # 插件包配置
├── src/
│   ├── index.js             # 插件入口
│   ├── meta.js              # 元数据定义
│   ├── Main.vue             # 主组件
│   ├── SystemIntegration.vue # 功能组件
│   ├── components/          # 通用组件
│   │   ├── ToolbarBase.vue
│   │   ├── ToolbarBaseButton.vue
│   │   └── ToolbarBaseIcon.vue
│   ├── composable/          # 可组合逻辑
│   │   ├── index.js
│   │   └── useSaveLocal.js
│   └── http.js              # HTTP 服务
├── vite.config.js           # 构建配置
└── README.md                # 文档

代码生成流程

const generatePreview = async () => {
  // 1. 获取当前页面的 Schema
  const currentSchema = getSchema();

  // 2. 获取应用元数据(i18n、dataSource、utils等)
  const metaData = await fetchMetaData(params);

  // 3. 获取页面列表和区块信息
  const pageList = await fetchPageList(appId);
  const blockSchema = await getAllNestedBlocksSchema();

  // 4. 调用代码生成引擎
  const result = await generateAppCode(appSchema);

  // 5. 过滤和转换生成的代码
  const transformedFiles = filteredFiles.map((file) => ({
    ...file,
    fileContent: transformForTinyPro(file.fileContent),
  }));
};

TinyPro 与 TinyEngine 通信

当用户在低代码设计器中点击“完成集成”时,插件首先通过 Token Manager 向认证接口 /api/auth/api-token 请求并获取访问凭证(Token),随后利用该 Token 调用一系列后台接口,包括国际化 API、菜单 API 和角色 API。插件通过这些接口自动完成 页面国际化词条创建、菜单注册、角色查询与权限分配 等步骤。整个过程中,HTTP Client 统一负责与后端通信,而返回的数据(菜单信息、角色信息、权限配置等)会实时更新到本地,最终实现了从页面设计到系统集成的一键闭环,使 TinyEngine 生成的页面能无缝接入 TinyPro 系统。

image 8.png

总结

通过 TinyPro 与 TinyEngine 的深度融合,我们实现了从「可视化设计」到「系统集成」的完整闭环,让不会写代码的用户也能轻松构建出高质量的前端页面

用户只需拖拽组件、填写配置、点击“出码”,插件便会自动生成符合 TinyPro 标准的代码,并完成菜单、权限、国际化等系统级配置。

这一过程无需手动修改代码或后台配置,就能一键完成页面创建、接口绑定与权限分配,实现真正意义上的「低门槛、高效率、可扩展」的前端开发体验。

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyPro 源码:github.com/opentiny/ti…
TinyEngine 源码: github.com/opentiny/ti…

欢迎进入代码仓库 Star🌟TinyPro、TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~

如果你也想要共建,可以进入代码仓库,找到 good first issue 标签,一起参与开源贡献~

Flutter-使用Gal展示和保存图片资源

Gal 是 Flutter 生态中一款轻量、高性能的图片管理与预览插件,专为简化 Flutter 应用中图片选择、预览、保存等核心场景设计。它封装了原生平台的图片处理能力,提供统一的 API 接口,让开发者无需关注 iOS/Android 底层差异,快速实现专业级的图片交互体验。

1. Gal 插件核心功能

Gal 插件的核心价值在于跨平台一致性易用性,主要覆盖以下场景:

  1. 图片预览:支持单张/多张图片的沉浸式预览,包含缩放、滑动切换、手势返回等交互;
  2. 相册操作:读取设备相册、筛选图片/视频、获取图片元信息(尺寸、路径、创建时间);
  3. 图片保存:将网络图片/本地图片保存到系统相册,自动处理权限申请;
  4. 权限管理:封装相册读写权限的申请与状态检测,适配 iOS/Android 权限机制差异;
  5. 性能优化:内置图片懒加载、内存缓存策略,避免大图集加载时的卡顿问题。

2. 核心 API 与属性详解

2.1. 基础配置

使用 Gal 前需先完成初始化,并配置权限相关参数(pubspec.yaml 配置):

使用最新版本:

dependencies:
  gal: ^2.1.0 # 建议使用最新稳定版

Android:


# Android 权限配置(android/app/src/main/AndroidManifest.xml)
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>

iOS:

# iOS 权限配置(ios/Runner/Info.plist)
<key>NSPhotoLibraryUsageDescription</key>
<string>需要访问相册以选择/保存图片</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>需要写入权限以保存图片到相册</string>

2.2. 核心 API 列表

API 方法 功能描述 参数说明 返回值
Gal.requestPermission() 申请相册读写权限 type: 权限类型(PermissionType.read/write Future<bool>: 是否授权成功
Gal.getPhotos() 获取相册图片列表 limit: 加载数量(默认全部)
albumId: 指定相册 ID(可选)
Future<List<GalPhoto>>: 图片信息列表
Gal.preview() 预览图片 photos: 图片列表
initialIndex: 初始预览索引
backgroundColor: 预览背景色
Future<void>
Gal.saveImage() 保存图片到相册 path: 图片本地路径/网络 URL
albumName: 自定义相册名称(可选)
Future<bool>: 是否保存成功
Gal.getAlbums() 获取设备相册列表 - Future<List<GalAlbum>>: 相册信息列表

2.3. 关键数据模型

GalPhoto(图片信息模型)

class GalPhoto {
  final String id; // 图片唯一标识
  final String path; // 本地路径
  final String? url; // 网络图片 URL(可选)
  final int width; // 图片宽度
  final int height; // 图片高度
  final DateTime createTime; // 创建时间
  final String mimeType; // 图片类型(image/jpeg 等)
}

GalAlbum(相册信息模型)

class GalAlbum {
  final String id; // 相册唯一标识
  final String name; // 相册名称
  final int count; // 相册内图片数量
  final String? coverPath; // 相册封面路径
}

3. 图片选择与预览功能Demo

以下是一个完整的 Demo,实现「获取相册图片 → 列表展示 → 点击预览 → 保存图片」的核心流程。

3.1 完整代码

import 'package:flutter/material.dart';
import 'package:gal/gal.dart';
import 'package:permission_handler/permission_handler.dart';

void main() => runApp(const GalDemoApp());

class GalDemoApp extends StatelessWidget {
  const GalDemoApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Gal 插件 Demo',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: const GalDemoPage(),
    );
  }
}

class GalDemoPage extends StatefulWidget {
  const GalDemoPage({super.key});

  @override
  State<GalDemoPage> createState() => _GalDemoPageState();
}

class _GalDemoPageState extends State<GalDemoPage> {
  List<GalPhoto> _photos = [];
  bool _isLoading = false;

  // 申请相册权限
  Future<bool> _requestPermission() async {
    final status = await Permission.photos.request();
    return status.isGranted;
  }

  // 加载相册图片
  Future<void> _loadPhotos() async {
    setState(() => _isLoading = true);
    try {
      final hasPermission = await _requestPermission();
      if (!hasPermission) {
        if (mounted) {
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(content: Text('请授予相册访问权限')),
          );
        }
        return;
      }

      // 获取相册图片(限制加载20张,避免性能问题)
      final photos = await Gal.getPhotos(limit: 20);
      setState(() => _photos = photos);
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('加载图片失败:$e')),
        );
      }
    } finally {
      setState(() => _isLoading = false);
    }
  }

  // 预览图片
  void _previewPhoto(int index) async {
    await Gal.preview(
      photos: _photos,
      initialIndex: index,
      backgroundColor: Colors.black,
    );
  }

  // 保存示例图片到相册
  Future<void> _saveSampleImage() async {
    const sampleImageUrl = 'https://picsum.photos/800/600';
    try {
      final success = await Gal.saveImage(
        sampleImageUrl,
        albumName: 'Gal Demo', // 自定义相册名称
      );
      if (success) {
        if (mounted) {
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(content: Text('图片保存成功')),
          );
          // 保存后重新加载图片列表
          _loadPhotos();
        }
      }
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('保存失败:$e')),
        );
      }
    }
  }

  @override
  void initState() {
    super.initState();
    // 页面初始化时加载图片
    _loadPhotos();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Gal 图片管理 Demo'),
        actions: [
          IconButton(
            icon: const Icon(Icons.save),
            onPressed: _saveSampleImage,
            tooltip: '保存示例图片',
          ),
        ],
      ),
      body: _buildBody(),
    );
  }

  Widget _buildBody() {
    if (_isLoading) {
      return const Center(child: CircularProgressIndicator());
    }
    if (_photos.isEmpty) {
      return const Center(child: Text('暂无图片,请检查权限或相册内容'));
    }
    // 网格展示图片
    return GridView.builder(
      padding: const EdgeInsets.all(8),
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 3, // 每行3crossAxisSpacing: 4,
        mainAxisSpacing: 4,
        childAspectRatio: 1, // 宽高比1:1
      ),
      itemCount: _photos.length,
      itemBuilder: (context, index) {
        final photo = _photos[index];
        return GestureDetector(
          onTap: () => _previewPhoto(index),
          child: Image.file(
            File(photo.path),
            fit: BoxFit.cover,
            errorBuilder: (context, error, stackTrace) {
              return const Icon(Icons.broken_image, color: Colors.grey);
            },
          ),
        );
      },
    );
  }
}

3.2. 代码说明

  1. 权限处理:结合 permission_handler 插件申请相册权限,这是使用 Gal 的前提;
  2. 图片加载:通过 Gal.getPhotos() 获取相册图片,限制加载数量避免卡顿;
  3. 图片展示:使用 GridView 展示图片列表,点击图片调用 Gal.preview() 实现沉浸式预览;
  4. 图片保存:调用 Gal.saveImage() 将网络图片保存到自定义相册,保存成功后刷新列表。

3.3. 运行效果

  1. 首次打开应用会弹出权限申请弹窗,授权后加载相册前20张图片;
  2. 图片以网格形式展示,点击任意图片进入全屏预览模式,支持滑动切换、双指缩放;
  3. 点击右上角「保存」按钮,可将示例网络图片保存到「Gal Demo」相册,保存后列表自动刷新。

4. 注意事项

  1. 权限适配

    1. Android 13+ 需单独申请 READ_MEDIA_IMAGES 权限,Android 10 需配置 android:requestLegacyExternalStorage="true"
    2. iOS 14+ 支持精确相册权限(仅允许选择部分图片),Gal 已适配该特性。
  2. 性能优化

    1. 加载大量图片时,务必设置 limit 参数分页加载,避免一次性加载全部图片导致内存溢出;
    2. 预览图片时,建议使用 CachedNetworkImage 缓存网络图片。
  3. 异常处理

    1. 所有 Gal API 均为异步操作,需添加 try/catch 捕获权限拒绝、文件不存在等异常;
    2. 保存网络图片时,需先判断网络状态,避免无网络时保存失败。

5. 总结

  1. Gal 插件是 Flutter 中高效的图片管理工具,核心覆盖「权限申请、图片读取、预览、保存」四大核心场景,API 设计简洁且跨平台一致;
  2. 使用 Gal 的关键步骤:配置权限 → 申请权限 → 调用核心 API → 异常处理;
  3. 实战中需注意性能优化(分页加载、缓存)和平台适配(不同系统的权限/路径差异),确保体验一致性。

通过 Gal 插件,开发者可以摆脱原生图片处理的繁琐逻辑,快速实现媲美原生应用的图片交互体验,是 Flutter 图片类应用的优选插件。

源码:传送门


本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

isexe@3.1.1源码阅读

发布日期 2023 年 8 月 3 日

isexe跨平台检查文件是否为「可执行文件」的专用工具包,核心解决「Windows 和 Unix 系统判断可执行文件的规则完全不同」的问题。

unix系统根据文件权限判断;window系统根据文件扩展名判断。

入口文件 index.js

isexe-3.1.1/src/index.ts

import * as posix from './posix.js' // 导入 POSIX 系统(Linux/macOS 等)的实现
import * as win32 from './win32.js' // 导入 Windows 系统的实现
export * from './options.js' // 导出配置选项类型(如 IsexeOptions)
export { win32, posix }  // 允许直接访问特定平台的实现

const platform = process.env._ISEXE_TEST_PLATFORM_ || process.platform
const impl = platform === 'win32' ? win32 : posix

/**
 * Determine whether a path is executable on the current platform.
 */
export const isexe = impl.isexe
/**
 * Synchronously determine whether a path is executable on the
 * current platform.
 */
export const sync = impl.sync

posix.isexe(异步)

isexe-3.1.1/src/posix.ts

const isexe = async (
  path: string,  // 要检查的文件路径(比如 "/usr/bin/node" 或 "C:\\node.exe")
  options: IsexeOptions = {}  // 配置项,默认空对象
): Promise<boolean> => {
  
  const { ignoreErrors = false } = options

  try {
    // await stat(path):获取文件状态
    // checkStat(statResult, options):判断是否可执行
    return checkStat(await stat(path), options)
  } catch (e) {
    // 把错误转为 Node.js 标准错误类型(带错误码)
    const er = e as NodeJS.ErrnoException
    if (ignoreErrors || er.code === 'EACCES') return false
    throw er // 非预期错误,向上抛出
  }
}
import { Stats, statSync } from 'fs'
import { stat } from 'fs/promises'

checkStat

const checkStat = (stat: Stats, options: IsexeOptions) =>
  stat.isFile() && checkMode(stat, options)

checkMode

const checkMode = (
  // 文件的 Stats 对象(通常由 fs.stat 或 fs.lstat 获取)
  // 包含文件的权限位(mode)、所有者 ID(uid)、所属组 ID(gid)等元数据。
  stat: Stats, 
  // 配置对象,允许自定义用户 ID(uid)、组 ID(gid)、用户所属组列表(groups),默认使用当前进程的用户信息。
  options: IsexeOptions
) => {
  // 1、获取用户与组信息
  // 当前用户的 ID(优先使用 options.uid,否则调用 process.getuid() 获取当前进程的用户 ID)。
  const myUid = options.uid ?? process.getuid?.()
  // 当前用户所属的组 ID 列表(优先使用 options.groups,否则调用 process.getgroups() 获取)。
  const myGroups = options.groups ?? process.getgroups?.() ?? []
  // 当前用户的主组 ID(优先使用 options.gid,否则调用 process.getgid(),或从 myGroups 取第一个组 ID)。
  const myGid = options.gid ?? process.getgid?.() ?? myGroups[0]
  // 若无法获取 myUid 或 myGid,抛出错误(权限判断依赖这些信息)
  if (myUid === undefined || myGid === undefined) {
    throw new Error('cannot get uid or gid')
  }

  // 2、构建用户所属组集合
  const groups = new Set([myGid, ...myGroups])

  // 3、解析文件权限位与归属信息
  const mod = stat.mode // 文件的权限位(整数,如 0o755 表示 rwxr-xr-x)
  const uid = stat.uid // 文件所有者的用户 ID
  const gid = stat.gid // 文件所属组的组 ID

  // 4、定义权限位掩码
  // 八进制 100 → 十进制 64 → 对应所有者的执行权限位(x)
  const u = parseInt('100', 8)
  // 八进制 010 → 十进制 8 → 对应所属组的执行权限位(x)
  const g = parseInt('010', 8)
  // 八进制 001 → 十进制 1 → 对应其他用户的执行权限位(x)
  const o = parseInt('001', 8)
  // 所有者和所属组的执行权限位掩码(64 | 8 = 72)
  const ug = u | g

  // 5、权限判断逻辑
  return !!(
    mod & o || // 1. 其他用户有执行权限
    (mod & g && groups.has(gid)) || // 2. 所属组有执行权限,且当前用户属于该组
    (mod & u && uid === myUid) || // 3. 所有者有执行权限,且当前用户是所有者
    (mod & ug && myUid === 0)  // 4. 所有者或组有执行权限,且当前用户是 root(UID=0)
  )
}

mod (权限位) :Unix 系统中用 9 位二进制表示文件权限(分为所有者、所属组、其他用户三类,每类 3 位,分别控制读 r、写 w、执行 x 权限)。例如 0o755 对应二进制 111 101 101,表示:

  • 所有者(u):可读、可写、可执行(rwx)。
  • 所属组(g):可读、可执行(r-x)。
  • 其他用户(o):可读、可执行(r-x)。

posix.sync (同步)

isexe-3.1.1/src/posix.ts

const sync = (
  path: string,
  options: IsexeOptions = {}
): boolean => {
  
  const { ignoreErrors = false } = options
  try {
    return checkStat(statSync(path), options)
    
  } catch (e) {
    const er = e as NodeJS.ErrnoException
    if (ignoreErrors || er.code === 'EACCES') return false
    throw er
  }
}
import { Stats, statSync } from 'fs'
import { stat } from 'fs/promises'

win32.isexe (异步)

isexe-3.1.1/src/win32.ts

const isexe = async (
  path: string,
  options: IsexeOptions = {}
): Promise<boolean> => {
  
  const { ignoreErrors = false } = options
  try {
    return checkStat(await stat(path), path, options)
  } catch (e) {
    const er = e as NodeJS.ErrnoException
    if (ignoreErrors || er.code === 'EACCES') return false
    throw er
  }
}
import { Stats, statSync } from 'fs'
import { stat } from 'fs/promises'

checkStat

const checkStat = (stat: Stats, path: string, options: IsexeOptions) =>
  stat.isFile() && checkPathExt(path, options)

checkPathExt

isexe-3.1.1/src/win32.ts

const checkPathExt = (path: string, options: IsexeOptions) => {

  // 获取可执行扩展名列表
  const { pathExt = process.env.PATHEXT || '' } = options

  const peSplit = pathExt.split(';')
  // 特殊情况处理:空扩展名
  // 空扩展名通常表示 “任何文件都视为可执行”,这是一种特殊配置
  if (peSplit.indexOf('') !== -1) {
    return true
  }

  // 检查文件扩展名是否匹配
  for (let i = 0; i < peSplit.length; i++) {
    // 转小写:避免大小写问题(比如.EXE和.exe视为同一个)
    const p = peSplit[i].toLowerCase()
    // 截取文件路径的最后N个字符(N是当前扩展名p的长度),也转小写
    const ext = path.substring(path.length - p.length).toLowerCase()

    // 匹配条件:扩展名非空 + 文件扩展名和列表中的扩展名完全一致
    if (p && ext === p) {
      return true
    }
  }
  return false
}

win32.sync(同步)

isexe-3.1.1/src/win32.ts

const sync = (
  path: string,
  options: IsexeOptions = {}
): boolean => {
  
  const { ignoreErrors = false } = options
  try {
    return checkStat(statSync(path), path, options)
    
  } catch (e) {
    const er = e as NodeJS.ErrnoException
    if (ignoreErrors || er.code === 'EACCES') return false
    throw er
  }
}

React Native 中 Styled Components 配置指南

React Native 中 Styled Components 配置指南

什么是 Styled Components?

Styled Components 是一个 CSS-in-JS 库,让你可以在 JavaScript/TypeScript 代码中编写样式,并将样式与组件紧密结合。

核心特性

1. CSS-in-JS

// 传统方式
const styles = StyleSheet.create({
  container: { padding: 16 }
});

// Styled Components 方式
const Container = styled.View`
  padding: 16px;
`;

2. 自动样式隔离 每个 styled component 都有唯一的 class 名,避免样式冲突:

const Button = styled.TouchableOpacity`...`;
// 生成类似:.Button-asdf1234 { ... }

3. 主题支持 内置主题系统,轻松实现深色/浅色主题:

const Title = styled.Text`
  color: ${props => props.theme.colors.text};
`;

4. 动态样式 基于 props 动态改变样式:

const Button = styled.TouchableOpacity<{ variant: 'primary' | 'secondary' }>`
  background-color: ${props =>
    props.variant === 'primary' ? '#007AFF' : '#5856D6'};
`;

优势对比

特性 StyleSheet Styled Components
样式隔离 ❌ 需要手动管理 ✅ 自动隔离
主题支持 ❌ 需要额外配置 ✅ 内置支持
动态样式 ⚠️ 条件语句复杂 ✅ 简洁直观
TypeScript ✅ 支持 ✅ 完整类型推断
样式复用 ⚠️ 需要手动合并 ✅ 继承机制
组件封装 ❌ 样式和组件分离 ✅ 样式与组件一体

如何配置 Styled Components

第一步:安装依赖

# 安装 styled-components
yarn add styled-components

# 安装类型定义和 Babel 插件
yarn add -D @types/styled-components babel-plugin-styled-components

依赖说明

  • styled-components: 核心库
  • @types/styled-components: TypeScript 类型定义
  • babel-plugin-styled-components: 优化开发体验和性能

第二步:配置 Babel

编辑 babel.config.js

module.exports = {
  presets: ['module:@react-native/babel-preset'],
  plugins: [
    // ... 其他插件
    [
      'babel-plugin-styled-components',
      {
        displayName: true,              // 开发模式下显示组件名
        meaninglessFileNames: ["index", "styles"],
        pure: true,                     // 移除不必要的辅助代码
      },
    ]
  ],
};

配置说明

  • displayName: true - 开发时在 React DevTools 中显示组件名称
  • meaninglessFileNames - 忽略这些文件名,不生成 class 名
  • pure: true - 启用 tree-shaking 优化

第三步:配置 TypeScript 类型

创建 app/types/styled-components-native.d.ts

import 'styled-components/native';

declare module 'styled-components/native' {
  // 主题模式类型
  type ThemeModeType = 'dark' | 'light';

  // 间距类型
  type SpacingType = {
    xs: number;
    sm: number;
    md: number;
    lg: number;
    xl: number;
    xxl: number;
    screenPadding: number;
    cardPadding: number;
    inputPadding: number;
    negSm: number;
    negMd: number;
    negLg: number;
  };

  // 字体类型
  type FontSizeType = {
    xs: number;
    sm: number;
    base: number;
    lg: number;
    xl: number;
    xxl: number;
    xxxl: number;
  };

  type FontWeightType = {
    regular: number;
    medium: number;
    semibold: number;
    bold: number;
  };

  type TypographyType = {
    fontSize: FontSizeType;
    fontWeight: FontWeightType;
  };

  // 颜色类型
  type ColorsType = {
    primary: string;
    secondary: string;
    background: string;
    text: string;
    textWhite: string;
    success: string;
    warning: string;
    error: string;
    info: string;
    border: string;
    overlay: string;
    transparent: string;
  };

  // 主题接口
  export interface DefaultTheme {
    mode: ThemeModeType;
    colors: ColorsType;
    spacing: SpacingType;
    typography: TypographyType;
  }
}

第四步:配置路径别名

更新 babel.config.jstsconfig.json 中的别名配置:

babel.config.js

module.exports = {
  plugins: [
    [
      'module-resolver',
      {
        root: ['./app'],
        alias: {
          '@': './app',
          '@providers': './app/providers',
          // ... 其他别名
        },
      },
    ],
  ],
};

tsconfig.json

{
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {
      "@providers": ["app/providers"],
      "@providers/*": ["app/providers/*"]
    }
  }
}

第五步:创建主题系统

1. 主题结构

创建以下文件结构:

app/styles/theme/
├── custom/
│   ├── spacing.ts      # 间距系统
│   └── typography.ts   # 字体系统
├── dark/
│   └── index.ts        # 深色主题颜色
├── light/
│   └── index.ts        # 浅色主题颜色
└── index.tsx           # 主题生成器
2. 定义间距系统

app/styles/theme/custom/spacing.ts

export const spacing = {
  // 基础间距(4px 基准)
  xs: 4,
  sm: 8,
  md: 16,
  lg: 24,
  xl: 32,
  xxl: 48,

  // 特殊间距
  screenPadding: 16,
  cardPadding: 16,
  inputPadding: 12,

  // 负间距
  negSm: -8,
  negMd: -16,
  negLg: -24,
} as const;

export type Spacing = typeof spacing;
3. 定义字体系统

app/styles/theme/custom/typography.ts

export const typography = {
  fontSize: {
    xs: 12,
    sm: 14,
    base: 16,
    lg: 18,
    xl: 20,
    xxl: 24,
    xxxl: 32,
  },
  fontWeight: {
    regular: 400,
    medium: 500,
    semibold: 600,
    bold: 700,
  },
} as const;

export type Typography = typeof typography;
4. 定义颜色

app/styles/theme/light/index.ts

import { ColorsType } from "styled-components/native";

const colors: ColorsType = {
  primary: '#007AFF',
  secondary: '#5856D6',
  background: '#FFFFFF',
  text: '#000000',
  textWhite: '#FFFFFF',
  success: '#34C759',
  warning: '#FF9500',
  error: '#FF3B30',
  info: '#5AC8FA',
  border: '#C6C6C8',
  overlay: 'rgba(0, 0, 0, 0.5)',
  transparent: 'transparent'
};

export { colors };

app/styles/theme/dark/index.ts

import { ColorsType } from "styled-components/native";

const colors: ColorsType = {
  primary: '#0A84FF',
  secondary: '#5E5CE6',
  background: '#121212',
  text: '#FFFFFF',
  textWhite: '#FFFFFF',
  success: '#32D74B',
  warning: '#FF9F0A',
  error: '#FF453A',
  info: '#64D2FF',
  border: '#3A3A3C',
  overlay: 'rgba(0, 0, 0, 0.7)',
  transparent: 'transparent'
};

export { colors };
5. 创建主题生成器

app/styles/theme/index.tsx

import { DefaultTheme, ThemeModeType } from 'styled-components/native';
import { colors as darkColor } from './dark';
import { colors as lightColor } from './light';
import { spacing } from './custom/spacing';
import { typography } from './custom/typography';

const getTheme: (type: ThemeModeType) => DefaultTheme = type => {
  const theme = type === 'dark' ? darkColor : lightColor;
  return {
    mode: type,
    spacing,
    typography,
    colors: theme,
  };
};

export { getTheme };

第六步:创建 ThemeProvider

app/providers/ThemeProvider/index.tsx

import { getTheme } from '@/styles';
import { createContext, PropsWithChildren, useCallback, useState } from 'react';
import { useColorScheme } from 'react-native';
import {
  DefaultTheme,
  ThemeModeType,
  ThemeProvider as StyledThemeProvider,
} from 'styled-components/native';

// Context 类型定义
type ContextProps = {
  mode: ThemeModeType;
  theme: DefaultTheme;
  toggleTheme: () => void;
};

// 默认主题
const defaultTheme: ContextProps = {
  mode: 'light',
  theme: getTheme('light'),
  toggleTheme: () => {},
};

// 创建 Context
export const ThemeContext = createContext<ContextProps>(defaultTheme);

// ThemeProvider 组件
export const ThemeProvider = ({ children }: PropsWithChildren) => {
  const isDarkMode = useColorScheme() === 'dark';
  const [mode, setMode] = useState<ThemeModeType>(isDarkMode ? 'dark' : 'light');

  // 切换主题函数
  const toggleTheme = useCallback(() => {
    setMode(prev => (prev === 'light' ? 'dark' : 'light'));
  }, []);

  const theme = getTheme(mode);

  return (
    <ThemeContext.Provider value={{ mode, theme, toggleTheme }}>
      <StyledThemeProvider theme={theme}>
        {children}
      </StyledThemeProvider>
    </ThemeContext.Provider>
  );
};

app/providers/index.ts

export { ThemeContext, ThemeProvider } from './ThemeProvider';

第七步:导出样式系统

app/styles/index.ts

// 主题 Design Tokens
export * from './theme';

// 通用样式
export * from './common';

第八步:验证配置

创建一个测试组件 app/index.tsx

import styled from 'styled-components/native';
import { ThemeProvider, ThemeContext } from '@providers';
import { useContext } from 'react';

const Container = styled.View`
  padding: ${props => props.theme.spacing.md}px;
  background-color: ${props => props.theme.colors.background};
`;

const Title = styled.Text`
  font-size: ${props => props.theme.typography.fontSize.xl}px;
  font-weight: ${props => props.theme.typography.fontWeight.bold};
  color: ${props => props.theme.colors.text};
`;

const Button = styled.TouchableOpacity`
  background-color: ${props => props.theme.colors.primary};
  padding: ${props => props.theme.spacing.md}px;
  border-radius: 8px;
  margin-top: ${props => props.theme.spacing.md}px;
`;

const ButtonText = styled.Text`
  color: ${props => props.theme.colors.textWhite};
  text-align: center;
`;

function App() {
  return (
    <ThemeProvider>
      <AppContent />
    </ThemeProvider>
  );
}

function AppContent() {
  const { toggleTheme, mode } = useContext(ThemeContext);

  return (
    <Container>
      <Title>Styled Components 配置成功!</Title>
      <Title>当前主题: {mode}</Title>
      <Button onPress={toggleTheme}>
        <ButtonText>切换主题</ButtonText>
      </Button>
    </Container>
  );
}

export default App;

第九步:重新构建

配置完成后,必须重新构建应用:

# 清理缓存并重启
yarn start --reset-cache

# 或者重新构建
# iOS
yarn ios

# Android
yarn android

配置检查清单

  • ✅ 安装了 styled-components
  • ✅ 安装了 @types/styled-components
  • ✅ 安装了 babel-plugin-styled-components
  • ✅ 配置了 babel.config.js
  • ✅ 创建了类型定义文件
  • ✅ 配置了路径别名(@providers
  • ✅ 创建了主题文件结构
  • ✅ 定义了间距系统
  • ✅ 定义了字体系统
  • ✅ 定义了颜色(深色/浅色)
  • ✅ 创建了主题生成器
  • ✅ 创建了 ThemeProvider
  • ✅ 导出了样式系统
  • ✅ 重新构建了应用

常见配置问题

1. TypeScript 类型错误

问题props.theme 报类型错误

解决

  • 确保 app/types/styled-components-native.d.ts 文件存在
  • 确保 DefaultTheme 接口定义了所有需要的字段
  • 重启 TypeScript 服务器(VSCode 中 Cmd+Shift+P -> "Restart TS Server")

2. 主题切换不生效

问题:点击切换主题,样式不变

检查

  1. 组件是否在 ThemeProvider 内部?
  2. 是否使用了 props.theme.colors.xxx 而不是硬编码颜色值?
  3. 是否重新构建了应用?

3. Babel 配置不生效

解决

  1. 清理缓存:yarn start --reset-cache
  2. 检查 babel.config.js 语法
  3. 重启 Metro bundler

4. 找不到模块 '@providers'

解决

  1. 检查 babel.config.jstsconfig.json 别名配置
  2. 确保路径正确:'./app/providers'
  3. 重启 TS 服务器

参考资源

【React-3/Lesson76(2025-12-18)】React Hooks 与函数式组件开发详解🧠

🧠在现代前端开发中,React 已经全面拥抱函数式编程范式。通过 Hooks,开发者可以在不编写 class 的情况下使用状态(state)和生命周期等特性。本文将深入解析你所接触的代码片段,并系统性地补充相关知识,涵盖 useStateuseEffect、纯函数、副作用、组件挂载/更新/卸载机制、响应式状态管理等核心概念。


🔁 useState:让函数组件拥有状态

在传统 React 中,只有类组件才能拥有状态(state)。而 useState Hook 的出现,彻底改变了这一限制。

const [num, setNum] = useState(0);

这行代码做了三件事:

  1. 声明一个名为 num 的状态变量;
  2. 提供一个名为 setNum 的函数用于更新该状态;
  3. 初始值为 0

✨ 初始化支持函数形式(惰性初始化)

当初始状态需要通过复杂计算获得时,可以传入一个初始化函数

const [num, setNum] = useState(() => {
  const num1 = 1 + 2;
  const num2 = 2 + 3;
  return num1 + num2; // 返回 6
});

⚠️ 注意:这个函数必须是同步的纯函数,不能包含异步操作(如 fetch),因为 React 需要确保状态的确定性和可预测性。

🔄 状态更新函数支持回调形式

更新状态时,可以传入一个函数,其参数是上一次的状态值:

setNum((prevNum) => {
  console.log(prevNum); // 打印旧值
  return prevNum + 1;   // 返回新值
});

这种方式在批量更新异步环境中特别安全,避免因闭包捕获旧状态而导致错误。


⚙️ useEffect:处理副作用的瑞士军刀

useEffect 是 React 中处理副作用(side effects)的核心 Hook。所谓“副作用”,是指那些不在纯函数范畴内的操作,例如:

  • 数据获取(如 API 请求)
  • 手动 DOM 操作
  • 订阅事件(如 WebSocket)
  • 启动定时器(setInterval / setTimeout

📌 基本用法

useEffect(() => {
  console.log('effect');
}, [num]);
  • 第一个参数:副作用函数(在渲染后执行)
  • 第二个参数:依赖数组(dependency array)

🔍 依赖项的三种情况

依赖项 行为 类比 Vue 生命周期
[](空数组) 仅在组件挂载后执行一次 onMounted
[a, b] ab 变化时重新执行 watch([a, b])
无依赖项(省略第二个参数) 每次渲染后都执行 onMounted + onUpdated

💡 在 React 18 的 <StrictMode> 下,开发环境会故意双次调用 useEffect(不含依赖或依赖为空时),以帮助开发者发现潜在的副作用问题(如未正确清理资源)。

🧹 清理副作用:返回清理函数

许多副作用需要在组件更新前或卸载时清理,否则会导致内存泄漏或重复订阅。

useEffect(() => {
  const timer = setInterval(() => {
    console.log(num);
  }, 1000);

  return () => {
    console.log('remove');
    clearInterval(timer); // 清理定时器
  };
}, [num]);
  • 返回的函数会在下一次 effect 执行前调用,或在组件卸载时调用。
  • 这利用了闭包机制:清理函数能访问到创建它时的 timer 变量。

✅ 最佳实践:所有开启的资源(定时器、订阅、监听器)都必须有对应的清理逻辑。


🧼 纯函数 vs 副作用

理解 useEffect 的设计哲学,必须先理解**纯函数(Pure Function)**的概念。

✅ 纯函数的特点

  • 相同输入 → 相同输出
  • 无副作用:不修改外部状态、不发起网络请求、不操作 DOM
  • 无随机性(如 Math.random()
// ✅ 纯函数
const add = (x, y) => x + y;

❌ 非纯函数(有副作用)

// ❌ 修改传入的数组(改变外部状态)
function add(nums) {
  nums.push(3); // 副作用!
  return nums.reduce((pre, cur) => pre + cur, 0);
}

React 组件本身应尽可能接近纯函数:props → JSX。但现实应用离不开副作用,因此 useEffect 被设计为隔离副作用的沙盒


🧩 组件生命周期在函数式组件中的映射

Class 组件生命周期 函数式组件(Hooks)
componentDidMount useEffect(() => {}, [])
componentDidUpdate useEffect(() => {}, [dep])
componentWillUnmount useEffect(() => { return () => {} }, [])

🔄 注意:useEffect 合并了挂载、更新、卸载三个阶段,通过依赖项和返回函数实现精细控制。


🏗️ 项目结构与入口分析

📄 main.jsx:应用入口

createRoot(document.getElementById('root')).render(<App />);
  • 使用 React 18 的 createRoot API(并发模式)
  • 渲染 <App />#root 容器
  • 注释掉的 <StrictMode> 是开发辅助工具,用于暴露潜在问题(如重复 effect)

🎨 样式文件 index.cssApp.css

  • 使用 CSS 自定义属性(:root)实现主题切换(亮色/暗色)
  • 响应式设计(min-width: 320px
  • 悬停动画、焦点样式等增强用户体验

🔍 深入 Demo.jsx:副作用与清理

export default function Demo() {
  useEffect(() => {
    console.log('123123'); // 模拟 onMounted
    const timer = setInterval(() => {
      console.log('timer');
    }, 1000);

    return () => {
      console.log('remove');
      clearInterval(timer);
    };
  }, []); // 仅挂载时执行

  return <div>偶数Demo</div>;
}
  • 即使 Demo 组件被多次渲染(因父组件 App 更新),由于依赖项为空,定时器只创建一次
  • App 卸载 Demo(如条件渲染切换),清理函数会执行,防止内存泄漏

📊 状态驱动 UI:响应式核心

App.jsx 中:

{num % 2 == 0 ? '偶数' : '奇数'}

这体现了 React 的核心思想:UI 是状态的函数
每当 num 变化,React 会重新执行组件函数,生成新的 JSX,然后高效地更新 DOM。


🚫 为什么不能在 useState 中直接异步初始化?

// ❌ 错误!useState 不支持异步初始化
const [data, setData] = useState(async () => {
  const res = await fetch(...);
  return res.json();
});

原因:

  • React 需要同步确定初始状态,以便进行协调(reconciliation)
  • 异步操作结果不确定,破坏纯函数原则

✅ 正确做法:在 useEffect 中请求数据

useEffect(() => {
  queryData().then(data => setNum(data));
}, []);

其中 queryData 是一个模拟异步请求的函数(见 App.jsx):

async function queryData() {
  const data = await new Promise((resolve) => {
    setTimeout(() => resolve(666), 2000);
  });
  return data;
}

🧪 开发者工具与调试技巧

  • 利用 console.log 观察 effect 执行时机
  • 注意 Strict Mode 下的双次调用(仅开发环境)
  • 使用 React DevTools 检查组件状态和依赖

✅ 总结:React Hooks 最佳实践

  1. 状态管理:用 useState 声明响应式状态,更新时优先使用回调形式
  2. 副作用隔离:所有非纯操作放入 useEffect
  3. 依赖声明:精确列出 effect 所依赖的所有变量(ESLint 插件可自动检测)
  4. 资源清理:务必在 effect 中返回清理函数
  5. 避免异步初始化:数据请求放在 useEffect
  6. 理解闭包:effect 和清理函数通过闭包捕获变量,注意 stale closure 问题(可通过 ref 解决)

通过以上详尽解析,你应该已经掌握了 React Hooks 的核心机制与工程实践。记住:函数式组件 + Hooks = 现代 React 开发的黄金标准。继续深入,你将能构建出高性能、可维护、可预测的前端应用!🚀

# Flutter Dio 网络请求库使用教程

Dio 是 Flutter 中最强大、最流行的 Dart HTTP 客户端库,提供了拦截器、全局配置、FormData、文件上传/下载、请求取消、超时等高级功能。

1. 安装与初始化

1.1 添加依赖

pubspec.yaml 文件中添加 dio 依赖:

dependencies:
  dio: ^5.4.1 # 请使用最新版本

运行 flutter pub get 安装依赖。

1.2 创建 Dio 实例

import 'package:dio/dio.dart';

// 方法一:创建实例时配置
Dio dio = Dio(
  BaseOptions(
    baseUrl: "https://api.example.com",
    connectTimeout: Duration(seconds: 5),
    receiveTimeout: Duration(seconds: 3),
    headers: {
      'Content-Type': 'application/json',
    },
  ),
);

// 方法二:创建后配置
Dio dio = Dio();
void configureDio() {
  dio.options.baseUrl = 'https://api.example.com';
  dio.options.connectTimeout = Duration(seconds: 5);
  dio.options.receiveTimeout = Duration(seconds: 3);
}

建议:在项目中通常使用单例模式管理 Dio 实例。

2. 发起 HTTP 请求

2.1 GET 请求

try {
  // 方式一:查询参数拼接在URL中
  Response response = await dio.get("/user?id=123");
  print(response.data);
  
  // 方式二:使用 queryParameters 参数(推荐)
  Response response2 = await dio.get(
    "/test",
    queryParameters: {'id': 12, 'name': 'dio'},
  );
  print(response2.data.toString());
} on DioException catch (e) {
  print(e.message);
}

2.2 POST 请求

try {
  // 发送 JSON 数据
  Response response = await dio.post(
    "/user",
    data: {'name': 'John', 'age': 25},
  );
  
  // 发送 FormData
  FormData formData = FormData.fromMap({
    'name': 'dio',
    'date': DateTime.now().toIso8601String(),
  });
  Response formResponse = await dio.post('/info', data: formData);
  
  print(response.data);
} on DioException catch (e) {
  print(e.message);
}

2.3 其他请求方法

// PUT 请求 - 更新资源
await dio.put("/user/123", data: {"name": "john doe"});

// DELETE 请求 - 删除资源
await dio.delete("/user/123");

// PATCH 请求 - 部分更新资源
await dio.patch("/user/123", data: {"name": "johnny"});

// HEAD 请求 - 获取头部信息
Response headResponse = await dio.head("/user/123");
print(headResponse.headers);

// OPTIONS 请求 - 获取通信选项
Response optionsResponse = await dio.options("/user/123");

3. 响应处理

3.1 响应数据结构

Response response = await dio.get('https://api.example.com/user');

print(response.data);       // 响应体(可能已被转换)
print(response.statusCode); // 状态码
print(response.headers);    // 响应头
print(response.requestOptions); // 请求信息
print(response.statusMessage); // 状态消息

// 获取流式响应
final streamResponse = await dio.get(
  url,
  options: Options(responseType: ResponseType.stream),
);
print(streamResponse.data.stream);

// 获取字节响应
final bytesResponse = await dio.get<List<int>>(
  url,
  options: Options(responseType: ResponseType.bytes),
);
print(bytesResponse.data); // List<int>

3.2 与 Flutter UI 集成

import 'package:flutter/material.dart';

class UserList extends StatelessWidget {
  Future<List<User>> fetchUsers() async {
    final response = await dio.get('/users');
    List<dynamic> jsonList = response.data;
    return jsonList.map((json) => User.fromJson(json)).toList();
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<List<User>>(
      future: fetchUsers(),
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return CircularProgressIndicator();
        } else if (snapshot.hasError) {
          return Text('Error: ${snapshot.error}');
        } else {
          return ListView.builder(
            itemCount: snapshot.data!.length,
            itemBuilder: (context, index) {
              User user = snapshot.data![index];
              return ListTile(
                title: Text(user.name),
                subtitle: Text(user.email),
              );
            },
          );
        }
      },
    );
  }
}

4. 错误处理

4.1 DioException 类型(新版本)

Dio 5.x 使用 DioException 替代旧的 DioError

try {
  Response response = await dio.get("/user?id=123");
} on DioException catch (e) {
  switch (e.type) {
    case DioExceptionType.connectionTimeout:
      print('连接超时');
      break;
    case DioExceptionType.sendTimeout:
      print('发送超时');
      break;
    case DioExceptionType.receiveTimeout:
      print('接收超时');
      break;
    case DioExceptionType.badResponse:
      print('服务器错误,状态码:${e.response?.statusCode}');
      print('响应数据:${e.response?.data}');
      break;
    case DioExceptionType.cancel:
      print('请求被取消');
      break;
    case DioExceptionType.connectionError:
      print('连接错误,请检查网络');
      break;
    case DioExceptionType.badCertificate:
      print('证书验证失败');
      break;
    case DioExceptionType.unknown:
    default:
      print('未知错误: ${e.message}');
      break;
  }
}

4.2 错误类型说明

  • connectionTimeout:连接服务器超时
  • sendTimeout:数据发送超时
  • receiveTimeout:接收响应超时
  • badResponse:服务器返回错误状态码(4xx、5xx)
  • cancel:请求被取消
  • connectionError:网络连接问题
  • badCertificate:HTTPS 证书验证失败
  • unknown:其他未知错误

5. 拦截器(Interceptors)

拦截器是 Dio 最强大的功能之一,允许在请求/响应流程中插入处理逻辑。

5.1 基础拦截器

dio.interceptors.add(
  InterceptorsWrapper(
    onRequest: (RequestOptions options, RequestInterceptorHandler handler) {
      // 请求前处理
      print('发送请求: ${options.uri}');
      
      // 添加认证token
      options.headers['Authorization'] = 'Bearer your_token_here';
      
      return handler.next(options); // 继续请求
    },
    
    onResponse: (Response response, ResponseInterceptorHandler handler) {
      // 响应后处理
      print('收到响应: ${response.statusCode}');
      return handler.next(response);
    },
    
    onError: (DioException error, ErrorInterceptorHandler handler) {
      // 错误处理
      print('请求错误: ${error.type}');
      return handler.next(error);
    },
  ),
);

5.2 实用拦截器示例

// 1. 日志拦截器
class LoggingInterceptor extends Interceptor {
  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    print('REQUEST[${options.method}] => PATH: ${options.path}');
    print('Headers: ${options.headers}');
    if (options.data != null) {
      print('Body: ${options.data}');
    }
    super.onRequest(options, handler);
  }
  
  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    print('RESPONSE[${response.statusCode}] => PATH: ${response.requestOptions.path}');
    print('Data: ${response.data}');
    super.onResponse(response, handler);
  }
}

// 2. Token 刷新拦截器
class TokenRefreshInterceptor extends Interceptor {
  final Dio _tokenDio = Dio();
  bool _isRefreshing = false;
  
  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode == 401 && !_isRefreshing) {
      _isRefreshing = true;
      try {
        // 刷新token
        await refreshToken();
        
        // 重试原始请求
        final response = await dio.request(
          err.requestOptions.path,
          data: err.requestOptions.data,
          queryParameters: err.requestOptions.queryParameters,
          options: Options(
            method: err.requestOptions.method,
            headers: err.requestOptions.headers,
          ),
        );
        handler.resolve(response);
      } catch (e) {
        handler.reject(err);
      } finally {
        _isRefreshing = false;
      }
    } else {
      handler.next(err);
    }
  }
}

6. 文件上传与下载

6.1 单文件上传

FormData formData = FormData.fromMap({
  'name': '文件名',
  'file': await MultipartFile.fromFile(
    './text.txt', 
    filename: 'upload.txt',
  ),
});

Response response = await dio.post(
  '/upload',
  data: formData,
  onSendProgress: (int sent, int total) {
    print('上传进度: $sent / $total');
  },
);

6.2 多文件上传

FormData formData = FormData.fromMap({
  'name': 'dio',
  'files': [
    await MultipartFile.fromFile('./text1.txt', filename: 'text1.txt'),
    await MultipartFile.fromFile('./text2.txt', filename: 'text2.txt'),
    await MultipartFile.fromFile('./text3.txt', filename: 'text3.txt'),
  ]
});

Response response = await dio.post('/upload-multiple', data: formData);

6.3 文件下载

// 获取应用临时目录
import 'package:path_provider/path_provider.dart';

void downloadFile() async {
  // 获取存储路径
  Directory tempDir = await getTemporaryDirectory();
  String savePath = '${tempDir.path}/filename.pdf';
  
  CancelToken cancelToken = CancelToken();
  
  try {
    await dio.download(
      'https://example.com/file.pdf',
      savePath,
      onReceiveProgress: (received, total) {
        if (total != -1) {
          double progress = (received / total) * 100;
          print('下载进度: ${progress.toStringAsFixed(2)}%');
        }
      },
      cancelToken: cancelToken,
      deleteOnError: true, // 下载出错时删除部分文件
    );
    print('下载完成: $savePath');
  } on DioException catch (e) {
    if (CancelToken.isCancel(e)) {
      print('下载已取消');
    } else {
      print('下载失败: ${e.message}');
    }
  }
}

// 取消下载
void cancelDownload() {
  cancelToken.cancel('用户取消下载');
}

7. 高级配置

7.1 请求选项(Options)

Response response = await dio.get(
  '/data',
  options: Options(
    headers: {'custom-header': 'value'},
    responseType: ResponseType.json,
    contentType: 'application/json',
    sendTimeout: Duration(seconds: 10),
    receiveTimeout: Duration(seconds: 10),
    extra: {'custom_info': '可以后续在拦截器中获取'}, // 自定义字段
    validateStatus: (status) {
      // 自定义状态码验证逻辑
      return status! < 500; // 只认为500以下的状态码是成功的
    },
  ),
);

7.2 请求取消

CancelToken cancelToken = CancelToken();

// 发起可取消的请求
Future<void> fetchData() async {
  try {
    Response response = await dio.get(
      '/large-data',
      cancelToken: cancelToken,
    );
    print(response.data);
  } on DioException catch (e) {
    if (CancelToken.isCancel(e)) {
      print('请求被取消');
    }
  }
}

// 取消请求
void cancelRequest() {
  cancelToken.cancel('用户取消操作');
}

// 在组件销毁时取消请求(防止内存泄漏)
@override
void dispose() {
  cancelToken.cancel('组件销毁');
  super.dispose();
}

7.3 并发请求

// 同时发起多个请求
Future<void> fetchMultipleData() async {
  try {
    List<Response> responses = await Future.wait([
      dio.get('/user/1'),
      dio.get('/user/2'),
      dio.get('/user/3'),
    ]);
    
    for (var response in responses) {
      print('用户数据: ${response.data}');
    }
  } on DioException catch (e) {
    print('请求失败: ${e.message}');
  }
}

8. 项目实战:封装 Dio 服务

8.1 基础封装示例

import 'package:dio/dio.dart';

class HttpService {
  static final HttpService _instance = HttpService._internal();
  late Dio _dio;
  
  factory HttpService() => _instance;
  
  HttpService._internal() {
    _dio = Dio(BaseOptions(
      baseUrl: 'https://api.example.com',
      connectTimeout: Duration(seconds: 10),
      receiveTimeout: Duration(seconds: 10),
      headers: {'Content-Type': 'application/json'},
    ));
    
    // 添加拦截器
    _dio.interceptors.add(LoggingInterceptor());
    _dio.interceptors.add(TokenInterceptor());
  }
  
  // GET 请求
  Future<Response> get(String path, {Map<String, dynamic>? queryParams}) async {
    try {
      return await _dio.get(
        path,
        queryParameters: queryParams,
      );
    } on DioException catch (e) {
      _handleError(e);
      rethrow;
    }
  }
  
  // POST 请求
  Future<Response> post(String path, {dynamic data}) async {
    try {
      return await _dio.post(path, data: data);
    } on DioException catch (e) {
      _handleError(e);
      rethrow;
    }
  }
  
  // 错误处理
  void _handleError(DioException e) {
    switch (e.type) {
      case DioExceptionType.connectionTimeout:
        throw Exception('连接超时,请检查网络');
      case DioExceptionType.badResponse:
        if (e.response?.statusCode == 401) {
          throw Exception('身份验证失败,请重新登录');
        } else if (e.response?.statusCode == 404) {
          throw Exception('请求的资源不存在');
        } else {
          throw Exception('服务器错误: ${e.response?.statusCode}');
        }
      case DioExceptionType.connectionError:
        throw Exception('网络连接失败,请检查网络设置');
      default:
        throw Exception('网络请求失败: ${e.message}');
    }
  }
}

// 使用示例
final http = HttpService();
User user = await http.get('/user/1');

8.2 结合状态管理的完整示例

// api_service.dart
class ApiService {
  final Dio _dio;
  
  ApiService({required String baseUrl}) 
    : _dio = Dio(BaseOptions(baseUrl: baseUrl)) {
    _setupInterceptors();
  }
  
  void _setupInterceptors() {
    _dio.interceptors.add(InterceptorsWrapper(
      onRequest: (options, handler) {
        // 从本地存储获取token
        final token = StorageService().getToken();
        if (token != null) {
          options.headers['Authorization'] = 'Bearer $token';
        }
        return handler.next(options);
      },
    ));
  }
  
  Future<T> request<T>(
    String path, {
    String method = 'GET',
    dynamic data,
    Map<String, dynamic>? queryParameters,
    CancelToken? cancelToken,
  }) async {
    try {
      final response = await _dio.request(
        path,
        data: data,
        queryParameters: queryParameters,
        options: Options(method: method),
        cancelToken: cancelToken,
      );
      
      // 使用 json_serializable 解析数据
      return _parseResponse<T>(response.data);
    } on DioException catch (e) {
      throw ApiException.fromDioException(e);
    }
  }
}

// 使用 GetX 控制器调用
class UserController extends GetxController {
  final ApiService apiService;
  var users = <User>[].obs;
  var isLoading = false.obs;
  
  UserController(this.apiService);
  
  Future<void> fetchUsers() async {
    isLoading.value = true;
    try {
      final userList = await apiService.request<List<User>>('/users');
      users.assignAll(userList);
    } on ApiException catch (e) {
      Get.snackbar('错误', e.message);
    } finally {
      isLoading.value = false;
    }
  }
}

9. 最佳实践与注意事项

  1. 单例模式:在整个应用中使用单个 Dio 实例,确保配置一致
  2. 环境区分:为开发、测试、生产环境配置不同的 baseURL
  3. 安全存储:敏感信息(如 API Keys)不要硬编码在代码中
  4. 证书验证:生产环境不要忽略 SSL 证书验证
  5. 内存管理:及时取消不再需要的请求,特别是在页面销毁时
  6. 错误重试:对特定错误(如网络波动)实现重试机制
  7. 响应缓存:对不常变的数据实现缓存策略,减少网络请求
  8. 进度反馈:长时间操作(上传/下载)提供进度提示

10. 扩展资源

  • 官方文档pub.dev/packages/di…
  • GitHub仓库github.com/cfug/dio
  • Awesome Dio:官方维护的插件和工具列表
  • JSON序列化:配合 json_serializable 处理复杂数据结构
  • 状态管理:与 GetX、Provider、Riverpod 等状态管理库结合使用

这份教程涵盖了 Dio 的核心功能和实际应用场景。建议从基础请求开始,逐步掌握拦截器、错误处理等高级特性,最后根据项目需求进行适当的封装。在实际开发中,合理的封装可以显著提高代码的可维护性和开发效率。

Three.js 色彩空间的正确使用方式

three中色彩空间常见用处

// 给材质设置色彩空间
material1.map.colorSpace = THREE.SRGBColorSpace;

// 给渲染器的输出色彩空间, 不设置的话默认值也是SRGBColorSpace
new THREE.WebGLRenderer( { outputColorSpace: THREE.SRGBColorSpace } );

three.js r152+ 之后默认就是 SRGBColorSpace,老版本(outputEncoding 时代)行为不同

色彩空间的选项

  • SRGBColorSpace-sRGB 色彩空间

  • LinearSRGBColorSpace-线性sRGB色彩空间

区别?

SRGBColorSpace进行了伽马校正

为什么会有伽马校正?

  1. 纠正硬件的问题

在液晶显示器普及之前,使用的是笨重的 CRT (阴影 栅格 显像管 电视。CRT 的工作原理是用电子枪射出电子束轰击屏幕,科学家发现,电子枪的电压值和屏幕产生的亮度之间并不是 1:1 的线性关系,而是一个幂函数关系:

也就是如下图中红色曲线所示,跟原本蓝色虚线比较,亮度是偏低的

所以为了还原真实效果,抵消调 CRT压低的亮度,那就把真实亮度数据提高,提高成绿色曲线那样,这样一抵消,显示就正常了,这个提高的过程就是伽马校正

  1. 也能满足存储空间合理分配

  • 人眼特性:我们对暗部的变化非常敏感,而对亮部的变化比较迟钝。

  • 数据分配的矛盾:如果我们在电脑里用“线性”方式存储亮度(比如 0 代表黑,128 代表半亮,255 代表全亮):

    • 在 0 到 10 之间(暗部),只有 10 个档位。因为我们眼睛太敏感,这 10 个档位之间的跳变看起来会像阶梯一样,非常不自然(这就是“色彩断层”)。
    • 在 200 到 250 之间(亮部),虽然有 50 个档位,但我们的眼睛根本分不出这 50 种亮度的区别。这部分昂贵的存储空间(位深)就被浪费了。
  • 解决方案(伽马编码) : 故意把 256 个档位中的大部分都分给“暗部”,只留少部分给“亮部”。这样既照顾了人眼的敏感度,又没有浪费存储空间。这样我们可以在 8 位(0-255)的空间里,把更多数值分配给敏感的暗部,让有限的资源发挥最大效用。如图所示(随便找一个以前的暗部区域值,映射后占居的区域明显变多)

为什么现在屏幕“正常”了,还需要它?

现在的液晶(LCD)或 OLED 屏幕完全可以做到“给 128 就亮 50%”,为什么还要折腾?

行业标准的惯性

全球互联网上 99% 的图片(JPEG)、视频(MP4)和网页标准(HTML/CSS)都是基于 sRGB 色彩空间存储的

  • 如果显示器突然改为“线性显示”,那么所有的互联网内容看起来都会变得非常亮

  • 并且图片大多还是如图所示8位,需要上面说过的满足存储空间并合理分配

正确色彩空间处理的流程

  1. 原始图片 默认是 sRGB 色彩空间,它自带一条 “上翘” 的伽马曲线

  2. 转成 线性空间 :在进入 GPU 运算前,需要先把图片从 sRGB 非线性空间转换为 Linear(线性)sRGB 空间。这一步会把上翘的曲线 “拉平” 成一条直线,让亮度数据恢复成物理上均匀的数值,确保后续的光照、混合等计算结果是准确的。

  3. 程序运算 :在线性空间里进行渲染计算,比如光影追踪、材质混合、特效合成等。因为线性空间的亮度是均匀的,所以计算出来的光影效果才符合物理规律,不会出现颜色偏差或暗部丢失。

  4. 渲染结果 :对计算结果,经过伽马校正后,得到的就是最终的 sRGB 格式渲染结果,它的亮度曲线和原始图片的格式是一致的。

  5. 显示器显示 🖥️显示器接收到 sRGB 信号后,会用它自带的伽马曲线(通常 γ≈2.2)来显示,这个过程会把信号 “压暗”。因为我们已经提前做了伽马校正,所以两次曲线变化刚好抵消,最终显示在屏幕上的亮度就和我们计算的结果完全一致。

  6. 人眼感知 👀最终画面被人眼看到,色彩和亮度都保持了设计和计算时的真实效果,不会出现过暗或过亮的问题。

总结

也就是我们要保证用来计算的时候是 Linear( 线性空间,用来渲染的时候是sRGB 空间,那在three中如何做到?

Three.js从输入的角度

Three.js中我们只需要指定 色彩空间 类型即可,程序会帮我们转成线性,所以我们要做的就是把应该指定为SRGBColorSpace的纹理,指定为SRGBColorSpace

举几种常见加载器对加载后的图片色彩空间的处理逻辑

TextureLoader

TextureLoader 不设置 colorSpace,保持默认 NoColorSpace,需要手动设置:

注意!颜色纹理需要手动指定色彩空间为SRGBColorSpace,像下文GLTFLoader中的逻辑一样,

例如

const texture = await loader.loadAsync( 'textures/land_ocean_ice_cloud_2048.jpg' );
texture.colorSpace = THREE.SRGBColorSpace;

CubeTextureLoader

CubeTextureLoader 固定设置为 SRGBColorSpace:

GLTFLoader

只有颜色纹理会被设置为 SRGBColorSpace,其他纹理保持 NoColorSpace:

设置为 SRGBColorSpace 的纹理:

  • baseColorTexture (map) → SRGBColorSpace

  • emissiveTexture (emissiveMap) → SRGBColorSpace

  • sheenColorTexture (sheenColorMap) → SRGBColorSpace

  • specularColorTexture (specularColorMap) → SRGBColorSpace

这几种色彩空间标记的处理逻辑

exture.colorSpace 内部格式 sRGB → Linear 转换
NoColorSpace(默认) RGBA8 不转换,原样上传
SRGBColorSpace SRGB8_ALPHA8 GPU 采样时自动转 SRGB8_ALPHA8 是 WebGL 2.0 的 sRGB 纹理格式 GPU 在采样时自动应用 sRGB EOTF(Electro-Optical Transfer Function)将 sRGB 转为线性
LinearSRGBColorSpace RGBA8 不转换,已是线性

也提供了方法可以手动转化,THREE.Color 类下即可调用

src/math/ColorManagement.js

export function SRGBToLinear( c ) {

    return ( c < 0.04045 ) ? c * 0.0773993808 : Math.pow( c * 0.9478672986 + 0.0521327014, 2.4 );

}

export function LinearToSRGB( c ) {

    return ( c < 0.0031308 ) ? c * 12.92 : 1.055 * ( Math.pow( c, 0.41666 ) ) - 0.055;

}

Three.js中-从输出的角度

threejs会在

  • MeshBasicMaterial
  • MeshPhysicalMaterial
  • MeshPhongMaterial
  • MeshLambertMaterial
  • MeshToonMaterial
  • MeshMatcapMaterial
  • SpriteMaterial
  • PointsMaterial 等等

材质输出的时候增加这样一段代码

src/renderers/shaders/ShaderChunk/colorspace_fragment.glsl.js

 gl_FragColor = linearToOutputTexel( gl_FragColor );

linearToOutputTexel函数会根据outputColorSpace来动态配置

  getTexelEncodingFunction( 'linearToOutputTexel', parameters.outputColorSpace ),

说白了就是输出的时候会跟你设置的outputColorSpace来判断需不需要转成SRGBColorSpace,默认是转成SRGBColorSpace

我们自己写的ShaderMaterial输出的时候怎么办

我们也可以调用linearToOutputTexel

因为linearToOutputTexel

  • 注入时机:在 WebGLProgram 构造函数中构建 prefixFragment 时

  • 注入方式:通过 getTexelEncodingFunction 动态生成函数代码,添加到 prefixFragment

  • 可用性:所有非 RawShaderMaterial 的材质(包括 ShaderMaterial)都会自动注入

总结

在 three.js 中,默认的色彩空间配置已经覆盖了大多数使用场景。只要遵循颜色纹理使用 sRGB、渲染计算在 线性空间 、输出再转回 sRGB这一基本原则,画面通常就是正确的。但是理解色彩空间与伽马校正的原理,才能在自定义 Shader、特殊纹理或渲染需求出现时,有意识地手动调整配置,而不是盲目试参数

一文吃透React核心:从问题到流程全解析

很多人学React时,先被JSX、Hooks、Fiber这些概念绕晕,越学越觉得“抽象”。其实核心不是记API,而是搞懂React到底在解决什么问题、按什么逻辑运行。这篇文章从底层逻辑出发,把React的核心原理拆成大白话,不管是面试还是实际开发,都能帮你打通任督二脉。

一、React 到底在解决什么问题?

先抛掉“React 是个库”这种废话

很多入门教程一上来就说“React是一个用于构建用户界面的JavaScript库”,这句话没错但毫无意义。React真正的价值,是解决了前端开发中“UI更新混乱、性能拉胯”的核心痛点,具体落地成三件事:

  1. 用 JS 描述 UI(声明式):不用手动操作DOM,只告诉React“我要什么样的UI”,而非“怎么做出这个UI”;

  2. 高效计算 UI 的变化(Diff + Fiber):页面更新时,不盲目重绘整个DOM,只找变化的部分;

  3. 可控地、分优先级地更新视图(Scheduler):优先响应用户交互(比如打字、点击),再处理耗时的渲染任务,避免页面卡顿。

一句话总结(记死,面试直接用):

React = UI 描述 + 更新计算 + 调度执行

这三个部分环环相扣,构成了React的核心骨架。

二、React 的整体运行流程(全局鸟瞰)

先给你一张“口述流程图”(面试版)

React的运行流程本质是“从描述到执行”的过程,用极简流程图概括如下,面试时能流畅说出来,基本就能碾压一半候选人:


JSX
↓
ReactElement(UI 描述)
↓
Fiber Tree(工作单元)
↓
Render 阶段(计算差异)
↓
Commit 阶段(更新 DOM)

再记一句核心口诀,帮你分清各阶段角色:

JSX 只是描述,Fiber 才是执行单位,DOM 更新只发生在 commit 阶段

这句话能帮你避开很多认知误区,比如“JSX直接生成DOM”“Fiber就是虚拟DOM”这类错误理解。

三、从 JSX 说起(为什么不是直接操作 DOM)

很多人第一次写JSX时会疑惑:为什么不直接用document.createElement创建DOM?反而要多一层JSX转换?核心原因是“直接操作DOM成本太高,且难以维护”,而JSX本质是“UI描述的语法糖”。

示例:一段简单的JSX


function App() {
  return <h1>Hello</h1>
}

JSX 编译后 ≈ 原生JS调用

浏览器无法直接识别JSX,需要通过Babel等工具编译,编译后会转换成React.createElement方法的调用:


React.createElement(
  'h1',  // 标签类型
  null,  // 标签属性(这里无属性,传null)
  'Hello' // 子元素
)

编译后得到的是什么?

答案是 ReactElement(不是DOM)。它是一个普通的JavaScript对象,用来描述UI的结构,本质是一次“UI描述快照”,代码示意如下:


{
  $$typeof: Symbol(react.element), // 标记这是React元素
  type: 'h1', // 元素类型(标签名/组件)
  props: { children: 'Hello' }, // 元素属性(含子元素)
  // 还有key、ref等可选属性
}

关键理解点(面试常问)

ReactElement 是一次 UI 描述快照,类似:虚拟 DOM、配置对象、UI 蓝图

这里要重点区分:ReactElement是“结果描述”,不是“创建DOM的过程”。它就像一张建筑图纸,告诉你最终要建成什么样,但本身不是房子(DOM)。这样设计的好处是:用极低成本的JS对象替代高成本的DOM操作,后续计算差异时,只操作这些对象即可。

四、为什么需要 Fiber?(核心思想)

Fiber是React 16引入的核心机制,也是面试的高频难点。要理解Fiber,先搞懂它要解决的问题——React 15的性能瓶颈。

问题背景(面试官很爱问)

React 15 的问题
  • 采用递归diff算法遍历DOM树,计算UI差异;

  • 更新过程一旦开始,就无法中断,必须一次性执行完;

  • 如果要更新一个包含上千个节点的列表,递归遍历会占用主线程很久,导致用户交互(打字、点击)、动画等操作卡顿。

举个例子 🌰

setState(() => {
  // 更新一个 5000 项列表
})

假设用户此时正在输入框打字,React 15和React 16(Fiber)的表现完全不同:

  • React 15:不管用户输入,先把5000项列表的差异算完,主线程被占满,用户输入无响应,页面卡顿;

  • React Fiber:优先响应用户输入,暂停列表更新计算,等用户输入结束后,再恢复计算,页面流畅无卡顿。

Fiber 是什么?(一句话版)

Fiber 是一个 可中断、可恢复、带优先级的工作单元

这里要纠正一个常见误区:Fiber ≠ 虚拟DOM。虚拟DOM是“UI描述”,而Fiber是“执行单位”,两者本质不同,用表格对比更清晰:

ReactElement Fiber
本质是UI描述(快照) 本质是工作单元(执行任务)
创建后不会变化 更新时可复用、可修改状态
无状态,只存结构信息 有状态,记录工作进度、优先级等

五、Fiber Tree 是什么结构?(为什么是链表)

Fiber Tree是由Fiber节点构成的树状结构,也是React的工作树。和React 15的递归树不同,Fiber Tree采用链表结构,每个Fiber节点只关心三件事,对应三个指针:


父节点 → return 指针
第一个子节点 → child 指针
下一个兄弟节点 → sibling 指针

结构示意

比如我们有这样的UI结构:


App
└─ div
   ├─ h1
   └─ span

用Fiber链表表示的实际结构的是:


App
 ↓ child(指向第一个子节点div)
div
 ↓ child(指向第一个子节点h1)        → sibling(指向兄弟节点span)
h1  --------------------------------->  span

为什么不用递归?

核心原因是链表结构支持“可中断、可恢复”的遍历,而递归不行。具体来说:

  • 链表遍历可以随时暂停,记录当前遍历到的节点(通过三个指针);

  • 等主线程空闲后,再通过记录的指针恢复遍历,继续执行工作;

  • 递归一旦开始,就必须走到最后,无法中途暂停,只能一直占用主线程。

面试时可以用这句话总结:

Fiber 把递归遍历拆成可暂停的循环任务,从而解决了主线程阻塞的问题。

六、双缓存 Fiber 树(React 的“影分身术”)

为了避免“一边计算差异,一边更新DOM”导致的页面抖动,React采用了“双缓存”机制,同时维护两棵Fiber树,各司其职。

两棵树的作用

树名 作用
current 当前屏幕上显示的Fiber树,对应真实DOM结构
workInProgress 后台正在计算、构建的Fiber树,基于current树复制而来,用于计算更新差异

更新流程 🌰


current(屏幕上的)
      ↓ 复制一份作为基础
workInProgress(后台计算差异、构建新树)
      ↓ 计算完成后
commit(一次性更新DOM)
      ↓ 指针切换
current 指向新树,workInProgress 清空等待下次更新

为什么要两棵树?

核心目的是“无副作用计算”。如果只有一棵树,计算差异时直接修改树结构,可能导致DOM更新不完整、页面出现中间状态(比如一半旧UI、一半新UI)。双缓存机制让计算和更新分离:后台在workInProgress树上安心计算,计算完再一次性替换current树并更新DOM,避免页面抖动。

面试时可以一句话概括:

每个Fiber节点通过alternate指针指向另一棵树的对应节点,实现无副作用的差异计算。

七、Render 阶段 vs Commit 阶段(必讲清)

React的更新过程分为两个核心阶段:Render阶段和Commit阶段,两者的职责、特点完全不同,也是面试高频考点。

Render 阶段(可以被打断)

做什么?
  • 基于ReactElement创建/更新Fiber节点,构建workInProgress树;

  • 对比current树和workInProgress树的差异,标记出需要执行的操作(比如插入、删除、修改DOM),这些操作被称为“副作用”(用flags标记);

  • 确定每个Fiber节点的更新优先级。

特点
  • ❌ 不操作真实DOM,只做计算和标记;

  • ✅ 可暂停、可中断、可丢弃(如果有更高优先级任务进来,直接放弃当前计算,重新开始);

  • ✅ 完全在内存中执行,不影响页面展示。

Commit 阶段(一次性执行)

做什么?
  • 执行Render阶段标记的副作用,比如插入、删除、修改真实DOM;

  • 执行组件的生命周期方法(比如componentDidMount、componentDidUpdate)和useEffect钩子;

  • 切换current树和workInProgress树的指针,完成更新。

特点
  • ❌ 不可中断,必须一次性执行完(否则会导致DOM状态不一致,页面出现异常);

  • ✅ 直接操作真实DOM,是唯一会影响页面展示的阶段;

  • ✅ 执行速度快,因为Render阶段已经做好了所有计算,这里只做“执行”工作。

面试标准总结(背下来)

Render 阶段是“算”(计算差异、标记副作用),Commit 阶段是“做”(执行副作用、更新DOM)。

八、更新从哪来?(setState 的整体模型)

我们常用的setState、useState更新状态,本质是触发React的更新流程。以setState为例,背后的逻辑流程很简单,却能帮你理解React的更新触发机制。


setCount(c => c + 1)

这句代码背后的完整逻辑流程:


setState(触发更新)
↓
创建 update 对象(记录更新内容、优先级等信息)
↓
将 update 对象放入对应组件的 updateQueue(更新队列)
↓
通过 Lane 机制标记该更新的优先级
↓
Scheduler(调度器)根据优先级,安排进入Render阶段

关键点理解:

setState 本身不更新视图,它只是“登记一次变更请求”。

也就是说,调用setState后,不会立刻执行更新,而是先把更新请求加入队列,再由调度器根据优先级安排执行。这也是为什么setState是“异步”的——它需要等待调度器的安排,而非立即更新DOM。

九、优先级 & Scheduler(为什么不卡)

Fiber解决了“可中断”问题,而Scheduler(调度器)解决了“什么时候执行”的问题,两者结合让React能够优先响应高优先级任务,避免页面卡顿。

不同更新,不同优先级

React根据任务的紧急程度,给更新划分了不同优先级,常见优先级排序(从高到低):

更新类型 优先级 说明
用户输入(打字、点击) 必须立即响应,否则影响交互体验
动画效果(CSS动画、过渡) 需要流畅执行,避免卡顿
列表渲染、数据加载 可延迟执行,不影响核心交互

🌰 举例

  • 用户在输入框打字时,输入对应的更新任务优先级最高,Scheduler会暂停当前正在执行的低优先级任务(比如列表渲染),先响应输入;

  • 用户输入结束后,Scheduler再重新调度低优先级任务,分帧完成列表渲染(每帧执行一小部分,不占用主线程过久)。

面试一句话总结:

Scheduler 决定“什么时候算”(调度任务执行时机),Fiber 决定“算什么”(具体的更新工作单元)。

十、Hooks 放在整体里的位置

Hooks(比如useState、useEffect)是React 16.8引入的特性,本质是“组件状态在Fiber上的表达方式”,它的底层依然依赖Fiber树的状态管理。

Hooks 存在哪里?

每个组件对应的Fiber节点上,有一个memoizedState属性,Hooks就存储在这个属性中,以链表的形式排列。比如:


// Fiber节点结构(简化)
{
  type: App,
  memoizedState: Hook1Hook2Hook3, // Hooks链表
  // 其他属性
}

当组件调用useState、useEffect时,React会沿着memoizedState的链表依次查找、创建或更新对应的Hook。

为什么不能写在if里?

这是面试高频问题,核心原因是Hooks依赖调用顺序。如果把Hooks写在if、for等条件语句中,会打乱链表的顺序,导致React无法正确匹配之前的Hook状态,出现bug。


// 错误示例
function App() {
  if (condition) {
    const [count, setCount] = useState(0); // 可能被跳过,打乱链表顺序
  }
  const [name, setName] = useState('');
  return <div>...</div>
}

十一、整体再压缩成 6 句话(面试王炸)

最后把整个React核心逻辑压缩成6句话,面试时能流畅说出来,基本能证明你对React底层有清晰理解,轻松碾压面试官:

  1. React 首先把 JSX 转成 ReactElement,完成UI描述;

  2. 再把 ReactElement 组织成 Fiber 树,将UI描述转化为可执行的工作单元;

  3. 更新时在 workInProgress 树上进行可中断的 Render 阶段,计算差异并标记副作用;

  4. Render 阶段只计算差异,不操作 DOM;

  5. Commit 阶段一次性执行所有副作用,更新真实 DOM,且不可中断;

  6. 通过 Scheduler 和 Lane 机制保证高优先级任务优先响应,避免页面卡顿。

写在最后

学React不要陷入“记API、背用法”的误区,先搞懂它的核心逻辑:从UI描述到工作单元,从差异计算到调度更新,每一步都是为了解决“高效、可控地更新UI”这个核心问题。理解了这些底层原理,不管是使用Hooks、排查性能问题,还是应对面试,都能游刃有余。

🔥 Vue3 + TypeScript 实现高性能图片懒加载v-lazyLoad指令(开箱即用)

🔥 Vue3 图片懒加载指令终极版:支持重试、自定义配置、TypeScript 全类型支持

在现代前端开发中,图片懒加载是提升页面加载性能的核心手段之一。原生的 loading="lazy" 虽然简单,但缺乏灵活的配置和错误重试机制。本文将分享一个生产级别的 Vue3 图片懒加载指令,基于 IntersectionObserver API 实现,支持失败重试、自定义占位图、样式控制等丰富功能,且全程使用 TypeScript 开发,类型提示完善。

在这里插入图片描述

🎯 核心特性

  • ✅ 基于 IntersectionObserver 实现,性能优异
  • ✅ 支持图片加载失败自动重试(指数退避策略)
  • ✅ 自定义占位图、错误图、加载状态样式类
  • ✅ 全 TypeScript 开发,类型定义完善
  • ✅ 支持指令参数灵活配置(字符串/对象)
  • ✅ 提供手动触发/重置加载的方法
  • ✅ 自动清理定时器和观察器,无内存泄漏
  • ✅ 支持跨域图片加载

📁 完整代码实现(优化版)

// lazyLoad.ts
import type { ObjectDirective, DirectiveBinding, App } from 'vue'

/**
 * 懒加载配置接口
 */
export interface LazyLoadOptions {
  root?: Element | Document | null          // 观察器根元素
  rootMargin?: string                       // 根元素边距
  threshold?: number | number[]             // 可见性阈值
  placeholder?: string                      // 占位图地址
  error?: string                            // 加载失败图地址
  loadingClass?: string                     // 加载中样式类
  loadedClass?: string                      // 加载完成样式类
  errorClass?: string                       // 加载失败样式类
  attempt?: number                          // 最大重试次数
  observerOptions?: IntersectionObserverInit // 观察器额外配置
  src?: string                              // 图片地址
}

/**
 * 指令绑定值类型:支持字符串(仅图片地址)或完整配置对象
 */
type LazyLoadBindingValue = string | LazyLoadOptions

/**
 * 元素加载状态枚举
 */
enum LoadStatus {
  PENDING = 'pending',   // 待加载
  LOADING = 'loading',   // 加载中
  LOADED = 'loaded',     // 加载完成
  ERROR = 'error'        // 加载失败
}

/**
 * 扩展元素属性:存储懒加载相关状态
 */
interface LazyElement extends HTMLElement {
  _lazyLoad?: {
    src: string
    options: LazyLoadOptions
    observer: IntersectionObserver | null
    status: LoadStatus
    attemptCount: number          // 已失败次数(从0开始)
    retryTimer?: number           // 重试定时器ID
    cleanup: () => void           // 清理函数
  }
}

/**
 * 默认配置:合理的默认值,兼顾通用性和易用性
 */
const DEFAULT_OPTIONS: LazyLoadOptions = {
  root: null,
  rootMargin: '0px',
  threshold: 0.1,
  // 透明占位图(最小体积)
  placeholder: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"%3E%3C/svg%3E',
  // 错误占位图(带❌标识)
  error: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"%3E%3Ctext x="0.5" y="0.5" font-size="0.1" text-anchor="middle"%3E❌%3C/text%3E%3C/svg%3E',
  loadingClass: 'lazy-loading',
  loadedClass: 'lazy-loaded',
  errorClass: 'lazy-error',
  attempt: 3,  // 默认重试3次
  observerOptions: {}
}

// 全局观察器缓存:避免重复创建,提升性能
let globalObserver: IntersectionObserver | null = null
const observerCallbacks = new WeakMap<Element, () => void>()

/**
 * 创建/获取全局IntersectionObserver实例
 * @param options 懒加载配置
 * @returns 观察器实例
 */
const getObserver = (options: LazyLoadOptions): IntersectionObserver => {
  const observerOptions: IntersectionObserverInit = {
    root: options.root,
    rootMargin: options.rootMargin,
    threshold: options.threshold,
    ...options.observerOptions
  }

  if (!globalObserver) {
    globalObserver = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const callback = observerCallbacks.get(entry.target)
          if (callback) {
            callback()
            globalObserver?.unobserve(entry.target)
            observerCallbacks.delete(entry.target)
          }
        }
      })
    }, observerOptions)
  }

  return globalObserver
}

/**
 * 核心加载逻辑:封装图片加载、重试、状态管理
 * @param el 目标元素
 * @param src 图片地址
 * @param options 配置项
 */
const loadImage = (el: LazyElement, src: string, options: LazyLoadOptions) => {
  const lazyData = el._lazyLoad
  if (!lazyData) return

  // 防止重复加载
  if (lazyData.status === LoadStatus.LOADING || lazyData.status === LoadStatus.LOADED) {
    return
  }

  // 更新状态:标记为加载中
  lazyData.status = LoadStatus.LOADING
  el.setAttribute('data-lazy-status', LoadStatus.LOADING)
  el.classList.add(options.loadingClass!)
  el.classList.remove(options.loadedClass!, options.errorClass!)

  // 创建新图片对象(每次重试创建新实例,避免缓存问题)
  const image = new Image()
  image.crossOrigin = 'anonymous'  // 支持跨域图片

  /**
   * 失败处理:指数退避重试 + 最终失败处理
   */
  const handleFail = () => {
    // 清除当前重试定时器
    if (lazyData.retryTimer) {
      clearTimeout(lazyData.retryTimer)
      lazyData.retryTimer = undefined
    }

    // 累加失败次数
    lazyData.attemptCount += 1

    // 还有重试次数:指数退避策略(1s → 2s → 4s,最大5s)
    if (lazyData.attemptCount < options.attempt!) {
      const delay = Math.min(1000 * Math.pow(2, lazyData.attemptCount - 1), 5000)
      lazyData.retryTimer = window.setTimeout(() => {
        lazyData.status = LoadStatus.PENDING
        loadImage(el, src, options)
      }, delay) as unknown as number
    } 
    // 重试耗尽:标记失败状态
    else {
      lazyData.status = LoadStatus.ERROR
      el.setAttribute('data-lazy-status', LoadStatus.ERROR)
      el.classList.remove(options.loadingClass!)
      el.classList.add(options.errorClass!)
      
      // 设置错误图片
      if (options.error) {
        (el as HTMLImageElement).src = options.error
      }
      
      // 触发自定义错误事件
      el.dispatchEvent(new CustomEvent('lazy-error', {
        detail: { src, element: el, attempts: lazyData.attemptCount }
      }))
    }
  }

  /**
   * 成功处理:更新状态 + 替换图片
   */
  const handleSuccess = () => {
    // 清除重试定时器
    if (lazyData.retryTimer) {
      clearTimeout(lazyData.retryTimer)
      lazyData.retryTimer = undefined
    }

    // 更新状态:标记为加载完成
    lazyData.status = LoadStatus.LOADED
    el.setAttribute('data-lazy-status', LoadStatus.LOADED)
    el.classList.remove(options.loadingClass!)
    if (el.classList) {
        el.classList.add(options.loadedClass!)
    }
    
    // 替换为目标图片
    (el as HTMLImageElement).src = src
    
    // 触发自定义成功事件
    el.dispatchEvent(new CustomEvent('lazy-loaded', {
      detail: { src, element: el }
    }))
  }

  // 绑定事件(once: true 确保只触发一次)
  image.addEventListener('load', handleSuccess, { once: true })
  image.addEventListener('error', handleFail, { once: true })

  // 开始加载(放在最后,避免事件绑定前触发)
  image.src = src
}

/**
 * 懒加载指令核心实现
 */
export const lazyLoad: ObjectDirective<LazyElement, LazyLoadBindingValue> = {
  /**
   * 指令挂载时:初始化配置 + 注册观察器
   */
  mounted(el: LazyElement, binding: DirectiveBinding<LazyLoadBindingValue>) {
    // 1. 解析配置和图片地址
    let src: string = ''
    const options: LazyLoadOptions = { ...DEFAULT_OPTIONS }

    if (typeof binding.value === 'string') {
      src = binding.value
    } else {
      Object.assign(options, binding.value)
      src = options.src || el.dataset.src || el.getAttribute('data-src') || ''
    }

    // 校验图片地址
    if (!src) {
      console.warn('[v-lazy-load] 缺少图片地址,请设置src或data-src属性')
      return
    }

    // 2. 初始化元素状态
    el.setAttribute('data-lazy-status', LoadStatus.PENDING)
    el.classList.add(options.loadingClass!)
    if (options.placeholder) {
      (el as HTMLImageElement).src = options.placeholder
    }

    // 3. 创建观察器
    const observer = getObserver(options)
    
    // 4. 定义清理函数:统一管理资源释放
    const cleanup = () => {
      observer.unobserve(el)
      observerCallbacks.delete(el)
      
      // 清理定时器
      if (el._lazyLoad?.retryTimer) {
        clearTimeout(el._lazyLoad.retryTimer)
        el._lazyLoad.retryTimer = undefined
      }

      // 清理样式和属性
      el.classList.remove(options.loadingClass!, options.loadedClass!, options.errorClass!)
      el.removeAttribute('data-lazy-status')
    }

    // 5. 保存核心状态
    el._lazyLoad = {
      src,
      options,
      observer,
      status: LoadStatus.PENDING,
      attemptCount: 0,
      retryTimer: undefined,
      cleanup
    }

    // 6. 注册观察回调
    observerCallbacks.set(el, () => loadImage(el, src, options))
    observer.observe(el)
  },

  /**
   * 指令更新时:处理图片地址变化
   */
  updated(el: LazyElement, binding: DirectiveBinding<LazyLoadBindingValue>) {
    const lazyData = el._lazyLoad
    if (!lazyData) return

    // 清理旧定时器
    if (lazyData.retryTimer) {
      clearTimeout(lazyData.retryTimer)
      lazyData.retryTimer = undefined
    }

    // 解析新地址
    let newSrc: string = ''
    if (typeof binding.value === 'string') {
      newSrc = binding.value
    } else {
      newSrc = binding.value.src || el.dataset.src || el.getAttribute('data-src') || ''
    }

    // 地址变化:重新初始化
    if (newSrc && newSrc !== lazyData.src) {
      lazyData.cleanup()
      lazyLoad.mounted(el, binding)
    }
  },

  /**
   * 指令卸载时:彻底清理资源
   */
  unmounted(el: LazyElement) {
    const lazyData = el._lazyLoad
    if (lazyData) {
      clearTimeout(lazyData.retryTimer)
      lazyData.cleanup()
      delete el._lazyLoad // 释放内存
    }
  }
}

/**
 * 手动触发图片加载(无需等待元素进入视口)
 * @param el 目标元素
 */
export const triggerLoad = (el: HTMLElement) => {
  const lazyEl = el as LazyElement
  const callback = observerCallbacks.get(lazyEl)
  if (callback) {
    callback()
    lazyEl._lazyLoad?.observer?.unobserve(lazyEl)
    observerCallbacks.delete(lazyEl)
  }
}

/**
 * 重置图片加载状态(重新开始懒加载)
 * @param el 目标元素
 */
export const resetLoad = (el: HTMLElement) => {
  const lazyEl = el as LazyElement
  const lazyData = lazyEl._lazyLoad
  
  if (lazyData) {
    // 清理旧状态
    clearTimeout(lazyData.retryTimer)
    lazyData.cleanup()
    delete lazyEl._lazyLoad
    
    // 重新注册观察器
    const observer = getObserver(lazyData.options)
    observerCallbacks.set(lazyEl, () => loadImage(lazyEl, lazyData.src, lazyData.options))
    observer.observe(lazyEl)
    
    // 重置样式和占位图
    lazyEl.setAttribute('data-lazy-status', LoadStatus.PENDING)
    lazyEl.classList.add(lazyData.options.loadingClass!)
    if (lazyData.options.placeholder) {
      (lazyEl as HTMLImageElement).src = lazyData.options.placeholder
    }
  }
}

/**
 * 全局注册懒加载指令
 * @param app Vue应用实例
 */
export const setupLazyLoadDirective = (app: App) => {
  app.directive('lazy-load', lazyLoad)
  // 挂载全局方法:方便在组件内调用
  app.config.globalProperties.$lazyLoad = {
    triggerLoad,
    resetLoad
  }
}

// TS类型扩展:增强类型提示
declare module 'vue' {
  export interface ComponentCustomProperties {
    $lazyLoad: {
      triggerLoad: typeof triggerLoad
      resetLoad: typeof resetLoad
    }
  }
}

declare global {
  interface HTMLElement {
    dataset: DOMStringMap & {
      src?: string
      lazyStatus?: string
    }
  }
}

🚀 使用指南

1. 全局注册指令

main.ts 中注册指令:

import { createApp } from 'vue'
import { setupLazyLoadDirective } from './directives/lazyLoad'
import App from './App.vue'

const app = createApp(App)
// 注册懒加载指令
setupLazyLoadDirective(app)
app.mount('#app')

2. 基础使用(字符串形式)

<template>
  <!-- 最简单的用法:直接传图片地址 -->
  <img v-lazy-load="imageUrl" alt="示例图片" />
</template>

<script setup lang="ts">
const imageUrl = 'https://example.com/your-image.jpg'
</script>

3. 高级使用(对象配置)

<template>
  <!-- 自定义配置 -->
  <img 
    v-lazy-load="{
      src: imageUrl,
      placeholder: 'https://example.com/placeholder.png',
      error: 'https://example.com/error.png',
      attempt: 5,  // 重试5次
      loadingClass: 'my-loading',
      rootMargin: '50px'
    }"
    @lazy-loaded="handleLoaded"
    @lazy-error="handleError"
    alt="高级示例"
  />
</template>

<script setup lang="ts">
const imageUrl = 'https://example.com/your-image.jpg'

// 加载成功回调
const handleLoaded = (e: CustomEvent) => {
  console.log('图片加载成功', e.detail)
}

// 加载失败回调
const handleError = (e: CustomEvent) => {
  console.error('图片加载失败', e.detail)
}
</script>

<style>
/* 自定义加载样式 */
.my-loading {
  background: #f5f5f5;
  filter: blur(2px);
}

.lazy-loaded {
  transition: filter 0.3s ease;
  filter: blur(0);
}

.lazy-error {
  border: 1px solid #ff4444;
}
</style>

4. 手动控制加载

在组件内手动触发/重置加载:

<template>
  <img ref="imageRef" v-lazy-load="imageUrl" alt="手动控制" />
  <button @click="handleTriggerLoad">手动加载</button>
  <button @click="handleResetLoad">重置加载</button>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { triggerLoad, resetLoad } from './directives/lazyLoad'

const imageRef = ref<HTMLImageElement>(null)
const imageUrl = 'https://example.com/your-image.jpg'

// 手动触发加载
const handleTriggerLoad = () => {
  if (imageRef.value) {
    triggerLoad(imageRef.value)
  }
}

// 重置加载状态
const handleResetLoad = () => {
  if (imageRef.value) {
    resetLoad(imageRef.value)
  }
}
</script>

🔧 核心优化点说明

1. 性能优化

  • 全局观察器缓存:避免为每个元素创建独立的 IntersectionObserver 实例,减少内存占用
  • WeakMap 存储回调:自动回收无用的回调函数,防止内存泄漏
  • 统一清理函数:在元素卸载/更新时,彻底清理定时器、观察器、样式类

2. 重试机制优化

  • 指数退避策略:重试间隔从 1s 开始,每次翻倍(1s → 2s → 4s),最大不超过 5s,避免频繁重试占用资源
  • 每次重试创建新 Image 实例:避免浏览器缓存导致的重试无效问题
  • 状态锁机制:防止重复加载/重试,确保状态一致性

3. 易用性优化

  • 灵活的参数格式:支持字符串(仅图片地址)和对象(完整配置)两种绑定方式
  • 全局方法挂载:通过 $lazyLoad 可以在任意组件内调用手动控制方法
  • 完善的类型提示:TypeScript 类型扩展,开发时自动提示配置项和方法

4. 健壮性优化

  • 状态标记:通过 data-lazy-status 属性标记元素状态,方便调试和样式控制
  • 自定义事件:触发 lazy-loaded/lazy-error 事件,方便业务层处理回调
  • 跨域支持:默认设置 crossOrigin = 'anonymous',支持跨域图片加载

📋 关键配置项说明

配置项 类型 默认值 说明
root Element/Document/null null 观察器的根元素,null 表示视口
rootMargin string '0px' 根元素的边距,用于扩展/收缩观察区域
threshold number/number[] 0.1 元素可见比例阈值(0-1)
placeholder string 透明SVG 加载前的占位图
error string 带❌的SVG 加载失败后的占位图
loadingClass string 'lazy-loading' 加载中样式类
loadedClass string 'lazy-loaded' 加载完成样式类
errorClass string 'lazy-error' 加载失败样式类
attempt number 3 最大重试次数
src string - 目标图片地址

🎨 样式示例

可以根据元素的 data-lazy-status 属性或样式类定制加载动画:

/* 加载中动画 */
.lazy-loading {
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: loading 1.5s infinite;
}

/* 加载完成过渡 */
.lazy-loaded {
  transition: opacity 0.3s ease;
  opacity: 1;
}

/* 初始状态 */
img[data-lazy-status="pending"] {
  opacity: 0;
}

@keyframes loading {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

📌 总结

本文实现的 Vue3 懒加载指令具备以下核心优势:

  1. 高性能:基于 IntersectionObserver,相比滚动监听性能提升显著
  2. 高可用:内置失败重试机制,提升图片加载成功率
  3. 高灵活:支持丰富的自定义配置,适配不同业务场景
  4. 高可维护:TypeScript 全类型支持,代码结构清晰,易于扩展
  5. 无内存泄漏:完善的资源清理逻辑,适配组件生命周期

这个指令可以直接用于生产环境,覆盖大部分图片懒加载场景。如果需要进一步扩展,可以在此基础上增加:

  • 支持背景图懒加载
  • 支持视频懒加载
  • 加载进度显示
  • 批量加载控制

希望这篇文章对你有帮助,欢迎点赞、收藏、评论交流!

❌