普通视图

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

css布局篇——两栏布局(无废话版)

2026年1月23日 11:28

核心思路

一般两栏布局指的是左边一栏宽度固定,右边一栏宽度自适应

第一种方法

  • 浮动 + margin-left(将左边元素宽度设置为200px,并且设置向左浮动。将右边元素的margin-left设置为200px)
<body>
  <div class = "box">
    <div class = "left"></div>
    <div class = "right"></div>
  </div>
  我是文字
</body>
<style>
    .left{
        width: 200px;
        height: 400px;
        background-color: orange;
        float :left;
  }
    .right{
        height: 300px;
        margin-left: 200px;
        background-color: yellowgreen;
    }
</style>

image.png

oh my god 下面的其他内容怎么跑上去了? 是因为我们使用了浮动

啥是浮动?

浮动会导致元素脱离原来普通的文档流。元素可以向左或者向右浮动,直到碰到父容器或其他浮动元素才停下。在原来的文档流里,它就像被 “删掉” 了一样,不占位置。这样一来,父元素就会因为没东西撑着,高度变成 0,出现高度塌陷,后面的内容也会自动往上跑,跟浮动元素叠在一起。

解决办法:

  • BFC,块级格式化上下文,BFC 规定了内部的块级元素的布局方式。常见的做法是为父元素添加:
overflow:hidden;

啥是BFC

块格式化上下文(Block Formatting Context,BFC),是Web页面的可视化CSS渲染的一部分,是布局过程中生成块级盒子的区域,也是浮动元素与其他元素的交互限定区域。

BFC的特点

  • 垂直方向上,自上而下排列,和文档流的排列方式一致。
  • 在BFC中上下相邻的两个容器的margin会重叠
  • 计算BFC的高度时,需要计算浮动元素的高度
  • BFC区域不会与浮动的容器发生重叠
  • BFC是独立的容器,容器内部元素不会影响外部元素
  • 每个元素的左margin值和容器的左border相接触
人话

BFC就是一个独立的布局容器,内部元素自己玩自己的,不跟外面互相干扰,还能把浮动元素 “包住”,解决高度塌陷。

创建BFC的条件

  • 根元素:body;
  • 元素设置浮动:float 除 none 以外的值;
  • 元素设置绝对定位:position (absolute、fixed);
  • display 值为:inline-block、table-cell、table-caption、flex等;
  • overflow 值为:hidden、auto、scroll;

创建BFC后的效果

回归正题,我们实现两栏布局只需要为.box 元素添加样式:

overflow:hidden;

image.png

第二种方法

  • 利用flex布局,将左边元素设置为固定宽度200px,将右边的元素设置为flex:1。
<style>
    .box{
        display: flex;
    }
    .left{
        width: 200px;
        height: 400px;
        background-color: orange;    
  }
    .right{
        flex: 1;
        height: 300px;
        background-color: yellowgreen;
    }
</style>

那些年我们追过的 ECMAScript

作者 xuyanzhuqing
2026年1月23日 11:27

JavaScript 从 ES2015 到 ES2026 的核心更新梳理

ES2015 (ES6) - 里程碑式更新

这是 JS 史上最重大的一次更新,重构了语言核心语法和特性:

  1. 块级作用域与声明

    1. let/const:替代 var,提供块级作用域,const 声明只读常量

    2. 示例:

      • const PI = 3.14159;
        let count = 0;
        if (true) {
          let count = 1; // 块内独立作用域
          console.log(count); // 1
        }
        console.log(count); // 0
        
  2. 箭头函数:简化函数写法,绑定词法 this

    1. const add = (a, b) => a + b;
      const obj = {
        name: "JS",
        sayHi: () => console.log(this.name) // 箭头函数this指向外层(全局)
      };
      
  3. 类与模块

    1. class 语法:简化原型链编程
    2. import/export:模块化标准
    3. // 模块导出
      export const name = "ES6";
      export class Person {
        constructor(name) {
          this.name = name;
        }
      }
      // 模块导入
      import { Person } from './module.js';
      
  4. 解构赋值:快速提取对象/数组数据

    1. const [a, b] = [1, 2];
      const { name, age } = { name: "Tom", age: 20 };
      
  5. 其他核心特性:模板字符串 `hello ${name}`、默认参数、扩展运算符 ...PromiseMap/Set 等。

ES2016 (ES7) - 小版本增量更新

  1. 数组 includes() 方法:替代 indexOf 判断元素是否存在(支持 NaN

    1. [1, 2, NaN].includes(NaN); // true
      [1, 2, NaN].indexOf(NaN); // -1
      
  2. 幂运算符 ** :替代 Math.pow()

    1. 2 ** 3; // 8 等同于 Math.pow(2, 3)
      

ES2017 (ES8)

  1. async/await:异步编程终极方案,简化 Promise 链式调用

    1. async function fetchData() {
        try {
          const res = await fetch('/api/data');
          const data = await res.json();
          return data;
        } catch (err) {
          console.error(err);
        }
      }
      
  2. 对象扩展

    1. Object.values()/Object.entries():获取对象值/键值对数组
    2. Object.getOwnPropertyDescriptors():获取对象属性完整描述
    3. const obj = { a: 1, b: 2 };
      Object.values(obj); // [1, 2]
      Object.entries(obj); // [['a', 1], ['b', 2]]
      
  3. 字符串填充padStart()/padEnd()

    1. '123'.padStart(5, '0'); // '00123'
      '123'.padEnd(5, '0'); // '12300'
      

ES2018 (ES9)

  1. 异步迭代器for await...of 遍历异步可迭代对象

    1. async function processAsyncData() {
        const asyncIterable = {
          [Symbol.asyncIterator]() {
            let i = 0;
            return {
              next() {
                if (i < 3) return Promise.resolve({ value: i++, done: false });
                return Promise.resolve({ done: true });
              }
            };
          }
        };
        for await (const num of asyncIterable) {
          console.log(num); // 0, 1, 2
        }
      }
      
  2. 正则扩展:反向断言、dotAll 模式(. 匹配任意字符)

    1. // 后行断言
      /(?<=$)\d+/.exec('$100'); // ['100']
      // dotAll 模式
      /a.b/s.test('a\nb'); // true(默认模式下为 false
  3. 对象扩展运算符... 解构/合并对象

    1. const obj1 = { a: 1 };
      const obj2 = { ...obj1, b: 2 }; // { a: 1, b: 2 }
      

ES2019 (ES10)

  1. 数组方法扩展

    1. Array.prototype.flat():扁平化数组(默认1层)
    2. Array.prototype.flatMap()map + flat 组合
    3. [1, [2, [3]]].flat(2); // [1, 2, 3]
      [1, 2, 3].flatMap(x => [x * 2]); // [2, 4, 6]
      
  2. 字符串 trimStart() / trimEnd() :替代 trimLeft()/trimRight(),语义更清晰

  3. Object.fromEntries() :将键值对数组转回对象(Object.entries 反向操作)

    1. const arr = [['a', 1], ['b', 2]];
      Object.fromEntries(arr); // { a: 1, b: 2 }
      
  4. 可选捕获绑定try/catchcatch 可以省略参数

    1. try {
        // 可能出错的代码
      } catch { // 无需写 (err)
        // 处理错误(无需使用err参数时)
      }
      

ES2020 (ES11)

  1. 可选链操作符 ?. :避免访问嵌套对象属性时的 Cannot read property 'xxx' of undefined 错误

    1. const obj = { a: { b: 1 } };
      console.log(obj?.a?.b); // 1
      console.log(obj?.c?.d); // undefined(不会报错)
      
  2. 空值合并运算符 ?? :仅当左侧为 null/undefined 时返回右侧(区别于 ||

    1. 0 ?? 1; // 0(|| 会返回1)
      null ?? 1; // 1
      
  3. BigInt:支持超大整数运算(超出 Number.MAX_SAFE_INTEGER 范围)

    1. const bigNum = 9007199254740991n + 2n; // 9007199254740993n
      
  4. import() 动态导入:按需加载模块(返回 Promise)

    1. async function loadModule() {
        const module = await import('./module.js');
        module.doSomething();
      }
      

ES2021 (ES12)

  1. 数字分隔符 _ :提升大数字可读性

    1. const billion = 1_000_000_000; // 等同于 1000000000
      
  2. String.prototype.replaceAll() :替换所有匹配项(无需正则全局标志)

    1. 'a b a b'.replaceAll('a', 'x'); // 'x b x b'
      
  3. Promise.any() :只要有一个 Promise 成功就返回(区别于 Promise.race

    1. Promise.any([Promise.reject(1), Promise.resolve(2)])
        .then(res => console.log(res)); // 2
      
  4. 逻辑赋值运算符&&=||=??=

    1. let a = 0;
      a ||= 1; // 1(等同于 a = a || 1)
      let b = 2;
      b &&= 3; // 3(等同于 b = b && 3)
      let c = null;
      c ??= 4; // 4(等同于 c = c ?? 4)
      

ES2022 (ES13)

  1. 类字段声明:支持在类中直接声明实例/静态字段(无需在 constructor 中赋值)

    1. class Person {
        name = 'Tom'; // 实例字段
        static age = 20; // 静态字段
        #privateField = '私有值'; // 私有字段(# 开头)
        
        getPrivate() {
          return this.#privateField;
        }
      }
      const p = new Person();
      console.log(p.name); // Tom
      console.log(Person.age); // 20
      console.log(p.#privateField); // 报错(无法访问私有字段)
      
  2. at() 方法:支持数组/字符串负索引访问

    1. const arr = [1, 2, 3];
      arr.at(-1); // 3(最后一个元素)
      'hello'.at(-2); // 'l'
      
  3. Object.hasOwn() :替代 Object.prototype.hasOwnProperty.call(),更安全

    1. const obj = { a: 1 };
      Object.hasOwn(obj, 'a'); // true
      
  4. Top-level await:模块顶层可直接使用 await(无需包裹 async 函数)

    1. // module.js
      const data = await fetch('/api/data').then(res => res.json());
      export default data;
      

ES2023 (ES14)

  1. 数组方法扩展findLast()/findLastIndex()(从后往前查找)

    1. const arr = [1, 2, 3, 2];
      arr.findLast(x => x === 2); // 2(最后一个2)
      arr.findLastIndex(x => x === 2); // 3
      
  2. Array.fromAsync() :从异步可迭代对象创建数组

    1. async function test() {
        const asyncIterable = (async function* () {
          yield 1;
          yield 2;
        })();
        const arr = await Array.fromAsync(asyncIterable);
        console.log(arr); // [1, 2]
      }
      
  3. WeakMap 支持 Symbol 键:此前仅支持对象键

ES2024 (ES15)

  1. Promise.withResolvers() :简化 Promise 手动创建(替代手动声明 resolve/reject)

    1. // 旧写法
      const promise1 = new Promise((resolve, reject) => {
        // 逻辑
      });
      // 新写法
      const { promise: promise2, resolve, reject } = Promise.withResolvers();
      
  2. 正则 /v 标志(Unicode 属性转义扩展) :更精准匹配 Unicode 字符

    1. // 匹配所有中文(更精准)
      /\p{Script=Han}/v.test('中文'); // true
      
  3. Array.prototype.toReversed() / toSorted() / toSpliced() :非破坏性数组方法(原数组不变)

    1. const arr = [3, 1, 2];
      const sortedArr = arr.toSorted(); // [1, 2, 3]
      console.log(arr); // [3, 1, 2](原数组未变)
      

ES2025 (ES16) - 已定稿特性

  1. Object.groupBy() :按条件分组对象/数组(替代手动遍历分组)

    1. const arr = [1, 2, 3, 4, 5];
      const grouped = Object.groupBy(arr, num => num % 2 === 0 ? 'even' : 'odd');
      // { odd: [1, 3, 5], even: [2, 4] }
      
  2. String.prototype.isWellFormed() / toWellFormed() :处理无效 Unicode 字符

    1. const str = '\ud800'; // 无效 Unicode
      str.isWellFormed(); // false
      str.toWellFormed(); // '\ufffd'(替换为替换字符)
      

ES2026 (ES17) - 候选/提案阶段核心特性

(注:ES2026 尚未最终定稿,以下是当前进入 Stage 3+ 的核心提案)

  1. 管道运算符 |> :简化函数调用链(替代嵌套调用)

    1. // 旧写法
      const result = multiply(add(1, 2), 3); // 9
      // 新写法
      const result = 1 |> add(2) |> multiply(3); // 9
      
  2. Record / Tuple:不可变数据类型(Record 是不可变对象,Tuple 是不可变数组)

    1. const record = #{ name: 'Tom', age: 20 }; // 不可变Record
      const tuple = #[1, 2, 3]; // 不可变Tuple
      tuple.push(4); // 报错(不可变)
      
  3. do 表达式:将语句块转为表达式(可在赋值/返回中使用)

    1. const value = do {
        if (num > 10) 'big';
        else 'small';
      };
      

总结

  1. 核心演进逻辑:ES2015 奠定现代 JS 基础,后续版本以“增量更新”为主,聚焦简化开发(如 async/await、可选链)、增强安全性(如私有字段、Object.hasOwn)、提升可读性(如数字分隔符、管道运算符)、完善异步编程(如异步迭代、Promise.any)。
  2. 高频实用特性:日常开发中最常用的特性集中在 ES2015(let/const、箭头函数、解构)、ES2017(async/await)、ES2020(可选链、空值合并)、ES2022(类字段、at())。
  3. 未来趋势:ES2026 重点探索不可变数据Record/Tuple)和语法简化(管道运算符),进一步提升代码的可维护性和性能。

如何将 Shadcn/ui 默认的 primitive 组件迁移至 BaseUI

作者 乐潇游
2026年1月23日 11:23

目前,shadcn/ui 支持 radix-uibase-ui 两种 primitive 组件,根据最新生态情况,更推荐使用 base-ui 作为默认的 primitive 组件。

Avatar 组件为例:

image.png

我发现无论 Radix UI 还是切换至 Base UI ,Command 命令都是:

pnpm dlx shadcn@latest add avatar

也就是说命令都是一样的,那如何让 shadcn/ui 执行自动安装命令的时候默认选择 base-ui 而不是 radix-ui ?找了一圈文档,没发现如何配置,问 AI 给出的结果也是不可用的,重新跑了一遍创建新项目,倒是发现了一些区别,话不多说,直接看下文。

方案

components.json 文件中的 "style": "new-york"(项目原本的)改成 Base UI 支持的 style 名称(例如 "base-vega")即可。

修改完之后再去执行:pnpm dlx shadcn@latest add avatar ,再去检查文件 components/ui/avatar.tsx 开头的 import:

  • Radix 版本:import * as AvatarPrimitive from "@radix-ui/react-avatar"
  • Base UI 版本:import { Avatar } from "@base-ui/react/avatar"

只要看到 @base-ui/react,就代表成功切换过来了。

由img 标签跨域问题,而引出的浏览器跨域原理

作者 hmfy
2026年1月23日 11:21

User:

img 标签为什么不会跨域呢,本质上也是 get 请求,而只要是从浏览器发请求,只要是协议域名端口不同不是就会跨域吗

GitHub Copilot:

img 标签(以及 <script><link> 等资源标签)确实可以发起跨域 GET 请求并加载资源(如图片),而不会触发浏览器的跨域限制(CORS 错误)。这与你提到的“协议、域名、端口不同就会跨域”并不完全一致,因为跨域限制并非对所有浏览器请求都适用,而是有特定规则。让我解释一下本质原因:

1. 跨域限制的本质

  • 跨域(Cross-Origin Resource Sharing, CORS)是浏览器的一种安全机制,旨在防止恶意网站窃取用户数据。它主要针对 能读取响应内容的请求,尤其是通过 JavaScript 发起的请求(如 XMLHttpRequest 或 fetch)。
  • 如果请求的协议、域名或端口不同,浏览器会检查服务器是否返回了允许跨域的 HTTP 头(如 Access-Control-Allow-Origin)。如果没有,请求会被阻塞。
  • 但并非所有请求都会触发 CORS 检查。img 标签的请求是 GET,但它属于“简单请求”(simple request),并且浏览器对资源标签的处理方式不同。

2. 为什么 img 标签不会跨域?

  • img 标签不执行脚本,也不允许 JavaScript 直接读取响应内容
    • img 标签只是告诉浏览器加载并显示图片。它不会执行任何代码,也不会让 JavaScript 访问图片的像素数据(除非通过 Canvas API,但那需要服务器允许 CORS)。
    • 浏览器认为这种请求是“安全的”,因为它不会泄露敏感信息。即使图片来自不同域,浏览器也会加载它(前提是服务器允许公开访问)。
  • 历史原因和浏览器设计
    • 早期 Web 设计中,img、script 等标签就被允许跨域加载资源,以支持网页嵌入外部内容(如广告图片或 CDN 资源)。
    • 相比之下,AJAX 请求(通过 XMLHttpRequest 或 fetch)可以读取响应体(JSON、XML 等),这可能包含敏感数据,所以浏览器强制 CORS 检查。
  • 简单请求的例外
    • img 的 GET 请求属于“简单请求”(simple request),它不预检(preflight),浏览器直接发送请求。如果服务器返回图片数据,浏览器就显示它。
    • 如果服务器设置了 CORS 头(如 Access-Control-Allow-Origin: *),img 标签也可以正常工作,但通常不需要,因为它不读取内容。

3. 对比其他 GET 请求

  • 通过 fetch 或 XMLHttpRequest 的 GET 请求:如果跨域,会触发 CORS 检查,必须有服务器的允许头,否则报错(如 Access to XMLHttpRequest at 'https://example.com/image.jpg' from origin 'https://yourdomain.com' has been blocked by CORS policy)。
  • 通过表单提交或链接跳转的 GET 请求:这些也不触发 CORS,因为它们是导航请求,不是 AJAX。
  • img 标签的 GET 请求:浏览器允许它跨域,因为它不涉及数据读取。

4. 潜在风险和限制

  • 虽然 img 可以跨域加载,但如果图片服务器禁止访问(HTTP 403),或图片是私有的,浏览器仍会失败。
  • 如果你想在 JavaScript 中读取图片数据(例如通过 Canvas),仍然需要 CORS 头,因为那涉及跨域数据访问。
  • 对于现代 Web,推荐服务器配置 CORS 以控制访问。

User:

那请总结一下哪些情况下会跨域,哪些常见情况不会,并说明原因,另外你提到 preflight 请详细说一下

GitHub Copilot:

哪些情况下会跨域(触发 CORS 检查)

跨域(CORS)限制主要针对浏览器发起的请求,如果请求涉及跨域(协议、域名或端口不同)且可能读取响应内容,浏览器会强制检查服务器的 CORS 头(如 Access-Control-Allow-Origin)。如果服务器不允许,请求会被阻塞。以下是常见会触发跨域的情况:

  1. AJAX 请求(XMLHttpRequest 或 fetch)

    • 原因:这些请求允许 JavaScript 读取响应体(JSON、XML 等),可能泄露敏感数据。浏览器强制 CORS 检查以保护用户隐私和安全。
    • 示例:从 https://yourdomain.com 用 fetch 请求 https://api.example.com/data。如果服务器没设置允许头,会报 CORS 错误。
    • 条件:请求方法不是简单请求(见下文),或包含自定义头、特定 Content-Type 等。
  2. 复杂请求(Non-Simple Requests)

    • 原因:这些请求可能修改服务器状态或发送复杂数据,浏览器需要预先确认权限。
    • 示例:使用 PUT、DELETE 等方法,或设置自定义头(如 Authorization)。
  3. WebSocket 连接

    • 原因:WebSocket 允许双向通信,浏览器检查是否允许跨域握手。
    • 示例:从不同域发起 WebSocket 连接,如果服务器不支持,会失败。
  4. 通过 Canvas 或其他 API 读取跨域资源数据

    • 原因:即使 img 标签加载了图片,如果 JavaScript 试图通过 Canvas API 读取像素数据,仍需 CORS 头。
    • 示例canvas.getImageData() 从跨域 img 读取数据,会报错,除非服务器允许。

哪些常见情况不会跨域

以下情况不会触发 CORS 检查,因为它们不涉及读取敏感响应内容,或是浏览器历史兼容性设计:

  1. 资源标签(img、script、link、iframe 等)

    • 原因:这些标签只是加载资源(如图片、脚本、样式),不执行代码或读取数据。浏览器允许跨域加载以支持网页嵌入外部内容(如 CDN 图片)。JavaScript 无法直接访问响应内容。
    • 示例<img src="https://cdn.example.com/image.jpg"> 可以正常加载,即使跨域。
  2. 表单提交(GET/POST)

    • 原因:表单提交是导航请求(页面跳转),不涉及 JavaScript 读取响应。浏览器不检查 CORS,因为它是用户主动行为。
    • 示例<form action="https://api.example.com/submit" method="post"> 提交到不同域,不会跨域。
  3. 链接跳转(a 标签)

    • 原因:类似表单,是导航行为,不读取数据。
    • 示例<a href="https://example.com"> 跳转到不同域。
  4. 同域请求

    • 原因:协议、域名、端口相同,无需检查。
    • 示例:从 https://yourdomain.com 请求 https://yourdomain.com/api
  5. 嵌入式资源(如字体、视频)

    • 原因:类似 img,浏览器允许加载,但如果需要读取(如通过 FontFace API),可能需 CORS。
    • 示例<video src="https://cdn.example.com/video.mp4">

详细说明 Preflight(预检请求)

Preflight 是 CORS 机制的一部分,用于处理“复杂请求”(non-simple requests)。浏览器在发送实际请求前,先发送一个预检请求来确认服务器是否允许该跨域操作。

为什么需要 Preflight?

  • 简单请求(simple requests)直接发送,无需预检。
  • 复杂请求可能有副作用(如修改数据),浏览器需要先“询问”服务器,避免意外操作。

Preflight 的过程

  1. 触发条件:请求不是简单请求时,浏览器自动发送 OPTIONS 请求(预检)。

    • 简单请求的标准(无需预检):
      • 方法:GET、HEAD、POST。
      • 头:只允许简单头(如 Accept、Accept-Language、Content-Language)。
      • Content-Type:只允许 application/x-www-form-urlencodedmultipart/form-datatext/plain
      • 无自定义头、无凭据(credentials)。
    • 复杂请求的示例:使用 PUT 方法、设置 Authorization 头,或 Content-Type 为 application/json
  2. 预检请求详情

    • 方法:OPTIONS。
    • :包括 Origin(请求来源)、Access-Control-Request-Method(实际请求方法)、Access-Control-Request-Headers(自定义头)。
    • 示例
      OPTIONS /api/data HTTP/1.1
      Host: api.example.com
      Origin: https://yourdomain.com
      Access-Control-Request-Method: PUT
      Access-Control-Request-Headers: Authorization, Content-Type
      
  3. 服务器响应

    • 必须返回允许头,如:
      • Access-Control-Allow-Origin: https://yourdomain.com*
      • Access-Control-Allow-Methods: PUT, GET
      • Access-Control-Allow-Headers: Authorization, Content-Type
      • Access-Control-Max-Age: 86400(缓存预检结果,单位秒)。
    • 如果服务器拒绝,实际请求不会发送。
  4. 实际请求

    • 如果预检通过,浏览器发送实际请求,并检查响应头。

注意

  • Preflight 只针对复杂请求,简单请求跳过。
  • 预检失败不会影响页面加载,但会阻止 AJAX 请求。
  • 服务器(如 Node.js/Express)需配置中间件处理 OPTIONS 请求。

如果你的项目涉及 CORS 配置(如在 Vue.js 中),可以分享代码,我可以帮你优化!3. 服务器响应

  • 必须返回允许头,如:
    • Access-Control-Allow-Origin: https://yourdomain.com*
    • Access-Control-Allow-Methods: PUT, GET
    • Access-Control-Allow-Headers: Authorization, Content-Type
    • Access-Control-Max-Age: 86400(缓存预检结果,单位秒)。
  • 如果服务器拒绝,实际请求不会发送。
  1. 实际请求
    • 如果预检通过,浏览器发送实际请求,并检查响应头。

注意

  • Preflight 只针对复杂请求,简单请求跳过。
  • 预检失败不会影响页面加载,但会阻止 AJAX 请求。
  • 服务器(如 Node.js/Express)需配置中间件处理 OPTIONS 请求。

User:

你提到 submit 是 用户主动行为 ,那如果通过定时脚本去触发带有 submit 按钮的点击事件呢。

GitHub Copilot:

脚本触发表单提交的跨域行为

如果你通过定时脚本(例如 JavaScript 的 setTimeoutsetInterval)去触发带有 submit 按钮的点击事件(如 document.getElementById('submitBtn').click()),它仍然不会触发跨域检查。原因如下:

原因分析

  • 表单提交的本质:无论手动点击还是脚本触发,表单提交都是“导航请求”(navigation request),浏览器将其视为用户发起的页面跳转或数据发送行为。它不涉及 JavaScript 读取响应内容(response body),所以浏览器不强制 CORS 检查。
    • 脚本触发只是模拟用户行为,不会改变请求类型。浏览器允许这种自动化,因为它不泄露数据。
  • 对比 AJAX 请求:如果脚本用 fetchXMLHttpRequest 来发送表单数据(即使是 POST),那就是 AJAX 请求,会触发 CORS 检查。因为它允许 JavaScript 处理响应。
  • 历史和安全设计:早期 Web 设计中,表单提交就被允许跨域,以支持多站点交互(如登录表单)。脚本触发被视为扩展的用户行为,不受 CORS 限制。

示例

  • 脚本触发表单(不会跨域):

    setTimeout(() => {
      document.querySelector('form').submit();  // 模拟提交到 https://api.example.com/submit
    }, 1000);
    
    • 浏览器会发送请求并跳转页面,无 CORS 错误。
  • 用 fetch 模拟(会跨域):

    fetch('https://api.example.com/submit', {
      method: 'POST',
      body: new FormData(document.querySelector('form'))
    });
    
    • 如果跨域且服务器无 CORS 头,会报错。

潜在注意点

  • 自动化检测:一些网站可能检测脚本触发(如检查事件来源),并阻止它(非 CORS 问题,而是反爬虫机制)。
  • 安全风险:脚本触发可能被滥用(如 CSRF 攻击),但 CORS 不直接相关。如果你担心跨域数据安全,建议服务器端验证(如 CSRF token)。
  • 现代浏览器:行为一致,但如果表单目标是 iframe 或新窗口,规则类似。

User:

意思是,如果该请求结果,前端不可控不可获取不可读,则认为没风险,允许跨域,一旦某个请求得到的结果前端可以获取可以操作,就认为不安全,就有跨域限制,我的理解准确吗

GitHub Copilot:

对 CORS 理解的准确性分析

你的理解大致准确,但需要一些澄清和细节补充。CORS 的核心确实是浏览器为了防止恶意脚本窃取或操作跨域数据而设计的“安全网”。关键在于:如果请求的结果(响应内容)能被前端 JavaScript 读取、操作或控制,就认为有风险,需要 CORS 检查;否则,允许跨域。以下是详细解释:

准确的部分

  • 前端不可控/不可获取/不可读 → 允许跨域

    • 这些请求不让 JavaScript 访问响应体(response body),所以浏览器认为“没风险”。例如:
      • img、script、link 等标签:加载资源,但 JS 无法直接读取内容(script 会执行代码,但不读取数据)。允许跨域,以支持网页嵌入。
      • 表单提交或链接跳转:请求后页面跳转,JS 无法读取响应。允许跨域,因为它是导航行为。
    • 原因:浏览器假设这些请求是“被动”的,不涉及数据泄露。
  • 前端可以获取/操作结果 → 有跨域限制

    • AJAX 请求(fetch/XMLHttpRequest)允许 JS 读取响应(如 JSON 数据),可能泄露敏感信息。所以强制 CORS 检查:服务器必须明确允许(通过头如 Access-Control-Allow-Origin)。
    • 原因:恶意网站可能用 JS 从其他域窃取用户数据(如银行 API)。

需要澄清的细节和例外

  • 不是绝对的“不可读”就安全

    • 即使资源标签加载了内容,如果 JS 试图间接读取,仍可能触发 CORS。例如:
      • img 加载跨域图片,JS 不能直接读像素。但如果用 canvas.drawImage() 然后 canvas.getImageData() 读取像素数据,会报 CORS 错误(除非服务器允许)。
      • 类似地,字体或视频资源如果 JS 通过 API(如 FontFace)操作,也需 CORS。
    • 为什么?:浏览器区分“加载”和“读取”。加载允许,读取需要权限。
  • 历史和设计因素

    • CORS 不是一开始就有的;早期 Web 允许一切跨域,后来 AJAX 普及才引入限制。资源标签因兼容性被豁免。
    • 简单请求 vs 复杂请求:即使是 AJAX,如果是简单请求(GET/POST,无自定义头),浏览器直接发送;复杂请求需预检(OPTIONS)。
  • 其他情况

    • 同域请求:无风险,无需检查。
    • WebSocket 或其他协议:类似 AJAX,需要检查。
    • 服务器端代理:前端请求同域服务器,后者转发跨域请求,可绕过 CORS(但不推荐滥用)。

总结

你的理解抓住了 CORS 的本质:保护数据读取。但实际规则更细致,取决于请求类型和 JS 的访问能力。如果请求允许 JS 控制或读取结果,就有风险;否则,豁免。现代 Web 推荐服务器配置 CORS 以精细控制。

User:

GET/POST,无自定义头 就是简单请求吗,非这种情况就是复杂请求是吗,另外,如果是同域复杂请求是不是也不需要预检(因为同域不存在跨域问题),预检仅仅是为了判断是否允许跨域吗

GitHub Copilot:

CORS 简单请求 vs 复杂请求的详细说明

你的理解基本准确,但需要一些细节澄清。以下基于 W3C CORS 规范回答你的问题:

1. GET/POST,无自定义头,就是简单请求吗?

  • 不完全是。简单请求(Simple Request)有严格条件,必须同时满足以下所有:
    • 方法:只能是 GET、HEAD 或 POST。
    • 请求头:只能使用 CORS 安全列表的头(不包括自定义头)。安全头包括:Accept, Accept-Language, Content-Language, Content-Type, DPR, Downlink, Save-Data, Viewport-Width, Width。
    • Content-Type:如果有,只能是 application/x-www-form-urlencoded、multipart/form-data 或 text/plain。
    • 无凭据:不设置 credentials(如 cookies,除非服务器允许)。
  • 示例
    • 简单:fetch('https://api.com', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } })
    • 非简单:添加自定义头(如 Authorization)或用 PUT 方法。
  • 原因:这些条件确保请求“安全”,不会意外修改服务器状态或发送复杂数据。

2. 非这种情况就是复杂请求吗?

  • 是的。如果不满足简单请求的任一条件,就是复杂请求(Non-Simple Request)。复杂请求需要浏览器先发送预检请求(OPTIONS)来检查权限。
  • 示例
    • 用 PUT/DELETE/PATCH 方法。
    • 设置自定义头(如 X-Custom-Header)。
    • Content-Type 为 application/json。
    • 包含凭据(credentials: 'include')。

3. 同域复杂请求是不是也不需要预检?

  • 是的。同域请求(协议、域名、端口相同)永远不会发送预检,即使是复杂请求。因为同域不存在跨域问题,浏览器直接发送实际请求,无需检查 CORS 头。
  • 原因:预检是为了跨域安全。同域时,浏览器信任本地服务器,无风险。
  • 示例:在 https://yourdomain.com 上用 PUT 方法请求 https://yourdomain.com/api,直接发送,无 OPTIONS。

4. 预检仅仅是为了判断是否允许跨域吗?

  • 是的。预检(Preflight)的唯一目的是在发送实际跨域请求前,确认服务器是否允许该操作。它通过 OPTIONS 请求询问服务器的 CORS 策略。
  • 过程
    • 浏览器发送 OPTIONS 请求,包含 OriginAccess-Control-Request-Method 等头。
    • 服务器响应允许头(如 Access-Control-Allow-Origin)。
    • 如果通过,才发送实际请求。
  • 注意:预检只针对跨域复杂请求。同域或简单请求跳过。

总结

  • 简单请求:满足特定条件,无预检。
  • 复杂请求:不满足条件,跨域时需预检,同域时直接发送。
  • 预检是跨域保护机制的核心。

User:

Preflight 时的请求是浏览器自行发起的并且不受跨域限制对吗,并且只会带一些信息到服务器用于询问服务器是否允许是吗,那服务器如何响应是通过如何响应是不通过呢,以 node.js 为例。给我核心伪代码

GitHub Copilot:

Preflight 请求的机制和服务器响应

1. Preflight 请求是浏览器自行发起的并且不受跨域限制对吗?

  • 是的。Preflight 是浏览器自动发起的 OPTIONS 请求,用于检查跨域权限。它不受 CORS 限制,因为它是“询问”机制的一部分——浏览器假设服务器会诚实地响应。如果服务器不支持 CORS,实际请求会被阻塞,但预检本身不会被跨域阻止(除非服务器完全拒绝 OPTIONS)。

2. 并且只会带一些信息到服务器用于询问服务器是否允许是吗?

  • 是的。预检请求只携带必要信息,不会发送实际数据。它用于询问服务器是否允许后续的实际请求。关键头包括:
    • Origin:请求来源域。
    • Access-Control-Request-Method:实际请求的方法(如 PUT)。
    • Access-Control-Request-Headers:实际请求的自定义头。
    • 其他:如 Access-Control-Request-Private-Network(私有网络检查)。

3. 服务器如何响应是通过还是不通过?以 Node.js 为例,给核心伪代码

  • 通过:服务器返回 HTTP 200 状态码,并包含允许的 CORS 头。浏览器检查这些头,如果匹配,则发送实际请求。
  • 不通过:服务器返回错误状态(如 403)或不包含必要头。浏览器不会发送实际请求,并报 CORS 错误。
  • 核心头
    • Access-Control-Allow-Origin:允许的来源(* 或具体域)。
    • Access-Control-Allow-Methods:允许的方法(如 PUT, GET)。
    • Access-Control-Allow-Headers:允许的头。
    • Access-Control-Max-Age:预检缓存时间(秒)。
    • 可选:Access-Control-Allow-Credentials(允许凭据)。
Node.js 核心伪代码(使用 Express)
const express = require('express');
const app = express();

// 处理预检请求(OPTIONS)
app.options('/api/data', (req, res) => {
  // 检查 Origin、方法等(可选:添加自定义逻辑)
  const allowedOrigins = ['https://yourdomain.com'];
  const origin = req.headers.origin;

  if (allowedOrigins.includes(origin)) {
    // 通过:设置允许头
    res.set({
      'Access-Control-Allow-Origin': origin,  // 或 '*' 如果允许所有
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
      'Access-Control-Max-Age': 86400  // 缓存 1 天
    });
    res.status(200).end();  // 空响应体
  } else {
    // 不通过:返回错误
    res.status(403).end();
  }
});

// 实际请求处理(假设是 PUT)
app.put('/api/data', (req, res) => {
  // 正常业务逻辑
  res.json({ message: 'Data updated' });
});

app.listen(3000);
  • 解释:预检时,服务器检查并响应头。如果通过,浏览器发送 PUT 请求;否则,阻止。
  • 注意:在生产中,使用中间件如 cors 包简化配置。实际请求也需设置 Access-Control-Allow-Origin

User:

cors 中间件的核心逻辑实际上就是给所有的请求加一个 opstions 响应的中间件对吗

GitHub Copilot:

CORS 中间件的理解

你的理解部分正确,但不完全准确。CORS 中间件(如 Express 的 cors 包)的核心逻辑不仅仅是“给所有请求加一个 OPTIONS 响应”,而是自动处理 CORS 头和预检请求。它为所有匹配的请求(包括预检和实际请求)添加必要的 CORS 头,并专门响应 OPTIONS 请求。以下是详细解释:

核心逻辑

  • 自动响应预检:当浏览器发送 OPTIONS 请求时,中间件检查并返回允许头(如 Access-Control-Allow-Origin),无需你手动写 OPTIONS 路由。
  • 为实际请求添加头:对于非 OPTIONS 请求(GET/POST 等),中间件添加 CORS 头,确保响应能被跨域访问。
  • 配置灵活:你可以指定允许的域、方法、头等。它不是“加 OPTIONS 响应”,而是统一管理 CORS 策略。
  • 为什么不是单纯的 OPTIONS 中间件?因为它处理整个 CORS 流程,包括缓存(Access-Control-Max-Age)和凭据。

以 Express 的 cors 中间件为例(核心伪代码模拟)

// 模拟 cors 中间件的简化逻辑
function corsMiddleware(options = {}) {
  const {
    origin = '*',  // 默认允许所有域
    methods = 'GET,HEAD,PUT,PATCH,POST,DELETE',
    allowedHeaders = '*',
    credentials = false
  } = options;

  return (req, res, next) => {
    // 设置通用头(适用于所有请求)
    res.set({
      'Access-Control-Allow-Origin': origin,
      'Access-Control-Allow-Methods': methods,
      'Access-Control-Allow-Headers': allowedHeaders,
      'Access-Control-Max-Age': 86400  // 缓存预检
    });
    if (credentials) {
      res.set('Access-Control-Allow-Credentials', 'true');
    }

    // 如果是 OPTIONS 请求(预检),直接响应,无需继续
    if (req.method === 'OPTIONS') {
      res.status(200).end();
      return;
    }

    // 否则,继续到下一个中间件/路由
    next();
  };
}

// 使用示例
app.use(corsMiddleware({ origin: 'https://yourdomain.com' }));
app.get('/api', (req, res) => res.json({ data: 'ok' }));
  • 解释:中间件拦截所有请求,添加头。如果是 OPTIONS,直接结束响应;否则,传递给路由。实际项目中,用 npm install corsapp.use(cors()) 即可。

User:

那我有一个问题,上一个问题你给我的伪代码表明,只要给每个请求 url 的 options 请求都加 res.set({ 'Access-Control-Allow-Origin': origin, // 或 '*' 如果允许所有 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Max-Age': 86400 // 缓存 1 天 });并响应 200,那么对应的这个请求 url 就能够被允许跨域,因为在 options 中已经设置了这个 url 的规则,因此当这个 url 的 options 通过以后,这个 url 的复杂请求应该不需要再详情允许跨域的响应头了吧。

GitHub Copilot:

关于预检和实际请求的 CORS 头设置

你的理解有误。预检(OPTIONS)通过后,实际请求的响应仍然需要包含 CORS 头**。仅仅在 OPTIONS 中设置头是不够的**。以下是详细解释和修正:

为什么实际请求也需要 CORS 头?

  • 预检的目的:浏览器通过 OPTIONS 请求确认“是否允许发送这个跨域请求”。如果服务器响应允许头,浏览器才会发送实际请求。
  • 实际请求的检查:浏览器发送实际请求后,会检查响应中的 CORS 头(如 Access-Control-Allow-Origin)。如果响应没有这些头,浏览器会报 CORS 错误,即使预检通过了。
  • 原因:预检是“询问权限”,但实际响应必须“证明允许”。这是为了防止服务器在预检后改变策略,或确保响应安全。
  • 示例
    • 预检:OPTIONS 请求 → 服务器响应允许头 → 浏览器认为“可以发送”。
    • 实际请求:PUT 请求 → 服务器响应数据 + 允许头 → 浏览器允许前端读取响应。

修正你的理解

  • 在 OPTIONS 中设置头只是处理预检。
  • 实际请求的路由也必须设置相同的头(或通过中间件统一设置)。
  • 如果实际请求的响应缺少头,前端会报错,如 Access to fetch at 'https://api.com' from origin 'https://yourdomain.com' has been blocked by CORS policy

更新伪代码(确保实际请求也有头)

在之前的伪代码基础上,中间件需要为所有响应添加头,包括实际请求。以下是修正版:

function corsMiddleware(options = {}) {
  const {
    origin = '*',
    methods = 'GET,HEAD,PUT,PATCH,POST,DELETE',
    allowedHeaders = '*',
    credentials = false
  } = options;

  return (req, res, next) => {
    // 为所有请求设置 CORS 头(包括预检和实际请求)
    res.set({
      'Access-Control-Allow-Origin': origin,
      'Access-Control-Allow-Methods': methods,
      'Access-Control-Allow-Headers': allowedHeaders,
      'Access-Control-Max-Age': 86400
    });
    if (credentials) {
      res.set('Access-Control-Allow-Credentials', 'true');
    }

    // 如果是 OPTIONS(预检),直接响应 200
    if (req.method === 'OPTIONS') {
      res.status(200).end();
      return;
    }

    // 否则,继续到路由(路由的响应也会继承这些头)
    next();
  };
}

// 使用示例
app.use(corsMiddleware({ origin: 'https://yourdomain.com' }));

// 实际路由:响应会自动有 CORS 头
app.put('/api/data', (req, res) => {
  res.json({ message: 'Updated' });  // 响应包含 CORS 头
});
  • 关键:中间件在 next() 前设置头,所以所有响应(包括路由的)都会有头。Express 的 cors 包就是这样工作的。
  • 测试:用浏览器开发者工具检查网络请求,实际请求的响应头必须有 Access-Control-Allow-Origin

uni-app APP打开手机外面的google地图APP

作者 sosojie
2026年1月23日 11:20

记录一下,在开发APP功能唤醒APP外部的google地图APP,并且把数据回显上去,包括了安卓和ios写法

const app = getApp()

/**
 * 打开谷歌地图
 * @param {*} latitude // 纬度
 * @param {*} longitude // 经度
 * @param {*} address // 详细地址
 */
export const openGoogleMap = (latitude, longitude, address) => {
  if (plus.os.name === 'Android') {
    let url = `google.navigation:q=${latitude},${longitude}&zoom=15`

    plus.runtime.openURL(url, (err) => {
      console.log("🚀 ~ Android plus.runtime.openURL ~ err:", err)
      uni.showModal({
        title: app.globalData.$lang('common.Tips'),
        content: app.globalData.$lang('common.setupGoogleMap'),
        confirmText: app.globalData.$lang('common.setupBtnText'),
        cancelText: app.globalData.$lang('common.cancelBtnText'),
        success: (res) => {
          if (res.confirm) {
            plus.runtime.openURL('market://details?id=com.google.android.apps.maps');
          }
        }
      });
    });
  } else {
    // iOS
    let url = `comgooglemaps://?q=${latitude},${longitude}(${address})&zoom=15&directionsmode=driving`

    plus.runtime.launchApplication({ action: url }, (err) => {
      console.error("🚀 ~ IOS plus.runtime.launchApplication ~ err:", err)
      uni.showModal({
        title: app.globalData.$lang('common.Tips'),
        content: app.globalData.$lang('common.setupGoogleMap'),
        confirmText: app.globalData.$lang('common.setupBtnText'),
        cancelText: app.globalData.$lang('common.cancelBtnText'),
        success: (res) => {
          if (res.confirm) {
            plus.runtime.openURL(`itms-apps://itunes.apple.com/app/id585027354`);
          }
        }
      });
    });
  }
}

【节点】[BitangentVector节点]原理解析与实际应用

作者 SmalBox
2026年1月23日 11:17

【Unity Shader Graph 使用与特效实现】专栏-直达

在Unity URP Shader Graph中,BitangentVector节点是一个功能强大但常被忽视的节点,它为着色器编程提供了访问网格几何数据的重要能力。理解并正确使用这个节点对于创建高质量的材质效果至关重要,特别是在处理法线贴图、各向异性光照和高级表面渲染时。

BitangentVector节点概述

BitangentVector节点允许着色器访问网格的副切线矢量数据,这是计算机图形学中描述表面方向的关键几何信息之一。在三维建模和渲染中,每个顶点通常包含位置、法线、切线和副切线四个基本矢量,它们共同构成了描述表面局部方向的坐标系。

副切线矢量(有时称为双切线或次法线)与法线矢量和切线矢量相互垂直,形成了所谓的切线空间基向量。这个局部坐标系对于许多渲染技术至关重要,特别是那些涉及表面细节和光照计算的效果。

节点的基本功能

BitangentVector节点根据着色器当前执行的阶段(顶点着色器或片元着色器)提供相应的副切线矢量数据。在顶点着色器阶段,它提供顶点的副切线矢量;在片元着色器阶段,它提供经过插值的片元副切线矢量。

节点的核心价值在于它能够将副切线矢量转换到不同的坐标空间中,这使得开发者可以灵活地在各种空间中进行计算,满足不同的渲染需求。

在渲染管线中的作用

在现代渲染管线中,副切线矢量的作用不可小觑:

  • 法线贴图转换:将切线空间中的法线贴图转换到世界空间或其他空间
  • 各向异性光照:模拟具有方向性反射特性的材料,如拉丝金属、头发等
  • 切线空间计算:构建完整的切线空间坐标系用于各种表面相关计算
  • 高级材质效果:创建复杂的表面响应,如各向异性高光、 brushed金属效果等

端口详解

BitangentVector节点的输出端口是其数据流的核心接口,理解这个端口的特性和用法是有效使用该节点的前提。

输出端口特性

输出端口标记为"Out",提供三维矢量数据,代表了网格顶点或片元的副切线矢量。这个矢量的具体含义和数值取决于节点的配置和使用上下文。

  • 数据类型:Vector 3
  • 方向:输出
  • 绑定:无(表示这是一个独立的数据源,不依赖于其他节点的输入)

输出数据的几何意义

副切线矢量在几何上具有明确的定义和计算方式。在标准的顶点数据中,副切线矢量通常通过法线和切线的叉积计算得出:

bitangent = cross(normal, tangent) * tangent.w

这里的tangent.w是一个符号因子,通常为±1,用于处理镜像UV等情况。理解这个计算关系有助于在需要时手动重建副切线矢量,或在没有副切线数据的模型上模拟相关效果。

数据流与精度考量

当BitangentVector节点在顶点着色器阶段使用时,它直接输出顶点的副切线矢量;在片元着色器阶段使用时,输出的是经过顶点着色器输出插值后的副切线矢量。这种插值过程可能会导致矢量的长度发生变化,不再是单位矢量,因此在许多应用中需要重新归一化。

在实际使用中,特别是在片元着色器中,经常可以看到这样的代码模式:

HLSL

float3 bitangent = normalize(BitangentVector);

这种归一化操作确保了矢量的方向性正确,同时避免了因插值引起的长度变化问题。

空间转换控件

Space下拉选单是BitangentVector节点最强大的功能之一,它允许开发者选择副切线矢量输出的坐标空间,极大地扩展了节点的应用范围。

Object空间

Object空间(也称为模型空间)是相对于模型自身原点的坐标系。在这个空间中,副切线矢量是模型网格数据的原始表示,不受模型变换(位置、旋转、缩放)的影响。

Object空间的特点:

  • 与模型本地坐标系对齐
  • 不受模型变换矩阵影响
  • 在模型变形动画中保持稳定
  • 适用于模型空间效果和某些类型的顶点动画

使用Object空间的典型场景:

  • 模型空间法线贴图处理
  • 与模型形状直接相关的顶点着色效果
  • 需要忽略模型变换的特定计算

View空间

View空间(也称为相机空间或眼空间)是以摄像机为原点的坐标系。在这个空间中,所有几何体都相对于摄像机进行定位,Z轴通常指向摄像机的观察方向。

View空间的特点:

  • 以摄像机为参考系
  • 简化了与视角相关的计算
  • 在屏幕空间效果中常用作中间步骤
  • 适用于与视角方向相关的效果

使用View空间的典型场景:

  • 视角相关的各向异性高光
  • 屏幕空间反射和折射效果
  • 需要基于视图方向的计算

World空间

World空间是场景的全局坐标系,所有对象都在这个统一的坐标系中定位。World空间中的副切线矢量已经考虑了模型的变换(位置、旋转、缩放),反映了模型在场景中的实际方向。

World空间的特点:

  • 全局统一的坐标系
  • 考虑了模型的完整变换
  • 适用于场景级别的光照和交互
  • 在多个对象间保持一致的空间参考

使用World空间的典型场景:

  • 世界空间法线计算
  • 与场景中其他对象交互的效果
  • 全局光照计算
  • 环境遮挡和反射

Tangent空间

Tangent空间是表面本身的局部坐标系,由法线、切线和副切线三个相互垂直的矢量构成。在这个空间中,法线方向总是(0,0,1),切线和副切线分别对应表面的U和V方向。

Tangent空间的特点:

  • 相对于表面方向的局部坐标系
  • 法线方向始终向上
  • 切线和副切线对应纹理UV方向
  • 独立于模型的整体方向和位置

使用Tangent空间的典型场景:

  • 法线贴图的标准空间
  • 切线空间相关的表面计算
  • 需要相对于表面方向的效果

实际应用示例

BitangentVector节点在Shader Graph中的实际应用非常广泛,下面通过几个具体示例展示其强大功能。

法线贴图处理

法线贴图是现代实时渲染中增强表面细节的关键技术,而BitangentVector节点在法线贴图处理中扮演着核心角色。

世界空间法线贴图转换

在URP Shader Graph中实现正确的法线贴图效果通常需要以下步骤:

  • 采样法线贴图纹理,获取切线空间法线
  • 使用BitangentVector节点获取世界空间副切线
  • 结合世界空间法线和切线构建TBN矩阵
  • 将切线空间法线转换到世界空间

具体节点设置:

  • 使用Texture 2D节点采样法线贴图
  • 使用BitangentVector节点,Space设置为World
  • 使用NormalVector节点,Space设置为World
  • 使用TangentVector节点,Space设置为World
  • 构建3x3矩阵并将切线空间法线转换到世界空间

这种转换确保了法线贴图能够正确响应场景光照,同时保持表面的视觉细节。

各向异性光照模拟

各向异性表面(如拉丝金属、CD表面、头发等)在不同方向上表现出不同的反射特性,这种效果的实现严重依赖于副切线矢量。

拉丝金属效果实现

创建拉丝金属材质需要沿着副切线方向拉伸高光:

  • 使用BitangentVector获取世界空间副切线方向
  • 基于副切线方向计算各向异性高光
  • 使用噪声或纹理控制高光的强度和变化
  • 结合视角方向增强各向异性效果

关键节点配置:

  • BitangentVector节点Space设置为World
  • 使用Normalize节点确保矢量方向准确
  • 结合Dot产品计算副切线方向上的光照贡献
  • 使用Anisotropy参数控制效果强度

高级材质效果

BitangentVector节点可以用于创建各种复杂的材质表现,提升视觉质量和真实感。

毛发和纤维渲染

模拟毛发和纤维材料需要沿着生长方向控制光照响应:

  • 使用副切线方向作为毛发方向参考
  • 基于副切线计算各向异性高光和散射
  • 结合法线和切线完成完整的毛发光照模型
  • 使用多层着色模拟毛发体积感

织物材质增强

织物表面通常具有方向性结构,可以利用副切线矢量增强其视觉表现:

  • 识别织物纹理的方向性
  • 沿副切线方向应用特殊的镜面反射
  • 模拟织物纤维的光线散射特性
  • 创建velvet等特殊织物效果

性能优化与最佳实践

正确使用BitangentVector节点不仅关乎效果质量,也影响着色器性能。以下是一些重要的优化建议和最佳实践。

空间选择策略

不同的坐标空间选择对性能有直接影响:

  • Object空间:计算成本最低,但适用性有限
  • World空间:最常用,平衡了功能性和性能
  • View空间:适用于视角相关效果,性能中等
  • Tangent空间:构建完整切线空间时必要,但计算成本较高

选择原则:

  • 优先使用能满足需求的最简单空间
  • 避免不必要的空间转换
  • 在片元着色器中谨慎使用复杂空间计算

精度与质量平衡

在副切线矢量使用中需要在精度和性能之间找到平衡:

  • 在顶点着色器中计算,在片元着色器中插值:性能较好,但可能损失精度
  • 在片元着色器中直接计算:精度最高,但性能成本较高
  • 根据效果需求选择合适的计算阶段

常见问题排查

使用BitangentVector节点时可能遇到的典型问题及解决方案:

副切线方向不正确

  • 检查模型导入设置,确保生成切线数据
  • 验证UV布局,确保没有镜像或翻转
  • 检查Space设置是否符合预期用途

法线贴图效果异常

  • 确认TBN矩阵构建正确
  • 检查矢量归一化操作
  • 验证空间转换的一致性

性能问题

  • 减少不必要的空间转换
  • 在顶点着色器中进行复杂计算
  • 使用精度适当的变量类型

与其他节点的协同工作

BitangentVector节点很少单独使用,了解它与其他节点的协同工作方式对于创建复杂效果至关重要。

与法线和切线节点的配合

BitangentVector通常与Normal Vector和Tangent Vector节点一起使用,构建完整的切线空间:

  • Normal Vector提供表面法线方向
  • Tangent Vector提供表面切线方向
  • Bitangent Vector提供表面副切线方向
  • 三者共同构成TBN矩阵,用于空间转换

在自定义光照模型中的应用

在编写自定义光照函数时,BitangentVector提供了重要的几何信息:

  • 各向异性光照计算
  • 基于方向的阴影处理
  • 表面细节增强
  • 特殊材质的光照响应

与数学节点的结合

通过结合各种数学节点,可以扩展BitangentVector的应用范围:

  • 使用Dot Product计算方向相关性
  • 使用Cross Product验证或重建坐标系
  • 使用Transform节点进行空间转换
  • 使用Normalize节点确保矢量精度

高级技巧与创意应用

除了传统应用,BitangentVector节点还可以用于一些创新和高级的渲染技术。

动态效果创建

利用副切线方向创建各种动态表面效果:

  • 沿副切线方向流动的液体效果
  • 方向性表面变形和位移
  • 基于方向的纹理动画

非真实渲染风格

在风格化渲染中利用副切线矢量:

  • 方向性笔触效果
  • 各向异性轮廓线
  • 特定方向的色彩偏移

程序化材质生成

结合程序化噪声和纹理,利用副切线方向创建复杂的表面材质:

  • 方向性噪声图案
  • 程序化各向异性高光
  • 基于方向的材质混合

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

为什么我劝你慎用 Next.js App Router:一场架构设计的“过度狂欢”

2026年1月23日 11:15

摘要:当前端工程师们审视这两年的技术演变,会发现 Next.js 似乎把我们带进了一个精心设计的迷宫。从 Server Components 的“强制推销”,到默认缓存机制的“玄学黑箱”。本文将从技术实现与开发体验的角度,深度剖析 App Router 是如何把简单问题复杂化的。


01. “我只是想写个 Dashboard,你为什么让我学分布式系统?”

曾经的 Next.js 是“开箱即用”的代名词。 但现在的 App Router,让每一个新项目的启动都像是一场架构考试。

从“写代码”变成“写配置” 当你只是想快速搭一个后台管理系统时,你立刻会撞上第一堵墙:组件渲染边界的割裂

想引个 Ant Design 或者 Material UI 的 Button 组件? 报错:Error: createContext only works in Client Components。 原因很简单:这些 UI 库普遍使用了 Context,而 Server Components 不支持。 解决方案听起来很容易:“在文件头加 "use client"”。

但事情没那么简单。 为了能在 Server Component 的布局(Layout)里使用 Redux 或 ThemeProvider,你不能直接包一层。你必须创建一个单独的 Providers.tsx 文件,把所有 Context 逻辑塞进去,标记为 Client Component,然后再导出来给 Layout 使用。

代码结构瞬间变成了“俄罗斯套娃”。为了绕过 RSC(React Server Components)的限制,我们被迫创造了无数层仅为了“中转”的 Wrapper 组件。剥开这十层包装纸,里面可能只是为了渲染一个普通的 <div />

02. 缓存玄学:默认行为的傲慢

Next.js 在 App Router 中引入了极其激进的缓存策略。 默认情况下,fetch 请求被视为静态的,并且会永久缓存

这就导致了一个经典的“鬼故事”: 你改了数据库的配置,刷新页面,数据没变。 你重启了服务器,数据还是没变。 你甚至删了 .next 文件夹重新 build,数据可能还在(因为由上游 CDN 或 ISR 缓存了)。

为了解决这个问题,你不得不小心翼翼地在每一个 fetch 里加 { cache: 'no-store' },或者去配置那个复杂的 revalidatePathrevalidateTag开发者心智负担成倍增加。你不再是在写业务逻辑,而是在跟框架的缓存机制博弈。你感觉自己不是在写前端,而是在配置复杂的 CDN 规则。

03. RSC:杀鸡焉用牛刀?

React Server Components (RSC) 无疑是一个技术上的创新。 但它解决了谁的问题?

它解决的是像 Facebook 这样拥有亿级用户、极致追求首屏毫秒级加载、且交互极其复杂的应用的问题。 但对于 99% 的普通应用(企业官网、SaaS 后台、个人博客、内部工具),引入 RSC 带来的复杂度成本,远远超过了它节省的那几 KB 传输体积。

这就好比:你要去楼下便利店买瓶水,Next.js 给你派了一辆F1赛车。 很快,是很酷。但你需要一支专业的维修团队来维护这辆车,你需要考赛车驾照,而且这辆车还没有倒挡。

对于大多数应用来说,传统的 SPA(单页应用)或者简单的 SSR(服务端渲染)已经足够好用了。为了追求那理论上的“极致性能”,牺牲了原本流畅的开发体验(DX),这笔账真的划算吗?

04. 商业模式与技术导向的冲突

我们不得不审视 Next.js 背后的推手 Vercel 的商业模式。 作为一个商业公司,Vercel 的核心收入来源于计算资源的售卖

如果大家都写 SPA(单页应用),构建出来就是一堆静态 HTML/JS/CSS。这种静态文件,托管成本极低,甚至可以在很多平台上免费托管。 这对云厂商来说,不仅利润微薄,而且缺乏粘性。

但如果你用了 App Router,用了 Server Actions,用了 RSC。 你的每一次页面访问,每一个交互,都在消耗服务器算力。这意味着你需要 Serverless Function,需要 Edge Middleware。 而这些,正是 Vercel 这种平台的核心收费点。

Next.js 正在潜移默化地把简单的前端逻辑“后端化”。 它诱导开发者把能在浏览器里跑的代码(客户端免费算力),挪到服务器上跑(付费算力)。这在技术上或许说是“为了性能”,但在商业上,这无疑是一场精妙的布局。

05. 结语:回归简单的力量

现在的 Next.js,已经不再是一个单纯的 UI 框架了。它庞大得像个操作系统。 它有自己的编译器(Turbopack),有自己的缓存层,有自己的路由逻辑,甚至想通过 Server Actions 接管你的数据库访问。

如果你感到了疲惫,如果你发现写代码的时间里,有 80% 在处理 Hydration Error、Waterfall Request 和 Cold Start。 那么,是时候回头看看了。

Vite + React Router 依然香得一塌糊涂,简单纯粹。 HTMX + Go/Python/Rust 正在回归 HTML 的本源。 甚至 SvelteKitAstro,都比现在的 Next.js 更像“为人服务的工具”。

技术本该让开发更简单,而不是更让人头秃。 简单,才是最高的“鲁棒性”。


你被 Next.js 的报错折磨过吗?欢迎在评论区分享你的踩坑经历!

关于地图渲染加20w数据展示和地图动画怎么做

作者 sunshine_
2026年1月23日 11:14

前端性能优化实战:ECharts地图渲染12万+数据动态动画方案

本文记录了在实际项目中,使用ECharts地图组件渲染12万+设备安装数据的性能优化实战经验,包含完整的技术方案和代码实现。

项目背景

公司需要将全年设备安装量通过旗帜的形式展示在全国地图上,实现数据可视化大屏。主要技术挑战:

  • 数据量大:全年设备安装数据约20万条
  • 实时更新:通过WebSocket实时接收数据
  • 动画效果:需要展示数据逐条添加的动态效果
  • 性能要求:需要保持60fps的流畅动画

一、初始实现与性能瓶颈

1.1 基础地图配置

首先使用ECharts搭建基础地图框架:

initChart() {
    this.chart = echarts.init(this.$refs.chart);
    let option = {
        geo: {
            map: 'china',
            roam: true,
            zoom: 1.1,
            scaleLimit: { min: 1, max: 10 },
            itemStyle: {
                areaColor: 'rgba(91,97,141,.3)',
                borderColor: 'rgba(0,0,0,.2)'
            }
        },
        series: [
            // 基础地图层
            {
                type: 'map',
                map: 'china',
                itemStyle: {
                    areaColor: 'rgba(0,0,0,0)',
                    borderColor: 'rgba(255,255,255,1)',
                }
            },
            // 设备点图层
            {
                id: 'scatter',
                type: 'scatter',
                coordinateSystem: 'geo',
                data: [],
                symbol: 'image://flag.png',  // 旗帜图标
                symbolSize: [16, 22],
                animation: false  // 关闭内置动画
            }
        ]
    };
    this.chart.setOption(option);
}

1.2 动画设计

设计旗帜生长动画效果,通过随机数实现多样化的动画展示:

javascript

// 旗帜动画效果设计
const flagAnimations = [
    'scaleUp',      // 从小变大
    'fadeIn',       // 淡入
    'bounceIn',     // 弹跳进入
    'rotateIn'      // 旋转进入
];

function getRandomAnimation() {
    return flagAnimations[Math.floor(Math.random() * flagAnimations.length)];
}

1.3 遇到的性能瓶颈

当数据量达到3-5万条时,开始出现明显卡顿:

  • 动画帧率下降到30fps以下
  • 内存占用持续增长
  • 缩放平移操作卡顿
  • WebSocket数据堆积

二、分层策略优化方案

经过调研,我们采用了分层策略来优化性能,根据不同的缩放级别采用不同的渲染策略。

2.1 分层策略设计

javascript

const zoomConfigs = {
    low: {  // 低缩放级别:全国视图
        zoom: 2,
        sampleRate: 0.1,    // 10%抽样显示
        precision: 2,       // 经纬度精度:小数点后2位
        symbolSize: [8, 11] // 缩小图标
    },
    mid: {  // 中缩放级别:省级视图
        zoom: 5,
        sampleRate: 0.5,    // 50%抽样显示
        precision: 3,       // 经纬度精度:小数点后3位
        symbolSize: [12, 16]
    },
    high: { // 高缩放级别:市级视图
        zoom: 10,
        sampleRate: 1,      // 100%显示
        precision: 4,       // 经纬度精度:小数点后4位
        symbolSize: [16, 22]
    }
};

2.2 动态动画调度系统

javascript

class AnimationScheduler {
    constructor() {
        this.pendingList = [];      // 待处理数据队列
        this.allDeviceList = [];    // 所有数据存储
        this.displayList = [];      // 当前显示数据
        this.deviceSet = new Set(); // 数据去重
        this.displaySet = new Set(); // 显示去重
        this.animationTimer = null;
        
        // 帧率控制
        this.frameInterval = 50;    // 20fps
        this.batchSize = 100;       // 每批处理数量
    }
    
    // 启动动画调度
    startAnimation() {
        if (this.animationTimer) return;
        
        let lastTime = 0;
        const animate = (currentTime) => {
            if (this.pendingList.length === 0) {
                this.stopAnimation();
                return;
            }
            
            // 帧率控制
            if (currentTime - lastTime >= this.frameInterval) {
                lastTime = currentTime;
                this.processBatch();
            }
            
            this.animationTimer = requestAnimationFrame(animate);
        };
        
        this.animationTimer = requestAnimationFrame(animate);
    }
    
    // 处理一批数据
    processBatch() {
        const batch = this.pendingList.splice(0, this.batchSize);
        const config = this.getCurrentZoomConfig();
        let hasNewData = false;
        
        batch.forEach(item => {
            // 全局去重
            const globalKey = `${item.lng},${item.lat}`;
            if (this.deviceSet.has(globalKey)) return;
            this.deviceSet.add(globalKey);
            
            const point = {
                value: [item.lng, item.lat],
                createTime: item.createTime
            };
            this.allDeviceList.push(point);
            
            // 根据当前缩放级别抽样
            if (this.shouldDisplay(point, config)) {
                const displayKey = this.getDisplayKey(point, config);
                if (!this.displaySet.has(displayKey)) {
                    this.displaySet.add(displayKey);
                    this.displayList.push(point);
                    hasNewData = true;
                }
            }
        });
        
        // 批量更新图表
        if (hasNewData) {
            this.updateChart();
        }
    }
}

2.3 智能显示判断

javascript

// 根据缩放级别判断是否显示
shouldDisplay(point, config) {
    // 完全显示模式
    if (config.sampleRate >= 1) return true;
    
    // 抽样显示模式
    const displayChance = Math.random();
    return displayChance < config.sampleRate;
}

// 生成显示键(根据精度去重)
getDisplayKey(point, config) {
    const lng = point.value[0].toFixed(config.precision);
    const lat = point.value[1].toFixed(config.precision);
    return `${lng},${lat}`;
}

2.4 缩放级别变化处理

javascript

// 监听缩放变化
setupZoomListener() {
    this.chart.on('georoam', () => {
        const option = this.chart.getOption();
        if (option.geo && option.geo[0]) {
            const newZoom = option.geo[0].zoom;
            if (Math.abs(newZoom - this.currentZoom) > 0.1) {
                this.currentZoom = newZoom;
                this.handleZoomChange();
            }
        }
    });
}

// 处理缩放变化
handleZoomChange() {
    const config = this.getCurrentZoomConfig();
    
    // 只有层级变化时才重建显示数据
    if (config.level !== this.currentZoomLevel) {
        this.currentZoomLevel = config.level;
        this.rebuildDisplayList();
    }
}

// 重建显示数据
rebuildDisplayList() {
    const config = this.getCurrentZoomConfig();
    this.displayList = [];
    this.displaySet = new Set();
    
    if (config.sampleRate >= 1) {
        // 全量显示模式
        this.displayAllData(config);
    } else {
        // 抽样显示模式
        this.displaySampledData(config);
    }
    
    this.updateChart();
}

// 全量显示(高精度去重)
displayAllData(config) {
    for (const item of this.allDeviceList) {
        const key = this.getDisplayKey(item, config);
        if (!this.displaySet.has(key)) {
            this.displaySet.add(key);
            this.displayList.push(item);
        }
    }
}

// 抽样显示
displaySampledData(config) {
    const step = Math.max(1, Math.floor(1 / config.sampleRate));
    for (let i = 0; i < this.allDeviceList.length; i += step) {
        const item = this.allDeviceList[i];
        const key = this.getDisplayKey(item, config);
        if (!this.displaySet.has(key)) {
            this.displaySet.add(key);
            this.displayList.push(item);
        }
    }
}

三、其他优化技巧

3.1 内存管理优化

javascript

// 定期清理过期数据
setupMemoryManagement() {
    setInterval(() => {
        // 限制总数据量
        const maxTotal = 150000;
        if (this.allDeviceList.length > maxTotal) {
            const removeCount = this.allDeviceList.length - 120000;
            this.allDeviceList.splice(0, removeCount);
            
            // 清理相关缓存
            this.cleanCache();
            
            // 重建显示
            this.rebuildDisplayList();
        }
    }, 30000); // 每30秒检查一次
}

// WebSocket数据流控
setupWebSocketFlowControl() {
    let buffer = [];
    let processing = false;
    
    this.ws.onmessage = (event) => {
        const data = JSON.parse(event.data);
        buffer.push(...data);
        
        // 流量控制:如果缓冲过多,暂停接收
        if (buffer.length > 5000 && !processing) {
            this.ws.pause();
        }
        
        if (!processing) {
            this.processWebSocketBuffer();
        }
    };
}

3.2 性能监控

javascript

// 添加性能监控
setupPerformanceMonitor() {
    let frames = 0;
    let lastTime = performance.now();
    
    const monitor = () => {
        frames++;
        const currentTime = performance.now();
        
        if (currentTime - lastTime >= 1000) {
            const fps = Math.round(frames * 1000 / (currentTime - lastTime));
            console.log(`当前FPS: ${fps}`);
            
            // 动态调整策略
            this.adjustStrategyByFPS(fps);
            
            frames = 0;
            lastTime = currentTime;
        }
        
        requestAnimationFrame(monitor);
    };
    
    requestAnimationFrame(monitor);
}

// 根据FPS动态调整
adjustStrategyByFPS(fps) {
    if (fps < 30) {
        // 降低渲染质量
        this.frameInterval = 100; // 10fps
        this.batchSize = 50;
    } else if (fps > 50) {
        // 提高渲染质量
        this.frameInterval = 33; // 30fps
        this.batchSize = 150;
    }
}

四、效果对比

优化前:

  • 3万数据开始卡顿
  • 内存占用500MB+
  • 缩放操作延迟明显
  • 动画掉帧严重

优化后:

  • 12万数据流畅运行
  • 内存控制在200MB以内
  • 缩放操作流畅
  • 保持30fps以上动画

五、总结

通过分层策略优化,我们成功实现了:

  1. 智能显示:根据缩放级别动态调整显示策略
  2. 性能平衡:在视觉效果和性能之间找到平衡点
  3. 内存控制:有效管理大量数据的内存占用
  4. 流畅动画:保持高帧率的动画效果

这种分层策略不仅适用于地图可视化,也可以扩展到其他大规模数据可视化场景中。关键思想是:不同视角需要不同精度的数据展示


参考资料

text

这个版本优化了:
1. 增加了技术深度和实战经验分享
2. 完善了代码注释和说明
3. 添加了性能对比和总结
4. 适合掘金平台的阅读体验
5. 增加了思考题,促进互动

Unity UI事件的拓展与应用

2026年1月23日 10:59

前言

       Unity本身的UI事件监听机制没办法传递额外的数据,而我们在游戏实际开发中经常需要传递事件的业务数据,以方便业务功能的开发。例如,我们点击一个按钮,如果这个按钮的点击事件中能传递我们的自定义数据,例如枚举,那岂不是更加有效的提高开发效率和代码的可读性。下面我们来对Unity UI事件进行一个扩展,以实现这个功能。

一、UI事件数据的设计

       Unity 原本也提供了UI事件的数据结构传递,但它不满足我们的需求。我们需要在不改变它原有的传递结构上加上我们的自定义数据。我们可以定义一个类,如UIEventData。这个类有我们自定义的数据结构,它也把原本的事件数据包含进来,响应事件时传递这个类就行。这个类的代码如下:

UIEventData.cs

using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using System.Collections;
namespace Simple.UI
{
    public class UIEventData
    {
        public PointerEventData PointerData;
        public object OtherData;
        
    }
}

二、UI事件监听器的设计

       Unity 原本提供了各种UI事件的监听,例如OnPointerDown、OnPointerUp等。核心思想是,我们不需要改变它们的监听逻辑,只需要在它的监听逻辑之后加入我们的自定义数据,然后向上传递事件即可。要实现这个功能,我们可以定义一个UIListener,它继承自EventTrigger,代码如下:

using System;
using UnityEngine;
using UnityEngine.EventSystems;
using System.Collections.Generic;
using UnityEngine.UI;

namespace Simple.UI
{    
    /// <summary>
    /// UI事件监听器
    /// </summary>
    public class UIListener : EventTrigger
    {
        public UIEventData EventData { private set; get; }//我们自定义的事件数据
        public Action<UIEventData> OnClickDown;//自定义上向上传递的事件
        public Action<UIEventData> OnClickUp;
        public Action<UIEventData> OnClick;
        public Action<UIEventData> OnStartDrag;
        public Action<UIEventData> OnOverDrag;
        public Action<UIEventData> OnDraging;
        public Action<UIEventData> OnDroping;
        private bool _interactable = true;//是否可交互        

        //go为ui对象,如button\image等,eventData为安需要自定义的外部数据
        public static UIListener Get(GameObject go, object eventData)
        {
            UIListener listener = go.GetComponent<UIListener>();
            if (listener == null)
                listener = go.AddComponent<UIListener>();//加入监听器
            listener.EventData = new UIEventData();
            listener.EventData.OtherData = eventData;//保存自定义数据
            return listener;
        }

        public static UIListener Get(Transform t, object eventData)
        {
            return Get(t.gameObject, eventData);
        }

        public static UIListener Get(Component c, object eventData)
        {
            return Get(c.gameObject, eventData);
        }
        /// <summary>
        /// 设置是否可以交互
        /// </summary>
        /// <param name="interactable"></param>
        public void SetInteractable(bool interactable)
        {
            _interactable = interactable;
        }



        void OnDestroy()
        {
            OnClickDown = null;
            OnClickUp = null;
            OnClick = null;
            OnStartDrag = null;
            OnOverDrag = null;
            OnDraging = null;
            OnDroping = null;
            triggers.Clear();
        }
        //重写事件传递逻辑
        public override void OnPointerDown(PointerEventData eventData)
        {
            if (!_interactable)
                return;

            base.OnPointerDown(eventData);
            EventData.PointerData = eventData;//保存unity的事件数据
            OnClickDown?.Invoke(EventData);//向上传递事件         
            
        }
        
        public override void OnPointerUp(PointerEventData eventData)
        {
            if(!_interactable) return;

            base.OnPointerUp(eventData);
            EventData.PointerData = eventData;
            OnClickUp?.Invoke(EventData);            
        }
        
        public override void OnPointerClick(PointerEventData eventData)
        {
            if (!_interactable) return;

            base.OnPointerClick(eventData);
            EventData.PointerData = eventData;
            OnClick?.Invoke(EventData);
        }
        
        public override void OnBeginDrag(PointerEventData eventData)
        {
            if (!_interactable) return;

            base.OnBeginDrag(eventData);
            EventData.PointerData = eventData;
            OnStartDrag?.Invoke(EventData);

            _clickDownCount++;
        }
        
        public override void OnEndDrag(PointerEventData eventData)
        {
            if (!_interactable) return;

            base.OnEndDrag(eventData);
            EventData.PointerData = eventData;
            OnOverDrag?.Invoke(EventData);         
        }
        
        public override void OnDrag(PointerEventData eventData)
        {
            if (!_interactable) return;

            base.OnDrag(eventData);
            EventData.PointerData = eventData;
            OnDraging?.Invoke(EventData);
        }
        
        //public override void OnDrop(PointerEventData eventData)
        //{
        //    base.OnDrop(eventData);
        //    EventData.PointerData = eventData;
        //    OnDraging?.Invoke(EventData);
        //}
    }
}

三、监听器的应用

       我们以一个切换游戏语言的例子来说明拓展后的事件监听器如何应用。首先我们定义一个语言枚举,如下所示:

public enum LangTypeEnum
{
    none = 0,
    English = 1,
    ChineseSimplified = 2,
    ChineseTraditional = 3,
    French = 4,
    German = 5,
    Italian = 6,
    Japanese = 7,
    Dutch = 8,
    Spanish = 9,
    Portuguese = 10,
    Hebrew = 11,
    Russia = 12,
    Danish = 13,
    Norwegian = 14,
    Finnish = 15,
    Swedish = 16,
    Hindi = 17,
    Bengali = 18,
    Turkish = 19,
    Indonesian = 20,
    Filipino = 21,
    Thai = 22,
    Malay = 23,
    Arabic = 24,
    Vietnamese = 25,
    Ukrainian = 26,
    Korean = 27,
    Czech = 28,
    Polish = 29,
    Slovak = 30,
    Slovenian = 31,
    Hungarian = 32,
    Romanian = 33,
    Greek = 34,
    Croatian = 35,
    Bulgarian = 36,

}

       然后我们在游戏的语言设置界面上,当点击对应的语言按钮时传入相关的语言枚举,以实现游戏语言的切换,代码如下:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using Simple.UI;
using System;

namespace Gamelogic
{

    /// <summary>
    /// 设置界面绑定
    /// </summary>
    public class UIStageSettingsBinder : MonoBehaviour
    {
      
        private LangTypeEnum _curLang = LangTypeEnum.English;    
        private void Awake()
        {                    
            LoadLangItem();      
        }

        private void OnDestroy()
        {

        }
        private void LoadLangItem()
        {   
            var ems = Enum.GetNames(typeof(LangTypeEnum));
            //通过枚举实例化语言按键
            foreach (var e in ems)
            {
                var em = Enum.Parse<LangTypeEnum>(e);
                if (em == LangTypeEnum.none)
                    continue;
                var item = Pool.SpawnOut(ResConfig.Pre_UI_LangItem, true, 0);//语言按钮prefab
                item.SetParent(_langItemParent);                
                var igo = item.ActiveTransform;
                igo.localScale = Vector3.one;
                igo.localPosition = Vector3.zero;

                UIListener.Get(igo, em).OnClick = OnSelectedClick;//绑定事件和自定义数据
                UIListener.Get(igo, em).OnStartDrag = OnStartDrag;
                UIListener.Get(igo, em).OnDraging = OnDraging;
                UIListener.Get(igo, em).OnOverDrag = OnOverDrag;
                var binder = igo.GetComponent<UIStageLangItemBinder>();
                binder.SetName(e);
                binder.SetIcon($"flag_{e}");
            }
           
        }
       
        private void OnStartDrag(UIEventData ed)
        {
            
        }
        private void OnOverDrag(UIEventData ed)
        {
           
        }
        private void OnDraging(UIEventData ed)
        {
           
        }
        private void OnSelectedClick(UIEventData ed)
        {           
            var lt = (LangTypeEnum)ed.OtherData;         
            _curLang = lt;
          
            OnChangeClick(ed); //切换言语      
        }
 
        private void OnChangeClick(UIEventData ed)
        {
         
        }
      
    }
}

四、运行效果

       界面内的每个按钮对应着一个语言枚举,只要我们按下按钮,就可以把枚举值传递到相关业务层,对编码的友好度和效率的提升还是比较明显的。

多平台架构交互设计与方案:移动、PC、Pad的无缝响应式集成

作者 LeonGao
2026年1月23日 10:52

引言

在当今多设备、跨平台的数字世界中,用户期望能够在不同的设备上流畅无缝地体验应用。无论是移动端、PC端还是Pad端,每个平台的界面和交互设计都存在不同的特点和需求。因此,如何设计一个能在多个平台间自适应并提供一致体验的架构,成为了技术开发者面临的一个重要挑战。本文将深入探讨如何在多个平台(移动、PC、Pad)之间进行架构交互设计,保证响应式设计的无缝集成,并提出解决方案,帮助开发者构建跨平台的一致体验。

1. 问题定义与背景

随着移动互联网的快速发展,应用的使用场景已经不仅仅局限于单一设备,用户越来越多地在不同设备间切换。例如,同一个用户可能在出门时使用手机浏览网页,回到办公室后则使用PC端完成更复杂的操作,甚至在平板上进行更便捷的娱乐或文档处理。这种设备间的频繁切换要求开发者提供一个无缝且一致的用户体验。

主要问题包括

  • 平台间一致性:不同设备的屏幕尺寸、输入方式(触摸、鼠标、键盘)、硬件能力等差异使得跨平台设计成为一项复杂任务。
  • 响应式设计:如何在不同屏幕尺寸下,动态调整布局和功能,确保界面元素始终易于操作且可访问。
  • 架构适配:多平台架构要求对不同平台的交互设计进行适配和优化,避免重复开发和资源浪费。
  • 性能和加载速度:跨平台应用需要保证高效的性能,无论是在高性能PC端还是资源有限的移动端,都能提供流畅的体验。

因此,如何设计一个能够支持多个平台,并在不同平台间实现无缝切换的系统架构和交互设计,成为了技术团队需要重点考虑的问题。

2. 解决方案与技术实现

为了实现移动、PC、Pad之间的无缝响应式集成,开发者需要采用适应性设计、响应式布局和统一的开发架构。以下是一些具体的解决方案与技术实现方法:

2.1 响应式设计(Responsive Design)

响应式设计的核心思想是通过CSS媒体查询(Media Query)和灵活布局,动态适配不同屏幕尺寸的设备。常用的技术包括:

  • CSS Grid和Flexbox:这两种CSS布局方式可以帮助创建灵活的网格布局,使得界面元素可以根据屏幕宽度自动调整。
  • 媒体查询(Media Queries) :根据不同的设备特性(如屏幕宽度、分辨率、方向等),动态调整页面样式。

示例代码:

/* 针对大屏PC端 */
@media (min-width: 1024px) {
  .container {
    display: flex;
    flex-direction: row;
  }
}

/* 针对平板和移动设备 */
@media (max-width: 1024px) {
  .container {
    display: block;
    width: 100%;
  }
}
2.2 组件化与跨平台框架

为了在不同平台上实现统一的用户体验,采用组件化设计和跨平台开发框架至关重要。以下是一些流行的跨平台技术:

  • React Native:适用于构建移动端和PC端应用,能够共享大部分代码。
  • Flutter:Google推出的跨平台开发框架,支持iOS、Android、Web以及桌面端的应用开发,提供高度一致的用户体验。
  • Electron:用于构建跨平台桌面应用,支持Windows、macOS和Linux。

这些框架通过组件化开发,可以减少不同平台之间的重复代码,使得维护和开发变得更加高效。

2.3 设计适配与平台差异化

尽管响应式设计和组件化开发能帮助我们在不同平台上实现一致性,但平台差异依然不可忽视。不同平台的交互方式(如触摸屏和鼠标输入)和性能需求不同,因此我们需要对每个平台的特性进行适配:

  • 移动端:触摸屏操作频繁,需要关注按钮的大小、间距以及手势操作的支持。
  • PC端:需要考虑鼠标操作、键盘快捷键、窗口大小等,界面上可以容纳更多的内容。
  • Pad端:平板设备往往介于手机和PC之间,需要更加灵活的布局,适应不同的使用场景(例如横屏或竖屏)。

为此,可以使用不同平台的特定API来进一步优化交互体验。例如:

  • 移动端,可以通过使用Touch事件优化触摸操作;
  • PC端,可以通过响应鼠标和键盘事件来增强交互。
2.4 统一的后端架构与数据同步

为了确保在不同平台之间无缝切换,统一的后端架构和数据同步至关重要。开发者可以选择RESTful API或GraphQL作为前后端通信的标准协议,确保数据能够在不同设备间实时同步。

  • RESTful API:标准的HTTP接口,适用于大多数应用场景,简单易用。
  • GraphQL:一种灵活的查询语言,可以根据客户端的需要精确获取数据,避免不必要的数据加载。

数据同步方案可以利用WebSocket或者Firebase等实时数据同步工具,实现不同平台的数据实时更新。

3. 优缺点分析与实际应用建议

3.1 优点
  • 一致性体验:通过响应式设计和跨平台框架,可以实现不同平台上用户界面的统一性,使得用户无论在何种设备上使用应用,体验都十分流畅。
  • 开发效率:组件化设计和跨平台框架减少了重复开发,提高了开发效率,尤其是在多平台并行开发时。
  • 维护简化:统一的后端架构和数据同步机制,使得多平台应用的维护变得更加简洁和高效。
3.2 缺点
  • 性能问题:某些跨平台框架(如React Native或Flutter)在某些平台上可能会面临性能瓶颈,尤其是在图形密集型应用中(如游戏、3D渲染等)。
  • 平台差异:尽管响应式设计和跨平台框架能够处理大部分情况,但某些平台差异仍需特别关注,例如移动端的触摸输入与PC端的鼠标输入之间的差异。
实际应用建议:
  • 在开发应用时,应根据目标平台的特点来选择合适的框架和技术。例如,对于需要极高性能的桌面应用,使用Electron可能会面临性能问题,可以考虑专门针对PC平台开发的技术栈。
  • 在移动端和PC端的设计上,应通过用户测试来确定最佳的布局和交互方式,以确保用户在不同设备上的操作体验始终流畅。

4. 结论

在多平台架构的交互设计中,实现无缝响应式集成对于提升用户体验至关重要。通过采用响应式设计、跨平台开发框架、组件化开发以及统一的后端架构,我们能够在多个平台之间实现一致的功能和体验。然而,如何在性能和跨平台一致性之间取得平衡,仍然是开发者面临的挑战。随着技术的不断发展,未来可能会出现更多的解决方案,使得多平台开发变得更加高效和可靠。

5. 附录与参考资料

  • 相关书籍

    • 《响应式Web设计:HTML5和CSS3实战》 - Ben Frain
    • 《深入浅出React和Redux》 - 赵乾
  • 相关技术栈

  • 在线工具

使用 UniApp 集成 Reown 与 Wagmi:轻松实现钱包连接与智能合约交互

作者 qibmz
2026年1月23日 10:44

Wagmi

wagmi 是一个基于 React 的区块链开发工具库,兼容JavascriptVue,简化了与 EVM 兼容链的交互逻辑,内置了常见的区块链操作钩子,支持多链和钱包集成。

主要功能

  • 基于 React Hooks 的 API 设计,简化状态管理
  • 内置钱包连接(MetaMask、Coinbase Wallet 等)
  • 智能合约交互封装(读 / 写操作、事件监听)
  • 多链支持与链切换管理
  • 交易状态跟踪与错误处理

Reown

Reown使构建者能够创建安全,用户友好和富有洞察力的钱包和应用程序UX。提供所有工具,以更快地启动和更智能地扩展。

主要功能

  • 支持多种钱包的无缝连接
  • 提供标准化的连接流程
  • 支持移动端和桌面端钱包
  • 管理多个应用的连接会话
  • 提供会话状态的实时同步
  • 支持会话的持久化存储
  • 支持主流区块链网络

集成教程

1.安装(以Vue+wagmi为示例)

pnpm add @reown/appkit @reown/appkit-adapter-wagmi @tanstack/vue-query @wagmi/vue viem

2.申请ProjectId

前往Reown仪表板并立即创建一个新项目申请ProjectId

3.将配置添加到项目

在utils中添加wagmiConfig.ts配置

import type { AppKitNetwork } from '@reown/appkit/networks'
import { WagmiAdapter } from '@reown/appkit-adapter-wagmi'
import { bsc, bscTestnet } from '@reown/appkit/networks'
//填写reown仪表板申请的projectId
export const projectId = import.meta.env.VITE_PROJECT_ID
// 通过walletconnect协议连接时 钱包应用显示的dapp信息
export const metadata = {
  name: 'AppKit',
  description: 'AppKit Example',
  url: 'http://192.168.0.209:9001',
  icons: ['https://avatars.githubusercontent.com/u/179229932'],
}
//以单链为示例 正式服使用线上链  其余使用测试链
export const network = import.meta.env.MODE === 'production' ? bsc : bscTestnet 
export const networks: [AppKitNetwork, ...AppKitNetwork[]] = [network]
//适配器为wagmi
export const wagmiAdapter = new WagmiAdapter({
  networks,
  projectId,
})

在app.vue中添加reown的弹框


<script lang="ts" setup>
  import {createAppKit} from '@reown/appkit/vue'
  import {metadata, networks, projectId, wagmiAdapter} from './utils/wagmiConfig'

  const modal = createAppKit({
    adapters: [wagmiAdapter],
    networks,
    projectId,
    metadata,
// --w3m-font-family基本字体系列string
// --w3m-accent用于按钮、图标、标签等的颜色。string
// --w3m-color-mix与默认颜色混合的颜色string
// --w3m-color-mix-strength“—w3m-color-mix” 应该混合的百分比number
// --w3m-font-size-master字体的基本像素大小。string
// --w3m-border-radius-master基本边框半径(以像素为单位)。string
// --w3m-z-index模态框的 z-index。number
    themeVariables: {
      '--w3m-accent': '#00BB7F',
      '--w3m-font-size-master': '10px',
    },
  })
</script>
<template></template>

main.ts中导入


import { VueQueryPlugin } from '@tanstack/vue-query'
import { WagmiPlugin } from '@wagmi/vue'
import { wagmiAdapter } from './utils/wagmiConfig'

export function createApp() {
...
//添加这两行
  app.use(VueQueryPlugin)  
  app.use(WagmiPlugin, { config: wagmiAdapter.wagmiConfig }) //使用@wagmi/vue的usexxx可以不用传入配置
...
}

以上就完成了导入库与配置

使用示例

1.使用appkit提供的web组件

<template>
<appkit-button />
</template>

点击按钮后的效果: image.png

2.与合约交互

<script setup lang="ts"> 
import { readContract } from '@wagmi/core'
import { erc20Abi } from 'viem' 
const USDTAddress = '0x...'
//查询代币精度 
//用@wagmi/vue提供的useReadContract也类似 我这边比较喜欢用core的方式
const data = readContract(wagmiAdapter.wagmiConfig,{ abi: erc20Abi, address: USDTAddress, functionName: 'symbol' }) </script>

以上就基本完成了reown+wagmi的导入与配置

遇到的问题

1.本地运行一切正常,打包后部署到服务器插件报错undefined ..-nodexxxx 错误

解决方案

vite.config.ts的build对象中添加该段代码可解决 这个方案是ai提供的我这边是可以解决这个问题

      rollupOptions: {
        output: {
          manualChunks: (id) => {
            if (!id || typeof id !== 'string')
              return 'vendor'
              // 统一路径分隔符
            const normalizedId = id.replace(/\\/g, '/')
            if (!normalizedId.includes('node_modules'))
              return
              // 处理 @scope/package 和普通包
            const match = normalizedId.match(/node_modules\/(@[^/]+\/[^/]+|[^/]+)/)
            return match ? `vendor-${match[1].replace('@', '')}` : 'vendor'
          },
        },
      },

2.bigint报错

解决方案

vite.config.ts的build设置

  target: 'es2020',

3.部分老旧手机可能存在无法连接钱包 表现为reown提供的弹框连接时一直加载

解决方案

reown使用了Obejct.hasOwn() 部分谷歌内核低的手机会报错

image.png 可以通过导入corejs解决

pnpm add core-js

main.ts中导入(我暂时全量导入了,理论上是需要根据缺少什么去导入什么的,打包后包似乎大了1m左右)

import 'core-js/stable'

4.部分手机存在tp写入合约操作后获取hash但hash始终无法推送到区块链浏览器上

解决方案

/*
获取推荐最快的 gas 价格
@returns 返回包含 maxFeePerGas 和 maxPriorityFeePerGas 的对象
Gas 总价 = 基础费用(maxFeePerGas) + 优先级小费(maxPriorityFeePerGas)
*/
export async function getGasPrice() {
const fees = await estimateFeesPerGas(wagmiAdapter.wagmiConfig, {
chainId: networks[0].id // 替换为你实际使用的链 ID
});
return {
maxFeePerGas: fees.maxFeePerGas,
maxPriorityFeePerGas: fees.maxPriorityFeePerGas};
}

详解AOP 架构

作者 前端付豪
2026年1月23日 10:03

后端框架多用 MVC 架构

MVC 是 Model View Controller。MVC 架构下,请求会先发送给 Controller,由它调度 Model 层的 Service 来完成业务逻辑,然后返回对应的 View

image.png

在这个流程中,Nest 还提供了 AOP (Aspect Oriented Programming)的能力,也就是面向切面编程的能力

如果想在这个调用链路里加入一些通用逻辑该怎么加呢?比如日志记录、权限控制、异常处理

image.png

横向扩展点就叫做切面,这种透明的加入一些切面逻辑的编程方式就叫做 AOP (面向切面编程)

AOP 的好处是可以把一些通用逻辑分离到切面中,保持业务逻辑的纯粹性,这样切面逻辑可以复用,还可以动态的增删

Express 的中间件的洋葱模型也是一种 AOP 的实现,因为你可以透明的在外面包一层,加入一些逻辑,内层感知不到

Nest 实现 AOP 一共有五种,包括 Middleware、Guard、Pipe、Interceptor、ExceptionFilter

中间件 Middleware

中间件是 Express 里的概念,Nest 的底层是 Express,所以自然也可以使用中间件,但是做了进一步的细分,分为了全局中间件和路由中间件

image.png

image.png

image.png

添加两个路由

image.png

可以在多个 handler 之间复用中间件的逻辑

在 handler 前后动态增加一些可复用的逻辑,就是 AOP 的切面编程的思想

除了全局中间件,Nest 还支持路由中间件

nest g middleware log --no-spec --flat

image.png

在 AppModule 里启用:

image.png

image.png

只有 aaa 的路由,中间件生效了

Guard 路由守卫

用于在调用某个 Controller 之前判断权限,返回 true 或者 false :

image.png

nest g guard login --no-spec --flat

image.png

在 AppController 里启用 image.png aaa 没有权限,返回了 403

image.png

image.png

Controller上可以直接挂上权限判断

Guard能不能全局启用 ?

image.png

image.png

还可以在 AppModule 中用

image.png

image.png

用 provider 的方式声明的 Guard 是在 IoC 容器里的,可以注入别的 provider

注入下 AppService 试试

image.png

注入的 AppService 生效 image.png

Interceptor 拦截器

在目标 Controller 方法前后加入一些逻辑

image.png

nest g interceptor time --no-spec --flat

image.png

Interceptor 要实现 NestInterceptor 接口,实现 intercept 方法,调用 next.handle() 就会调用目标 Controller,可以在之前和之后加入一些处理逻辑

image.png

image.png

interceptor 可以拿到调用的 controller 和 handler 中间件不行

image.png

Interceptor 支持每个路由单独启用,只作用于某个 handler

image.png

也可以在 controller 级别启动,作用于下面的全部 handler

image.png

image.png

也同样支持全局启用,作用于全部 controller

image.png

image.png

也可以的

image.png

Pipe 管道

对参数做一些检验和转换

image.png

nest g pipe validate --no-spec --flat

image.png

Pipe 要实现 PipeTransform 接口,实现 transform 方法,里面可以对传入的参数值 value 做参数验证,比如格式、类型是否正确,不正确就抛出异常。也可以做转换,返回转换后的值

这里的 value 就是传入的参数,如果不能转成数字,就返回参数错误,否则乘 10 再传入 handler

在 AppController 添加一个 handler,然后应用这个 pipe

image.png

image.png

image.png

Nest 内置了一些 Pipe

  • ValidationPipe
  • ParseIntPipe
  • ParseBoolPipe
  • ParseArrayPipe
  • ParseUUIDPipe
  • DefaultValuePipe
  • ParseEnumPipe
  • ParseFloatPipe
  • ParseFilePipe

也可以对 整个 Controller 都生效

image.png

也可以全局生效

image.png

image.png

ExceptionFilter 对异常处理

image.png

image.png

这里的错误 就是 它处理的

image.png

nest g filter test --no-spec --flat

image.png

实现 ExceptionFilter 接口,实现 catch 方法,就可以拦截异常了。

拦截什么异常用 @Catch 装饰器来声明,然后在 catch 方法返回对应的响应,给用户更友好的提示

image.png

image.png

Nest 内置了很多 http 相关的异常,都是 HttpException 的子类:

  • BadRequestException
  • UnauthorizedException
  • NotFoundException
  • ForbiddenException
  • NotAcceptableException
  • RequestTimeoutException
  • ConflictException
  • GoneException
  • PayloadTooLargeException
  • UnsupportedMediaTypeException
  • UnprocessableException
  • InternalServerErrorException
  • NotImplementedException
  • BadGatewayException
  • ServiceUnavailableException
  • GatewayTimeoutException

可以自定义

image.png

Nest 通过这样的方式实现了异常到响应的对应关系,代码里只要抛出不同的异常,就会返回对应的响应

同样,ExceptionFilter 也可以选择全局生效或者某个路由生效

某个 handler

image.png

整个 controller

image.png

全局

image.png

image.png

几种 AOP 机制的顺序

Middleware、Guard、Pipe、Interceptor、ExceptionFilter 都可以透明的添加某种处理逻辑到某个路由或者全部路由,这就是 AOP 的好处

它们之间的顺序关系是什么呢?

请求进入
  ↓
Middleware
  ↓
Guard
  ↓
Interceptor (before)
  ↓
Pipe
  ↓
Controller 方法
  ↓
Interceptor (after)
  ↓
Exception Filter(仅异常时)
  ↓
响应返回

image.png

useEffect 完全指南:从原理到精通

作者 Kryon
2026年1月23日 09:25

useEffect 完全指南:从原理到精通

目录

  1. useEffect 是什么
  2. 内部实现原理
  3. 基础语法与依赖数组
  4. 模拟生命周期
  5. 清理函数 (Cleanup)
  6. 依赖项最佳实践
  7. 你可能不需要 useEffect
  8. useEffect vs useLayoutEffect
  9. 复杂场景与陷阱
  10. React 19.2 新特性:useEffectEvent
  11. 常见错误与解决方案
  12. 最佳实践总结

1. useEffect 是什么

useEffect 是 React 的副作用 Hook,用于处理组件中的"副作用"操作——即那些影响组件外部世界的操作。

副作用包括:

  • 数据获取 (API 调用)
  • 订阅 (WebSocket、事件监听)
  • 手动 DOM 操作
  • 定时器 (setTimeout、setInterval)
  • 日志记录
useEffect(() => {
  // 副作用代码
  return () => {
    // 清理代码 (可选)
  };
}, [dependencies]);

2. 内部实现原理

Fiber 架构中的 Effect

React 使用 Fiber 架构管理组件树,每个组件对应一个 Fiber 节点。useEffect 的工作原理:

组件渲染 → Fiber 节点更新 → 收集 Effect → 浏览器绑制 → 异步执行 Effect

核心数据结构

// Effect 对象结构
interface Effect {
  tag: number;           // 标记 Effect 类型和是否需要执行
  create: () => void;    // 我们传入的回调函数
  destroy: () => void;   // cleanup 函数
  deps: any[] | null;    // 依赖数组
  next: Effect | null;   // 链表指针,指向下一个 Effect
}

执行流程

初次挂载 (mountEffect):

  1. 创建新的 Hook 对象
  2. 调用 pushEffect() 创建 Effect 对象
  3. 设置 HookHasEffect 标记,表示需要执行
  4. Effect 存储在 Fiber 的 updateQueue

更新阶段 (updateEffect):

  1. 获取当前 Hook
  2. 比较新旧依赖数组 (areHookInputsEqual)
  3. 如果依赖变化,设置 HookHasEffect 标记
  4. 如果依赖未变,不设置标记,Effect 不会执行
// 简化的依赖比较逻辑
function areHookInputsEqual(nextDeps, prevDeps) {
  if (prevDeps === null) return false;
  
  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    if (Object.is(nextDeps[i], prevDeps[i])) continue;
    return false;
  }
  return true;
}

Effect 执行时机

Render Phase (可中断)
    ↓
Commit Phase (不可中断)
    ├── Before Mutation (DOM 更新前)
    ├── Mutation (DOM 更新)
    └── Layout (useLayoutEffect 执行)
    ↓
浏览器绑制
    ↓
Passive Effects (useEffect 异步执行)

关键点: useEffect 在浏览器绘制后异步执行,不会阻塞渲染。


3. 基础语法与依赖数组

三种依赖模式

// 1. 无依赖数组:每次渲染后都执行
useEffect(() => {
  console.log('每次渲染后执行');
});

// 2. 空依赖数组:仅在挂载时执行一次
useEffect(() => {
  console.log('仅挂载时执行');
}, []);

// 3. 有依赖数组:依赖变化时执行
useEffect(() => {
  console.log(`count 变化了: ${count}`);
}, [count]);

依赖比较机制

React 使用 Object.is() 进行浅比较:

// 基本类型:值比较
Object.is(1, 1);           // true
Object.is('a', 'a');       // true

// 引用类型:引用比较
Object.is({}, {});         // false (不同引用)
Object.is([], []);         // false (不同引用)

const obj = { a: 1 };
Object.is(obj, obj);       // true (同一引用)

陷阱: 每次渲染创建的新对象/数组/函数都是新引用!

// ❌ 错误:每次渲染 options 都是新对象,导致无限循环
useEffect(() => {
  fetchData(options);
}, [options]); // options = { page: 1 } 每次都是新引用

// ✅ 正确:使用 useMemo 稳定引用
const options = useMemo(() => ({ page }), [page]);
useEffect(() => {
  fetchData(options);
}, [options]);

4. 模拟生命周期

useEffect 可以模拟 Class 组件的生命周期方法:

componentDidMount

useEffect(() => {
  // 组件挂载后执行
  console.log('Component mounted');
}, []); // 空依赖数组 = 仅执行一次

componentDidUpdate

// 方式一:监听特定状态变化
useEffect(() => {
  console.log('count updated:', count);
}, [count]);

// 方式二:跳过首次渲染,仅在更新时执行
const isFirstRender = useRef(true);

useEffect(() => {
  if (isFirstRender.current) {
    isFirstRender.current = false;
    return;
  }
  console.log('Component updated (not first render)');
});

componentWillUnmount

useEffect(() => {
  return () => {
    // 组件卸载前执行
    console.log('Component will unmount');
  };
}, []);

完整生命周期模拟

function LifecycleDemo({ userId }) {
  const [user, setUser] = useState(null);
  const isFirstRender = useRef(true);

  // componentDidMount
  useEffect(() => {
    console.log('Mounted');
    return () => {
      console.log('Unmounted'); // componentWillUnmount
    };
  }, []);

  // componentDidUpdate (仅 userId 变化时)
  useEffect(() => {
    if (isFirstRender.current) {
      isFirstRender.current = false;
      return;
    }
    console.log('userId changed:', userId);
  }, [userId]);

  // 数据获取
  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  return <div>{user?.name}</div>;
}

5. 清理函数 (Cleanup)

为什么需要清理

清理函数用于防止内存泄漏和避免对已卸载组件进行状态更新。

清理函数执行时机

Effect 执行 → 返回 cleanup 函数
    ↓
下次 Effect 执行前 → 先执行上次的 cleanup
    ↓
组件卸载时 → 执行最后一次的 cleanup

常见清理场景

1. 事件监听器
useEffect(() => {
  const handleResize = () => {
    setWidth(window.innerWidth);
  };
  
  window.addEventListener('resize', handleResize);
  
  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []);
2. 定时器
useEffect(() => {
  const timer = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  
  return () => clearInterval(timer);
}, []);
3. 订阅
useEffect(() => {
  const subscription = dataSource.subscribe(handleData);
  
  return () => subscription.unsubscribe();
}, [dataSource]);
4. 异步请求 (AbortController)
useEffect(() => {
  const controller = new AbortController();
  
  async function fetchData() {
    try {
      const response = await fetch(`/api/user/${userId}`, {
        signal: controller.signal,
      });
      const data = await response.json();
      setUser(data);
    } catch (error) {
      if (error.name !== 'AbortError') {
        setError(error);
      }
    }
  }
  
  fetchData();
  
  return () => controller.abort();
}, [userId]);
5. 使用标志位处理异步
useEffect(() => {
  let isCancelled = false;
  
  async function fetchData() {
    const data = await fetchUser(userId);
    if (!isCancelled) {
      setUser(data);
    }
  }
  
  fetchData();
  
  return () => {
    isCancelled = true;
  };
}, [userId]);

6. 依赖项最佳实践

规则一:诚实声明所有依赖

// ❌ 错误:遗漏依赖
useEffect(() => {
  fetchData(userId, page); // userId 和 page 都被使用
}, [userId]); // 遗漏了 page

// ✅ 正确:声明所有依赖
useEffect(() => {
  fetchData(userId, page);
}, [userId, page]);

规则二:使用函数式更新避免依赖

// ❌ 需要依赖 count
useEffect(() => {
  const timer = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(timer);
}, [count]); // 每次 count 变化都重新创建定时器

// ✅ 使用函数式更新,无需依赖 count
useEffect(() => {
  const timer = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(timer);
}, []); // 只创建一次定时器

规则三:使用 useCallback 稳定函数引用

// ❌ 每次渲染 handleClick 都是新函数
const handleClick = () => {
  console.log(count);
};

useEffect(() => {
  document.addEventListener('click', handleClick);
  return () => document.removeEventListener('click', handleClick);
}, [handleClick]); // 每次渲染都重新绑定

// ✅ 使用 useCallback 稳定引用
const handleClick = useCallback(() => {
  console.log(count);
}, [count]);

useEffect(() => {
  document.addEventListener('click', handleClick);
  return () => document.removeEventListener('click', handleClick);
}, [handleClick]); // 仅 count 变化时重新绑定

规则四:使用 useMemo 稳定对象引用

// ❌ 每次渲染 config 都是新对象
const config = { theme, language };

useEffect(() => {
  initializeApp(config);
}, [config]); // 每次渲染都执行

// ✅ 使用 useMemo
const config = useMemo(() => ({ theme, language }), [theme, language]);

useEffect(() => {
  initializeApp(config);
}, [config]); // 仅 theme 或 language 变化时执行

规则五:将函数移入 Effect 内部

// ❌ 函数在外部,需要作为依赖
const fetchData = () => {
  return fetch(`/api/user/${userId}`);
};

useEffect(() => {
  fetchData().then(setUser);
}, [fetchData, userId]); // fetchData 每次都是新引用

// ✅ 将函数移入 Effect 内部
useEffect(() => {
  const fetchData = () => {
    return fetch(`/api/user/${userId}`);
  };
  
  fetchData().then(setUser);
}, [userId]); // 只依赖 userId

7. 你可能不需要 useEffect

React 官方文档强调:Effect 是 React 范式的"逃生舱",用于与外部系统同步。很多场景不需要 useEffect。

场景一:基于 props/state 计算派生数据

// ❌ 错误:使用 Effect 计算派生状态
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');

useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

// ✅ 正确:直接在渲染时计算
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const fullName = `${firstName} ${lastName}`; // 直接计算

场景二:过滤/转换数据

// ❌ 错误:使用 Effect 过滤列表
const [items, setItems] = useState([]);
const [filter, setFilter] = useState('');
const [filteredItems, setFilteredItems] = useState([]);

useEffect(() => {
  setFilteredItems(items.filter(item => item.includes(filter)));
}, [items, filter]);

// ✅ 正确:直接计算,必要时用 useMemo
const filteredItems = useMemo(
  () => items.filter(item => item.includes(filter)),
  [items, filter]
);

场景三:响应用户事件

// ❌ 错误:使用 Effect 响应表单提交
const [submitted, setSubmitted] = useState(false);

useEffect(() => {
  if (submitted) {
    sendAnalytics('form_submitted');
    setSubmitted(false);
  }
}, [submitted]);

const handleSubmit = () => {
  setSubmitted(true);
};

// ✅ 正确:直接在事件处理器中执行
const handleSubmit = () => {
  submitForm();
  sendAnalytics('form_submitted');
};

场景四:初始化应用

// ❌ 错误:在 Effect 中初始化
useEffect(() => {
  initializeApp();
}, []);

// ✅ 正确:在模块顶层或应用入口初始化
// app.ts
if (typeof window !== 'undefined') {
  initializeApp();
}

场景五:重置状态

// ❌ 错误:使用 Effect 重置状态
function ProfilePage({ userId }) {
  const [comment, setComment] = useState('');
  
  useEffect(() => {
    setComment('');
  }, [userId]);
}

// ✅ 正确:使用 key 强制重新挂载
function ProfilePage({ userId }) {
  return <Profile userId={userId} key={userId} />;
}

function Profile({ userId }) {
  const [comment, setComment] = useState(''); // userId 变化时自动重置
}

何时需要 useEffect

  • 与外部系统同步 (DOM、第三方库、WebSocket)
  • 数据获取 (但推荐使用 React Query、SWR 等库)
  • 设置订阅
  • 发送分析日志 (页面访问,非用户操作)

8. useEffect vs useLayoutEffect

执行时机对比

Render → DOM 更新 → useLayoutEffect (同步) → 浏览器绘制 → useEffect (异步)
特性 useEffect useLayoutEffect
执行时机 浏览器绘制后 浏览器绘制前
执行方式 异步 同步
阻塞渲染
性能影响 可能较大

何时使用 useLayoutEffect

1. 测量 DOM 元素
function Tooltip({ children, targetRef }) {
  const [position, setPosition] = useState({ top: 0, left: 0 });
  
  // 使用 useLayoutEffect 避免闪烁
  useLayoutEffect(() => {
    const rect = targetRef.current.getBoundingClientRect();
    setPosition({
      top: rect.bottom + 10,
      left: rect.left,
    });
  }, [targetRef]);
  
  return (
    <div style={{ position: 'absolute', ...position }}>
      {children}
    </div>
  );
}
2. 同步 DOM 变更
function AutoFocus({ children }) {
  const ref = useRef(null);
  
  useLayoutEffect(() => {
    ref.current?.focus();
  }, []);
  
  return <div ref={ref} tabIndex={-1}>{children}</div>;
}
3. 防止视觉闪烁
function AnimatedComponent() {
  const ref = useRef(null);
  
  // 在绘制前设置初始位置,避免闪烁
  useLayoutEffect(() => {
    ref.current.style.transform = 'translateX(-100%)';
    // 触发重排后设置动画
    requestAnimationFrame(() => {
      ref.current.style.transition = 'transform 0.3s';
      ref.current.style.transform = 'translateX(0)';
    });
  }, []);
  
  return <div ref={ref}>Animated</div>;
}

默认使用 useEffect

// ✅ 大多数情况使用 useEffect
useEffect(() => {
  fetchData();
  subscribeToEvents();
  logAnalytics();
}, []);

// ⚠️ 仅在需要同步 DOM 操作时使用 useLayoutEffect
useLayoutEffect(() => {
  measureElement();
  updatePosition();
}, []);

9. 复杂场景与陷阱

陷阱一:无限循环

// ❌ 无限循环:Effect 中更新依赖
const [count, setCount] = useState(0);

useEffect(() => {
  setCount(count + 1); // 更新 count → 触发 Effect → 更新 count...
}, [count]);

// ✅ 解决方案 1:使用函数式更新
useEffect(() => {
  setCount(c => c + 1);
}, []); // 不依赖 count

// ✅ 解决方案 2:添加条件判断
useEffect(() => {
  if (count < 10) {
    setCount(count + 1);
  }
}, [count]);

陷阱二:闭包陷阱 (Stale Closure)

// ❌ 闭包陷阱:count 永远是 0
const [count, setCount] = useState(0);

useEffect(() => {
  const timer = setInterval(() => {
    console.log(count); // 永远打印 0
  }, 1000);
  return () => clearInterval(timer);
}, []); // 空依赖,闭包捕获初始值

// ✅ 解决方案 1:添加依赖
useEffect(() => {
  const timer = setInterval(() => {
    console.log(count);
  }, 1000);
  return () => clearInterval(timer);
}, [count]); // 每次 count 变化重新创建定时器

// ✅ 解决方案 2:使用 ref 存储最新值
const countRef = useRef(count);
countRef.current = count;

useEffect(() => {
  const timer = setInterval(() => {
    console.log(countRef.current); // 始终是最新值
  }, 1000);
  return () => clearInterval(timer);
}, []);

陷阱三:竞态条件 (Race Condition)

// ❌ 竞态条件:快速切换 userId 可能导致显示错误数据
useEffect(() => {
  fetchUser(userId).then(setUser);
}, [userId]);

// ✅ 解决方案:使用标志位或 AbortController
useEffect(() => {
  let cancelled = false;
  
  fetchUser(userId).then(user => {
    if (!cancelled) {
      setUser(user);
    }
  });
  
  return () => {
    cancelled = true;
  };
}, [userId]);

陷阱四:对象/数组依赖

// ❌ 每次渲染都是新对象,导致 Effect 每次都执行
function Component({ items }) {
  const config = { sortBy: 'name', items };
  
  useEffect(() => {
    processData(config);
  }, [config]); // 每次都是新引用
}

// ✅ 解决方案 1:展开为基本类型
useEffect(() => {
  processData({ sortBy: 'name', items });
}, [items]); // 只依赖 items

// ✅ 解决方案 2:使用 useMemo
const config = useMemo(() => ({ sortBy: 'name', items }), [items]);

useEffect(() => {
  processData(config);
}, [config]);

// ✅ 解决方案 3:使用 JSON.stringify (谨慎使用)
useEffect(() => {
  processData(config);
}, [JSON.stringify(config)]);

陷阱五:StrictMode 双重执行

React 18+ 的 StrictMode 会在开发环境中双重执行 Effect,用于检测副作用是否正确清理。

// ❌ 不幂等的 Effect
useEffect(() => {
  items.push(newItem); // 双重执行会添加两次
}, []);

// ✅ 幂等的 Effect
useEffect(() => {
  if (!items.includes(newItem)) {
    items.push(newItem);
  }
}, []);

// ✅ 或者使用 cleanup 确保正确清理
useEffect(() => {
  const subscription = subscribe();
  return () => subscription.unsubscribe();
}, []);

10. React 19.2 新特性:useEffectEvent

React 19.2 引入了 useEffectEvent,解决了 Effect 中读取最新 props/state 而不触发重新执行的问题。

问题场景

// ❌ 问题:onTick 变化会导致定时器重建
function Timer({ onTick, interval }) {
  useEffect(() => {
    const id = setInterval(() => {
      onTick(); // 需要最新的 onTick
    }, interval);
    return () => clearInterval(id);
  }, [onTick, interval]); // onTick 变化会重建定时器
}

useEffectEvent 解决方案

import { useEffectEvent } from 'react';

function Timer({ onTick, interval }) {
  // useEffectEvent 创建一个稳定的函数,但总是读取最新的 props
  const tick = useEffectEvent(() => {
    onTick(); // 总是调用最新的 onTick
  });
  
  useEffect(() => {
    const id = setInterval(tick, interval);
    return () => clearInterval(id);
  }, [interval]); // 不需要依赖 onTick
}

useEffectEvent 特点

  1. 返回稳定的函数引用
  2. 函数内部总是读取最新的 props/state
  3. 不需要作为 Effect 的依赖
  4. 只能在 Effect 内部调用

使用场景

// 场景:日志记录需要最新的 props,但不应触发 Effect 重新执行
function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    logConnection(roomId, theme); // 读取最新的 theme
  });
  
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('connected', onConnected);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // 只在 roomId 变化时重连,theme 变化不会重连
}

11. 常见错误与解决方案

错误 1:async 函数作为 Effect 回调

// ❌ 错误:async 函数返回 Promise,不是 cleanup 函数
useEffect(async () => {
  const data = await fetchData();
  setData(data);
}, []);

// ✅ 正确:在内部定义 async 函数
useEffect(() => {
  async function fetchData() {
    const data = await fetch('/api/data');
    setData(data);
  }
  fetchData();
}, []);

// ✅ 或使用 IIFE
useEffect(() => {
  (async () => {
    const data = await fetchData();
    setData(data);
  })();
}, []);

错误 2:遗漏依赖

// ❌ ESLint 警告:React Hook useEffect has a missing dependency
useEffect(() => {
  fetchUser(userId);
}, []); // 遗漏 userId

// ✅ 添加所有依赖
useEffect(() => {
  fetchUser(userId);
}, [userId]);

错误 3:在卸载后更新状态

// ❌ 警告:Can't perform a React state update on an unmounted component
useEffect(() => {
  fetchData().then(data => {
    setData(data); // 组件可能已卸载
  });
}, []);

// ✅ 使用标志位检查
useEffect(() => {
  let mounted = true;
  
  fetchData().then(data => {
    if (mounted) {
      setData(data);
    }
  });
  
  return () => {
    mounted = false;
  };
}, []);

错误 4:Effect 执行顺序假设

// ❌ 错误假设:Effect 按声明顺序执行
useEffect(() => {
  console.log('Effect 1');
}, []);

useEffect(() => {
  console.log('Effect 2');
}, []);

// 实际上:子组件的 Effect 先于父组件执行
// 同一组件内的 Effect 按声明顺序执行

错误 5:过度使用 Effect

// ❌ 过度使用:用 Effect 同步状态
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');

useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

// ✅ 直接计算
const fullName = `${firstName} ${lastName}`;

12. 最佳实践总结

核心原则

  1. Effect 是逃生舱 - 仅用于与外部系统同步
  2. 诚实声明依赖 - 不要欺骗 React 关于依赖
  3. 保持 Effect 幂等 - 多次执行应产生相同结果
  4. 始终清理副作用 - 防止内存泄漏

依赖项检查清单

依赖类型 处理方式
基本类型 (string, number, boolean) 直接添加到依赖数组
函数 使用 useCallback 或移入 Effect 内部
对象/数组 使用 useMemo 或展开为基本类型
Ref 不需要添加 (ref.current 是可变的)
setState 函数 不需要添加 (React 保证稳定)

代码模板

数据获取
function useData(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    
    async function fetchData() {
      try {
        setLoading(true);
        const response = await fetch(url, { signal: controller.signal });
        const json = await response.json();
        setData(json);
        setError(null);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err);
        }
      } finally {
        setLoading(false);
      }
    }
    
    fetchData();
    
    return () => controller.abort();
  }, [url]);

  return { data, loading, error };
}
事件监听
function useEventListener(eventName, handler, element = window) {
  const savedHandler = useRef(handler);
  
  useLayoutEffect(() => {
    savedHandler.current = handler;
  }, [handler]);

  useEffect(() => {
    const eventListener = (event) => savedHandler.current(event);
    
    element.addEventListener(eventName, eventListener);
    
    return () => element.removeEventListener(eventName, eventListener);
  }, [eventName, element]);
}
定时器
function useInterval(callback, delay) {
  const savedCallback = useRef(callback);

  useLayoutEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    if (delay === null) return;
    
    const id = setInterval(() => savedCallback.current(), delay);
    
    return () => clearInterval(id);
  }, [delay]);
}

参考资源


文档基于 React 19.2 及 2025-2026 年最新实践整理,部分内容参考自 React 官方文档、jser.dev 等来源并进行了改写。

v-elSelectLoadMore 解决el-select下拉选项的滚动加载(无限滚动)

2026年1月23日 09:02

v-elSelectLoadMore 指令技术文档

📖 概述

v-elSelectLoadMore 是一个专为 Element Plus 的 el-select 组件设计的 Vue 3 自定义指令,用于实现**下拉选项的滚动加载(无限滚动)**功能。该指令解决了 el-select 组件原生不支持分页加载的痛点,特别适用于需要展示大量选项数据的场景。

7949acf3d44c669afc633c38a0870299.png

核心特性

  • 零侵入设计:通过指令方式实现,不需要修改组件内部逻辑
  • 高性能:内置防抖机制,避免频繁触发
  • 类型安全:完整的 TypeScript 类型定义
  • 灵活配置:支持自定义触发距离和 loading 效果
  • 内存安全:完善的清理机制,防止内存泄漏
  • 精准定位:通过 popperClass 精确匹配下拉框
  • 智能 Loading:自动显示加载动画,支持 Promise 自动隐藏

🎯 适用场景

典型应用场景

  1. 大数据量下拉选择

    • 用户列表(成千上万的用户)
    • 商品列表
    • 城市/地区选择
  2. 远程搜索 + 分页

    • 搜索关键词后,结果分页加载
    • 减少首次加载时间
  3. 实时数据流

    • 日志选择
    • 消息列表

不适用场景

  • 选项数量少于 50 条(建议一次性加载)
  • 需要复杂的树形结构(建议使用 el-tree-select

🚀 快速开始

1. 指令注册

在应用入口文件中注册指令:

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import elSelectLoadMore from '@common/directives/elSelectLoadMore'

const app = createApp(App)

// 注册指令
app.directive('elSelectLoadMore', elSelectLoadMore)

app.mount('#app')

2. 基础用法

<template>
  <el-select
    v-model="selectedValue"
    v-elSelectLoadMore="{
      loadMore: handleLoadMore,
      popperClass: uniqueClass
    }"
    :popper-class="uniqueClass"
    placeholder="请选择"
  >
    <el-option
      v-for="item in options"
      :key="item.value"
      :label="item.label"
      :value="item.value"
    />
  </el-select>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { v4 as uuidv4 } from 'uuid'

const selectedValue = ref('')
const options = ref([])
const uniqueClass = `my-select-${uuidv4()}`

const handleLoadMore = () => {
  // 加载下一页数据的逻辑
  console.log('加载更多...')
}
</script>

📚 API 文档

指令值类型

{
  popperClass: string                    // 必填:下拉框的唯一标识类名
  loadMore: () => void | Promise<unknown> // 必填:触底时的回调函数,可返回 Promise
  distance?: number                      // 可选:触发距离(像素),默认 20
  showLoading?: boolean                  // 可选:是否显示 loading 动画,默认 true
}

参数详解

参数 类型 必填 默认值 说明
popperClass string - 下拉框的唯一标识类名,用于精确定位
loadMore () => void | Promise<unknown> - 滚动到底部时触发的回调函数,可返回 Promise
distance number 20 距离底部多少像素时触发加载(单位:px)
showLoading boolean true 是否显示加载动画(loading 图标 + "加载中..."文字)

重要说明

⚠️ popperClass 必须与 el-selectpopper-class 属性保持一致!

<!-- ✅ 正确 -->
<el-select
  v-elSelectLoadMore="{ loadMore, popperClass: 'my-unique-class' }"
  :popper-class="'my-unique-class'"
>

<!-- ❌ 错误:不一致 -->
<el-select
  v-elSelectLoadMore="{ loadMore, popperClass: 'class-a' }"
  :popper-class="'class-b'"
>

💡 完整示例

示例 1:基础分页加载

<template>
  <div class="example-container">
    <el-select
      v-model="selectedUser"
      v-elSelectLoadMore="{
        loadMore: loadMoreUsers,
        popperClass: userSelectClass
      }"
      :popper-class="userSelectClass"
      :loading="loading"
      filterable
      remote
      placeholder="请选择用户"
    >
      <el-option
        v-for="user in users"
        :key="user.id"
        :label="user.name"
        :value="user.id"
      />
    </el-select>
    <p>已加载: {{ users.length }} / {{ totalUsers }}</p>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive } from 'vue'
import { v4 as uuidv4 } from 'uuid'

interface User {
  id: string
  name: string
}

const selectedUser = ref('')
const loading = ref(false)
const users = ref<User[]>([])
const userSelectClass = `user-select-${uuidv4()}`

const pagination = reactive({
  pageNo: 1,
  pageSize: 20,
  totalUsers: 0
})

// 初始化:加载第一页
const initUsers = async () => {
  loading.value = true
  try {
    const response = await fetchUsers(1, pagination.pageSize)
    users.value = response.data
    pagination.totalUsers = response.total
  } finally {
    loading.value = false
  }
}

// 加载更多(返回 Promise,指令会自动处理 loading 动画)
const loadMoreUsers = async () => {
  // 防止重复加载
  if (loading.value) return
  
  // 检查是否还有更多数据
  const hasMore = users.value.length < pagination.totalUsers
  if (!hasMore) return

  loading.value = true
  pagination.pageNo++

  try {
    const response = await fetchUsers(pagination.pageNo, pagination.pageSize)
    users.value.push(...response.data)
  } catch (error) {
    console.error('加载失败:', error)
    pagination.pageNo-- // 回滚页码
  } finally {
    loading.value = false
  }
}

// 模拟 API 请求
const fetchUsers = (page: number, size: number) => {
  return new Promise<{ data: User[], total: number }>((resolve) => {
    setTimeout(() => {
      const start = (page - 1) * size
      const data = Array.from({ length: size }, (_, i) => ({
        id: `user-${start + i + 1}`,
        name: `用户 ${start + i + 1}`
      }))
      resolve({ data, total: 100 })
    }, 500)
  })
}

// 组件挂载时初始化
initUsers()
</script>

示例 2:自定义触发距离和禁用 Loading

<template>
  <el-select
    v-model="value"
    v-elSelectLoadMore="{
      loadMore: loadMore,
      popperClass: selectClass,
      distance: 50,        // 距离底部 50px 时触发
      showLoading: false   // 禁用内置 loading 动画
    }"
    :popper-class="selectClass"
  >
    <el-option
      v-for="item in options"
      :key="item.value"
      :label="item.label"
      :value="item.value"
    />
  </el-select>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { v4 as uuidv4 } from 'uuid'

const value = ref('')
const options = ref([])
const selectClass = `select-${uuidv4()}`

const loadMore = () => {
  // 提前 50px 触发,用户体验更流畅
  // 禁用了内置 loading,可以自定义 loading 效果
  console.log('提前触发加载')
  
  return new Promise<void>((resolve) => {
    setTimeout(() => {
      // 加载数据...
      resolve()
    }, 1000)
  })
}
</script>

示例 3:结合远程搜索

<template>
  <el-select
    v-model="selectedProduct"
    v-elSelectLoadMore="{
      loadMore: loadMoreProducts,
      popperClass: productSelectClass
    }"
    :popper-class="productSelectClass"
    :loading="loading"
    filterable
    remote
    :remote-method="handleSearch"
    placeholder="搜索商品"
  >
    <el-option
      v-for="product in products"
      :key="product.id"
      :label="product.name"
      :value="product.id"
    />
  </el-select>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { v4 as uuidv4 } from 'uuid'
import { useDebounceFn } from '@vueuse/core'

const selectedProduct = ref('')
const loading = ref(false)
const products = ref([])
const productSelectClass = `product-select-${uuidv4()}`

let currentPage = 1
let searchKeyword = ''

// 搜索处理(防抖)
const handleSearch = useDebounceFn((query: string) => {
  searchKeyword = query
  currentPage = 1
  products.value = []
  loadProducts()
}, 300)

// 加载商品
const loadProducts = async () => {
  if (loading.value) return

  loading.value = true
  try {
    const response = await fetchProducts(searchKeyword, currentPage)
    if (currentPage === 1) {
      products.value = response.data
    } else {
      products.value.push(...response.data)
    }
  } finally {
    loading.value = false
  }
}

// 加载更多
const loadMoreProducts = () => {
  currentPage++
  loadProducts()
}

const fetchProducts = (keyword: string, page: number) => {
  // API 调用逻辑
  return Promise.resolve({ data: [] })
}
</script>

完整源码

/**
 * v-loadMore 指令
 * 用于 el-select 组件的触底加载更多功能
 */
import type { Directive } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { createVNode, render } from 'vue'
import { Loading } from '@element-plus/icons-vue'

// 扩展 HTMLElement 类型,添加自定义属性
interface ExtendedHTMLElement extends HTMLElement {
  _scrollLoadCleanup?: () => void
}

// 指令值类型定义
type ElSelectLoadMoreValue = {
  popperClass: string // 必填样式表 唯一标识
  loadMore: () => void | Promise<unknown>  // 加载更多的回调函数,可以返回 Promise
  distance?: number     // 距离底部多少像素触发,默认 20
  showLoading?: boolean // 是否显示 loading 图标,默认 true
}

const elSelectLoadMore: Directive<ExtendedHTMLElement, ElSelectLoadMoreValue> = {
  mounted(el, binding) {
    // 标准化 binding.value,支持函数和对象两种形式
    const loadMoreFn = binding.value?.loadMore
    const distance = binding.value?.distance ?? 20
    const popperClass = binding.value?.popperClass ?? ''
    const showLoading = binding.value?.showLoading ?? true
    let dropdownWrap: HTMLElement | null = findDropdown()
    let loadingElement: HTMLElement | null = null
    let isLoading = false

    dropdownWrap?.addEventListener('scroll', useDebounceFn(handleScroll, 300))

    /**
     * 显示 loading 效果
     */
    function showLoadingIcon() {
      if (!showLoading || !dropdownWrap || loadingElement) return

      // 创建 loading 容器
      const loadingContainer = document.createElement('div')
      loadingContainer.className = 'el-select-dropdown__loading'
      loadingContainer.style.cssText = `
        display: flex;
        justify-content: center;
        align-items: center;
        padding: 8px 0;
        color: var(--el-color-primary);
        font-size: 14px;
      `

      // 创建 loading 图标
      const loadingIcon = document.createElement('i')
      loadingIcon.className = 'el-icon is-loading'
      loadingIcon.style.marginRight = '8px'

      // 使用 Vue 的 createVNode 和 render 来渲染 Loading 组件
      const vnode = createVNode(Loading)
      render(vnode, loadingIcon)

      // 创建文本
      const loadingText = document.createElement('span')
      loadingText.textContent = '加载中...'

      loadingContainer.appendChild(loadingIcon)
      loadingContainer.appendChild(loadingText)

      // 添加到下拉框底部
      const dropdown = dropdownWrap.parentElement
      if (dropdown) {
        dropdown.appendChild(loadingContainer)
        loadingElement = loadingContainer
      }
    }

    /**
     * 隐藏 loading 效果
     */
    function hideLoadingIcon() {
      if (loadingElement) {
        loadingElement.remove()
        loadingElement = null
      }
      isLoading = false
    }

    /**
     * 滚动事件处理函数
     * 当滚动到距离底部指定距离时触发加载更多
     */
    function handleScroll() {
      if (!dropdownWrap || isLoading) return

      const { scrollTop, scrollHeight, clientHeight } = dropdownWrap
      const distanceToBottom = scrollHeight - scrollTop - clientHeight

      // 当距离底部小于等于设定距离时触发加载
      if (distanceToBottom <= distance) {
        isLoading = true
        showLoadingIcon()

        // 执行加载函数
        const result = loadMoreFn?.()

        // 如果返回 Promise,等待完成后隐藏 loading
        if (result && typeof result === 'object' && 'then' in result) {
          (result as Promise<unknown>).finally(() => {
            hideLoadingIcon()
          })
        } else {
          // 如果不是 Promise,延迟隐藏(给用户反馈)
          setTimeout(() => {
            hideLoadingIcon()
          }, 500)
        }
      }
    }

    /**
     * 查找下拉框的滚动容器
     * 优先通过 popper-class 精确查找,备用方案是查找最新的下拉框
     */
    function findDropdown(): HTMLElement | null {
      if (popperClass) {
        const classes = popperClass.split(' ')
        for (const cls of classes) {
          if (cls) {
            const dropdown = document.querySelector(`.${cls}`)
            if (dropdown) {
              const wrap = dropdown.querySelector('.el-select-dropdown__wrap') as HTMLElement
              if (wrap) {
                return wrap
              }
            }
          }
        }
      }
      return null
    }

    /**
     * 清理函数
     * 移除所有事件监听器和定时器,防止内存泄漏
     */
    el._scrollLoadCleanup = () => {
      hideLoadingIcon()
      if (dropdownWrap) {
        dropdownWrap.removeEventListener('scroll', handleScroll)
        dropdownWrap = null
      }
    }
  },

  /**
   * 组件卸载时清理资源
   */
  unmounted(el) {
    if (el._scrollLoadCleanup) {
      el._scrollLoadCleanup()
      delete el._scrollLoadCleanup
    }
  }
}

export default elSelectLoadMore

🎨 Loading 效果说明

自动 Loading 管理

指令内置了智能的 loading 效果管理机制:

1. Promise 自动管理(推荐)

loadMore 函数返回 Promise 时,指令会:

  • ✅ 自动显示 loading 动画
  • ✅ 等待 Promise 完成后自动隐藏
  • ✅ 处理 Promise 失败的情况
const loadMore = async () => {
  // 返回 Promise,指令自动管理 loading
  const response = await fetchData()
  options.value.push(...response.data)
}
2. 非 Promise 延迟隐藏

如果 loadMore 函数不返回 Promise:

  • ✅ 显示 loading 动画
  • ✅ 500ms 后自动隐藏(给用户反馈)
const loadMore = () => {
  // 不返回 Promise,loading 会在 500ms 后自动隐藏
  fetchData().then(data => {
    options.value.push(...data)
  })
}
3. 禁用 Loading 动画

如果你想使用自定义的 loading 效果:

<el-select
  v-elSelectLoadMore="{
    loadMore: loadMore,
    popperClass: selectClass,
    showLoading: false  // 禁用内置 loading
  }"
  :popper-class="selectClass"
  :loading="customLoading"  // 使用 el-select 自带的 loading
>

Loading 样式

内置的 loading 效果包含:

  • 🔄 旋转的加载图标(Element Plus Loading 组件)
  • 📝 "加载中..." 文字提示
  • 🎨 使用 Element Plus 主题色

样式定位在下拉框底部,不会遮挡选项内容。


🔧 技术实现原理

1. 核心流程

┌─────────────────┐
│  指令 mounted   │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│ 解析指令参数    │
│ - loadMore 函数 │
│ - distance 距离 │
│ - popperClass   │
│ - showLoading   │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│ 查找下拉框容器  │
│ findDropdown()  │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│ 绑定滚动事件    │
│ + 防抖处理      │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│ 监听滚动位置    │
│ handleScroll()  │
└────────┬────────┘
         │
         ▼
    ┌────┴────┐
    │ 距离判断 │
    └────┬────┘
         │
    ┌────┴────┐
    │ <= 阈值? │
    └────┬────┘
         │ Yes
         ▼
┌─────────────────┐
│ 显示 Loading    │
│showLoadingIcon()│
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│ 触发 loadMore() │
└────────┬────────┘
         │
         ▼
    ┌────┴────┐
    │返回Promise?│
    └────┬────┘
         │
    ┌────┴────┐
    │   Yes   │ No (500ms后)
    └────┬────┴────┐
         │         │
         ▼         ▼
┌─────────────────┐
│ 隐藏 Loading    │
│hideLoadingIcon()│
└─────────────────┘

2. 关键代码解析

2.1 查找下拉框容器
function findDropdown(): HTMLElement | null {
  if (popperClass) {
    // 支持多个 class(空格分隔)
    const classes = popperClass.split(' ')
    for (const cls of classes) {
      if (cls) {
        // 通过 popperClass 精确定位
        const dropdown = document.querySelector(`.${cls}`)
        if (dropdown) {
          // 找到滚动容器
          const wrap = dropdown.querySelector('.el-select-dropdown__wrap') as HTMLElement
          if (wrap) {
            return wrap
          }
        }
      }
    }
  }
  return null
}

为什么需要 popperClass

Element Plus 的 el-select 下拉框是通过 Teleport 渲染到 body 下的,不在组件的 DOM 树中。如果页面上有多个 el-select,无法准确判断滚动事件来自哪个下拉框。通过唯一的 popperClass,可以精确定位到当前 select 对应的下拉框。

2.2 滚动距离计算
function handleScroll() {
  if (!dropdownWrap) return

  const { scrollTop, scrollHeight, clientHeight } = dropdownWrap
  const distanceToBottom = scrollHeight - scrollTop - clientHeight

  // 当距离底部小于等于设定距离时触发加载
  if (distanceToBottom <= distance) {
    loadMoreFn()
  }
}

计算公式:

distanceToBottom = scrollHeight - scrollTop - clientHeight

其中:
- scrollHeight: 内容总高度
- scrollTop: 已滚动的距离
- clientHeight: 可视区域高度
- distanceToBottom: 距离底部的距离
2.3 Loading 效果实现
/**
 * 显示 loading 效果
 */
function showLoadingIcon() {
  if (!showLoading || !dropdownWrap || loadingElement) return

  // 创建 loading 容器
  const loadingContainer = document.createElement('div')
  loadingContainer.className = 'el-select-dropdown__loading'
  loadingContainer.style.cssText = `
    display: flex;
    justify-content: center;
    align-items: center;
    padding: 8px 0;
    color: var(--el-color-primary);
    font-size: 14px;
  `

  // 创建 loading 图标(使用 Element Plus 的 Loading 组件)
  const loadingIcon = document.createElement('i')
  loadingIcon.className = 'el-icon is-loading'
  const vnode = createVNode(Loading)
  render(vnode, loadingIcon)

  // 添加文本
  const loadingText = document.createElement('span')
  loadingText.textContent = '加载中...'

  loadingContainer.appendChild(loadingIcon)
  loadingContainer.appendChild(loadingText)

  // 添加到下拉框底部
  const dropdown = dropdownWrap.parentElement
  if (dropdown) {
    dropdown.appendChild(loadingContainer)
    loadingElement = loadingContainer
  }
}

/**
 * 智能处理 Promise 返回值
 */
const result = loadMoreFn?.()
if (result && typeof result === 'object' && 'then' in result) {
  // 如果返回 Promise,等待完成后隐藏
  (result as Promise<unknown>).finally(() => {
    hideLoadingIcon()
  })
} else {
  // 如果不是 Promise,延迟隐藏
  setTimeout(() => {
    hideLoadingIcon()
  }, 500)
}

关键技术点:

  1. 使用 createVNoderender 动态渲染 Vue 组件
  2. 使用 CSS 变量 var(--el-color-primary) 保持主题一致性
  3. 智能判断返回值类型,自动管理 loading 生命周期
  4. 防止重复显示(通过 loadingElementisLoading 标志)
2.4 防抖优化
import { useDebounceFn } from '@vueuse/core'

dropdownWrap?.addEventListener('scroll', useDebounceFn(handleScroll, 300))

使用 @vueuse/coreuseDebounceFn 进行防抖处理,避免滚动时频繁触发回调。

2.5 资源清理
// 保存清理函数到元素上
el._scrollLoadCleanup = () => {
  hideLoadingIcon()  // 清理 loading 元素
  if (dropdownWrap) {
    dropdownWrap.removeEventListener('scroll', handleScroll)
    dropdownWrap = null
  }
}

// unmounted 时调用清理
unmounted(el) {
  if (el._scrollLoadCleanup) {
    el._scrollLoadCleanup()
    delete el._scrollLoadCleanup
  }
}

清理内容:

  1. 移除 loading DOM 元素
  2. 移除滚动事件监听器
  3. 清空引用,防止内存泄漏

⚠️ 注意事项与最佳实践

1. 必须使用唯一的 popperClass

错误示例

<!-- 多个 select 使用相同的 class -->
<el-select :popper-class="'my-select'">...</el-select>
<el-select :popper-class="'my-select'">...</el-select>

正确示例

<script setup>
import { v4 as uuidv4 } from 'uuid'

const class1 = `select-${uuidv4()}`
const class2 = `select-${uuidv4()}`
</script>

<template>
  <el-select :popper-class="class1">...</el-select>
  <el-select :popper-class="class2">...</el-select>
</template>

2. 在回调中添加 loading 状态

错误示例

const loadMore = () => {
  // 没有 loading 判断,可能重复触发
  fetchData().then(data => {
    options.value.push(...data)
  })
}

正确示例

const loading = ref(false)

const loadMore = () => {
  if (loading.value) return  // 防止重复触发
  
  loading.value = true
  fetchData()
    .then(data => {
      options.value.push(...data)
    })
    .finally(() => {
      loading.value = false
    })
}

3. 检查是否还有更多数据

const loadMore = () => {
  if (loading.value) return
  
  // 检查是否已加载全部数据
  const hasMore = currentPage * pageSize < total
  if (!hasMore) return
  
  // ... 加载逻辑
}

4. 错误处理

const loadMore = async () => {
  if (loading.value) return
  
  loading.value = true
  const currentPageBackup = currentPage
  currentPage++
  
  try {
    const data = await fetchData(currentPage)
    options.value.push(...data)
  } catch (error) {
    console.error('加载失败:', error)
    currentPage = currentPageBackup  // 回滚页码
    ElMessage.error('加载失败,请重试')
  } finally {
    loading.value = false
  }
}

5. 性能优化建议

  1. 合理设置 pageSize

    • 太小:频繁请求,用户体验差
    • 太大:首次加载慢
    • 建议:20-50 条
  2. 调整 distance

    • 默认 20px 适合大多数场景
    • 网络较慢时可设置为 50-100px,提前加载
  3. 使用虚拟滚动

    • 如果选项数量超过 1000 条,建议结合虚拟滚动库(如 vue-virtual-scroller

🐛 常见问题

Q1: 指令不生效,滚动到底部没有触发?

可能原因:

  1. popperClass 不唯一或与 popper-class 不一致
  2. 下拉框还未渲染完成

解决方案:

<script setup>
import { v4 as uuidv4 } from 'uuid'

// 确保唯一性
const uniqueClass = `my-select-${uuidv4()}`
</script>

<template>
  <el-select
    v-elSelectLoadMore="{ loadMore, popperClass: uniqueClass }"
    :popper-class="uniqueClass"
  >
    <!-- ... -->
  </el-select>
</template>

Q2: 触发了多次加载?

原因: 没有添加 loading 状态判断

解决方案:

const loading = ref(false)

const loadMore = () => {
  if (loading.value) return  // 关键!
  loading.value = true
  // ...
}

Q3: 如何在搜索后重置分页?

const handleSearch = (query: string) => {
  searchKeyword = query
  currentPage = 1        // 重置页码
  options.value = []     // 清空选项
  loadData()             // 重新加载
}

Q4: Loading 动画不显示?

可能原因:

  1. 设置了 showLoading: false
  2. loadMore 函数执行太快(< 100ms)

解决方案:

// 确保 showLoading 为 true(或不设置,默认为 true)
v-elSelectLoadMore="{
  loadMore: loadMore,
  popperClass: selectClass,
  showLoading: true  // 明确启用
}"

// 如果是异步操作,返回 Promise
const loadMore = async () => {
  const data = await fetchData()  // 返回 Promise
  options.value.push(...data)
}

Q5: 能否用于 el-cascader 或其他组件?

不能。 该指令专为 el-select 设计,因为它依赖 .el-select-dropdown__wrap 这个特定的 DOM 结构。

如需支持其他组件,需要修改 findDropdown 函数中的选择器。


🔄 与其他方案对比

方案 优点 缺点 适用场景
v-elSelectLoadMore 指令 零侵入、易用、可复用 依赖 DOM 结构 大多数场景
封装自定义组件 完全可控 维护成本高、不够通用 特殊定制需求
使用第三方库 功能丰富 增加依赖、可能过度设计 复杂场景
一次性加载全部 实现简单 数据量大时性能差 数据量少(< 100)

📦 依赖

{
  "dependencies": {
    "vue": "^3.3.0",
    "@vueuse/core": "^10.0.0"
  },
  "devDependencies": {
    "uuid": "^9.0.0"  // 用于生成唯一 class
  }
}

🎓 总结

v-elSelectLoadMore 指令通过以下技术要点实现了高效的下拉选项滚动加载:

  1. 精准定位:通过 popperClass 精确匹配下拉框
  2. 性能优化:防抖处理 + loading 状态
  3. 内存安全:完善的事件清理机制
  4. 类型安全:完整的 TypeScript 支持
  5. 易于使用:指令方式,零侵入

使用建议

  • 数据量 < 50:直接一次性加载
  • 数据量 50-1000:使用本指令
  • 数据量 > 1000:考虑虚拟滚动 + 本指令

📖 相关资源


科技爱好者周刊(第 382 期):独立软件的黄昏

作者 阮一峰
2026年1月23日 08:13

这里记录每周值得分享的科技内容,周五发布。

本杂志开源,欢迎投稿。另有《谁在招人》服务,发布程序员招聘信息。合作请邮件联系(yifeng.ruan@gmail.com)。

封面图

巫山县城建在山坡上,为了方便居民和促进观光,在中轴线上建设了神女大扶梯,总长905米,高240余米,相当于80层楼,全程需要20分钟。(via

独立软件的黄昏

软件可以分成两种:一种是公司开发的,另一种是个人开发的。后者就称为"独立软件"(indie software)。

它的历史非常悠久,从古至今,很多程序员依靠出售独立软件谋生。

有一种东西"共享软件"(Shareware),年轻的朋友未必知道,二三十年前曾经非常流行。用户免费使用软件的试用版,如果满意,就向开发者购买一个注册码。

这就是一种独立软件,当年很多著名软件都是这个模式,比如国外的 WinZip 和 WinRAR,国内的网络蚂蚁(NetAnts)、网际快车(FlashGet)、豪杰解霸。

时至今日,大家看看目前流行的软件,还有多少属于独立软件?你每天使用的软件,又有多少是个人开发的?

很少很少了。

一位二十年的独立软件开发者哀叹现在的市场上,公司开发的比重越来越大,个人开发的比重越来越小,独立软件正在没落。

"我销售自己的软件20年了,2005年以后,互联网开始普及,独立软件迎来了黄金年代。而最近两三年,环境一直在快速变化,销售明显变难了,我感觉自己越来越难维持生计了。"

独立软件的大发展,是从2005年开始的。

  1. 互联网的普及,网民数量急剧增长。
  2. 智能手机创造了手机软件,一个全新的软件大市场。
  3. 在线支付的普及和简化。
  4. 互联网使软件分发变得容易且免费。
  5. 免费的高质量开发工具(编译器、IDE、版本控制系统、Web 服务器)不断涌现。

这些因素让程序员切切实实获利了,要是你再做一些 SEO、买一些付费广告,完全可能赚到大钱。很多人就是这样发展起来的,从独立软件变成了大公司。

但是,最近两三年情况变了,上面这些因素都到头了。

独立软件正在慢慢退潮,你能够想起名字的独立软件越来越少,更不要说掏钱购买了,即使有也是多年前的作品。根据我的观察,依靠出售自己软件维生的程序员似乎也在减少。

主要原因有下面几个。

(1)AI 改变了互联网流量,独立软件失去了推广渠道。网站的访问量显著减少,人们更多跟大模型交互,而不是浏览网页。通过搜索引擎和在线广告获取流量的策略,越来越没有效果。

视频是为数不多仍然有效的推广渠道之一,但制作视频非常耗时,而且竞争异常激烈。另外,AI 生成的劣质视频迟早会大量出现,推广效果也会变差。

(2)AI 使得软件开发变得容易。它加快了开发速度,降低了进入门槛,让更多人加入竞争。以前,用户可能购买某个功能,现在直接让 AI 生成即可。

(3)新软件汗牛充栋,越来越难脱颖而出。iPhone 应用商店有大约200万个应用,用户很难发现你。另一方面,应用商店更喜欢推广那些能帮它赚更多钱的大公司软件,而不是独立软件。

(4)人们越来越习惯使用基于网络的软件,独立软件属于需要下载安装的原生应用,它的市场在萎缩。

基于网络的软件与其说是产品,不如说是一种服务,全天候24小时可用的服务。越来越多的个人开发者顺应这种趋势,改为以提供 SaaS 服务为主。

(5)平台的风险。现在的很多独立软件,都依靠云服务商的平台或底层服务,而平台随时会改变规则(比如关闭 API),或者推出竞品,一大批应用随之死掉,这种事情屡见不鲜。

(6)用户期望软件是免费的,或者非常便宜。售价略微高一点,就会无人问津。因此,独立软件要想获得可观的回报,就需要巨大的销售规模,这根本做不到。别的不说,个人开发者完全无力提供满意的客服。

(7)以上这些因素将长期存在,只会加深,不会逆转。独立软件的时代可能真的要结束了,个人开发者以后大概很难靠销售自己的软件为生,而要改为销售自己维护的 SaaS 服务,尽管这也很难。

科技动态

1、VS Code 的定位

微软公司的 VS Code 是非常流行的代码编辑器,市场份额很高。

现在的官网上,它的产品定位是"开源 AI 代码编辑器"。

但是,2025年上半年,它的产品定位还是"你的代码编辑器,由 AI 重定义"。

更早的2024年,产品定位是"重新定义的代码编辑"。

令人感慨啊,这么成功的软件,AI 本来只是附属功能,现在也要蹭热点,把自己包装成 AI 主导的产品。

2、智能脖巾

英国科研人员发明了一种智能脖巾。它围在脖子上,可以感受到穿戴者的心跳和喉部肌肉运动。

它的用户主要是中风后丧失说话能力的人。这些人可以张嘴,做出说话的口型,但是无法正常发音。

他们佩戴这个脖巾后,颈部的运动数据就通过它传给电脑,经过模型训练,可以用电脑语音还原出用户想说的话。

3、雪宝机器人

人形机器人何必一定做成人形。

迪斯尼最近发布了一个机器人,样子就是电影《冰雪奇缘》的雪宝。

它用来在迪斯尼乐园,跟游客互动。

它启示我们,人形机器人做成卡通形状也很好。

另外,LG 公司在美国 CES 展会上,展示了他们最新的家务机器人

这个机器人的功能就是做家务,比如叠衣服和洗碗。我觉得,国内厂商可以借鉴,展示机器人功能时,不要展示跳舞打拳,而要展示如何做家务。

文章

1、别用 MySQL,改用 MariaDB(英文)

曾经的明星数据库 MySQL,最近几个月的代码提交数为0(上图)。作者认为,种种迹象表明甲骨文已经放弃了这个项目。

2、10秒获得 AI 代码评审结果(英文)

本文介绍一个技巧,让 AI 快速给出提交代码的评审结果,方法是不要提交整个代码库,只提交 diff 的部分。

3、使用 Pandoc 生成静态网站(英文)

文档格式转换工具 Pandoc 可以用来生成静态网站,作者介绍自己是怎么做的。

4、锚点元素<a>的一些鲜为人知的地方(英文)

锚点元素<a>用来生成链接,本文介绍如果链接到一些特殊字符的情况。

5、学习自定义元素(英文)

一篇 HTML 自定义元素的教程文章,写得简单清晰。

6、Go、Rust 和 Zig 的一些想法(英文)

作者是一个高级程序员,谈谈他对 Go、Rust、Zig 三种语言的感受。有趣的地方是,这三种语言都没有类,也不支持面向对象编程。

7、我的个人基础设施(英文)

作者介绍他自己的家庭实验室。比较有趣的是,他的个人网站是本地构建后,自动用 Syncthing 同步到服务器,这对小型静态网站确实简单。

工具

1、GoRead

开源的电子书阅读器应用,支持桌面与移动端(Android/iOS)。(@zhashut 投稿)

2、EasyPostman

用于 API 调试的跨平台桌面应用,对标 Postman + JMeter。(@lakernote 投稿)

3、Port Sentinel(端口哨兵)

Windows 桌面应用,查看端口占用情况。(@Sanjeever 投稿)

4、Building Sunlight Simulator

基于 Web 的楼盘采光 3D 日照模拟工具,帮助购房者评估小区采光。(@SeanWong17 投稿)

5、Office App

一个纯本地的 Office 网页应用,可以离线在网页创建/编辑 Word、Excel、PowerPoint 文件。(@baotlake 投稿)

6、ScreenshotSnap

免费的网站截屏在线工具,提供 API,可以直接将截图代码插入网页。(@phpiscute 投稿)

7、tsshd

SSH 服务器登录协议的全新实现,特点是连接不掉线,可以重连前一个对话。(@lonnywong 投稿)

8、AirScan-QR

一个开源网页应用,通过动态二维码发送/接收文件。(@topcss 投稿)

9、LuCI Bandix

开源路由器操作系统 OpenWRT 的一个插件,可以监控局域网各设备的实时流量和目的地。(@timsaya 投稿)

10、pure-genealogy

开源的网页族谱工具,用来生成家族族谱,基于 Next.js + Supabase。(@yunfengsa 投稿)

11、mdto.page

这个网站免费将 Markdown 文件转成 HTML 格式,发布成公开访问的网页。

AI 相关

1、ChatGPT 翻译

OpenAI 悄悄发布的翻译功能,只有在官网可用。

2、Mango Desk

一个跨平台的桌面应用,使用自然语言进行本地文件搜索。(@moyangzhan 投稿)

3、OpenWork

Claude 公司新产品 CoWork 的开源替代品,让普通用户不编程,就能完成文件操作,定位就是"Claude Code 的非编程版"。

另有一个类似项目 Open Claude Cowork。(@aiagentbuilder 投稿)

4、Wolfcha(猹杀)

开源的网页游戏 AI 狼人杀,除了玩家自己,其他所有角色(女巫、猎人、守卫、狼人等)都由 AI 扮演。(@oil-oil 投稿)

资源

1、维基百科25周年

维基百科是2001年1月13日上线的,今年是25周年纪念。这个网站是官方的纪念网站,以互动形式展示了发展历程。

另外,还有一篇文章,介绍互联网档案馆的历史(下图)。

2、HTTP:COLON

这个网页可以查看指定网站返回的 HTTP 标头,详细解释每个字段的含义。

3、现代 Java(Modern Java)

面向初学者的 Java 语言教程。

图片

1、中国新能源建设的惊人规模

90后摄影师储卫民拍摄的中国新能源建设。

他说:"从地面上很难体会这些发电厂的规模,但当你升到空中时,就能看到它们与山脉、沙漠和海洋之间的关系。"

青海冷湖镇

浙江象山县

青海塔拉滩

内蒙古阿拉善

"我一开始只是拍摄风景,但2022年我去贵州、云南、青海等地旅行时,不断看到风力发电场和太阳能发电厂出现在我的镜头里。我意识到这就是我们这个时代的故事----但几乎没有人系统地记录它。"

文摘

1、谷歌14年工作的教训

大约14年前,我加入谷歌,以为这份工作就是编写优秀的代码。

这个想法部分正确。但随着时间的推移,我越来越意识到,真正成功的工程师不一定是最优秀的程序员,而是懂得驾驭代码之外一切的人。

下面就是我得到的经验教训。有些教训是我走了几个月的弯路得到的,还有一些需要数年才完全领悟。它们都与具体的技术无关----技术变化太快,根本无关紧要。

(1)工程师想在大公司生存,必须学会沟通。

因为在大公司,团队是组织的基本单位,推进项目必须跟其他团队沟通。项目越大,你花在跟其他人、其他团队沟通的时间就越多,比编写代码的时间还多。大多数"慢"的团队实际上是不沟通的团队。

为了顺利沟通,清晰是第一位的要求。它不仅可以加快沟通,还能降低代码风险。最优秀的工程师都会用清晰易懂的代码来代替炫技。

为了提高表达的清晰性,你可以尝试写作和去教别人。如果你能用简单的语言解释某件事,你就是真的理解它了。

(2)想要得到晋升,必须有人为你说话。

职业生涯初期,我曾认为优秀的工作成果代表了一切,但我错了。代码默默地躺在代码库里,不会为你说话。

那些对你至关重要的会议,你本人很可能没有机会参加。你需要你的经理、同事在会上提到你、推荐你。他们可能这样做,也可能不会。

平时工作中,你尽量不要为自己增加阻力。如果开会的时候,你赢得每一场辩论,很可能就是在积累无声的阻力。你之所以"赢",不是因为你说服了别人,而是因为他们不再与你争论,放弃了,将会在其他场合表达这种不满。

(3)专注于你能控制的事情,忽略你无法控制的事情。

很多事情,你改变不了,不要为这种事情烦恼。这不是被动接受,而是策略性分配精力。如果你把精力浪费在无法改变的事情上,就等于放弃改变那些原本可以改变的事情。

(4)简化工作往往可以提高绩效。

当系统运行缓慢时,人们的第一反应是增加缓存层、并行处理和更智能的算法。有时这样做没错,但我发现,删除不必要的工作几乎总是更有效果。下次进行优化之前,你要先问问自己这项工作是否应该存在。

(5)时间比金钱更有价值,你要抓紧时间。

职业生涯初期,你用时间换取金钱,各种事情都做----这无可厚非。但到了某个阶段,情况就完全不同了,你会开始意识到,时间才是不可再生资源。你要专注于那些对你最重要的事情,放弃其他事情。

言论

1、

AGI(通用人工智能)中的 A 代表广告(advertisement)。

-- 一位程序员评论 OpenAI 宣布在 AI 对话中加入广告

2、

Netflix 的电影不追求视觉效果,因为大多数观众是在手机、平板和笔记本电脑上看,内容不需要为大银幕制作、而是为小屏幕制作的。

-- 马特·达蒙,美国著名演员

3、

我从未见过哪个群体比程序员更热衷于分享知识。其他行业都是严守知识、保守秘密,程序员则是免费提供源代码、书籍、博客文章、演示文稿、视频教程等等。

编程领域没有什么神圣不可侵犯的东西。如果你想学习,你可以找到免费书籍、完整的源代码、论坛、聊天室、邮件列表、线下聚会、博客文章、视频讲座、教程以及你可能需要的一切资源。尽管举手,总会有人乐于助人,倾囊相授。

-- 《我是如何学习所有编程知识的》

4、

今年的 iOS 26 中,一些 UI 元素利用 HDR 屏幕,采用高光,比纯白色更亮。如果你曾经在 iPhone(或其他任何支持 HDR 的屏幕)上看过 HDR 照片,然后再看看以 SDR 模式显示的 UI,你就会知道它看起来有多么灰暗黯淡。

-- 《亮模式的膨胀》,作者发现 iOS 每年都变得更亮,容易产生视觉疲劳,让他不得不使用暗模式

5、

如果你想批评大型组织的运作方式,首先要了解它们为何如此运作。否则,批评会显得尖锐,但却毫无意义。

-- 《关于大型软件公司的常见误解》

往年回顾

年底的未来已来(#335)

为什么 PPT 不如备忘录(#285)

青年失业率与选择创业(#235)

美国宪法拍卖,一个区块链案例(#185)

(完)

文档信息

  • 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证
  • 发表日期: 2026年1月23日

UniApp+vue3开发微信小程序,全局注册组件-不需要每个page都写一遍的那种

作者 波波酱
2026年1月23日 01:31

全局注册组件-不需要每个page都写一遍的那种

  • 需求来源:项目开发进入尾声,临近上线了客户要求给系统加水印
  • 功能分析:微信小程序没有vue的路由机制,不能像vue一样在App.vue里注册一个全局组件。麻烦一点的方式就是每个page都写一遍,虽然是copy paste,但是重复没有意义的事儿我是一点都不想干。那就写个组件,在源码层面,编译前给每个page都加上这个水印组件。
  • 实现思路
    • 写一个打水印的组件
    • 组件全局注册
    • 写一个vite插件,实现修改源码的功能
    • vite.config中使用插件
    • over

Step1: 水印组件实现

// /components/watermark/watermark.vue
<template>
  <!-- 增加一层判断,确保有图片才渲染 -->
  <view
    v-if="mask"
    class="watermark-mask"
    :style="{ backgroundImage: `url(${mask})` }"
  ></view>
</template>

<script setup>
import { ref, onMounted, nextTick } from "vue";

import { useStore } from "vuex";
const store = useStore();
const user = computed(() => store.getters.user);

const mask = ref("");

const draw = () => {
  try {
    // 微信小程序创建离屏 canvas 的标准写法
    const canvas = uni.createOffscreenCanvas({
      type: "2d",
      width: 200,
      height: 200,
    });
    const ctx = canvas.getContext("2d");

    ctx.font = "14px Arial";
    ctx.fillStyle = "rgba(200, 200, 200, 0.2)";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";

    ctx.translate(100, 100);
    ctx.rotate(-Math.PI / 9);
    ctx.fillText(`${user.value.nickName || ''}    ${user.value.phonenumber || ''}`, 0, 0);

    // 生成 base64
    mask.value = canvas.toDataURL();
    console.log("水印生成成功");
  } catch (e) {
    console.error("水印生成失败:", e);
  }
};

watch(
  () => store.getters.user,
  () => {
    draw();
  },
  {
    deep: true,
  }
);

onMounted(() => {
  console.log("水印组件已挂载");
});
</script>

<style scoped>
.watermark-mask {
  position: fixed;
  inset: 0;
  z-index: 999999;
  pointer-events: none;
  background-repeat: repeat;
  /* 确保不遮挡背景 */
  background-color: transparent;
}
</style>

Step2: 组件全局注册

方案一:在pages.json中注册
// 在你的pages.json中加入这段代码全局注册一下
"easycom": {
    "autoscan": true,
    "custom": {
        "^watermark$": "@/components/watermark/watermark.vue"
    }
}

hh,于是问题出现了,编译好的代码里能看到watermark标签,但是小程序模拟器审查元素没有这个元素。😁😁

组件代码中已经引入了watermark组件,且标签类似 <watermark u-i="xxx" bind:__l="__l" />,ai说标签带有u-i(uni-app唯一标识)和bind:__l(生命周期绑定),说明uniapp编译器已经把它识别为一个组件了,但是模拟器里找不到该节点,说明页面.json中没有注册这个组件。微信小程序会直接忽略没有在json中注册的节点,所以模拟器里找不到。

插件是动态注入的,uniapp的依赖扫描器可能在插件运行前就完成了扫描,导致它没有把watermark写进页面的usingComponents里。方案一PASS!!

方案二:在main.js中注册

防止uniapp扫描依赖漏掉,直接全局强制注册组件

import watermark from "@/components/watermark/watermark.vue";
  
const app = createSSRApp(App);
app.component("watermark", watermark);

Step3: vite插件修改源码

import path from "path";
import fs from "fs";

export default function viteInsetLoader() {
  // 读取 pages.json
  // 项目里我的每个page里都有一些子组件./components/...vue,只需要给page级页面注册就好了,为了减少代码冗余我这里加了个是否是page的判断
  const pagesJsonPath = path.resolve(__dirname, "pages.json");
  const pagesJson = JSON.parse(fs.readFileSync(pagesJsonPath, "utf-8"));
  const pages = pagesJson.pages.map((item) => item.path);
  return {
    name: "vite-inset-loader",
    // 确保在 uni 插件之前执行,这样修改的是原始源码
    enforce: "pre",
    transform(code, id) {
      // 1. 过滤非页面文件
      const isPage = pages.find((item) => id.indexOf(item) > -1);
      if (!isPage || !id.endsWith(".vue")) return null;

      // 2. 检查代码中是否包含 <template>
      if (code.includes("<template>")) {
        console.log("--- 正在注入水印到页面 ---:", id);

        // 3. 注入逻辑:建议注入在第一个 <view> 之后,或者 </template> 之前
        // 尽量匹配带空格或换行的 </template>
        const newCode = code.replace(/([\s\S]*)<\/template>/, (match, p1) => {
          return `${p1}  <watermark />\n</template>`;
        });
        return {
          code: newCode,
          map: null,
        };
      }
      return null;
    },
  };
}

Step4: 使用插件

import uni from "@dcloudio/vite-plugin-uni";
import appendWatermark from "./vite-plugin-watermark";

export default defineConfig({
  plugins: [
    uni(),
    appendWatermark(), // 写在uni后面
  ]
})

补充一句:web-view的页面水印加不上,就算是z-index:99999999也不起作用,小程序的webview层级太高咧,h5自行加吧。

先总结到这里,持续更新。。。。

❌
❌