普通视图

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

A股三大指数午间休盘涨跌不一,金风科技涨停

2026年1月23日 11:32
36氪获悉,A股三大指数午间休盘涨跌不一,沪指涨0.27%,深成指涨0.24%,创业板指跌0.17%;发电设备、文化传媒、互联网板块领涨,金风科技、浙文互联涨停,青木科技涨超6%;能源设备、电脑硬件、煤炭板块跌幅居前,特瑞斯跌超7%,中国长城跌6%,陕西煤业跌超1%。

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`);
          }
        }
      });
    });
  }
}

50家品牌集体签约阿里速卖通

2026年1月23日 11:18
36氪获悉,1月22日,包括荣耀、倍思、努比亚等超50家行业品牌集体签约速卖通“超级品牌出海计划”,借势平台出海。当天参与签约的品牌主要是消费电子、智能硬件、智能家电等高科技、高附加值类目。

【节点】[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. 增加了思考题,促进互动

抖音美妆年度盛典在沪举办

2026年1月23日 11:09
近日,“2026抖音美妆年度盛典”在上海举行。盛典现场,“臻美奖”正式发布,44个品牌、33款单品、7位行业人物获奖。该奖项旨在表彰过去一年在内容种草、全域经营及产品创新方面表现卓越的品牌与人物。基于用户数据与市场反馈,最终从营销创新、产品表现与品牌经营三大维度,评选出年度“臻美”品牌、单品及人物奖项。盛典现场,抖音平台代表分享了美妆行业最新趋势及营销策略,并表示美妆营销已率先进入“营销一体MAX 3.0”时代。

商务部:2025年线上服务消费增长22%

2026年1月23日 11:08
商务部电子商务司负责人介绍2025年我国电子商务发展情况。商务大数据监测,2025年线上服务消费增长22%,其中线上购票线下体验的体育赛事,旅游产品和到店餐饮网零额分别增长63.3%、40.6%和23.7%。(证券时报)

泡泡玛特已注册PUCKY商标

2026年1月23日 11:04
36氪获悉,近日,“泡泡玛特电子木鱼”引发网友热议。泡泡玛特2026年1月推出的“PUCKY敲敲系列”毛绒挂件盲盒,因轻拍即可发出清脆木鱼声,被网友戏称为“电子木鱼”。爱企查App显示,北京泡泡玛特文化创意有限公司已申请注册多枚“PUCKY”商标,国际分类涉及珠宝钟表、方便食品、燃料油脂等,目前上述商标部分已成功注册。

中国信通院:即将召开“星算•智联”太空算力研讨会,发布“算力星网”联合推进倡议

2026年1月23日 10:59
为深入贯彻落实国家关于发展新质生产力、建设航天强国和数字中国的重要部署,把握空天信息与智能计算深度融合的战略机遇,谋划推动构建面向未来的太空算力基础设施体系。中国信息通信研究院拟于1月26日召开2026年“星算·智联”太空算力研讨会。会议将举行“算力星网”联合推进倡议暨《太空算力发展前瞻研究报告(2026年)》启动仪式。另外,国星宇航将介绍“国星宇航星算计划”,之江实验室将介绍“三体计算星座”,北京空间飞行器总体设计部将进行太空数智基础设施发展展望等。(证券时报)

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)
        {
         
        }
      
    }
}

四、运行效果

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

连连国际:2025年跨境商家信心指数呈现显著分化特征

2026年1月23日 10:55
36氪获悉,近日,连连国际发布的LianLian跨境商家信心指数2025年度回顾报告显示,2025年跨境商家信心指数呈现显著分化特征:总指数全年稳定运行在105-119的高位区间,即期指数则维持在84-93的相对低位(始终低于100基准线),反映出现实经营环境中的谨慎态度与运营压力;预期指数持续运行在125-148的乐观区间,显示出卖家对未来发展的长期信心。

多平台架构交互设计与方案:移动、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》 - 赵乾
  • 相关技术栈

  • 在线工具

❌
❌