普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月21日掘金 前端

破局 AI 幻觉:构建以 NoETL 语义编织为核心的 AI 就绪数据架构

2026年1月21日 17:07

企业部署大模型分析应用时,常遭遇“幻觉”困扰——AI 输出的数据结论看似合理,实则错误。根源在于传统数据架构无法为 AI 提供准确、一致、实时、可信的数据供给。破局之道在于构建以 NoETL 语义编织为核心的 AI 就绪数据架构。该架构通过创建“统一指标语义层”作为业务与数据间的“标准协议”,并采用 NL2MQL2SQL 技术路径,确保大模型生成 100% 准确的 SQL 查询,从根本上杜绝“数据幻觉”,赋能可信的智能决策。

传统数据架构为何成为 AI“幻觉”的温床?

当大模型(LLM)接入企业数据时,传统数据架构的固有缺陷被急剧放大,成为制造“数据幻觉”的系统性风险源。

  1. 数据孤岛与指标歧义:混乱的源头 企业内通常存在多套独立系统(CRM、ERP、财务软件等),导致同一业务指标(如“销售额”)在不同系统中的定义、计算口径和取数逻辑各不相同。当大模型从这些矛盾的数据源中检索信息时,必然输出逻辑混乱、结论错误的回答。指标口径不统一,是 AI 产生幻觉的首要原因。

  2. “黑盒”式数据访问:错误的催化剂 主流 NL2SQL 方案让大模型直接理解原始数据库的复杂 Schema(表结构、关联关系),并生成 SQL。这要求 AI 具备数据库专家的知识,无异于“盲人摸象”。结果常出现:错误的表连接、误解的业务逻辑、性能低下的查询。生成的错误数据难以追溯和调试,幻觉在查询阶段就已注定。

  3. 僵化的数据供给:失效的决策 基于 ETL 的批处理数据管道,开发周期长达数周甚至数月。当业务人员提出一个临时、跨域的分析需求时,数据无法及时就绪。AI 基于过时、片面的数据进行分析,必然滞后于市场变化,丧失决策时效性。

  4. 可信度与安全缺失:不可逾越的鸿沟 分析结果缺乏透明的数据血缘,管理者无法信任其来源。同时,直接向 AI 开放数据库查询权限,缺乏在查询生成过程中的动态权限校验,极易导致敏感数据泄露。

让大模型在“数据迷雾”中工作,幻觉是必然产出。 要获得可信 AI,必须先解决数据架构的“可信”问题。

NoETL 数据语义编织——AI 就绪的数据架构范式

NoETL 数据语义编织是一种创新的数据架构范式,其核心是构建一个介于原始数据与 AI 应用之间的“翻译层”与“契约层”。

  1. 核心组件:统一指标语义层 这是整个架构的基石与中枢。它使用业务语言(如“毛利率”、“月活跃用户”)明确定义每一个指标的计算公式、数据来源、关联维度及刷新周期。它成为企业唯一可信的“数据事实源”,确保在任何场景(AI 查询、BI 报表、API 服务)下,同一指标的计算逻辑绝对一致,从根本上消灭了指标歧义,为 AI 提供了清晰、无矛盾的指令集。

  2. 工作原理:从“搬运”到“编织”

  • 传统 ETL 模式:通过复杂的代码,将数据从源头“搬运”到数仓,过程僵化,变更成本高。

  • NoETL 语义编织:

    1. 虚拟接入:通过逻辑数据编织平台,以虚拟化方式连接全域数据源,无需物理搬迁。
    2. 自动转化:系统自动扫描数据源,将技术元数据(如sales_db.orders.amount)与语义层的业务术语(如“订单金额”)关联。
    3. 动态查询:形成一张全局可查询的“语义网络”。用户和 AI 只需与这张网络交互,完全屏蔽底层数百张表的复杂性。
  1. 架构优势:敏捷与无侵入 最大的优势在于以逻辑统一替代物理集中。数据准备时间从“数月”缩短至“数周”,并能随时根据业务变化调整语义逻辑,实现低成本、高敏捷的响应。

基于 NoETL 语义编织的可信 Data Agent

基于 NoETL 语义层,可构建可信的 Data Agent(数据智能体)。其核心技术路径为 NL2MQL2SQL ,这是区分“玩具”与“企业级”AI 分析的关键。

三步实现 100% 准确查询:

  1. NL2MQL(自然语言→指标查询语言):用户问:“上海地区 Q3 的销售毛利率如何?”大模型理解意图后,依据语义层,输出标准化的 MQL。例如:{“metric”: “gross_profit_margin”, “filters”: {“city”: “上海”, “quarter”: “Q3”}}。MQL 指向的是已定义的、无歧义的指标。
  2. MQL2SQL(指标查询语言→SQL):语义层引擎(规则驱动)接收 MQL,像编译器一样,根据预定义的指标逻辑(如gross_profit_margin = (revenue - cost) / revenue),确定性地生成优化后的 SQL。此步骤由规则保障,彻底杜绝大模型生成错误 SQL 的可能。
  3. 执行与返回:引擎通过智能路由与加速技术,高效执行 SQL,将结果返回给大模型进行解读与呈现。

构建分析决策闭环: 在此可信数据基础上,Data Agent 能实现更高级的能力:

  • 智能归因:面对“利润率为何下降?”的提问,能自动进行多维度(产品、渠道、地区)下钻,定位核心影响因子。
  • 智能报告:对“准备季度经营分析”等复杂指令,能自动规划分析框架,整合数据、洞察与建议,生成结构化报告。
  • 场景化助手:企业可为不同部门(财务、营销、供应链)配置专属助手,每个助手基于同一语义层,但拥有不同的数据权限和知识上下文,实现安全、合规的数据民主化。

NL2MQL2SQL 通过在 AI 与数据之间引入“语义层”这一关键中间件,在准确性与灵活性上取得了根本平衡,是企业构建可信数据智能的基石路径。

常见疑问(FAQ)

Q1: 与传统的数据仓库或数据湖相比,NoETL 数据语义编织架构最大的优势是什么?

传统数仓/湖依赖沉重的、周期长的 ETL 管道“搬运”和“固化”数据,变更成本高。NoETL 架构通过虚拟化和语义层,无需大规模物理搬迁数据,并能提供逻辑统一的实时数据视图,使数据准备时间从数月缩短至数周,并能灵活响应不断变化的业务分析需求。

Q2: 引入 NoETL 和 Data Agent,企业数据团队的角色会发生怎样的变化?

数据团队的工作重心将从繁琐的“需求响应”(写 SQL、做报表)向更高价值的“数据资产管理与赋能”转变。 团队将更专注于:1、设计和维护统一、标准的指标语义层;2、治理数据质量与安全;3、培训和配置业务部门的场景化分析助手。这释放了数据团队的生产力,聚焦于数据战略和创新。

Q3: 如何衡量一个数据架构是否真正达到了“AI-Ready”的标准?

可以参考“三真三好”的可信 AI 标准进行评估:三真即口径真(指标全局一致)、数据真(来源可靠、质量可控)、血缘真(计算逻辑全程可追溯);三好即听力好(准确理解自然语言意图)、眼力好(能进行多维度、深层次的洞察与归因)、脑力好(能整合信息,形成决策建议与报告)。满足这些标准的数据架构,才能支撑起可信、有用的企业级 AI 应用。

未来展望:

以 NoETL 语义编织为核心的 AI 就绪架构,不仅是解决当前 AI 幻觉问题的方案,更是面向未来“数据智能时代”的基础设施。它将使数据以一种更自然、更可靠的方式服务于每一位决策者,真正实现“数据驱动”从口号到现实的跃迁。企业越早构建这一架构,就越能在智能化竞争中占据先机。

TESOLLO小巧轻便灵巧手“DG-5F-S”发布

作者 爱迪斯通
2026年1月21日 17:05

机器人手爪专家Tesollo宣布,已经开发出“DG-5F-S”,这是一种新型人形机器人手,是其现有旗舰产品的紧凑和轻便版本。该产品计划于今年上半年正式推出,原型将在CES 2026上首次亮相。

DG-5F-S的特点是其紧凑和轻便的设计,通过推进Tesollo用于机器人手的内部致动器技术实现,同时保持现有模型的核心结构。

与现有的DG-5F一样,DG-5F-S保留了五个手指,20个自由度(DoF)的结构,应用了相同的类人配置,其中五个手指中的每个手指都由四个关节独立驱动。这增强了人形机器人所需的精确操纵性能和敏捷性。

图片1.png

1公斤以下的超轻设计和接近成年人手的紧凑尺寸,使得DG-5F-S可以自然集成到各种人形机器人平台中。此外,根据客户要求,其支持扩展选项,如触觉传感器集成、防水涂层和操作算法的定制,从而实现从研究使用到实际过程部署的广泛适用性。

通过采用直接驱动机制,DG-5F-S可最大限度地减少反冲,并提供高位置精度和直观的控制环境。此外DG-5F-S还可以通过算法稳定地抓取和操纵各种形状和材料的物体,并通过支持在工业现场的通信协议来提高可用性。

Tesollo预计价格将设定在低DG-5F更低的入门水平,以大大减轻初创公司、研究机构和中小型企业的采用负担。

此前,Tesollo于2024年推出了20自由度人形手DG-5F,该产品搭载其自主开发的致动器技术,并于同年在智能机器人与系统国际会议(IROS)上首次亮相。DG-5F目前已出口到全球16个国家,证明了其技术竞争力和广阔市场。

VSCode 如何断点调试 uv:`uv run langchain serve` - 前端学 FastAPI 系列

作者 Legend80s
2026年1月21日 16:50

How to debug `uv run ...` python program in VSCode

想要调试下 FastAPI 中 sqlmodel(底层是 sqlalchemy)是如何通过主键 id 获取一个数据库记录的:

@app.get("/heroes/{hero_id}")
def read_hero(hero_id: int, session: SessionDep) -> HeroPublic:
    hero = session.get(Hero, hero_id)

    if not hero:
        raise HTTPException(status_code=404, detail="Hero not found")

    return HeroPublic.model_validate(hero)

今天尝试了很久才成功在 uv run langchain serve 运行的 python 程序中打断点。当然 Trae 等 VSCode IDE 一律可用。

👩‍🏫 抄作业

.vscode/launch.json

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "✅ `uv run langchain serve` debugger",
      "type": "debugpy",
      "request": "launch",
      "program": "${workspaceFolder}/.venv/Scripts/langchain.exe",
      "args": ["serve"],
      "console": "integratedTerminal",
      "justMyCode": false,
      "cwd": "${workspaceFolder}",
      "env": {
        "PYTHONPATH": "${workspaceFolder}"
      }
    }
  ]
}

说明

  1. 因为我们想调试包源码,故 "justMyCode": false
  2. langchain 位置如何确定?首先进入项目根目录且确保虚拟环境已经启动:
which langchain 
/f/workspace/github/my-app/.venv/Scripts/langchain

or

❯ uv run which langchain
/f/workspace/github/my-app/.venv/Scripts/langchain

注意 Windows 需要加 .exe "program": "${workspaceFolder}/.venv/Scripts/langchain.exe", 否则报错:

FileNotFoundError: [Errno 2] No such file or directory: 'F:\workspace\github\my-app\.venv\Scripts\langchain'

🐞 开启调试

已 Trae 为例:打断点 → 然后点击左侧 Bug 小虫子 🐞 标志 → 下拉框选择 uv run langchain serve debugger → 点击右侧绿色小虫子(Start Debugging)或直接 F5F5 开启调试,日志如下:

❯  cd F:\\workspace\\github\\my-app ; /usr/bin/env f:\\workspace\\github\\my-app\\.venv\\Scripts\\python.exe c:\\Users\\liuchuanzong\\.trae-cn\\extensions\\ms-python.debugpy-2025.18.0-win32-x64\\bundled\\libs\\debugpy\\launcher 54274 -- F:\\workspace\\github\\my-app/.venv/Scripts/langchain.exe serve
INFO:     Will watch for changes in these directories: ['F:\\workspace\\github\\my-app']
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [18892] using StatReload
INFO:     Started server process [16184]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

触发

❯ curl -s http://localhost:8000/heroes/1 | jq

{
  "name": "legend80s",
  "age": null,
  "id": 1
}

可以看到我们的程序断在了我们刚刚打的断点处。

💭 感想

还是 DeepSeek 帮我解决了问题,Kimi 2 胡说八道,社区方案并不可信,uv 官方这个 issue Running uv scripts in debug mode #8558 一直是 open,还在等着 VSCode 官方解决 🐒🐎 🐌。

更多有用文章请关注「JavaScript与编程艺术」。

🚀 告别繁琐配置!这款 Vue 云上传组件让文件上传变得如此简单

2026年1月21日 16:44

🚀 告别繁琐配置!这款 Vue 云上传组件让文件上传变得如此简单

前端开发中,文件上传功能几乎是每个项目都绕不开的需求。但你是否也曾为对接腾讯云COS、华为云OBS、阿里云OSS而头疼?是否也曾为分片上传、断点续传、进度显示等功能而熬夜加班?

今天,我要向大家推荐一款开箱即用、功能强大的 Vue 云上传组件 —— vue-cloud-upload,它将彻底改变你对文件上传的认知!

✨ 为什么选择 vue-cloud-upload?

🎯 痛点一:三大云平台 SDK 对接繁琐

传统做法:

  • 需要分别学习腾讯云、华为云、阿里云的 SDK 文档
  • 每个平台的 API 调用方式各不相同
  • 临时凭证获取逻辑需要自己实现
  • 代码冗余,维护成本高

vue-cloud-upload 的解决方案:

<template>
  <CloudUpload
    cloudType="tencent"
    :cloudConfig="cloudConfig"
    v-model="fileList"
    @success="handleSuccess"
  />
</template>

<script>
import COS from 'cos-js-sdk-v5';
import CloudUpload, { setExternalCOS } from 'vue-cloud-upload';

setExternalCOS(COS);

export default {
  data() {
    return {
      cloudConfig: {
        bucket: "your-bucket",
        region: "ap-guangzhou",
        path: "uploads/",
        getTempCredential: this.getTempCredential
      }
    };
  }
};
</script>

只需三步:

  1. 安装对应云平台的 SDK
  2. 配置云平台参数
  3. 引入组件即可使用!

🎯 痛点二:大文件上传体验差

传统做法:

  • 大文件上传容易失败
  • 网络波动需要重新上传
  • 用户无法看到上传进度
  • 用户体验极差

vue-cloud-upload 的解决方案:

  • 自动分片上传:大文件自动切分成小块上传
  • 断点续传:网络中断后可继续上传,无需重新开始
  • 实时进度显示:上传进度实时更新,用户一目了然
  • 分片大小可配置:根据网络环境灵活调整

🎯 痛点三:文件预览功能缺失

传统做法:

  • 上传后只能看到文件名
  • 无法预览图片、PDF、视频等内容
  • 需要额外开发预览功能
  • 增加开发成本

vue-cloud-upload 的解决方案:

  • 📸 图片预览:支持图片缩放、旋转、全屏查看
  • 📄 PDF 预览:直接在线查看 PDF 文档
  • 🎬 视频播放:内置视频播放器,支持在线播放
  • 🎵 音频播放:支持音频文件在线播放
  • 📝 TXT 预览:文本文件直接查看内容

🌟 核心特性一览

1️⃣ 三大云平台无缝对接

  • 🅰️ 腾讯云 COS
  • 🅱️ 华为云 OBS
  • 🅾️ 阿里云 OSS

2️⃣ 丰富的功能特性

功能 说明
多文件上传 支持同时上传多个文件
拖拽上传 支持拖拽文件到上传区域
文件类型限制 可限制上传文件类型
文件大小限制 可限制单个文件大小
上传进度显示 实时显示上传进度
文件列表管理 支持查看、删除已上传文件
附件回显 支持通过文件 key 回显附件
自定义样式 支持自定义上传组件样式
丰富的事件回调 支持上传成功、失败、进度等事件

3️⃣ 灵活的配置选项

cloudConfig: {
  bucket: "your-bucket",           // 桶名
  region: "ap-guangzhou",          // 地域
  path: "uploads/",                // 上传目录
  getTempCredential: async () => { // 获取临时凭证
    const response = await fetch('/api/sts');
    return await response.json();
  }
}

4️⃣ 多种文件 key 生成策略

  • uuid:使用 UUID 生成唯一文件名
  • name:使用原始文件名
  • uuid+name:使用 UUID + 原始文件名(默认)
  • customKey:自定义函数生成文件 key

📦 快速开始

安装组件

npm install vue-cloud-upload

安装对应云平台 SDK

# 腾讯云 COS
npm install cos-js-sdk-v5

# 华为云 OBS
npm install esdk-obs-browserjs

# 阿里云 OSS
npm install ali-oss

基础使用示例

<template>
  <div>
    <CloudUpload
      cloudType="tencent"
      :cloudConfig="cloudConfig"
      v-model="fileList"
      :multiple="true"
      :limit="5"
      :maxSize="100"
      @success="handleSuccess"
      @error="handleError"
      @progress="handleProgress"
    />
  </div>
</template>

<script>
import COS from 'cos-js-sdk-v5';
import "vue-cloud-upload/dist/vue-cloud-upload.css";
import CloudUpload, { setExternalCOS } from 'vue-cloud-upload';

setExternalCOS(COS);

export default {
  components: { CloudUpload },
  data() {
    return {
      fileList: [],
      cloudConfig: {
        bucket: "your-bucket",
        region: "ap-guangzhou",
        path: "uploads/",
        getTempCredential: this.getTempCredential
      }
    };
  },
  methods: {
    async getTempCredential() {
      const response = await fetch('/api/sts');
      return await response.json();
    },
    handleSuccess(result, file) {
      console.log('上传成功:', result.url);
    },
    handleError(error, file) {
      console.error('上传失败:', error);
    },
    handleProgress(percent, file) {
      console.log('上传进度:', percent);
    }
  }
};
</script>

🎨 功能演示

各类文件上传

各类型文件上传.png

上传进度展示

上传进度.png

丰富的参数配置

参数配置.png

视频预览

视频预览.png

图片预览

图片预览.png

PDF 预览

pdf预览.png

💡 实战场景

场景一:企业级文件管理系统

<CloudUpload
  cloudType="aliyun"
  :cloudConfig="cloudConfig"
  v-model="fileList"
  :multiple="true"
  :limit="10"
  :maxSize="500"
  listType="picture-card"
  :previewConfig="{
    image: true,
    pdf: true,
    video: true,
    audio: true
  }"
/>

场景二:图片上传组件

<CloudUpload
  cloudType="tencent"
  :cloudConfig="cloudConfig"
  v-model="imageList"
  accept=".jpg,.jpeg,.png,.gif"
  :maxSize="10"
  listType="picture-card"
  :keyType="'uuid'"
/>

场景三:文档上传组件

<CloudUpload
  cloudType="huawei"
  :cloudConfig="cloudConfig"
  v-model="docList"
  accept=".pdf,.doc,.docx,.xls,.xlsx"
  :maxSize="50"
  listType="text"
/>

🔮 未来规划

组件正在持续迭代中,以下功能正在开发中:

  • 🔄 图片添加水印
  • 🔄 图片无损压缩
  • 🔄 视频首帧截取
  • 🔄 Office 文档在线预览(Word, Excel, PowerPoint)
  • 🔄 更多云存储平台支持

📊 项目数据

  • ⭐ GitHub Stars:持续增长中
  • 📦 NPM 下载量:月下载量稳步上升
  • 🎯 支持平台:腾讯云、华为云、阿里云
  • 📝 文档完善度:详细的使用文档和示例
  • 🐛 问题响应:快速响应和修复

🤝 贡献与支持

如果你觉得这个组件对你有帮助,欢迎:

  • 给项目一个 ⭐️ Star
  • 提交 Issue 和 Pull Request
  • 分享给你的同事和朋友

📚 完整文档

更多详细的使用文档和 API 说明,请查看:

📧 联系方式

商务合作请通过邮箱联系:shazhoulen@outlook.com


vue-cloud-upload —— 让文件上传变得更简单!

如果你正在为文件上传功能而烦恼,不妨试试这个组件,相信它会给你带来惊喜!🎉


相关推荐:

ThreeJS GSAP动画库综合应用

2026年1月21日 16:31

本文档涵盖了Three.js与GSAP动画库综合应用的关键技术和实现方法,基于实际代码示例进行讲解,展示如何利用GSAP库创建丰富的3D动画效果。

1. GSAP动画库导入与初始化

在项目中使用GSAP动画库需要先导入并进行初始化:

import * as THREE from "three";
// 导入轨道控制器
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
// 导入动画库
import gsap from "gsap";
// 导入dat.gui
import * as dat from "dat.gui";

const textureLoader = new THREE.TextureLoader();
const particlesTexture = textureLoader.load("./textures/particles/1.png");

// 1、创建场景
const scene = new THREE.Scene();

// 2、创建相机
const camera = new THREE.PerspectiveCamera(
  75,
  window.innerWidth / window.innerHeight,
  0.1,
  300
);

// 设置相机位置
camera.position.set(0, 0, 18);
scene.add(camera);

// 初始化渲染器
const renderer = new THREE.WebGLRenderer({ alpha: true });
// 设置渲染的尺寸大小
renderer.setSize(window.innerWidth, window.innerHeight);
// 开启场景中的阴影贴图
renderer.shadowMap.enabled = true;
renderer.physicallyCorrectLights = true;

// 将webgl渲染的canvas内容添加到body
document.body.appendChild(renderer.domElement);

// 添加坐标轴辅助器
const axesHelper = new THREE.AxesHelper(5);
scene.add(axesHelper);

// 设置时钟
const clock = new THREE.Clock();

// 鼠标的位置对象
const mouse = new THREE.Vector2();

// 创建投射光线对象
const raycaster = new THREE.Raycaster();

// 红色材质(用于交互效果)
const redMaterial = new THREE.MeshBasicMaterial({
  color: "#ff0000",
});

2. 立方体网格动画

2.1 立方体网格创建

创建一个由多个立方体组成的3D网格,用于第一屏的视觉效果: Title

// 创建立方体几何体
const cubeGeometry = new THREE.BoxBufferGeometry(2, 2, 2);
const material = new THREE.MeshBasicMaterial({
  wireframe: true,                    // 线框模式
});

// 创建立方体网格
let cubeArr = [];
let cubeGroup = new THREE.Group();
for (let i = 0; i < 5; i++) {
  for (let j = 0; j < 5; j++) {
    for (let z = 0; z < 5; z++) {
      const cube = new THREE.Mesh(cubeGeometry, material);
      cube.position.set(i * 2 - 4, j * 2 - 4, z * 2 - 4);  // 设置立方体位置
      cubeGroup.add(cube);
      cubeArr.push(cube);
    }
  }
}

scene.add(cubeGroup);

2.2 立方体网格旋转动画

使用GSAP库实现立方体网格的连续旋转动画:

// 立方体网格旋转动画
gsap.to(cubeGroup.rotation, {
  x: "+=" + Math.PI * 2,              // X轴旋转一周
  y: "+=" + Math.PI * 2,              // Y轴旋转一周
  duration: 10,                        // 动画持续时间10秒
  ease: "power2.inOut",                // 缓动函数
  repeat: -1,                          // 无限重复
});

3. 三角形几何体动画

3.1 随机三角形生成

创建一系列随机形状的三角形,形成独特的视觉效果:

// 创建三角形组
var sjxGroup = new THREE.Group();
for (let i = 0; i < 50; i++) {
  // 每一个三角形,需要3个顶点,每个顶点需要3个值
  const geometry = new THREE.BufferGeometry();
  const positionArray = new Float32Array(9);
  for (let j = 0; j < 9; j++) {
    if (j % 3 == 1) {
      positionArray[j] = Math.random() * 10 - 5;  // Y轴特殊处理
    } else {
      positionArray[j] = Math.random() * 10 - 5;
    }
  }
  geometry.setAttribute(
    "position",
    new THREE.BufferAttribute(positionArray, 3)
  );
  
  // 随机颜色
  let color = new THREE.Color(Math.random(), Math.random(), Math.random());
  const material = new THREE.MeshBasicMaterial({
    color: color,
    transparent: true,
    opacity: 0.5,
    side: THREE.DoubleSide,
  });
  
  // 根据几何体和材质创建物体
  let sjxMesh = new THREE.Mesh(geometry, material);
  sjxGroup.add(sjxMesh);
}
sjxGroup.position.set(0, -30, 0);     // 设置三角形组的位置
scene.add(sjxGroup);

3.2 三角形组旋转动画

使用GSAP库实现三角形组的连续旋转动画:

// 三角形组旋转动画
gsap.to(sjxGroup.rotation, {
  x: "-=" + Math.PI * 2,              // X轴反向旋转一周
  z: "+=" + Math.PI * 2,              // Z轴正向旋转一周
  duration: 12,                        // 动画持续时间12秒
  ease: "power2.inOut",                // 缓动函数
  repeat: -1,                          // 无限重复
});

4. 点光源与小球动画

4.1 球体和光源设置

创建一个带有光源的动态场景:

// 创建球体组
const sphereGroup = new THREE.Group();
const sphereGeometry = new THREE.SphereBufferGeometry(1, 20, 20);
const spherematerial = new THREE.MeshStandardMaterial({
  side: THREE.DoubleSide,
});
const sphere = new THREE.Mesh(sphereGeometry, spherematerial);
sphere.castShadow = true;              // 启用阴影投射

sphereGroup.add(sphere);

// 创建平面
const planeGeometry = new THREE.PlaneBufferGeometry(20, 20);
const plane = new THREE.Mesh(planeGeometry, spherematerial);
plane.position.set(0, -1, 0);
plane.rotation.x = -Math.PI / 2;
plane.receiveShadow = true;            // 启用阴影接收
sphereGroup.add(plane);

// 添加环境光
const light = new THREE.AmbientLight(0xffffff, 0.5);
sphereGroup.add(light);

// 创建小球(带光源)
const smallBall = new THREE.Mesh(
  new THREE.SphereBufferGeometry(0.1, 20, 20),
  new THREE.MeshBasicMaterial({ color: 0xff0000 })
);
smallBall.position.set(2, 2, 2);

// 点光源
const pointLight = new THREE.PointLight(0xff0000, 3);
pointLight.castShadow = true;
pointLight.shadow.radius = 20;         // 阴影模糊度
pointLight.shadow.mapSize.set(512, 512); // 阴影贴图分辨率

smallBall.add(pointLight);
sphereGroup.add(smallBall);

sphereGroup.position.set(0, -60, 0);
scene.add(sphereGroup);

4.2 小球位置动画

使用GSAP库实现小球的位置动画,创造弹跳和移动效果:

// 小球水平移动动画
gsap.to(smallBall.position, {
  x: -3,                               // 移动到x=-3位置
  duration: 6,                         // 动画持续时间6秒
  ease: "power2.inOut",                // 缓动函数
  repeat: -1,                          // 无限重复
  yoyo: true,                          // 往返运动
});

// 小球垂直移动动画(弹跳效果)
gsap.to(smallBall.position, {
  y: 0,                                // 移动到y=0位置
  duration: 0.5,                       // 动画持续时间0.5秒
  ease: "power2.inOut",                // 缓动函数
  repeat: -1,                          // 无限重复
  yoyo: true,                          // 往返运动
});

5. 动画循环与渲染

5.1 动画循环函数

实现基本的动画循环和渲染:

function render() {
  let deltaTime = clock.getDelta();

  // 鼠标移动影响相机位置
  camera.position.x += (mouse.x * 10 - camera.position.x) * deltaTime * 5;
  
  renderer.render(scene, camera);
  requestAnimationFrame(render);
}

render(); // 启动动画循环

// 监听鼠标位置
window.addEventListener("mousemove", (event) => {
  mouse.x = event.clientX / window.innerWidth - 0.5;
  mouse.y = event.clientY / window.innerHeight - 0.5;
});

5.2 相机跟随动画

实现相机跟随鼠标移动的动画效果:

// 监听鼠标位置
window.addEventListener("mousemove", (event) => {
  mouse.x = event.clientX / window.innerWidth - 0.5;
  mouse.y = event.clientY / window.innerHeight - 0.5;
});

function render() {
  let deltaTime = clock.getDelta();

  // 鼠标移动影响相机位置
  camera.position.x += (mouse.x * 10 - camera.position.x) * deltaTime * 5;
  
  renderer.render(scene, camera);
  requestAnimationFrame(render);
}

6. 交互动画

6.1 鼠标点击交互动画

实现鼠标点击时的交互效果:

// 创建投射光线对象
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();

// 监听鼠标点击事件
window.addEventListener("click", (event) => {
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -((event.clientY / window.innerHeight) * 2 - 1);
  raycaster.setFromCamera(mouse, camera);
  let result = raycaster.intersectObjects(cubeArr);
  
  // 改变相交物体的材质颜色
  result.forEach((item) => {
    item.object.material = redMaterial;
  });
});

总结

本章详细介绍了Three.js中创建3D动画特效的几种主要动画效果:

  1. GSAP动画库导入与初始化:导入GSAP动画库并初始化所需变量
  2. 立方体网格动画:通过GSAP库实现的连续旋转动画,创造动态的3D网格效果
  3. 三角形几何体动画:随机生成的三角形组合,配合旋转动画创造抽象艺术效果
  4. 点光源与小球动画:带动画的小球与点光源,展现动态光影效果
  5. 动画循环与渲染:实现基本的动画循环和渲染机制
  6. 相机跟随动画:实现相机跟随鼠标移动的动画效果

此外,还实现了实现鼠标点击交互效果,共同构成了完整的3D动画体验。通过合理运用Three.js和GSAP动画库,可以创造出丰富多样的3D动画效果。

CSS-HTML Form 表单交互深度指南

2026年1月21日 16:15

前言

虽然现代前端框架(Vue/React)已经极大简化了表单处理,但理解原生 Form 表单 的事件流、控件属性和 API,依然是处理复杂业务逻辑(如埋点、自定义验证、无刷新提交)的基础。

一、 Form 表单的核心机制

<form> 是所有输入控件的容器。它不仅负责数据的收集,还管理着数据的提交 (Submit)重置 (Reset) 周期。

1. 关键属性

  • action:数据提交的目标 URL。
  • method:HTTP 请求方式(GET 拼接到 URL,POST 放入请求体)。

2. 提交与拦截

当表单内存在 type="submit" 的按钮时,点击会触发 submit 事件。

const form = document.querySelector("#myForm");

form.addEventListener("submit", (event) => {
  // 1. 阻止浏览器默认的跳转刷新行为
  event.preventDefault(); 
  
  // 2. 自定义验证逻辑
  if (inputValue === "") {
    alert("内容不能为空");
    return;
  }
  
  // 3. 执行异步提交(如使用 Fetch/Axios)
  console.log("表单已提交");
});

3. 重置行为

form.reset() 不仅仅是清空。它会将所有字段恢复为页面加载时的初始值(例如 <input value="default"> 会恢复为 "default" 而非空)。


二、 输入控件的“通用武器库”

无论 inputselect 还是 textarea,它们都共享以下核心属性和方法:

1. 公共属性与方法

  • disabled:禁用控件,数据不会被提交。
  • readOnly:只读,数据随表单提交。
  • form:只读属性,返回当前控件所属的表单对象引用。
  • focus() / blur() :手动控制焦点的获取与失去。

2. 三大核心事件

事件 触发时机
focus 控件获得焦点时。
blur 控件失去焦点时(常用于输入后的实时验证)。
change 内容改变且失去焦点时触发(注意:与 input 事件实时触发不同)。

三、 文本输入:Input vs Textarea

1. 单行文本框 <input type="text">

  • placeholder:提示文本。
  • maxlength:硬性限制用户输入的字符长度。

2. 多行文本框 <textarea>

  • rows/cols:控制显示的行数和宽度。

  • wrap 换行控制

    • soft(默认):提交时不带换行符。
    • hard:提交的数据中包含换行符(必须配合 cols 使用)。

四、 选择框:Select 与 Option

<select> 元素在 JavaScript 中拥有更丰富的集合操作。

1. Select 关键操作

  • multiple:是否允许多选(按住 Ctrl/Command 键)。
  • options:返回包含所有 <option> 元素的 HTMLCollection。
  • remove(index) :移除指定索引的选项。

2. Option 选项详情

每一个 Option 对象都有:

  • index:在下拉列表中的位置。
  • selected:布尔值,通过设置为 true 可实现代码控制选中。
  • text:用户看到的文字。
  • value:提交给后端的值。

五、 面试模拟题

Q1:submit 事件和按钮的 click 事件有什么区别?

参考回答:

submit 事件是绑定在 form 元素上的。如果用户在输入框中按“回车键”,或者点击了 type="submit" 的按钮,都会触发 form 的 submit 事件。相比点击事件,监听 submit 能更全面地捕获提交动作。

Q2:如何通过原生 JS 获取表单内的所有数据?

参考回答:

现代浏览器推荐使用 FormData 对象:

const formData = new FormData(formElement);
// 获取特定字段
const username = formData.get('username');
// 转化为对象
const data = Object.fromEntries(formData.entries());

Q3:disabledreadOnly 在表单提交时有什么区别?

参考回答:

  • 设置了 disabled 的控件,其值在表单提交时会被忽略,且用户无法交互。
  • 设置了 readOnly 的控件,用户无法修改值,但其值在提交时会被包含在表单数据中。

前端-通信机制

作者 Soler
2026年1月21日 16:02

业务开发中,通信可能会涉及到同源与跨域的场景,Web开发中,同源策略(协议+域名+端口一致)是保障用户信息安全的核心机制。但业务中常需实现页面间通信,本文提供了三种主流方案:同源高效的BroadcastChannel、基于StorageEvent的跨标签页同步、跨域安全的postMessage

一、BroadcastChannel:同源页面间的广播站

核心特性

  • 严格遵循同源策略,不同源页面自动隔离
  • 发布-订阅模式,一对多通信
  • 频道名称在同源内唯一,跨页面同名频道自动关联

代码示例

// 页面A(商品详情页)
const productChannel = new BroadcastChannel('product_updates');
productChannel.postMessage({
  type: 'PRICE_UPDATE',
  data: { sku: 'SKU-123', price: 199 }
});

// 页面B(购物车页面)
const cartChannel = new BroadcastChannel('product_updates');
cartChannel.addEventListener('message', (e) => {
  if (e.data.type === 'PRICE_UPDATE') {
    updateCartItem(e.data.data.sku, e.data.data.price);
  }
});

关键要点

  1. 必须使用new BroadcastChannel(channelName)创建同名频道
  2. 消息建议包含type字段作为标识符,便于接收方路由处理
  3. 浏览器自动管理连接,无需手动维护窗口引用

二、StorageEvent:跨标签页的状态同步

触发条件

  • 必须由不同标签页/窗口触发
  • 必须通过localStorage.setItem()/removeItem()/clear()修改
  • 同一标签页内的修改不会触发事件

代码示例

// 页面A(主题设置页)
document.getElementById('theme-btn').addEventListener('click', () => {
  const darkMode = !JSON.parse(localStorage.getItem('darkMode'));
  localStorage.setItem('darkMode', JSON.stringify(darkMode)); // 触发事件
});

// 页面B(所有页面)
window.addEventListener('storage', (e) => {
  if (e.key === 'darkMode') {
    applyTheme(JSON.parse(e.newValue));
  }
});

调试技巧

  • 使用window.open()打开测试窗口确保同源
  • 在控制台检查e.url确认触发来源
  • 避免使用sessionStorage(仅当前标签页有效)

三、跨域通信:postMessage实践

示例代码

// 父页面(https://main.com)
const iframe = document.createElement('iframe');
iframe.src = 'https://trusted-subdomain.com/widget';
document.body.appendChild(iframe);

// iframe.contentWindow,子页面window发送事件
iframe.contentWindow.postMessage({
  type: 'INIT_WIDGET',
  apiKey: 'ABC123'
}, 'https://trusted-subdomain.com');

// 父页面监听子页面e.source.postMessage
window.addEventListener('message', (e) => {
 
});

// iframe页面(https://trusted-subdomain.com),子页面监听
window.addEventListener('message', (e) => {
  if (e.origin !== 'https://main.com') return;
  
  if (e.data.type === 'INIT_WIDGET') {
    initWidget(e.data.apiKey);
    // e.source父页面的windowd对象
    e.source.postMessage({ status: 'READY' }, e.origin);
  }
});

以上代码是基于iframe嵌套的跨域页面实现的,也可以基于windowNew = window.open(url),即多个window跨域窗口通信,本质上获取window对象是关键

安全要点

  1. 永远不要使用targetOrigin: '*'(生产环境)
  2. 消息数据应包含类型字段便于路由
  3. 使用e.source而非直接操作window.opener
  4. 敏感数据需加密传输

利用 nvm 管理 node.js 版本(卸载、安装、环境变量、镜像源全覆盖)

作者 梅川_酷子
2026年1月21日 15:46

nvm-nodejs.png

前言

  本文是基于 windows 系统,实现 nvm 管理 nodejs。

  因为公司最近更换使用云桌面,又遇到项目依赖安装问题,所以部署了几次nodejs环境。索性把安装配置的过程记录下来,让日后遇到需要环境配置的时候,更加无痛畅快!

1. 卸载已安装的node\nvm(未安装过可忽略)

在安装nvm的时候没有卸载node,可能导致使用nvm安装完之后,node和npm都不可用

node
  1. 打开「控制面板」→「程序和功能」,找到「Node.js」右键选择「卸载」,按提示完成卸载

  2. 手动删除残留文件,

   - 常见路径:C:\Program Files\nodejs、C:\Program Files (x86)\nodejs;

   - 删除用户目录下的缓存 / 配置:C:\Users\你的用户名\AppData\Roaming\npm 和 C:\Users\你的用户名\AppData\Roaming\npm-cache。

nvm
  1. 打开「控制面板」→「程序和功能」,找到「nvm for Windows」,右键选择「卸载」,按提示完成卸载;

  2. 手动删除 NVM 安装目录(默认是 C:\Users\你的用户名\AppData\Roaming\nvm 或 C:\Program Files\nvm),直接右键删除文件夹即可。

环境变量
  1. 右键「此电脑」→「属性」→「高级系统设置」→「环境变量」;

  2. 在「系统变量」和「用户变量」的「Path」中,删除所有包含 nvm、nodejs、npm 的路径;

  3. 若有 NVM_HOME、NVM_SYMLINK 等系统变量,直接删除。

验证删除

执行以下命令,若提示「不是内部或外部命令」则说明删除成功:

node -v
npm -v
nvm -v

2.nvm下载安装

  • 首先,下载一个安装包

nvm下载地址:nvm.uihtm.com/doc/downloa…

nvm1.png

  • 选择第一个 同意安装协议

nvm2.png

  • 选择安装目录,建议安装在D盘根目录

nvm3.png

  • 选择安装nodejs的目录,建议放在nvm下的nodejs

nvm4.png

  • 邮件订阅通知,可全部取消

nvm5.png

  • 订阅通知的邮箱,留空,可以不填

nvm6.png

  • 安装完成打开powershell

nvm7.png

  • 输入nvm -v查看是否有版本号输出。如果报错,尝试重开cmd后输入

nvm8.png

  • settings配置文件检查

nvm9.png

nvm10.png

  • 如果未有版本号输出,请手动添加到环境变量(可跳过)

nvm11.png

注意(重要!):一定要修改nvm文件夹, nodejs文件夹的属性,在“属性->安全”一栏中, 设置完全控制权限。如果权限不足,可能会导致使用时一些不可预料的问题发生

nvm15.png

3. 通过nvm,安装、管理node.js版本

安装指定node版本,切换版本并启用nvm

# 安装指定node版本
nvm install 22.15.0
  
# 使用指定版本
nvm use 22.15.0

# 打开nvm的版本控制
nvm on

此时node版本文件会下载到 \nvm 文件夹中,并生成nodejs文件

nvm16.png

查看当前版本

# 查看当前已下载的所有版本
nvm ls

(当前只下载了一个版本的nodejs)

nvm12.png

查看可用版本

# 查看可供使用的node版本
nvm ls available

这四列的区别: CURRENT(当前最新版本)、LTS(长期支持版本)、OLD STABLE(旧稳定版本)、OLD UNSTABLE(旧非稳定版本)。 点击其中底部连接可查看更全的版本信息 nvm13.png

安装新版本,切换版本

# 下载一个新版本
nvm install 20.20.0

# 使用这个新版本
nvm use 20.20.0

星号所在的版本位置,就是当前使用的node版本。从22版本,切换到了20版本 nvm14.png

4. 配置npm 缓存目录、npm 全局包安装目录 的环境变量(非必需,但强烈建议)

理由:

  nvm 切换版本后,无法使用前版本的全局包,因 npm 下载的全局包,只在对应的版本中

  缓存文件默认存在系统用户目录,Windows 下默认路径易触发权限问题(比如无法写入 C 盘用户目录)

配置流程

  1.  先在本地建两个文件夹(路径避免空格 / 中文 / 特殊字符)
    D:\nvm\node_prefix(全局包目录)、D:\nvm\node_cache(缓存目录)

nvm19.png

  1.  执行 npm 配置命令(关键!)
# 配置全局包安装目录
npm config set prefix "D:\nvm\node_prefix"


# 配置 npm 缓存目录
npm config cache prefix "D:\nvm\node_cache"

        检查配置是否成功

# 查看全局包目录
npm config get prefix
# 打印结果为设置的路径

# 查看缓存目录
npm config get cache
# 打印结果为设置的路径
  1.  配置环境变量

    找到「Path」,点击「编辑」→「新建」,粘贴你的 prefix 目录

nvm17.png

    新增「NODE_PATH」变量,prefix 目录 + node_modules

nvm18.png

  补充:在新版 Node(v16+)中,NODE_PATH可以忽略。

  原因:Node(v16+)在正确配置 npm config set prefix 的前提下, Node 会自动把 prefix 目录下的 node_modules 加入模块查找路径,无需你再通过 NODE_PATH 重复指定。

  1.  检验是否成功
# 检查 PATH 中是否包含全局包目录
echo %PATH%
# 输出中能看到 `D:\nvm\node_prefix` 即成功


# 检查 NODE_PATH 配置
echo %NODE_PATH%
# 输出 `D:\nvm\node_prefix\node_modules` 即成功
# 安装一个全局包(比如 yarn)
npm install -g yarn
# 执行命令,能输出版本号即成功
yarn -v


# 安装一个全局包(比如 express)
npm install -g express

# 测试 Node 加载全局模块(可选)
node -e "console.log(require.resolve('express'))"
# 输出应指向你的 npm_global 目录,如 "D:\nvm\node_prefix\node_modules\express\index.js"

  1.  如果想恢复默认路径,但应该不会吧
npm config delete prefix

npm config delete cache

5. 更换镜像源

  1. npm 查看镜像源
npm config get registry
  1. npm 配置镜像源
npm config set registry https://registry.npmmirror.com
  1. 可用镜像源列表

恭喜,完成!

更多信息及常见问题:
www.nvmnode.com/zh/faq/
nvm.uihtm.com/doc/faqs.ht…

深挖 van-list:一次分页加载问题的完整排查

作者 ursazoo
2026年1月21日 15:16

太长不看版

问题:切换筛选项时,如果不滚动页面,loading 会一直显示,但滚动后再切换就正常。

原因

  1. processingData 判断逻辑有问题:当数据量刚好等于 pageSize 时,错误地判断 finished = false
  2. van-list 的 watch 机制:当 finishedfalse 变成 false 时不会触发加载
  3. 两个问题叠加导致切换筛选后没有触发数据加载

解决方案:修改 processingData 的判断逻辑,用 累积数据量 >= total 代替 list.length < pageSize


背景

最近在做会员详情页,有个余额明细的列表,可以切换筛选项(全部/充值/消费)。看起来很简单的功能,结果遇到了一个莫名其妙的问题。

项目技术栈

  • Vue 3 + Vant 4
  • 分页加载用的 van-list 组件

问题现象

  • 进入页面,默认显示"全部"筛选的数据(10 条,total=10)
  • 不滑动页面,直接点击"充值"或"消费"筛选,一直 loading,没有数据
  • 但如果先滑动页面,再切换筛选,正常

第一次遇到这种问题,完全摸不着头脑。为什么滑不滑动页面会有区别?


临时修复

因为要上线,我需要快速解决这个问题。看了代码发现切换筛选时会调用 resetListParams()

const resetListParams = () => {
  nextTick(() => {
    state.list = []
    queryParams.page = 1
    scrollView?.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
    state.finished = false
  })
  showLoadingToast({
    message: '加载中...',
    forbidClick: true,
    duration: 0
  })
}

我猜测可能是因为没有触发数据加载,于是加了一行手动调用 getList()

const resetListParams = () => {
  nextTick(() => {
    state.list = []
    queryParams.page = 1
    scrollView?.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
    state.finished = false
    getList()  // 加了这一行
  })
  showLoadingToast({
    message: '加载中...',
    forbidClick: true,
    duration: 0
  })
}

结果:能用,切换筛选正常了。

但我心里还是觉得不踏实:为什么原本的代码不行?滑动页面后就正常?其他用 van-list 的地方会不会也有这个问题?


深入排查

上线后,我打算搞清楚这个问题。

第一步:重现问题

把之前加的 getList() 注释掉,重新测试:

const resetListParams = () => {
  nextTick(() => {
    state.list = []
    queryParams.page = 1
    scrollView?.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
    state.finished = false
    // getList()  // 注释掉,重现问题
  })
  showLoadingToast({
    message: '加载中...',
    forbidClick: true,
    duration: 0
  })
}

问题重现

  • 不滑动页面,直接切换筛选,一直 loading
  • 滑动页面后,再切换筛选,正常

第二步:确认请求没发起

打开开发者工具,Network 面板里确实没有新的请求。所以问题不是请求失败,而是根本没发起请求

第三步:分析为什么滑动就正常

看一下 van-list 的用法:

<van-list
  v-model:loading="state.listLoading"
  :finished="state.finished"
  :offset="30"
  @load="getList"
  :finished-text="state.list?.length ? '加载完成' : ''"
>
  <div
    class="member-item"
    v-for="item in state.list"
    :key="item.orderNo"
  >
    <!-- 列表项内容 -->
  </div>
</van-list>

我知道 van-list 会在滚动到底部时触发 @load 事件加载更多数据。

但现在的问题是:切换筛选后,我已经在 resetListParams() 里设置了 state.finished = false,按理说 van-list 应该要重新加载数据才对。为什么不滚动的话,@load 就不会触发呢?难道 van-list 只能通过滚动来触发加载,没有其他方式吗?

我开始怀疑是 finished 状态的问题。

第四步:检查 finished 的判断逻辑

第一次加载数据的代码:

CustomerApiFetch.customerCardBalanceChangeListPost(params)
  .then((res) => {
    const { total, list } = res  // total=10, list.length=10
    const { data, finished } = processingData(state.list, list, queryParams)
    state.list = [...data]
    state.total = total
    state.finished = finished
  })

processingData 是项目的公共 hook,用于处理分页数据:

export const processingData = (data, list, param) => {
  const newData = param.page === 1 ? list : [...data, ...list]
  const finished = list.length < param?.pageSize  // 判断逻辑
  if (!finished) {
    param.page++
  }
  return { data: newData, finished }
}

发现问题

当第一次加载时:

  • list.length = 10(返回 10 条数据)
  • param.pageSize = 10
  • finished = 10 < 10 = false(错误)

但实际上,total 也是 10,说明数据已经全部加载完了,finished 应该是 true 才对!

第五步:理解 van-list 的触发机制

现在我明白了问题的一半:finished 被错误地判断为 false

但还有一个疑问:为什么滑动页面后就正常了?

我去看了 van-list 的源码(node_modules/vant/lib/list/List.js):

// 第 124 行:监听 props 变化
(0, import_vue.watch)(() => [props.loading, props.finished, props.error], check);

// 第 143 行:监听滚动事件
(0, import_use.useEventListener)("scroll", check, {
  target: scroller,
  passive: true
});

原来 van-list 有两种触发 check() 的方式

  1. watch 监听 props 变化:当 finishedloadingerror 改变时
  2. scroll 事件监听:用户滚动时

再看 check() 函数的实现(第 58-85 行):

const check = () => {
  (0, import_vue.nextTick)(() => {
    if (loading.value || props.finished || props.disabled || props.error ||
        (tabStatus == null ? void 0 : tabStatus.value) === false) {
      return;  // 如果 finished=true,直接返回
    }

    // 计算是否滚动到边缘
    // ...

    if (isReachEdge) {
      loading.value = true;
      emit("update:loading", true);
      emit("load");  // 触发 @load 事件
    }
  });
};

现在全部串起来了

第六步:对比两种情况

情况 1:滑动页面后切换(正常)

1. 第一次加载
   ├─ 返回 10 条数据,total=10
   ├─ processingData 判断:list.length(10) < pageSize(10) = false
   └─ state.finished = false(错误判断)

2. 用户滑动页面
   ├─ 触发 scroll 事件
   ├─ van-list 调用 check()
   ├─ 检测到滚动到底部
   └─ 触发 @load

3. 第二次加载(page=2)
   ├─ 返回空数组(因为只有 10 条数据)
   ├─ processingData 判断:list.length(0) < pageSize(10) = true
   └─ state.finished = true(被意外纠正了)

4. 用户切换筛选
   ├─ resetListParams() 设置 state.finished = false
   ├─ finished: true 变成 false(状态改变了!)
   ├─ van-list 的 watch 被触发
   └─ 自动触发 @load

情况 2:不滑动直接切换(问题)

1. 第一次加载
   ├─ 返回 10 条数据,total=10
   ├─ processingData 判断:list.length(10) < pageSize(10) = false
   └─ state.finished = false(错误判断)

2. 用户直接切换筛选(没有滚动)
   ├─ resetListParams() 设置 state.finished = false
   ├─ finished: false 变成 false(状态没变!)
   ├─ van-list 的 watch 不触发(Vue watch 机制:值没变就不触发)
   ├─ scroll 事件也没有(用户没滚动)
   └─ 没有任何方式触发 @load

3. 结果
   ├─ showLoadingToast() 已经显示
   ├─ 但没有请求发起
   └─ loading 永远不会关闭

搞明白了


根本原因

问题有两个层面:

1. processingData 的判断逻辑有缺陷

const finished = list.length < param?.pageSize

这个判断在以下情况下是错误的

场景 list.length pageSize total 实际状态 判断结果 是否正确
还有数据 10 10 30 未完成 false 正确
刚好加载完 10 10 10 已完成 false 错误
最后一页不足 5 10 15 已完成 true 正确

当返回的数据量刚好等于 pageSize,且已经是全部数据时,finished 会被错误地判断为 false。

2. van-list 的触发机制

van-list 的 @load 有两种触发方式:

触发方式 触发条件 使用场景
watch 监听 finishedloadingerror 状态改变 状态切换(true 和 false 互相切换)
scroll 事件 用户滚动到底部 正常的分页加载

当 finished 从 false 变成 false 时

  • watch 不会触发(Vue 的 watch 机制,值没变就不触发)
  • scroll 也不会触发(用户没滚动)
  • 结果:没有任何方式触发 @load

3. 两个问题叠加

processingData 错误判断
  |
  v
finished = false(应该是 true
  |
  v
用户切换筛选
  |
  v
resetListParams 设置 finished = false
  |
  v
finished: false 变成 false(状态没变)
  |
  v
van-list  watch 不触发
  |
  v
没有 scroll 事件
  |
  v
@load 不触发
  |
  v
一直 loading

解决方案对比

方案 1:手动调用 getList()(临时方案)

const resetListParams = () => {
  nextTick(() => {
    state.list = []
    queryParams.page = 1
    state.finished = false
    getList()  // 手动调用
  })
  showLoadingToast({ ... })
}

优点

  • 快速修复,立即上线

缺点

  • 治标不治本,finished 的判断还是错的
  • 其他 10 个使用 processingData 的页面也有同样的问题

适用场景:紧急上线,先解决问题


方案 2:修复 processingData 判断逻辑

修改 src/hooks/processingData.ts

/**
 * @function 处理分页数据
 * @param { Array } data 保存的数据
 * @param { Array } list 接口请求回来的数据
 * @param { Object } param 请求接口的分页数据
 * @param { Number } total 数据总数
 * @return { data } 处理后的数据
 * @return { finished } 数据是否全部请求完
 */
export const processingData = (data, list, param, total) => {
  const newData = param.page === 1 ? list : [...data, ...list]
  const finished = newData.length >= total  // 使用累积数据量判断
  if (!finished) {
    param.page++
  }
  return { data: newData, finished }
}

然后更新所有 10 个调用的文件,传入 total 参数:

// 修改前
const { data, finished } = processingData(state.list, list, queryParams)

// 修改后
const { data, finished } = processingData(state.list, list, queryParams, total)

优点

  • 从根源解决问题
  • 所有使用分页加载的页面都受益
  • finished 状态永远准确
  • 不需要在 resetListParams 里手动调用 getList()

缺点

  • 需要修改 10 个文件
  • 需要测试所有相关页面

适用场景:彻底解决问题,消除技术债


方案 3:强制触发 watch(hack 方案)

const resetListParams = () => {
  nextTick(() => {
    state.list = []
    queryParams.page = 1

    // 先设置为 true,再设置为 false,强制触发 watch
    state.finished = true
    nextTick(() => {
      state.finished = false  // true 变成 false,触发 van-list 的 watch
    })
  })
  showLoadingToast({ ... })
}

优点

  • 不需要改 processingData
  • 不需要改其他文件
  • 利用了 van-list 的 watch 机制

缺点

  • 非常 hack,不优雅
  • finished 的判断还是错的
  • 状态闪烁(true 变成 false)可能有副作用

最终选择

我选择方案 2:修复 processingData 判断逻辑


踩坑总结

  1. 公共 hook 的判断逻辑要严谨

    • list.length < pageSize 看起来对,但有边界情况
    • 应该用 累积数据量 >= total 来判断
    • 边界情况很容易被忽略
  2. 了解组件库的触发机制很重要

    • 不要只会用,要知道原理
    • van-list 的 watch + scroll 两种触发方式
    • 状态改变和滚动事件的区别
  3. 临时方案要知道只是临时的

    • 昨晚加 getList() 是为了上线
    • 但不能一直用临时方案
    • 要找时间深入研究,彻底解决

一些想法

昨晚为了赶上线,我就直接加了 getList() 就完事了。当时就想着"能用就行",但心里总觉得哪里不对。

今天重新看这个问题,发现还挺有意思的:

  • processingData 的判断逻辑有问题
  • van-list 的 watch 机制
  • 两个问题叠加就出现了

要不是"滑动就正常"这个线索,我估计还得调试更久。就是这个奇怪的现象让我发现,滑动前后 finished 的状态不一样,顺着这个思路才找到根本原因。

还有就是看源码真的有用。之前我就只会用 van-list,知道有 finished@load,但完全不知道它内部怎么工作的。看了源码才明白 watch 和 scroll 两种触发方式,也搞清楚了为什么 false 变成 false 不会触发。

关于临时方案和彻底修复,我觉得都需要吧。昨晚的临时方案让我能按时上线,今天的深入研究让我理解了问题本质。不能因为有临时方案就不去研究,也不能因为追求完美就一直不上线。

generator的学习

2026年1月21日 15:02

JS 中的 Generator(生成器) 是一个非常强大但略显晦涩的概念。它是理解现代 JS 异步编程(特别是 async/await 原理)的基石。

简单来说,async/await 就是 Generator 加上一个自动执行器(Auto Runner)的语法糖。

下面我将分三个阶段带你学习:从基本用法,到底层原理,最后手写一个 async/await 的实现


第一阶段:Generator 怎么用?(基础语法)

普通函数一旦执行,就会从头跑到尾。而 Generator 函数是可以“中途暂停”和“恢复执行”的函数

1. 核心关键词

  • function*:声明一个生成器函数。
  • yield:暂停点(产出值)。
  • .next():遥控器(继续执行)。

2. 最简单的例子

function* helloWorldGenerator() {
  console.log("1. 开始执行");
  yield 'hello'; // 暂停在这里,并把 'hello' 扔出去
  
  console.log("2. 恢复执行");
  yield 'world'; // 再次暂停,把 'world' 扔出去
  
  console.log("3. 结束");
  return 'ending'; // 最终结束
}

// 1. 调用 Generator 不会立即执行代码,而是返回一个“遍历器对象”(指针)
const gen = helloWorldGenerator();

// 2. 第一次调用 next(),代码运行到第一个 yield 处停止
const result1 = gen.next(); 
console.log(result1); // { value: 'hello', done: false }

// 3. 第二次调用 next(),代码从上次停止的地方继续,直到下一个 yield
const result2 = gen.next();
console.log(result2); // { value: 'world', done: false }

// 4. 第三次调用 next(),代码运行到结束或 return
const result3 = gen.next();
console.log(result3); // { value: 'ending', done: true }

3. 双向数据交换(重要)

yield 不仅能输出数据,还能通过 next(参数) 接收数据。这是 async/await 能实现的关键。

function* calculate() {
  // 注意:yield 表达式本身没有返回值,val1 的值取决于下一次 next() 传进来的参数
  const val1 = yield 1; 
  console.log(`接收到了: ${val1}`);
  
  const val2 = yield 2;
  console.log(`接收到了: ${val2}`);
  
  return val1 + val2;
}

const gen = calculate();

// 第1步:启动,运行到 yield 1。此时 val1 还没赋值。
console.log(gen.next().value); // 输出: 1

// 第2步:传入 10。这个 10 会被赋值给上一个 yield 的返回值(即 val1)
console.log(gen.next(10).value); // 控制台: "接收到了: 10", 输出: 2

// 第3步:传入 20。这个 20 会被赋值给 val2
console.log(gen.next(20)); // 控制台: "接收到了: 20", 输出: { value: 30, done: true }

第二阶段:为什么说 async/await 是 Generator 变的?

让我们来看一个异步的场景。

1. 目标:顺序读取两个文件(模拟网络请求)

如果用 Generator 写,大概长这样:

function* myTask() {
  // 看起来是不是非常像 async/await ?
  const data1 = yield fetch('/api/user'); // 假设 fetch 返回 Promise
  console.log(data1);
  
  const data2 = yield fetch('/api/posts');
  console.log(data2);
}

2. 手动执行(痛点)

上面的代码虽然长得像同步代码,但它不会自己动。我们需要手动处理 Promise:

const gen = myTask();

const p1 = gen.next().value; // 拿到第一个 Promise

p1.then(data1 => {
  // 拿到数据后,通过 next(data1) 传回给 generator,并继续执行
  const p2 = gen.next(data1).value; 
  
  p2.then(data2 => {
    // 拿到第二个数据,传回去
    gen.next(data2);
  });
});

痛点:如果流程很长,这种嵌套(Callback Hell)依然存在,只是换了个地方。我们需要一个能自动执行 next() 的东西。


第三阶段:手写 async/await 实现(自动执行器)

async/await 的本质就是:Generator 函数 + 自动执行器(Auto Runner)

  • async 函数 = Generator 函数
  • await = yield
  • 自带的引擎 = 下面我们要写的 run 函数

我们来写一个函数 run,它接受一个 Generator,然后自动帮你调用 .then().next()

1. 核心代码(背下来就是原理)

function run(generatorFunc) {
  // 1. 初始化生成器
  const gen = generatorFunc();

  // 2. 定义递归函数,用来处理每一步
  function step(nextF) {
    let next;
    try {
      next = nextF(); // 执行 gen.next()
    } catch(e) {
      return Promise.reject(e);
    }

    // 3. 如果 generator 结束了,直接返回最终结果
    if(next.done) {
      return Promise.resolve(next.value);
    }

    // 4. 如果没结束,next.value 通常是一个 Promise
    // 我们用 Promise.resolve 包裹它(以防它不是 Promise)
    Promise.resolve(next.value).then(
      // Promise 成功:把结果 v 传回给 generator,继续下一步
      (v) => step(() => gen.next(v)), 
      
      // Promise 失败:把错误 e 抛回给 generator
      (e) => step(() => gen.throw(e))
    );
  }

  // 5. 启动递归
  return step(() => gen.next());
}

2. 验证效果

现在我们可以像写 async/await 一样写 Generator 了:

// 模拟一个异步请求
const getData = (n) => new Promise(resolve => setTimeout(() => resolve(n * 2), 1000));

// 这里的 function* 相当于 async function
function* main() {
  console.log("开始");
  
  // yield 相当于 await
  const r1 = yield getData(10); 
  console.log(`r1: ${r1}`); // 1秒后输出 20
  
  const r2 = yield getData(20);
  console.log(`r2: ${r2}`); // 再过1秒后输出 40
  
  return "完成";
}

// 运行它!
run(main).then(result => console.log(result)); 

总结

  1. Generator 是什么:一个可以通过 yield 暂停,通过 next() 恢复并传值的状态机。
  2. 转变关系
    • async function ====> function* + 自动执行器
    • await promise ====> yield promise
  3. 原理
    • 代码执行到 yield,暂停,返回 Promise 给执行器。
    • 执行器等待 Promise resolve
    • 执行器拿到结果,调用 next(结果),把数据还给函数内部,代码继续运行到下一个 yield

在日常开发中,直接使用 Generator 的场景已经很少了(主要被 async/await 取代),但在以下场景依然很有用:

  • Redux-Saga:React 中处理复杂副作用的库,完全基于 Generator。
  • 控制流:需要精细控制“暂停/取消”任务的时候。
  • 迭代器:自定义复杂的遍历规则(例如遍历二叉树)。

学习Three.js--纹理贴图(Texture)

2026年1月21日 15:01

学习Three.js--纹理贴图(Texture)

前置核心说明

纹理贴图是 Three.js 中让3D模型呈现真实外观的核心手段,本质是将2D图片(纹理)「贴」到3D几何体表面,替代单一的纯色材质,实现照片级的视觉效果(如墙面纹理、地面瓷砖、金属质感、木纹等)。

核心规则

  1. 核心流程加载图片 → 创建纹理对象(Texture) → 绑定到材质.map属性 → 几何体通过UV坐标映射纹理
  2. 颜色空间必设:加载纹理后必须设置 texture.colorSpace = THREE.SRGBColorSpace,否则图片会出现偏色(Three.js r152+ 版本新增,适配真实色彩);
  3. UV坐标是桥梁:UV坐标(2D)关联纹理图片和几何体顶点(3D),是纹理「贴在哪个位置」的核心控制手段;
  4. 材质适配:所有 Mesh 系列材质(MeshBasicMaterial/MeshStandardMaterial 等)都支持 map 纹理属性,仅 Line/Points/Sprite 材质不支持。

一、纹理核心概念与基础加载

1. 核心术语解析

术语 核心说明
纹理对象(Texture) Three.js 对2D图片的封装,包含图片数据、映射规则、重复模式等属性
UV坐标 2D纹理坐标系(U=横向,V=纵向),范围默认0~1,(0,0)=图片左下角,(1,1)=图片右上角
纹理加载器(TextureLoader) Three.js 专门用于加载图片并生成纹理对象的工具类
映射(Mapping) 纹理通过UV坐标与几何体顶点的绑定关系,决定图片哪部分贴在几何体哪个位置

2. 纹理加载(TextureLoader):完整用法与参数

TextureLoader 是加载纹理的核心工具,支持单张加载、批量加载,可处理加载进度/错误/完成回调。

2.1 基础加载
// 1. 创建纹理加载器实例
const texLoader = new THREE.TextureLoader();

// 2. 加载图片并创建纹理对象
// 语法:texLoader.load(图片路径, 加载完成回调, 加载进度回调, 加载错误回调)
const texture = texLoader.load(
  './gravelly_sand_diff_1k.jpg', // 必传:图片路径(本地/CDN)
  (texture) => { // 可选:加载完成回调
    console.log('纹理加载完成', texture);
  },
  (xhr) => { // 可选:加载进度回调(xhr=XMLHttpRequest)
    console.log(`加载进度:${(xhr.loaded / xhr.total) * 100}%`);
  },
  (err) => { // 可选:加载错误回调
    console.error('纹理加载失败', err);
  }
);

// 3. 关键:设置颜色空间(避免图片偏色,r152+必加)
texture.colorSpace = THREE.SRGBColorSpace;
2.2 TextureLoader 核心参数(load方法)
参数名 类型 必填 说明
url String 图片路径(支持本地相对路径、CDN链接、Base64)
onLoad Function 加载完成回调,参数为生成的Texture对象
onProgress Function 加载进度回调,参数为XMLHttpRequest对象
onError Function 加载失败回调,参数为错误对象
2.3 批量加载纹理(TextureLoader+Promise)
// 封装批量加载函数
async function loadTextures(urls) {
  const loader = new THREE.TextureLoader();
  const textures = [];
  for (const url of urls) {
    const texture = await new Promise((resolve, reject) => {
      loader.load(url, resolve, null, reject);
    });
    texture.colorSpace = THREE.SRGBColorSpace;
    textures.push(texture);
  }
  return textures;
}

// 使用:加载多张纹理
const urls = ['./texture1.jpg', './texture2.jpg'];
loadTextures(urls).then(textures => {
  console.log('所有纹理加载完成', textures);
});
2.4 跨域问题解决

加载本地图片或跨域CDN图片时,若出现 THREE.WebGLRenderer: Texture has no image data 错误:

  1. 本地开发:启动HTTP服务(如VSCode的Live Server),不要直接打开HTML文件;
  2. CDN/服务器:配置图片服务器的CORS跨域头(Access-Control-Allow-Origin: *);
  3. 临时方案:将图片转为Base64格式嵌入代码(适合小图片)。

二、UV坐标核心解析(纹理映射的关键)

UV坐标是「2D纹理」和「3D几何体」的桥梁,决定了纹理图片的哪部分会贴在几何体的哪个面上。

1. UV坐标基础规则

UV坐标 对应图片位置 说明
(0, 0) 图片左下角 纹理原点
(1, 0) 图片右下角 U轴(横向)最大值
(0, 1) 图片左上角 V轴(纵向)最大值
(1, 1) 图片右上角 UV坐标最大值
(0.5, 0.5) 图片中心 UV中点

2. 自定义UV坐标(BufferGeometry)

预设几何体(BoxGeometry/SphereGeometry)已内置UV坐标,若使用自定义 BufferGeometry,需手动定义UV属性:

2.1 完整映射(纹理全部显示)
// 步骤1:创建自定义几何体(4个顶点的矩形)
const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([
  -0.5, -0.5, 0, // 顶点0
   0.5, -0.5, 0, // 顶点1
   0.5,  0.5, 0, // 顶点2
  -0.5,  0.5, 0  // 顶点3
]);
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));

// 步骤2:定义UV坐标(完整映射,4个顶点对应图片4个角)
const uvs = new Float32Array([
  0, 0,  // 顶点0 → 图片左下角
  1, 0,  // 顶点1 → 图片右下角
  1, 1,  // 顶点2 → 图片右上角
  0, 1   // 顶点3 → 图片左上角
]);
// 绑定UV属性:itemSize=2(每2个值为一组UV坐标)
geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
2.2 局部映射(仅显示纹理1/4区域)
// UV坐标范围设为0~0.5,仅映射图片左下角1/4区域
const uvs = new Float32Array([
  0,   0,   // 顶点0 → 图片(0,0)
  0.5, 0,   // 顶点1 → 图片(0.5,0)
  0.5, 0.5, // 顶点2 → 图片(0.5,0.5)
  0,   0.5  // 顶点3 → 图片(0,0.5)
]);
geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
2.3 圆形几何体映射(CircleGeometry)

CircleGeometry 内置了适配圆形的UV坐标,无需自定义,直接绑定纹理即可:

// 创建圆形几何体(半径2,分段数100,越高分段越平滑)
const geometry = new THREE.CircleGeometry(2, 100);

// 加载纹理
const texLoader = new THREE.TextureLoader();
const texture = texLoader.load('./gravelly_sand_diff_1k.jpg');
texture.colorSpace = THREE.SRGBColorSpace;

// 创建材质(双面渲染,避免背面不可见)
const material = new THREE.MeshBasicMaterial({
  map: texture, // 绑定纹理
  side: THREE.DoubleSide
});

// 创建网格对象
const circleMesh = new THREE.Mesh(geometry, material);
scene.add(circleMesh);

3. 预设几何体UV特点

几何体 UV坐标特点 适用场景
BoxGeometry 每个面独立UV,纹理会贴到6个面上 立方体、盒子
SphereGeometry UV按经纬度分布,纹理包裹球体 星球、球体模型
PlaneGeometry 单平面UV,完整映射纹理 地面、墙面
CircleGeometry 圆形UV,纹理适配圆形 圆形地面、雷达图

三、纹理对象核心属性(参数详解+用法)

纹理对象(Texture)的核心属性决定了纹理的显示方式(重复、偏移、旋转等),是实现瓷砖阵列、UV动画的关键,以下是高频使用的属性:

1. 重复模式:wrapS / wrapT

控制纹理在U轴(横向)/V轴(纵向)超出0~1范围时的显示模式,必须配合 repeat 属性使用。

属性值 说明 示例
THREE.ClampToEdgeWrapping(默认) 超出范围时,拉伸纹理最后一行/列像素 纹理只显示一次,边缘拉伸
THREE.RepeatWrapping 超出范围时,重复显示纹理 实现瓷砖、地板阵列效果
THREE.MirroredRepeatWrapping 超出范围时,镜像重复显示纹理 无缝拼接的对称纹理
用法示例(地面瓷砖阵列)
const geometry = new THREE.PlaneGeometry(10, 10); // 10x10的地面
const texLoader = new THREE.TextureLoader();
const texture = texLoader.load('./cizhuang.jpg');
texture.colorSpace = THREE.SRGBColorSpace;

// 1. 设置重复模式(U/V轴都重复)
texture.wrapS = THREE.RepeatWrapping; // U轴(横向)
texture.wrapT = THREE.RepeatWrapping; // V轴(纵向)

// 2. 设置重复数量(U轴10次,V轴10次)
texture.repeat.set(10, 10); // 格式:repeat.set(U重复数, V重复数)

// 3. 创建材质并绑定纹理
const material = new THREE.MeshLambertMaterial({ map: texture });
const groundMesh = new THREE.Mesh(geometry, material);
groundMesh.rotation.x = -Math.PI / 2; // 旋转为地面
scene.add(groundMesh);

2. 重复数量:repeat

  • 类型:THREE.Vector2(包含x/y属性,对应U/V轴);
  • 作用:设置纹理在U/V轴的重复次数,值越大,纹理显示越多块;
  • 用法:
    texture.repeat.x = 10; // U轴重复10次
    texture.repeat.y = 10; // V轴重复10次
    // 或批量设置
    texture.repeat.set(10, 10);
    

3. 偏移:offset

  • 类型:THREE.Vector2(x=U轴偏移,y=V轴偏移);
  • 范围:0~1(偏移1=整个纹理宽度/高度);
  • 作用:控制纹理在几何体上的偏移位置,常用于UV动画;
  • 用法:
    texture.offset.x = 0.5; // U轴向右偏移50%
    texture.offset.y = 0.5; // V轴向上偏移50%
    // 或批量设置
    texture.offset.set(0.5, 0.5);
    

4. 旋转:rotation

  • 类型:Number(弧度);
  • 作用:纹理绕中心点旋转,单位为弧度;
  • 配合属性:center(设置旋转中心,默认(0.5,0.5)即纹理中心);
  • 用法:
    texture.rotation = Math.PI / 4; // 旋转45°
    texture.center.set(0.5, 0.5); // 绕纹理中心旋转(默认值)
    // 绕图片左下角旋转
    texture.center.set(0, 0);
    

5. 纹理过滤:magFilter / minFilter

控制纹理在「放大/缩小」时的显示质量,解决纹理模糊/锯齿问题:

属性 作用 推荐值
magFilter 纹理放大时的过滤方式 THREE.LinearFilter(线性过滤,平滑)
minFilter 纹理缩小时的过滤方式 THREE.LinearMipmapLinearFilter(Mipmap线性过滤,最清晰)
用法:
// 提升纹理显示质量(解决模糊)
texture.magFilter = THREE.LinearFilter;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.generateMipmaps = true; // 生成Mipmap(minFilter生效必备)

6. 各向异性过滤:anisotropy

  • 类型:Number;
  • 作用:提升纹理在倾斜视角下的清晰度(如地面纹理斜看时不模糊);
  • 用法:
    // 获取渲染器支持的最大各向异性值
    const maxAnisotropy = renderer.capabilities.getMaxAnisotropy();
    texture.anisotropy = maxAnisotropy; // 设置为最大值,效果最佳
    

四、纹理高级应用场景(完整用法+示例)

1. UV动画(纹理滚动)

通过动态修改 texture.offset 实现纹理滚动(如流水、火焰、移动的地面):

// 加载纹理
const texLoader = new THREE.TextureLoader();
const texture = texLoader.load('./water.jpg');
texture.colorSpace = THREE.SRGBColorSpace;
// 开启重复模式(动画更自然)
texture.wrapS = THREE.RepeatWrapping;
texture.repeat.x = 5; // U轴重复5次

// 动画循环
function animate() {
  requestAnimationFrame(animate);
  // U轴偏移量递增,实现纹理向右滚动
  texture.offset.x += 0.01;
  // 可选:V轴偏移,实现斜向滚动
  // texture.offset.y += 0.005;
  
  controls.update();
  renderer.render(scene, camera);
}
animate();

2. 阵列+UV动画组合(瓷砖地面滚动)

// 加载瓷砖纹理
const texture = texLoader.load('./cizhuang.jpg');
texture.colorSpace = THREE.SRGBColorSpace;

// 1. 设置阵列模式
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(10, 10); // 10x10瓷砖

// 2. 动画循环(UV滚动)
function animate() {
  requestAnimationFrame(animate);
  texture.offset.x += 0.005; // 缓慢向右滚动
  texture.offset.y += 0.002; // 缓慢向上滚动
  
  controls.update();
  renderer.render(scene, camera);
}
animate();

3. 多纹理叠加(基础色+法线+粗糙度)

PBR材质(MeshStandardMaterial)支持多纹理叠加,实现更真实的质感:

// 加载多组纹理
const texLoader = new THREE.TextureLoader();
const colorMap = texLoader.load('./wood_color.jpg'); // 基础色纹理
const normalMap = texLoader.load('./wood_normal.jpg'); // 法线纹理(凹凸感)
const roughnessMap = texLoader.load('./wood_roughness.jpg'); // 粗糙度纹理

// 设置颜色空间(仅基础色纹理需要)
colorMap.colorSpace = THREE.SRGBColorSpace;

// 创建PBR材质,绑定多纹理
const material = new THREE.MeshStandardMaterial({
  map: colorMap, // 基础色
  normalMap: normalMap, // 法线(凹凸)
  roughnessMap: roughnessMap, // 粗糙度
  roughness: 1.0, // 全局粗糙度(与纹理叠加)
  metalness: 0.1 // 金属度
});

五、完整实战示例(纹理加载+UV自定义+阵列+动画)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Three.js 纹理贴图完整示例</title>
  <style>body { margin: 0; overflow: hidden; }</style>
</head>
<body>
  <script>
    import * as THREE from 'https://threejsfundamentals.org/threejs/resources/threejs/r132/build/three.module.js';
    import { OrbitControls }  from "https://threejsfundamentals.org/threejs/resources/threejs/r132/examples/jsm/controls/OrbitControls.js";

    // 1. 创建三大核心
    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 1000);
    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(window.innerWidth, window.innerHeight);
    document.body.appendChild(renderer.domElement);
    camera.position.set(3, 3, 5);

    // 2. 加载纹理(示例使用CDN纹理,避免本地路径问题)
    const texLoader = new THREE.TextureLoader();
    // 瓷砖纹理(CDN示例)
    const texture = texLoader.load('https://threejs.org/examples/textures/tiles/tiles_diff.jpg', () => {
      renderer.render(scene, camera); // 加载完成后渲染
    });
    // 关键:设置颜色空间,避免偏色
    texture.colorSpace = THREE.SRGBColorSpace;

    // 3. 纹理配置(阵列+过滤优化)
    texture.wrapS = THREE.RepeatWrapping;
    texture.wrapT = THREE.RepeatWrapping;
    texture.repeat.set(8, 8); // 8x8瓷砖阵列
    // 提升纹理质量
    texture.magFilter = THREE.LinearFilter;
    texture.minFilter = THREE.LinearMipmapLinearFilter;
    texture.generateMipmaps = true;
    // 开启各向异性过滤
    texture.anisotropy = renderer.capabilities.getMaxAnisotropy();

    // 4. 创建地面几何体(PlaneGeometry)
    const groundGeo = new THREE.PlaneGeometry(10, 10);
    const groundMat = new THREE.MeshStandardMaterial({
      map: texture,
      side: THREE.DoubleSide
    });
    const groundMesh = new THREE.Mesh(groundGeo, groundMat);
    groundMesh.rotation.x = -Math.PI / 2; // 旋转为地面
    scene.add(groundMesh);

    // 5. 创建立方体(自定义UV示例)
    const cubeGeo = new THREE.BoxGeometry(2, 2, 2);
    // 自定义立方体UV(仅修改正面,其他面默认)
    const uvs = new Float32Array([
      0, 0, 1, 0, 1, 1, 0, 1, // 正面UV(完整映射)
      0, 0, 0.5, 0, 0.5, 0.5, 0, 0.5, // 右侧面UV(1/4映射)
      // 其他面UV省略,使用默认值
    ]);
    cubeGeo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
    const cubeMat = new THREE.MeshStandardMaterial({ map: texture });
    const cubeMesh = new THREE.Mesh(cubeGeo, cubeMat);
    cubeMesh.position.y = 1; // 立方体放在地面上
    scene.add(cubeMesh);

    // 6. 添加光源(PBR材质需要光源)
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
    const dirLight = new THREE.DirectionalLight(0xffffff, 1);
    dirLight.position.set(5, 8, 5);
    scene.add(ambientLight, dirLight);

    // 7. 轨道控制器
    const controls = new OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true;
    controls.dampingFactor = 0.05;

    // 8. UV动画循环
    function animate() {
      requestAnimationFrame(animate);
      // 纹理缓慢滚动(U轴+V轴)
      texture.offset.x += 0.002;
      texture.offset.y += 0.001;
      // 立方体旋转
      cubeMesh.rotation.x += 0.01;
      cubeMesh.rotation.y += 0.01;
      
      controls.update();
      renderer.render(scene, camera);
    }
    animate();

    // 9. 窗口适配
    window.addEventListener('resize', () => {
      camera.aspect = window.innerWidth / window.innerHeight;
      camera.updateProjectionMatrix();
      renderer.setSize(window.innerWidth, window.innerHeight);
    });
  </script>
</body>
</html>

示例效果

e628d623-98ee-42f3-85e8-aa2eb050cd5d.png

  1. 场景包含10x10的瓷砖地面,纹理8x8阵列显示,且缓慢滚动;
  2. 地面上有一个立方体,正面完整映射纹理,右侧面仅显示纹理1/4区域;
  3. 支持鼠标旋转/缩放视角,立方体自动旋转,纹理滚动动画流畅;
  4. 纹理显示清晰,无偏色、无模糊问题。

六、纹理优化与避坑指南

1. 性能优化技巧

  • 图片尺寸优化:纹理图片尺寸建议为2的幂次方(如256x256、512x512、1024x1024),GPU处理更快;
  • 复用纹理对象:多个模型使用同一张纹理时,复用同一个Texture对象,避免重复加载;
  • 关闭不必要的功能:静态纹理关闭generateMipmaps,减少内存占用;
  • 压缩纹理:使用basis Universal等压缩纹理格式,减小图片体积,提升加载速度。

2. 常见坑点与解决

问题 原因 解决方法
纹理偏色 未设置colorSpace 添加texture.colorSpace = THREE.SRGBColorSpace
纹理不显示 跨域问题/图片路径错误 启动HTTP服务/检查路径/配置CORS
纹理模糊 过滤方式未优化 设置magFilter=LinearFilter+minFilter=LinearMipmapLinearFilter
阵列不生效 未设置wrapS/wrapT 开启texture.wrapS/wrapT = THREE.RepeatWrapping
UV动画卡顿 偏移量递增过快 减小offset递增步长(如0.001~0.01)

核心总结

  1. 核心流程TextureLoader加载图片 → 设置colorSpace → 配置纹理属性(wrap/repeat/offset) → 绑定到材质.map → 几何体UV映射
  2. UV坐标:0~1范围,是纹理与几何体的桥梁,自定义几何体需手动设置UV属性;
  3. 关键属性
    • wrapS/wrapT:控制重复模式,实现瓷砖阵列;
    • repeat:设置重复数量;
    • offset:实现UV动画;
    • colorSpace:避免纹理偏色(r152+必加);
  4. 优化原则:图片尺寸为2的幂次方,开启各向异性过滤,复用纹理对象,关闭不必要的Mipmap。

自定义指令(详细完整版)

作者 nnnnna
2026年1月21日 14:53

一、自定义指令的生命周期钩子

钩子函数 促发时机 常用参数
created 绑定元素属性/事件监听器应用前触发 el, binding, vnode
beforeMount 元素被挂载到DOM前触发 el, binding, vnode
mounted 元素被挂载到DOM后触发 el, binding, vnode
beforeUpdate 组件更新前触发 el, binding, vnode, prevVnode
updated 组件更新后触发 el, binding, vnode, prevVnode
beforeUnmount 元素从DOM中卸载前触发 el, binding, vnode
unmounted 元素从DOM中卸载后触发 el, binding, vnode

二、钩子参数

el : 指令绑定的真实DOM

binding : 一个对象,包含以下属性

value : 传递给指令的值
oldValue : 之前的值,仅在 beforeUpdate  updated 中可用
arg : 传递给指令的参数,例如:v-directive:test中,参数为test
modifiers : 一个包含修饰符的对象,例如:v-directive.foo.bar,那么修饰符对象为{foo:true,bar:true}
instance : 使用指令的当前组件实例

vnode : 绑定元素的底层VNode

prevVnode : 之前渲染中指令绑定的元素VNode,仅在 beforeUpdate 和 updated 中可用

三、局部自定义指令

例如:让input输入框聚焦

<template>
  <div>
    <input v-focus="true" />
  </div>
</template>

<script setup lang="ts">
const vFocus = {
  mounted(el, binding) {
    if (binding.value === true) el.focus();
  },
};
</script>

任何以v开头的驼峰式命名的变量都可以当作自定义指令使用,例如vFocus在模板中以v-focus的形式使用

四、全局自定义指令

import { createApp } from "vue";
const app = createApp(App);
app.directive("focus", {
  mounted(el, binding) {
    if (binding.value === true) el.focus();
  },
});

五、简化形式(函数式指令)

当仅需要在mounted和updated上实现相同的行为,不需要使用到其他钩子函数时,可以直接用一个函数来定义指令

import { createApp } from "vue";
const app = createApp(App);
app.directive("focus", (el, binding) => {
  if (binding.value === true) el.focus();
});

六、实战

自定义权限控制指令

第一步:我们定义一个权限数组,代表当前登陆人有的所有权限(实际项目中一般从后端获取)

const userPermissions = ["add", "delete", "reset"];

第二步:自定义全局的权限控制的指令

const hasPermission = (needPermissions: string | string[]) => {
  //无权限要求时,默认显示
  if (!needPermissions) return true;
  
  //将传入的权限标识统一转为数组处理
  const needPerms = Array.isArray(needPermissions)
    ? needPermissions
    : [needPermissions];
    
  //传入的权限标识必须全部拥有才为true
  return needPerms.every((perm) => userPermissions.includes(perm));
};

app.directive("permission", (el, binding) => {
  const isShow = hasPermission(binding.value);
  if (!isShow && el) el.remove();
});

第三步:在需要权限控制的页面使用自定义指令控制权限

<template>
  <div v-permission="'add'">新增</div>
  <div v-permission="'delete'">删除</div>
  <div v-permission="'reset'">修改</div>
  <div v-permission="'find'">查找</div>
  <div v-permission="['add', 'delete']">新增和删除</div>
  <div v-permission="['reset', 'find']">修改和查找</div>
</template>

最后,验证结果如下

image.png

掌握 CSS 布局基石:行内、块级、行内块元素深度解析

2026年1月21日 14:43

前言

在 CSS 世界中,每个元素都有一个默认的 display 属性。理解这些元素的显示模式,是解决“为什么我的宽高设置无效?”、“为什么两个 div 不在一行?”等问题的关键。

一、 三大元素显示模式对比

1. 块级元素 (Block Elements)

块级元素就像是积木,默认从上往下堆叠。

  • 特点

    • 独占一行:默认占满父容器 100% 宽度。
    • 属性全开:支持设置 widthheightmarginpadding
    • 嵌套规则:可以包含行内元素和其他块级元素(注意:ph1~h6 比较特殊,建议不要包裹块级元素)。
  • 代表标签div, p, h1~h6, ul, ol, li, header, footer, section 等。

2. 行内元素 (Inline Elements)

行内元素就像是文本,随内容流动。

  • 特点

    • 并排显示:相邻元素在同一行内排列,直到排不下才换行。
    • 宽高无效:设置 widthheight 不起作用,宽度由内容撑开。
    • 间距局限:水平方向的 marginpadding 有效;垂直方向无效(不占据空间,但可能背景会溢出)。
  • 代表标签span, a, strong, em, i, label

3. 行内块元素 (Inline-Block)

结合了前两者的优点,既能并排显示,又能设置宽高。

  • 特点

    • 并排排列:不独占一行。
    • 属性支持:支持设置 widthheightmarginpadding
  • 代表标签img, input, button, textarea, select

    :这些元素在 CSS 规范中被称为“可替换元素”,它们天生具有行内块的特性。


二、 inline-block 的“间隙之谜”

1. 产生原因

当你给子元素设置 display: inline-block 时,HTML 代码中标签之间的空格或换行符会被浏览器解析为一个约 4px 的空白字符。

2. 解决方案

  • 方法 A:父元素设置 font-size: 0(最常用)

    .parent { font-size: 0; }
    .child { display: inline-block; font-size: 14px; } /* 子元素需手动恢复字号 */
    
  • 方法 B:标签首尾相接(代码极丑,不推荐)

    <div class="child">A</div><div class="child">B</div>
    
  • 方法 C:改用 Flex 布局(现代开发首选)

    .parent { display: flex; } /* 彻底告别间隙问题 */
    

三、 空元素 (Void Elements)

空元素是指没有子节点且没有结束标签的元素,它们通常通过属性来承载内容。

  • 常见标签<br>, <hr>, <img>, <input>, <link>, <meta>

四、 面试模拟题

Q1:如何让行内元素(如 span)支持宽高?

参考回答:

  1. 修改 display 属性为 blockinline-block
  2. 设置 float(浮动后的元素会自动变为块级表现)。
  3. 设置 position: absolutefixed

Q2:img 标签是行内元素还是块级元素?为什么它可以设置宽高?

参考回答: img 在表现上属于行内元素(不换行),但它是一个可替换元素(Replaced element) 。可替换元素的内容不受 CSS 控制,其外观由标签属性决定。浏览器在渲染这类元素时,会赋予它们类似 inline-block 的特性,因此可以设置宽高。

Q3:display: nonevisibility: hidden 有什么区别?

参考回答:

  • display: none:脱离文档流,不占据空间,会引起回流(Reflow)。
  • visibility: hidden:隐藏内容,但保留占据的物理空间,不会引起回流,仅引起重绘(Repaint)。

Promise详解-手写

2026年1月21日 14:27

初始化

我们知道Promise内部有三种状态,因此我们定义status:

const FULFILLED = 'fullfilled'
const REJECTED = 'rejected'
const PENDING = 'pending'

class Promise{
  status = PENDING
}

其次,在Promise兑现和拒绝的时候,需要有变量来存储值,也就是then()中回调可以接收的值。then((res)=>{})的这个res

const FULFILLED = 'fullfilled'
const REJECTED = 'rejected'
const PENDING = 'pending'

class Promise{
  status = PENDING
  value = undefined
  reason = undefined
}

接下来我们思考构造函数,我们考虑new Promise(executor), 这里会接收executor,excutor是什么形式呢?(resolve,reject)=> {}, 其中resolve和reject是可以改变Promise状态的函数,可以给Promise调用。因此我们考虑写出以下代码:

const FULFILLED = 'fullfilled'
const REJECTED = 'rejected'
const PENDING = 'pending'

class Promise{
  status = PENDING
  value = undefined
  reason = undefined

  construct(executor){
    const reject = (val) => {
      this.status = REJECTED
      this.value = val
    }
    const resolve = (reason) => {
      this.status = FULFILLED
      this.reason = reason
    }
    try{
      // 从这里我们可以知道new Promise(executor)中的executor是同步代码,会执行一次
      executor(resolve, reject)
    }catch(e){
      reject(e)
    }
  }
}

然后我们考虑实现then方法,then(onFulfilled, onRejected), MDN上的解释如下:

onFulfilled 可选一个在此 Promise 对象被兑现时异步执行的函数。它的返回值将成为 then() 返回的 Promise 对象的兑现值。此函数被调用时将传入以下参数:

valuePromise 对象的兑现值。

如果 onFulfilled 不是一个函数,则内部会被替换为一个恒等函数((x) => x),它只是简单地将兑现值向前传递。

onRejected 可选一个在此 Promise 对象被拒绝时异步执行的函数。它的返回值将成为 catch() 返回的 Promise 对象的兑现值。此函数被调用时将传入以下参数:

reasonPromise 对象被拒绝的原因。

如果 onRejected 不是一个函数,则内部会被替换为一个抛出器函数((x) => { throw x; }),它会抛出它收到的拒绝原因

同时我们需要明确,then的返回是一个新的promise,因此我们有以下实现:

const FULFILLED = 'fullfilled'
const REJECTED = 'rejected'
const PENDING = 'pending'

class Promise{
  status = PENDING
  value = undefined
  reason = undefined

  construct(executor){
    const reject = (val) => {
      this.status = REJECTED
      this.value = val
    }
    const resolve = (reason) => {
      this.status = FULFILLED
      this.reason = reason
    }
    try{
      // 从这里我们可以知道new Promise(executor)中的executor是同步代码,会执行一次
      executor(resolve, reject)
    }catch(e){
      reject(e)
    }
  }
  
  then(onFulfilled, onRejected){
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : (x) => (x)
    onRejected = typeof onRejected === 'function' ? onRejected : (x) => { throw x }
    return new Promise((resolve, reject)=>{
    })
  }
}

then中的回调是在Promise发生改变的时候会调用的,因此我们肯定是要在Promise中判断相关的状态,我们补全这块代码,针对每种状态填写相应的逻辑,对于新的返回的新的Promise,我们也需要变更它的状态,因此有以下代码:

const FULFILLED = 'fullfilled'
const REJECTED = 'rejected'
const PENDING = 'pending'

class Promise{
  status = PENDING
  value = undefined
  reason = undefined

  construct(executor){
    const reject = (val) => {
      this.status = REJECTED
      this.value = val
    }
    const resolve = (reason) => {
      this.status = FULFILLED
      this.reason = reason
    }
    try{
      // 从这里我们可以知道new Promise(executor)中的executor是同步代码,会执行一次
      executor(resolve, reject)
    }catch(e){
      reject(e)
    }
  }

  then(onFulfilled, onRejected){
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : (x) => (x)
    onRejected = typeof onRejected === 'function' ? onRejected : (x) => { throw x }
    // 既然我们需要知道当前Promise的状态,我们就需要保存一个this变量
    const self = this
    return new Promse((resolve, reject)=> {
      if(self.status === PENDING){
        // 此时调用then的promise还未完成,因此需要将回调保存到队列中
      }else if(self.status === FULFILLED){
        // 此时调用then的promise已经完成,可以执行回调
      }else if(self.status === REJECTED){
        // 此时调用then的promise已经为拒绝状态,可以执行回调
      }
    })
  }
}

从注释代码中我们可以知道,当PENDING的时候,我们需要保存这些回调函数,那么这些回调函数会在什么时候执行呢?在exectuor调用resolve()/reject()的时候,我们需要执行这些回调函数。所以在这之前,我们需要有地方能够存储这些回调。然后在resolve和reject的时候执行这些回调。

const FULFILLED = 'fullfilled'
const REJECTED = 'rejected'
const PENDING = 'pending'

class Promise{
  status = PENDING
  value = undefined
  reason = undefined
  resolvedCallbacks = []
  rejectedCallbacks = []  

  construct(executor){
    const reject = (reason) => {
      this.status = REJECTED
      this.reason = reason
      if(rejectedCallbacks.length > 0){
        setTimeout(()=>{
          rejectedCallbacks.forEach((callback)=>{
            callback(this.reason)
          })
        })
      }
    }
    const resolve = (val) => {
      this.status = FULFILLED
      this.value = val
      if(resolvedCallbacks.length > 0){
        setTimeout(()=>{
          resolvedCallbacks.forEach((callback)=>{
            callback(this.value)
          })
        })
      }
    }
    try{
      // 从这里我们可以知道new Promise(executor)中的executor是同步代码,会执行一次
      executor(resolve, reject)
    }catch(e){
      reject(e)
    }
  }

  then(onFulfilled, onRejected){
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : (x) => (x)
    onRejected = typeof onRejected === 'function' ? onRejected : (x) => { throw x }
    // 既然我们需要知道当前Promise的状态,我们就需要保存一个this变量
    const self = this
    return new Promse((resolve, reject)=> {
      if(self.status === PENDING){
        // 此时调用then的promise还未完成,因此需要将回调保存到队列中
      }else if(self.status === FULFILLED){
        // 此时调用then的promise已经完成,可以执行回调
      }else if(self.status === REJECTED){
        // 此时调用then的promise已经为拒绝状态,可以执行回调
      }
    })
  }
}

我们需要思考如何把回调函数推进callbacks数组中,我们不能直接把onFulfilled和onRejcted推进数组中,因为then返回的Promise的状态也需要改变,如果我们直接使用onFulfilled和onRejected,那么返回的then返回的Promise的状态可能一直都是PENDING的,无法被改变,因此我们需要一层包装函数,接受then返回的Promise中的resolve和reject,以此达到改变新Promise状态的目的。因此我们可以有以下实现:

// 只写then方法部分
then(onFulfilled, onRejected){
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : (x) => (x)
    onRejected = typeof onRejected === 'function' ? onRejected : (x) => { throw x }
    // 既然我们需要知道当前Promise的状态,我们就需要保存一个this变量
    const self = this
    return new Promse((resolve, reject)=> {
    const handleFulfilled = (value) => {
      try{
        let res = onFulfilled(value)
        resolve(res)
      }catch(e){
        reject(e)
      }
    }

    const handleRejected = (reason) => {
      try{
        let res = onRejected(reason)
        resolve(res)
      }catch(e){
        reject(e)
      }
    }

    if(self.status === PENDING){
        // 此时调用then的promise还未完成,因此需要将回调保存到队列中
        this.resolvedCallbacks.push(handleFulfilled)
        this.rejectedCallbacks.push(handleRejected)
      }else if(self.status === FULFILLED){
        // 此时调用then的promise已经完成,可以执行回调
        setTimeout(()=>handleFulfilled(this.value))
      }else if(self.status === REJECTED){
        // 此时调用then的promise已经为拒绝状态,可以执行回调
        setTimeout(()=>handleRejected(this.reason))
      }
    })
 }

至此的话,简单的实现就差不多了,还差最后的关键,如果then中的回调返回的是一个Promise,我们该如何处理,then(()=>new Promise()).then(xxx)这种情况下,我们需要等待前一个then中的Promise完成,才能够执行下一个then。例子中()=>new Promise(),这个新建的Promise也就是我们onFulfilled方法的返回,也就是res。因此我们需要判断这个res是否为Promise,如果是Promise,需要等待这个Promise完成。

// 只写then方法部分
  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === "function" ? onFulfilled : (x) => x;
    onRejected =
      typeof onRejected === "function"
        ? onRejected
        : (x) => {
            throw x;
          };
    // 既然我们需要知道当前Promise的状态,我们就需要保存一个this变量
    const self = this;
    return new Promse((resolve, reject) => {
      const handleFulfilled = (value) => {
        try {
          let res = onFulfilled(value);
          if (res instanceof Promise) {
            // 将我们现在的resovle和reject传递下去
            res.then(resolve, reject);
          } else {
            resolve(res);
          }
        } catch (e) {
          reject(e);
        }
      };

      const handleRejected = (reason) => {
        try {
          let res = onRejected(reason);
          if (res instanceof Promise) {
            res.then(resolve, reject);
          } else {
            resolve(res);
          }
        } catch (e) {
          reject(e);
        }
      };

      if (self.status === PENDING) {
        // 此时调用then的promise还未完成,因此需要将回调保存到队列中
        this.resolvedCallbacks.push(handleFulfilled);
        this.rejectedCallbacks.push(handleRejected);
      } else if (self.status === FULFILLED) {
        // 此时调用then的promise已经完成,可以执行回调
        setTimeout(() => handleFulfilled(this.value));
      } else if (self.status === REJECTED) {
        // 此时调用then的promise已经为拒绝状态,可以执行回调
        setTimeout(() => handleRejected(this.reason));
      }
    });
  }

完整代码:

const FULFILLED = "fullfilled";
const REJECTED = "rejected";
const PENDING = "pending";

class Promise {
  status = PENDING;
  value = undefined;
  reason = undefined;
  resolvedCallbacks = [];
  rejectedCallbacks = [];

  construct(executor) {
    const reject = (reason) => {
      this.status = REJECTED;
      this.reason = reason;
      if (rejectedCallbacks.length > 0) {
        setTimeout(() => {
          rejectedCallbacks.forEach((callback) => {
            callback(this.reason);
          });
        });
      }
    };
    const resolve = (val) => {
      this.status = FULFILLED;
      this.value = val;
      if (resolvedCallbacks.length > 0) {
        setTimeout(() => {
          resolvedCallbacks.forEach((callback) => {
            callback(this.value);
          });
        });
      }
    };
    try {
      // 从这里我们可以知道new Promise(executor)中的executor是同步代码,会执行一次
      executor(resolve, reject);
    } catch (e) {
      reject(e);
    }
  }

  // 只写then方法部分
  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === "function" ? onFulfilled : (x) => x;
    onRejected =
      typeof onRejected === "function"
        ? onRejected
        : (x) => {
            throw x;
          };
    // 既然我们需要知道当前Promise的状态,我们就需要保存一个this变量
    const self = this;
    return new Promse((resolve, reject) => {
      const handleFulfilled = (value) => {
        try {
          let res = onFulfilled(value);
          if (res instanceof Promise) {
            // 将我们现在的resovle和reject传递下去
            res.then(resolve, reject);
          } else {
            resolve(res);
          }
        } catch (e) {
          reject(e);
        }
      };

      const handleRejected = (reason) => {
        try {
          let res = onRejected(reason);
          if (res instanceof Promise) {
            res.then(resolve, reject);
          } else {
            resolve(res);
          }
        } catch (e) {
          reject(e);
        }
      };

      if (self.status === PENDING) {
        // 此时调用then的promise还未完成,因此需要将回调保存到队列中
        this.resolvedCallbacks.push(handleFulfilled);
        this.rejectedCallbacks.push(handleRejected);
      } else if (self.status === FULFILLED) {
        // 此时调用then的promise已经完成,可以执行回调
        setTimeout(() => handleFulfilled(this.value));
      } else if (self.status === REJECTED) {
        // 此时调用then的promise已经为拒绝状态,可以执行回调
        setTimeout(() => handleRejected(this.reason));
      }
    });
  }
}

以上就是Promise的简单实现,其实我们也就主要实现了整体的架子和then方法,如果错误也请大家纠正哈。下一篇我们将继续实现resolve, reject, race,all这些方法.

关于 vue-office 第三方使用踩坑小计

作者 心源xinyuan
2026年1月21日 14:17

vue-office 这个库适用于vue项目里面查看相应文档展示的一个组件,具体使用: vue-office

1.导入报错

官方文档:

image.png

我的使用:

image.png

这里报错!!!

后面改成在main.ts中配置 解决。。。(具体问题没找到,有人遇到类似的吗,是啥原因,版本问题?) image.png

2.PPT使用

这个文档里面没有具体说明导入,但是demo里有


   npm install @vue-office/pptx vue-demi //安装
   
    //使用
   import VueOfficePptx from '@vue-office/pptx'
  <vue-office-pptx 
      :src="pptxUrl" 
      style="height: 80vh"
      @rendered="handleRendered" 
      @error="handleError" />
     
    pptxUrl: 'https://501351981.github.io/vue-office/examples/dist/static/test-files/test.pptx'
    handleRendered() {
        console.log('PPTX渲染完成')
    }, handleError(error)
    { 
    console.error('渲染失败:', error)
    }

前端开发效率神器:MockJS 实战全解析,彻底告别“等后端接口”时代

作者 栀秋666
2026年1月21日 14:10

引言

在现代前后端分离的开发模式下,前端工程师最熟悉的场景莫过于:“功能写完了,就差一个接口。”
后端开发进度滞后、联调环境不稳定、接口返回格式频繁变更……这些问题常常让前端陷入被动等待,严重影响开发节奏与交付效率。

有没有一种方式,能让前端不依赖后端,独立完成页面开发、交互调试和逻辑验证?
答案是:有!—— MockJS + vite-plugin-mock 就是破解这一痛点的终极利器。

本文将带你从局部安装、核心价值到真实项目实战,全面掌握 MockJS 的使用逻辑与落地技巧。以一个“帖子列表分页接口”为例,手把手教你如何用 MockJS 构建高仿真的模拟服务,实现前端自主开发闭环,大幅提升开发效率。


一、为什么选择 MockJS?它解决了什么问题?

MockJS 是一款轻量级的前端数据模拟库,能够在浏览器或 Node.js 环境中生成随机但结构化的模拟数据,并结合请求拦截机制,模拟真实的 API 接口响应。

它的核心价值体现在四个方面:

  1. 解耦开发节奏:前端无需等待后端接口上线,只要接口文档确定,即可立即开始开发。
  2. 生成真实感数据:支持中文标题、随机时间、图片链接、用户信息等业务常见字段,数据更贴近真实场景。
  3. 低成本覆盖异常流:轻松模拟参数错误、空数据、404/500 错误等边界情况,提升代码健壮性。
  4. 无缝对接真实接口:模拟接口完全遵循约定规范,上线前只需切换 baseURL,无需修改任何业务逻辑。

简而言之:MockJS 让前端真正实现了“接口自由”。


二、局部安装:轻量接入,团队协作无忧

在实际项目中,我们推荐使用 局部安装(devDependencies),避免污染全局环境,也便于版本统一管理。

以 Vite + React 项目为例,执行以下命令安装核心依赖:

pnpm i mockjs -D
pnpm i vite-plugin-mock -D

mockjs 负责生成模拟数据
vite-plugin-mock 负责将模拟接口注入 Vite 开发服务器,实现请求拦截

接着,在 vite.config.ts 中注册插件并配置路径:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { ViteMockServe } from 'vite-plugin-mock'

export default defineConfig({
  plugins: [
    react(),
    ViteMockServe({
      mockPath: 'mock',        // 模拟文件存放目录
    })
  ],
})

📁 所有模拟接口代码将统一放在项目根目录的 /mock 文件夹下,结构清晰,易于维护。


三、实战案例:构建“帖子列表”分页接口

假设我们要开发一个社区类应用的“帖子列表页”,需调用如下接口:

🔹 接口文档(前后端约定)

  • URL: GET /api/posts
  • 参数: page=1, limit=10
  • 响应体:
{
  "code": 200,
  "message": "success",
  "items": [...],
  "pagination": {
    "current": 1,
    "limit": 10,
    "total": 45,
    "totalPage": 5
  }
}

现在,我们就基于这份文档,用 MockJS 完整实现该接口的模拟。


步骤 1:编写 Mock 文件(mock/posts.js

import Mock from 'mockjs'

// 定义标签池
const tags = ["前端", "后端", "AI", "职场", "算法", "面经", "副业"]

// 生成 45 条模拟帖子数据
const posts = Mock.mock({
  'list|45': [{
    id: '@increment(1)',
    title: '@ctitle(8,20)',
    brief: '@ctitle(20,100)',
    totalcomment: '@integer(1,30)',
    totalLikes: '@integer(0,500)',
    publishedAt: '@datetime("YYYY-MM-dd HH:mm:ss")',
    User: {
      id: '@integer(1,100)',
      name: '@cname',
      avatar: '@image(100x100, #4A90E2, #fff, Avatar)'
    },
    tags: () => Mock.Random.pick(tags, 2),
    thumbnail: '@image(300x200)',
    pick: ['@image(300x200)', '@image(300x200)', '@image(300x200)']
  }]
}).list

// 导出模拟接口配置
export default [
  {
    url: '/api/posts',
    method: 'get',
    response: ({ query }) => {
      const { page = '1', limit = '10' } = query
      const currentPage = parseInt(page)
      const size = parseInt(limit)

      // 参数校验
      if (isNaN(currentPage) || isNaN(size) || currentPage < 1 || size < 1) {
        return {
          code: 400,
          msg: 'Invalid page or pageSize',
          data: null
        }
      }

      const total = posts.length
      const start = (currentPage - 1) * size
      const end = start + size
      const items = posts.slice(start, end)

      return {
        code: 200,
        message: 'success',
        items,
        pagination: {
          current: currentPage,
          limit: size,
          total,
          totalPage: Math.ceil(total / size)
        }
      }
    }
  }
]

📌 关键点说明:

  • 使用 @ctitle 自动生成中文标题,@cname 生成中文姓名;
  • @increment(1) 实现 ID 自增,保证唯一性;
  • Mock.Random.pick(tags, 2) 随机选取两个标签;
  • 分页计算精准还原真实逻辑,支持翻页、总数展示。

步骤 2:前端调用 —— 与真实接口无异

封装 Axios 请求实例(config.js

import axios from 'axios'

const instance = axios.create({
  baseURL: '/api', // 指向本地 mock 服务
  timeout: 5000
})

export default instance

封装获取帖子方法

import axios from './config'
import type { Post } from '@/types'

export const fetchPosts = async (page: number = 1, limit: number = 10) => {
  try {
    const res = await axios.get('/posts', { params: { page, limit } })
    return res.data
  } catch (err) {
    console.error('请求失败:', err)
    throw err
  }
}

组件中直接调用即可:

useEffect(() => {
  fetchPosts(1, 10).then(data => {
    setPosts(data.items)
    setPagination(data.pagination)
  })
}, [])

此时,控制台已能打印出完整的分页数据,前端可正常进行 UI 渲染、分页器绑定、加载状态处理等全流程开发。


四、上线前:一键切换真实接口

当后端接口 ready 后,只需修改 baseURL 为真实地址:

const instance = axios.create({
  baseURL: 'https://api.yourdomain.com', // 切换为真实后端域名
  timeout: 10000
})

✅ 不需要修改任何组件逻辑
✅ 不需要调整数据结构
✅ 无缝衔接,零成本迁移

这就是“契约先行 + 模拟开发”带来的巨大优势。


五、最佳实践与注意事项

  1. 严格遵循接口文档
    字段名、类型、嵌套结构必须一致,否则上线时容易出 bug。

  2. 覆盖异常场景
    在 mock 中添加非法页码、超限请求等分支判断,提前暴露问题:

    if (currentPage > Math.ceil(total / size)) {
      return { code: 404, message: '暂无更多数据' }
    }
    
  3. 生产环境务必关闭 Mock
    通过 prodEnabled: false 确保线上不会误用模拟数据。

  4. 纳入 Git 版本管理
    所有 mock/*.js 文件应提交至仓库,确保团队成员使用同一套模拟规则,避免“我在跑,你报错”的协作尴尬。

  5. 不要过度模拟复杂逻辑
    Mock 只用于开发调试,不必完全复刻后端业务逻辑,保持简洁高效才是关键。


六、总结:MockJS 是前端的“时间机器”

它让我们可以穿越到“后端接口已完成”的未来,提前完成所有前端工作。
无论是新功能开发、UI 调试,还是异常流程测试,MockJS 都能提供强大支撑。

更重要的是,它推动了团队协作方式的升级——
从前端“求着后端给接口”,变成“拿着文档自己造接口”,真正实现高效协同、并行开发。

如果你还在因为“等接口”而耽误进度,那现在就是拥抱 MockJS 的最佳时机。

🎯 掌握 MockJS,不只是掌握一个工具,更是掌握一种主动开发、掌控节奏的思维方式。

立即在你的下一个项目中引入 MockJS,体验丝滑流畅的前端开发之旅吧!


WPF 使用 HLSL + Clip 实现高亮歌词光照效果

作者 大黄评测
2026年1月21日 14:09

在 WPF 中实现高亮歌词的光照效果(如舞台追光、聚光灯扫过文字),可以通过 HLSL 像素着色器(Pixel Shader) + Clip 几何裁剪 相结合的方式,实现高性能、流畅且视觉惊艳的动画效果。下面是一个完整的技术方案与实现示例。


✅ 效果目标

  • 歌词文本静态显示;
  • 一个“光斑”从左到右扫过当前行歌词;
  • 光斑区域高亮(白色/暖色),其余区域保持原色或变暗;
  • 支持平滑动画,60fps 流畅运行;
  • 利用 GPU 加速,避免频繁重绘文本。

🔧 技术组合

技术 作用
WPF TextBlock 显示歌词文本
ShaderEffect (HLSL) 实现动态光照遮罩
Clip 属性 限制光照仅作用于歌词区域(防溢出)
DoubleAnimation 驱动光斑位置变化

第一步:编写 HLSL 像素着色器(HighlightLight.ps)

创建 HighlightLight.ps 文件(编译为 .ps 后缀):

// HighlightLight.ps
sampler2D InputSampler : register(s0);
float2 LightCenter : register(c0);   // 光斑中心(归一化坐标 0~1)
float LightRadius : register(c1);    // 光斑半径(归一化)
float4 AmbientColor : register(c2);  // 背景/暗部颜色
float4 HighlightColor : register(c3); // 高亮颜色

float4 main(float2 uv : TEXCOORD) : COLOR
{
    float4 original = tex2D(InputSampler, uv);
    
    // 计算当前像素到光斑中心的距离(归一化)
    float dist = distance(uv, LightCenter);
    
    // 光照强度:使用 smoothstep 实现柔和边缘
    float intensity = smoothstep(LightRadius, LightRadius * 0.7, dist);
    // 注意:smoothstep(edge0, edge1, x) 在 x<edge0 时为1,x>edge1 时为0
    
    // 混合:高亮区用 HighlightColor,其他用 AmbientColor
    float4 finalColor = lerp(HighlightColor, AmbientColor, intensity);
    
    // 保留原始 alpha(确保透明背景)
    return float4(finalColor.rgb, original.a);
}

💡 编译命令(使用 fxc):

fxc /T ps_3_0 /E main /Fo HighlightLight.ps HighlightLight.hlsl

第二步:在 C# 中封装 ShaderEffect

public class HighlightLightEffect : ShaderEffect
{
    public static readonly DependencyProperty InputProperty = 
        RegisterPixelShaderSamplerProperty("Input", typeof(HighlightLightEffect), 0);

    public static readonly DependencyProperty LightCenterProperty =
        DependencyProperty.Register("LightCenter", typeof(Point), typeof(HighlightLightEffect),
            new UIPropertyMetadata(new Point(0.5, 0.5), PixelShaderConstantCallback(0)));

    public static readonly DependencyProperty LightRadiusProperty =
        DependencyProperty.Register("LightRadius", typeof(double), typeof(HighlightLightEffect),
            new UIPropertyMetadata(0.3, PixelShaderConstantCallback(1)));

    public static readonly DependencyProperty AmbientColorProperty =
        DependencyProperty.Register("AmbientColor", typeof(Color), typeof(HighlightLightEffect),
            new UIPropertyMetadata(Colors.Gray, PixelShaderConstantCallback(2)));

    public static readonly DependencyProperty HighlightColorProperty =
        DependencyProperty.Register("HighlightColor", typeof(Color), typeof(HighlightLightEffect),
            new UIPropertyMetadata(Colors.White, PixelShaderConstantCallback(3)));

    public Brush Input
    {
        get => (Brush)GetValue(InputProperty);
        set => SetValue(InputProperty, value);
    }

    public Point LightCenter
    {
        get => (Point)GetValue(LightCenterProperty);
        set => SetValue(LightCenterProperty, value);
    }

    public double LightRadius
    {
        get => (double)GetValue(LightRadiusProperty);
        set => SetValue(LightRadiusProperty, value);
    }

    public Color AmbientColor
    {
        get => (Color)GetValue(AmbientColorProperty);
        set => SetValue(AmbientColorProperty, value);
    }

    public Color HighlightColor
    {
        get => (Color)GetValue(HighlightColorProperty);
        set => SetValue(HighlightColorProperty, value);
    }

    public HighlightLightEffect()
    {
        PixelShader = new PixelShader
        {
            UriSource = new Uri("pack://application:,,,/Shaders/HighlightLight.ps")
        };
        UpdateShaderValue(InputProperty);
        UpdateShaderValue(LightCenterProperty);
        UpdateShaderValue(LightRadiusProperty);
        UpdateShaderValue(AmbientColorProperty);
        UpdateShaderValue(HighlightColorProperty);
    }
}

第三步:XAML 布局 + Clip 裁剪

<Grid>
    <!-- 背景 -->
    <Rectangle Fill="Black" />

    <!-- 歌词容器(关键:设置 Clip 限制光照范围) -->
    <Border
        x:Name="LyricContainer"
        HorizontalAlignment="Center"
        VerticalAlignment="Center"
        Background="Transparent">
        
        <!-- 应用着色器的 TextBlock -->
        <TextBlock
            x:Name="LyricText"
            Text="这是一句高亮歌词示例"
            FontSize="48"
            Foreground="White"
            Effect="{StaticResource HighlightLightEffect}" />
            
    </Border>
</Grid>

⚠️ 为什么需要 Clip
若不裁剪,光照会作用于整个渲染区域(包括透明背景),造成性能浪费和视觉溢出。可通过代码动态设置 Clip 为歌词边界:

// 在窗口 Loaded 事件中
var bounds = LyricText.RenderSize;
LyricContainer.Clip = new RectangleGeometry(new Rect(bounds));

第四步:启动动画(C# 后台)

private void StartHighlightAnimation()
{
    var effect = (HighlightLightEffect)LyricText.Effect;
    
    var animation = new DoubleAnimation
    {
        From = -0.2,      // 从左侧外开始
        To = 1.2,         // 到右侧外结束
        Duration = TimeSpan.FromSeconds(3),
        RepeatBehavior = RepeatBehavior.Forever,
        AutoReverse = true
    };

    var centerPoint = new Point();
    var centerBinding = new PropertyGroupDescription();
    
    // 绑定 X 坐标动画
    Storyboard.SetTarget(animation, effect);
    Storyboard.SetTargetProperty(animation, new PropertyPath("LightCenter.X"));
    
    var sb = new Storyboard();
    sb.Children.Add(animation);
    sb.Begin();
}

🌟 优势总结

  • GPU 加速:HLSL 在显卡上运行,CPU 零负担;
  • 视觉柔和smoothstep 实现无锯齿光斑边缘;
  • 灵活可控:可调节光斑大小、颜色、速度;
  • 资源高效Clip 避免无效像素处理;
  • WPF 原生集成:无需第三方库,兼容 .NET Framework / .NET Core。

🔜 扩展方向

  • 多光斑同步(副歌部分双光效);
  • 结合音频节奏驱动光斑速度;
  • 使用 WriteableBitmap 实现更复杂的粒子+光照混合。

通过 HLSL + Clip + 动画 的组合,WPF 完全可以实现媲美游戏引擎的动态歌词高光效果,既保持了 XAML 的声明式优势,又释放了 GPU 的渲染潜力。

TypeScript的对象类型:interface vs type

作者 wuhen_n
2026年1月21日 14:02

TypeScript 中定义对象类型有两种方式:interface 和 type。但在实际开发中,常常会让我们陷入选择困难,究竟应该用哪个?它们真的有性能差异吗?本篇文章将通过实测数据和深度分析,彻底解决这个经典问题。

结论:90%的情况下,它们真的没区别

首先打破一个迷思:在绝大多数日常使用场景中,interface 和 type 的性能差异可以忽略不计。让我们通过实测来验证:

性能测试:编译速度对比

// 测试代码:创建1000个类型定义
const generateCode = (useInterface: boolean) => {
  let code = '';
  for (let i = 0; i < 1000; i++) {
    if (useInterface) {
      code += `interface User${i} {\n  id: number;\n  name: string;\n  age?: number;\n}\n\n`;
    } else {
      code += `type User${i} = {\n  id: number;\n  name: string;\n  age?: number;\n};\n\n`;
    }
  }
  return code;
};

// 测试结果(TypeScript 5.0+,M1 MacBook Pro):
// interface版本:编译时间 ~1.2秒
// type版本:编译时间 ~1.3秒
// 差异:<10%,日常使用中完全可以忽略

内存使用对比

// 使用TypeScript Compiler API测试内存占用
import ts from 'typescript';

function measureMemory(useInterface: boolean) {
  const code = generateCode(useInterface);
  const sourceFile = ts.createSourceFile(
    'test.ts',
    code,
    ts.ScriptTarget.Latest
  );
  
  const program = ts.createProgram(['test.ts'], {
    target: ts.ScriptTarget.ES2022,
    declaration: true
  });
  
  const checker = program.getTypeChecker();
  const sourceFile = program.getSourceFile('test.ts');
  
  // 测量类型检查后的内存使用
  if (sourceFile) {
    const type = checker.getTypeAtLocation(sourceFile);
    // 实际测量显示差异 < 5%
  }
}

结论:除非你的项目有数万个类型定义,否则性能差异不应该成为选择的主要依据。

核心差异:语义与能力的较量

虽然性能相近,但interface和type在语义和能力上有显著差异:

interface只能定义对象类型:

interface User {
  id: number;
  name: string;
}

type可以定义任何类型

type ID = number | string;          // 联合类型
type Coordinates = [number, number]; // 元组
type Callback = (data: any) => void; // 函数类型
type Maybe<T> = T | null;           // 泛型类型别名

interface支持声明合并

interface Window {
  myCustomMethod(): void;
}

// 再次声明,TypeScript会合并它们
interface Window {
  anotherMethod(): void;
}

type支持联合类型和交叉类型

// type在处理复杂类型组合时更自然
type ID = string | number;

type Draggable = {
  draggable: true;
  onDragStart: () => void;
};

type Resizable = {
  resizable: true;
  onResize: () => void;
};

// 交叉类型:组合多个类型
type UIComponent = Draggable & Resizable & {
  id: string;
  position: { x: number; y: number };
};

决策流程图:何时用interface?何时用type?

我们可以通过一个流程图,来判断到底何时用 interface,何时用 type : interface vs type

何时使用interface?

面向对象编程,需要类实现

// interface是面向对象的最佳选择
interface Animal {
  name: string;
  age: number;
  makeSound(): void;
}

// 类实现接口
class Dog implements Animal {
  constructor(public name: string, public age: number) {}
  
  makeSound(): void {
    console.log("Woof!");
  }
}

// 接口继承
interface Pet extends Animal {
  owner: string;
  isVaccinated: boolean;
}

class Cat implements Pet {
  constructor(
    public name: string,
    public age: number,
    public owner: string,
    public isVaccinated: boolean
  ) {}
  
  makeSound(): void {
    console.log("Meow!");
  }
}

定义公共API契约

// 库或框架的公共API应该使用interface
// 因为它支持声明合并,用户可以进行扩展

// 库中定义
export interface Plugin {
  name: string;
  initialize(config: PluginConfig): void;
  destroy(): void;
}

// 用户使用时可以扩展
declare module 'my-library' {
  interface Plugin {
    // 用户添加自定义属性
    version?: string;
    priority?: number;
  }
}

// type无法做到这一点!

需要更清晰的错误信息

// interface通常提供更友好的错误信息
interface Point {
  x: number;
  y: number;
}

type PointAlias = {
  x: number;
  y: number;
};

function printPoint(p: Point) {
  console.log(p.x, p.y);
}

const badObj = { x: 1, z: 2 };

// 使用interface的错误信息:
// 类型"{ x: number; z: number; }"的参数不能赋给类型"Point"的参数。
//   对象文字可以只指定已知属性,并且"z"不在类型"Point"中。

// 使用type的错误信息类似,但interface有时更精确

何时使用type?

需要联合类型或交叉类型

// type在处理复杂类型组合时更自然
type ID = string | number;

type Draggable = {
  draggable: true;
  onDragStart: () => void;
};

type Resizable = {
  resizable: true;
  onResize: () => void;
};

// 交叉类型:组合多个类型
type UIComponent = Draggable & Resizable & {
  id: string;
  position: { x: number; y: number };
};

需要使用映射类型

// type是映射类型的唯一选择
type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

type Partial<T> = {
  [P in keyof T]?: T[P];
};

type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};

type Record<K extends keyof any, T> = {
  [P in K]: T;
};

// 实际使用
interface User {
  id: number;
  name: string;
  email: string;
}

type ReadonlyUser = Readonly<User>;
// { readonly id: number; readonly name: string; readonly email: string; }

type UserPreview = Pick<User, 'id' | 'name'>;
// { id: number; name: string; }

需要条件类型

// type支持条件类型,interface不支持
type IsString<T> = T extends string ? true : false;

type ExtractType<T> = T extends Promise<infer U> ? U : T;

type NonNullable<T> = T extends null | undefined ? never : T;

// 实际应用:类型安全的函数重载
type AsyncFunction<T> = T extends (...args: infer A) => Promise<infer R>
  ? (...args: A) => Promise<R>
  : never;

元组和字面量类型

// type更自然地表达这些类型
type Point = [number, number, number]; // 三维点

type RGB = [number, number, number]; // RGB颜色值
type RGBA = [number, number, number, number]; // RGBA颜色值

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';

type Size = 'small' | 'medium' | 'large';

// 模板字面量类型(TypeScript 4.1+)
type Route = `/${string}`;
type CssValue = `${number}px` | `${number}em` | `${number}rem`;

// interface无法定义这些类型!

简化和重命名复杂类型

// 当类型表达式很复杂时,使用type提高可读性
interface ApiResponse<T> {
  data: T;
  meta: {
    pagination: {
      page: number;
      pageSize: number;
      total: number;
      totalPages: number;
    };
    timestamp: string;
    version: string;
  };
}

// 使用type简化嵌套访问
type PaginationInfo = ApiResponse<any>['meta']['pagination'];

// 或者提取特定部分的类型
type ApiMeta<T> = ApiResponse<T>['meta'];
type ApiData<T> = ApiResponse<T>['data'];

互相转换与兼容性

interface转type

interface Original {
  id: number;
  name: string;
  optional?: boolean;
}

// 等价type
type AsType = {
  id: number;
  name: string;
  optional?: boolean;
};

// 实际上,对于简单对象类型,它们可以互换

type转interface

type Original = {
  id: number;
  name: string;
  optional?: boolean;
};

// 等价interface
interface AsInterface {
  id: number;
  name: string;
  optional?: boolean;
}

// 注意:如果type包含联合类型等,无法直接转换
type Complex = { x: number } | { y: string };
// 无法用interface直接表示!

互相扩展

// interface扩展type
type BaseType = {
  id: number;
  createdAt: Date;
};

interface User extends BaseType {
  name: string;
  email: string;
}

// type扩展interface
interface BaseInterface {
  id: number;
  createdAt: Date;
}

type Product = BaseInterface & {
  name: string;
  price: number;
  category: string;
};

// 这是完全可行的!

声明合并:interface的超能力

什么是声明合并?

即:同一作用域内,同名的interface会自动合并。

// 同一作用域内,同名的interface会自动合并
interface User {
  id: number;
  name: string;
}

// 稍后在同一个文件中(或通过模块声明)
interface User {
  age?: number;
  email: string;
}

// 最终User类型为:
// {
//   id: number;
//   name: string;
//   age?: number;
//   email: string;
// }

声明合并的好处

扩展第三方库类型

// 为第三方库添加类型定义
import { SomeLibrary } from 'some-library';

declare module 'some-library' {
  interface SomeLibrary {
    // 添加自定义方法
    myCustomMethod(): void;
    
    // 添加属性
    customConfig: {
      enabled: boolean;
      timeout: number;
    };
  }
}

// 现在可以在代码中使用
SomeLibrary.myCustomMethod();
console.log(SomeLibrary.customConfig.enabled);

为全局对象添加类型

// 扩展Window对象
interface Window {
  // 添加自定义属性
  myAppConfig: {
    apiUrl: string;
    debug: boolean;
  };
  
  // 添加自定义方法
  trackEvent(event: string, data?: any): void;
}

// 使用
window.myAppConfig = {
  apiUrl: 'https://api.example.com',
  debug: true
};

window.trackEvent('page_loaded');

合并函数和命名空间

// 创建具有静态方法的函数类型
interface MathUtils {
  (x: number, y: number): number;
  version: string;
  description: string;
}

// 稍后添加静态方法
interface MathUtils {
  add(x: number, y: number): number;
  multiply(x: number, y: number): number;
}

// 实现
const mathUtils: MathUtils = (x, y) => x + y;
mathUtils.version = '1.0';
mathUtils.description = 'Math utility functions';
mathUtils.add = (x, y) => x + y;
mathUtils.multiply = (x, y) => x * y;

声明合并的危害

意外的合并

// 危险:分散的声明可能导致意外合并
// file1.ts
interface Config {
  apiUrl: string;
  timeout: number;
}

// file2.ts(另一个开发者创建)
interface Config {
  retryCount: number;
  // 可能意外添加了冲突的属性
}

// file3.ts(又一个开发者)
interface Config {
  apiUrl: string; // 重复定义,可能与其他定义不一致
  cacheEnabled: boolean;
}

// 最终Config类型是所有声明的合并
// 这可能导致类型不一致和难以调试的问题

与类合并的陷阱

class User {
  id: number = 0;
  name: string = '';
  
  greet() {
    return `Hello, ${this.name}`;
  }
}

// 危险:通过interface向类添加类型
interface User {
  email?: string; // 这不会在运行时存在!
  sendEmail(): void; // 这也不会存在!
}

const user = new User();
user.email = 'test@example.com'; // 编译通过,但运行时错误!
user.sendEmail(); // 编译通过,但运行时错误!

// 正确的做法:使用类继承或混入

模块扩展冲突

// module-a.d.ts
declare module 'some-module' {
  interface Options {
    enabled: boolean;
  }
}

// module-b.d.ts(另一个包)
declare module 'some-module' {
  interface Options {
    enabled: string; // 冲突!类型不匹配
    timeout: number; // 添加新属性
  }
}

// 冲突会导致编译错误或意外行为

实际项目中的最佳实践

保持一致性

即:在项目中声明:要么统一使用interface,要么统一使用type。

// 坏:混合使用,没有规则
interface User {
  id: number;
  name: string;
}

type Product = {
  id: number;
  name: string;
  price: number;
};

// 好:项目级规范
// 方案A:全部使用interface(面向对象项目)
interface User {
  id: number;
  name: string;
}

interface Product {
  id: number;
  name: string;
  price: number;
}

// 方案B:全部使用type(函数式项目)
type User = {
  id: number;
  name: string;
};

type Product = {
  id: number;
  name: string;
  price: number;
};

// 方案C:混合但规则明确(推荐)
// 规则:
// 1. 对象类型用interface
// 2. 联合/交叉/元组用type
// 3. 工具类型用type

优先考虑可扩展性

// 库作者应该优先使用interface
export interface PluginAPI {
  register(plugin: Plugin): void;
  unregister(plugin: Plugin): void;
  // 留出扩展空间
}

文档化选择

在项目README或CONTRIBUTING中说明。

团队协作工具

如使用ESLint规则强制执行。

结语

在 TypeScript 的世界里,interface 和 type 不是敌人,而是互补的伙伴。 理解它们的差异,善用它们的长处,我们就能写出更优雅、更健壮的类型定义。

对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

CSS 核心基石-彻底搞懂“盒子模型”与“外边距合并”

2026年1月21日 13:55

前言

在网页布局中,万物皆“盒子”。理解盒子模型的构造及其不同模式的差异,是实现精准布局的前提。本文将从基础构成到进阶属性,带你全方位梳理 CSS 盒子模型。

一、 盒子的基本构成

一个完整的 CSS 盒子由内到外由以下四个部分组成:

  1. Content(内容) :存放文本或图片的区域。
  2. Padding(内边距) :内容与边框之间的透明区域。
  3. Border(边框) :包裹在内边距和内容外的线。
  4. Margin(外边距) :盒子与其他元素之间的距离。

1. Margin 的简写规则(顺时针原则)

  • 1 个值all (四周)
  • 2 个值top/bottom , left/right
  • 3 个值top , left/right , bottom
  • 4 个值top , right , bottom , left (上右下左,顺时针)

2. Border 的复合属性

语法:border: width style color; 例如:border: 2px solid #333;


二、 两大盒模型:标准 vs IE

通过 box-sizing 属性,我们可以切换盒子的计算方式。这是开发中处理“明明设置了宽度,盒子却被撑大”问题的关键。

1. 标准盒模型 (content-box)

  • 默认值
  • 计算公式实际宽度 = width

2. IE 盒模型 / 怪异盒模型 (border-box)

  • 推荐使用
  • 计算公式实际宽度 = width + padding + border
  • 优势:设定好的宽度不会被 padding 撑开,更符合人的直觉。
/* 全局推荐方案 */
* {
  box-sizing: border-box;
}

三、 外边距合并与合并高度

普通文档流中,两个垂直相邻的块级元素,其 margin-topmargin-bottom 会发生折叠(Collapse)。

1. 合并规则

  • 同号:取两者中的较大值
  • 异号:取两者相加之和

2. 经典面试:如何防止外边距合并?

这通常涉及触发 BFC(块级格式化上下文)

  • 为元素设置 display: inline-block
  • 设置 overflow 不为 visible(如 hidden)。
  • 使用 flexgrid 布局(它们内部的子元素不会发生 margin 合并)。

四、 现代布局新特性

1. aspect-ratio(宽高比)

现在只需要一个属性即可设置元素宽高比:

.video-card {
  width: 100%;
  aspect-ratio: 16 / 9; /* 自动根据宽度计算高度 */
  background: #000;
}

五、 面试模拟题

Q1:为什么设置 width: 100% 后再加 padding 页面会出现滚动条?如何解决?

参考回答: 因为默认是标准盒模型 (content-box),100% 宽度加上 padding 后的总宽度超过了父容器。 解决方案:将该元素的 box-sizing 设置为 border-box

Q2:什么是 BFC?它与盒模型有什么关系?

参考回答: BFC 是页面上的一个独立渲染区域。在 BFC 内部,盒子的布局不会影响到外部。利用 BFC 可以:

  1. 防止垂直外边距合并。
  2. 清除内部浮动(父元素高度塌陷问题)。
  3. 防止元素被浮动元素遮盖。

Q3:margin: auto 为什么能实现水平居中?

参考回答: 在块级元素设定了固定 width 的情况下,将左右 margin 设置为 auto,浏览器会自动平分剩余的可用空间,从而使元素居中。注意:垂直方向的 margin: auto 只有在 Flex 布局或绝对定位下才有效。

❌
❌