阅读视图

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

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

前言

虽然现代前端框架(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 的控件,用户无法修改值,但其值在提交时会被包含在表单数据中。

前端-通信机制

业务开发中,通信可能会涉及到同源与跨域的场景,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 版本(卸载、安装、环境变量、镜像源全覆盖)

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:一次分页加载问题的完整排查

太长不看版

问题:切换筛选项时,如果不滚动页面,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的学习

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)

学习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。

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

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

钩子函数 促发时机 常用参数
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 布局基石:行内、块级、行内块元素深度解析

前言

在 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详解-手写

初始化

我们知道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 第三方使用踩坑小计

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 实战全解析,彻底告别“等后端接口”时代

引言

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

有没有一种方式,能让前端不依赖后端,独立完成页面开发、交互调试和逻辑验证?
答案是:有!—— 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 实现高亮歌词光照效果

在 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

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 核心基石-彻底搞懂“盒子模型”与“外边距合并”

前言

在网页布局中,万物皆“盒子”。理解盒子模型的构造及其不同模式的差异,是实现精准布局的前提。本文将从基础构成到进阶属性,带你全方位梳理 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 布局或绝对定位下才有效。

🔥Vue3 自定义拖拽指令封装:高性能、高扩展性的拖拽实现方案(v-draggable实现)

🔥 Vue3 自定义拖拽指令封装:高性能、高扩展性的拖拽实现方案

在前端开发中,拖拽功能是非常常见的交互需求,比如弹窗拖拽、面板调整、组件布局等场景。本文将分享一个基于 Vue3 自定义指令封装的高性能、高可配置拖拽指令 v-draggable,支持手柄拖拽、边界限制、轴向锁定、网格吸附等核心功能,且代码结构清晰、易于扩展。 在这里插入图片描述

🎯 指令特性

  • ✅ 支持拖拽手柄(指定元素/选择器),精准控制拖拽触发区域
  • ✅ 边界限制:可限制在父容器/指定容器内拖拽,防止越界
  • ✅ 轴向锁定:支持仅X轴、仅Y轴、双轴拖拽
  • ✅ 网格吸附:按指定步长拖拽,适配网格布局场景
  • ✅ 节流优化:可配置拖拽移动节流,提升性能
  • ✅ 完整生命周期:提供开始/移动/结束回调,灵活控制拖拽过程
  • ✅ 动态配置:支持指令参数动态更新,适配状态变化
  • ✅ 自动清理:组件卸载自动清理事件/样式,避免内存泄漏
  • ✅ 样式隔离:拖拽时自动添加类名/层级,支持自定义样式

📝 完整指令代码(优化版)

// draggable.ts
import type { Directive, DirectiveBinding } from 'vue'

/**
 * 拖拽指令配置接口
 * @interface DraggableOptions
 */
export interface DraggableOptions {
  /** 拖拽手柄选择器或元素(仅手柄区域可触发拖拽) */
  handle?: string | HTMLElement
  /** 边界限制:true(父容器)/选择器/null(无限制) */
  boundary?: boolean | string
  /** 是否禁用拖拽 */
  disabled?: boolean
  /** 拖拽轴限制:both(x+y)/x/y */
  axis?: 'both' | 'x' | 'y'
  /** 网格吸附步长 [x, y] */
  grid?: [number, number]
  /** 拖拽开始回调 */
  onStart?: (event: MouseEvent, el: HTMLElement) => void
  /** 拖拽移动回调(返回偏移量) */
  onMove?: (event: MouseEvent, el: HTMLElement, offset: { x: number, y: number }) => void
  /** 拖拽结束回调 */
  onEnd?: (event: MouseEvent, el: HTMLElement) => void
  /** 拖拽时添加的类名 */
  className?: string
  /** 拖拽时的z-index层级 */
  zIndex?: number
  /** 移动事件节流时间(ms),0表示不节流 */
  throttle?: number
  /** 是否保留拖拽后的位置(组件更新时不重置) */
  preservePosition?: boolean
}

/**
 * 扩展元素属性,存储拖拽相关状态
 * @interface DraggableElement
 */
interface DraggableElement extends HTMLElement {
  _draggable?: {
    handleMouseDown: (e: MouseEvent) => void
    options: DraggableOptions
    cleanup: () => void
    originalStyle: string
    originalPosition: string
    originalZIndex: string
  }
}

/**
 * 节流函数
 * @param fn 执行函数
 * @param delay 节流时间
 * @returns 节流后的函数
 */
const throttle = (fn: Function, delay: number) => {
  let timer: NodeJS.Timeout | null = null
  return (...args: any[]) => {
    if (!timer) {
      timer = setTimeout(() => {
        fn(...args)
        timer = null
      }, delay)
    }
  }
}

/**
 * Vue3 拖拽指令
 * @description 支持手柄、边界、轴向、网格、节流等特性的高性能拖拽实现
 */
export const draggable: Directive = {
  /**
   * 指令挂载时初始化
   * @param el 绑定元素
   * @param binding 指令绑定值
   */
  mounted(el: DraggableElement, binding: DirectiveBinding<DraggableOptions | boolean>) {
    // 默认配置
    const defaultOptions: DraggableOptions = {
      handle: undefined,
      boundary: true,
      disabled: false,
      axis: 'both',
      grid: [1, 1],
      className: 'dragging',
      zIndex: 9999,
      throttle: 0,
      preservePosition: true
    }

    // 合并配置(支持布尔值快捷配置)
    let options: DraggableOptions
    if (typeof binding.value === 'boolean') {
      options = { ...defaultOptions, disabled: !binding.value }
    } else {
      options = { ...defaultOptions, ...binding.value }
    }

    // 禁用状态直接返回
    if (options.disabled) return

    // ========== 1. 初始化拖拽手柄 ==========
    let handleElement: HTMLElement = el
    if (options.handle) {
      if (typeof options.handle === 'string') {
        const found = el.querySelector<HTMLElement>(options.handle)
        if (found) {
          handleElement = found
        } else {
          console.warn(`[v-draggable] 拖拽手柄选择器 "${options.handle}" 未找到,将使用元素本身作为手柄`)
        }
      } else if (options.handle instanceof HTMLElement) {
        handleElement = options.handle
      }
    }
    handleElement.style.cursor = 'move' // 设置手柄光标样式

    // ========== 2. 保存原始样式(用于恢复) ==========
    const originalStyle = el.style.cssText
    const originalPosition = el.style.position || window.getComputedStyle(el).position
    const originalZIndex = el.style.zIndex || window.getComputedStyle(el).zIndex

    // 确保元素有定位属性(absolute/relative/fixed)
    if (!['absolute', 'relative', 'fixed'].includes(originalPosition)) {
      el.style.position = 'absolute'
    }

    // ========== 3. 拖拽状态变量 ==========
    let isDragging = false
    let startX = 0
    let startY = 0
    let initialLeft = 0
    let initialTop = 0
    let elementRect: DOMRect
    let boundaryRect: DOMRect | null = null

    // ========== 4. 工具函数 ==========
    /**
     * 初始化边界限制
     */
    const initBoundary = () => {
      if (!options.boundary) return

      let boundaryElement: HTMLElement | null = null
      if (typeof options.boundary === 'string') {
        boundaryElement = document.querySelector<HTMLElement>(options.boundary)
      } else {
        boundaryElement = el.parentElement
      }

      if (boundaryElement) {
        // 确保边界容器有定位属性
        const boundaryPos = window.getComputedStyle(boundaryElement).position
        if (boundaryPos === 'static') {
          boundaryElement.style.position = 'relative'
        }
        boundaryRect = boundaryElement.getBoundingClientRect()
      }
    }

    /**
     * 获取元素当前位置
     * @returns { left: number, top: number }
     */
    const getCurrentPosition = (): { left: number; top: number } => {
      const computedStyle = window.getComputedStyle(el)
      return {
        left: parseFloat(computedStyle.left) || 0,
        top: parseFloat(computedStyle.top) || 0
      }
    }

    /**
     * 边界检查(限制元素在边界内)
     * @param left 目标left值
     * @param top 目标top值
     * @returns 修正后的位置
     */
    const checkBoundary = (left: number, top: number): { left: number; top: number } => {
      if (!boundaryRect || !elementRect) return { left, top }

      const maxLeft = boundaryRect.width - elementRect.width
      const maxTop = boundaryRect.height - elementRect.height

      // 限制在0到最大值之间
      return {
        left: Math.max(0, Math.min(left, maxLeft)),
        top: Math.max(0, Math.min(top, maxTop))
      }
    }

    // ========== 5. 核心事件处理 ==========
    /**
     * 鼠标按下事件(开始拖拽)
     * @param e 鼠标事件
     */
    const handleMouseDown = (e: MouseEvent) => {
      // 仅处理左键拖拽
      if (e.button !== 0) return

      // 初始化状态
      initBoundary()
      isDragging = true
      elementRect = el.getBoundingClientRect()

      // 记录初始位置
      const currentPos = getCurrentPosition()
      initialLeft = currentPos.left
      initialTop = currentPos.top
      startX = e.clientX
      startY = e.clientY

      // 设置拖拽样式
      if (options.zIndex) el.style.zIndex = options.zIndex.toString()
      if (options.className) el.classList.add(options.className)

      // 触发开始回调
      options.onStart?.(e, el)

      // 添加全局事件监听
      document.addEventListener('mousemove', handleMouseMove)
      document.addEventListener('mouseup', handleMouseUp)
      document.addEventListener('mouseleave', handleMouseUp)
    }

    /**
     * 鼠标移动事件(拖拽中)
     * @param e 鼠标事件
     */
    const handleMouseMoveBase = (e: MouseEvent) => {
      if (!isDragging) return

      // 计算偏移量
      let deltaX = e.clientX - startX
      let deltaY = e.clientY - startY

      // 网格吸附
      if (options.grid) {
        deltaX = Math.round(deltaX / options.grid[0]) * options.grid[0]
        deltaY = Math.round(deltaY / options.grid[1]) * options.grid[1]
      }

      // 轴向限制
      if (options.axis === 'x') deltaY = 0
      if (options.axis === 'y') deltaX = 0

      // 计算新位置
      let newLeft = initialLeft + deltaX
      let newTop = initialTop + deltaY

      // 边界检查
      const correctedPos = checkBoundary(newLeft, newTop)
      newLeft = correctedPos.left
      newTop = correctedPos.top

      // 更新元素位置
      el.style.left = `${newLeft}px`
      el.style.top = `${newTop}px`

      // 触发移动回调
      options.onMove?.(e, el, { x: deltaX, y: deltaY })
    }

    // 节流处理移动事件
    const handleMouseMove = (options.throttle || 0) > 0 
      ? throttle(handleMouseMoveBase, options.throttle!) 
      : handleMouseMoveBase

    /**
     * 鼠标释放事件(结束拖拽)
     * @param e 鼠标事件
     */
    const handleMouseUp = (e: MouseEvent) => {
      if (!isDragging) return

      isDragging = false

      // 恢复样式(保留位置)
      if (options.className) el.classList.remove(options.className)
      // 如需拖拽结束后恢复层级,可取消下面注释
      // if (options.zIndex) el.style.zIndex = originalZIndex

      // 移除全局事件
      document.removeEventListener('mousemove', handleMouseMove)
      document.removeEventListener('mouseup', handleMouseUp)
      document.removeEventListener('mouseleave', handleMouseUp)

      // 触发结束回调
      options.onEnd?.(e, el)
    }

    // ========== 6. 事件绑定与清理 ==========
    /**
     * 清理函数(卸载/更新时调用)
     */
    const cleanup = () => {
      // 移除事件监听
      handleElement.removeEventListener('mousedown', handleMouseDown)
      document.removeEventListener('mousemove', handleMouseMove)
      document.removeEventListener('mouseup', handleMouseUp)
      document.removeEventListener('mouseleave', handleMouseUp)

      // 恢复样式(根据配置决定是否保留位置)
      if (!options.preservePosition) {
        el.style.cssText = originalStyle
        el.style.position = originalPosition
        el.style.zIndex = originalZIndex
      }
      
      // 移除拖拽类名
      if (options.className) el.classList.remove(options.className)
      
      // 恢复手柄光标
      handleElement.style.cursor = ''
    }

    // 绑定按下事件
    handleElement.addEventListener('mousedown', handleMouseDown)

    // 保存状态到元素属性(用于更新/卸载)
    el._draggable = {
      handleMouseDown,
      options,
      cleanup,
      originalStyle,
      originalPosition,
      originalZIndex
    }
  },

  /**
   * 指令参数更新时处理
   * @param el 绑定元素
   * @param binding 新的绑定值
   */
  updated(el: DraggableElement, binding: DirectiveBinding<DraggableOptions | boolean>) {
    const instance = el._draggable
    if (!instance) return

    // 合并新配置
    let newOptions: DraggableOptions
    if (typeof binding.value === 'boolean') {
      newOptions = { ...instance.options, disabled: !binding.value }
    } else {
      newOptions = { ...instance.options, ...binding.value }
    }

    // 配置未变化且禁用状态未变,直接返回
    if (
      JSON.stringify(newOptions) === JSON.stringify(instance.options) &&
      newOptions.disabled === instance.options.disabled
    ) {
      return
    }

    // 清理旧配置
    instance.cleanup()
    
    // 重新初始化
    draggable.mounted(el, binding as DirectiveBinding<DraggableOptions>)
  },

  /**
   * 组件卸载时清理
   * @param el 绑定元素
   */
  unmounted(el: DraggableElement) {
    const instance = el._draggable
    if (instance) {
      instance.cleanup()
      delete el._draggable // 清除元素属性,释放内存
    }
  }
}

// Vue 全局组件类型声明(TS支持)
declare module 'vue' {
  export interface GlobalComponents {
    vDraggable: typeof draggable
  }
}

/**
 * 全局注册指令(可选)
 * @param app Vue应用实例
 */
export const setupDraggableDirective = (app: any) => {
  app.directive('draggable', draggable)
}

🚀 核心优化点说明

1. 功能增强

  • 新增 preservePosition 配置:控制组件更新时是否保留拖拽位置(默认保留)
  • 封装独立节流函数:代码更模块化,便于维护
  • 完善类型定义:补充接口注释,提升TS开发体验
  • 边界检查优化:抽离为独立函数,逻辑更清晰
  • 定位属性自动处理:自动检测/设置元素定位属性,避免拖拽无效
  • 手柄容错处理:手柄选择器未找到时给出友好提示,降级使用元素本身

2. 性能优化

  • 节流函数优化:使用定时器实现节流,减少高频mousemove事件触发
  • 事件监听优化:仅在拖拽开始时绑定全局mousemove/mouseup,减少全局事件数量
  • 样式操作优化:批量保存/恢复样式,减少DOM操作次数
  • 内存管理优化:卸载时彻底清除元素属性,避免内存泄漏

3. 代码健壮性

  • 完善的边界判断:空值检查、类型校验,避免运行时错误
  • 友好的错误提示:手柄未找到时给出警告,便于调试
  • 事件兼容处理:仅响应鼠标左键拖拽,避免右键误触发
  • 样式兼容处理:兼容不同定位属性的元素,适配各种布局场景

📖 使用指南

1. 全局注册(推荐)

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { setupDraggableDirective } from './directives/draggable'

const app = createApp(App)
// 注册拖拽指令
setupDraggableDirective(app)
app.mount('#app')

2. 局部使用

<script setup>
import { draggable } from './directives/draggable'
</script>

<template>
  <div v-draggable>可拖拽元素</div>
</template>

3. 基础用法(布尔值快捷配置)

<!-- 启用拖拽(默认配置) -->
<div v-draggable="true">基础拖拽</div>

<!-- 禁用拖拽 -->
<div v-draggable="false">禁用拖拽</div>

4. 高级用法(完整配置)

<template>
  <!-- 带手柄的弹窗拖拽 -->
  <div class="dialog" v-draggable="draggableOptions">
    <div class="dialog-header" ref="handleRef">弹窗标题(仅此处可拖拽)</div>
    <div class="dialog-content">
      弹窗内容...
    </div>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'

const handleRef = ref<HTMLElement>()

// 拖拽配置
const draggableOptions = reactive({
  // 拖拽手柄(支持选择器或DOM元素)
  handle: '.dialog-header', // 或 handle: handleRef.value
  // 限制在父容器内拖拽
  boundary: true,
  // 仅Y轴拖拽
  axis: 'y',
  // 网格吸附(每10px移动一步)
  grid: [10, 10],
  // 拖拽时的类名
  className: 'dialog-dragging',
  // 拖拽时的层级
  zIndex: 1000,
  // 移动节流(提升性能)
  throttle: 16,
  // 拖拽生命周期回调
  onStart: (e, el) => {
    console.log('拖拽开始', el)
  },
  onMove: (e, el, offset) => {
    console.log('拖拽中', offset.x, offset.y)
  },
  onEnd: (e, el) => {
    console.log('拖拽结束', el)
  }
})
</script>

<style>
.dialog {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 400px;
  height: 300px;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  background: #fff;
}

.dialog-header {
  padding: 12px 16px;
  border-bottom: 1px solid #e5e7eb;
  cursor: move;
}

.dialog-dragging {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
</style>

5. 动态控制禁用状态

<template>
  <div v-draggable="!disabled">
    可动态禁用的拖拽元素
  </div>
  <button @click="disabled = !disabled">
    {{ disabled ? '启用拖拽' : '禁用拖拽' }}
  </button>
</template>

<script setup>
import { ref } from 'vue'

const disabled = ref(false)
</script>

🎨 配置项详解

配置项 类型 默认值 说明
handle string / HTMLElement undefined 拖拽手柄,指定触发拖拽的元素/选择器
boundary boolean / string true 边界限制:true(父容器)、选择器(指定容器)、false(无限制)
disabled boolean false 是否禁用拖拽
axis 'both' / 'x' / 'y' 'both' 拖拽轴限制
grid [number, number] [1, 1] 网格吸附步长,[x步长, y步长]
className string 'dragging' 拖拽时添加到元素的类名
zIndex number 9999 拖拽时元素的z-index
throttle number 0 移动事件节流时间(ms),0表示不节流
preservePosition boolean true 组件更新时是否保留拖拽位置
onStart Function undefined 拖拽开始回调:(e, el) => void
onMove Function undefined 拖拽移动回调:(e, el, offset) => void
onEnd Function undefined 拖拽结束回调:(e, el) => void

🛠️ 常见问题与解决方案

问题1:元素无法拖拽

  • 检查元素是否有定位属性(absolute/relative/fixed),指令会自动设置,但建议手动指定
  • 检查 disabled 配置是否为false
  • 检查手柄选择器是否正确,或尝试不指定handle(使用元素本身)

问题2:拖拽越界

  • 确保边界容器有定位属性(relative/absolute/fixed)
  • 检查边界容器的尺寸是否正确(可通过console.log(boundaryRect)调试)
  • 如需取消边界限制,设置 boundary: false

问题3:拖拽卡顿

  • 开启节流:设置 throttle: 16(约60帧)
  • 减少onMove回调中的复杂计算
  • 检查是否有其他mousemove事件冲突

问题4:组件更新后位置重置

  • 设置 preservePosition: true(默认开启)
  • 检查是否在updated钩子中重新设置了元素样式

📌 扩展方向

  1. 触摸支持:添加touch事件支持,适配移动端拖拽
  2. 拖拽吸附:支持拖拽到指定区域自动吸附
  3. 多元素拖拽排序:扩展为拖拽排序指令,支持列表排序
  4. 拖拽克隆:支持拖拽时创建元素克隆,实现拖放功能
  5. 自定义插值:支持拖拽结束后动画回弹到指定位置
  6. 拖拽限制区域:支持指定多个限制区域,而非仅父容器

🎯 总结

这个 Vue3 拖拽指令具备以下核心优势:

  1. 高性能:节流优化、事件按需绑定、减少DOM操作,适配高频拖拽场景
  2. 高可配置:支持手柄、边界、轴向、网格等10+配置项,覆盖大部分拖拽需求
  3. 高健壮性:完善的错误处理、样式兼容、内存管理,避免生产环境问题
  4. 易扩展:模块化设计,便于添加新功能(如触摸支持、吸附等)

指令可直接集成到 Vue3 项目中,适用于弹窗拖拽、面板调整、自定义布局等场景,开箱即用!相比第三方拖拽库,该指令体积更小、更轻量,且完全可控,适合对性能和定制化要求高的项目。

JS-前端埋点神器 navigator.sendBeacon 全指南

前言

在前端开发中,埋点系统是必不可少的一环。我们经常需要在用户关闭页面刷新跳转路由时,向服务器发送最后一条统计数据(比如用户停留时长、页面跳出率)。

但这看似简单的需求,在实现时却危机四伏:请求发不出去?页面跳转卡顿?今天我们就来聊聊这个问题的终极解决方案 —— navigator.sendBeacon

一、 痛点与传统方案的挣扎

场景还原

当用户点击关闭按钮时,浏览器会触发生命周期事件(unloadvisibilitychange)。如果我们直接使用普通的异步 AJAX (xhrfetch) 发送请求,浏览器通常会忽略它,因为页面都要销毁了,浏览器不想处理未完成的请求。

传统方案:同步 XHR

为了保证数据能发出去,以前的做法是将请求改为同步(Synchronous)

const syncReport = (url, { data = {}, headers = {} } = {}) => {
  const xhr = new XMLHttpRequest();
  // 第三个参数 false 表示同步请求
  xhr.open('POST', url, false);
  xhr.withCredentials = true;
  Object.keys(headers).forEach((key) => {
    xhr.setRequestHeader(key, headers[key]);
  });
  xhr.send(JSON.stringify(data));
};

致命缺陷

  1. 用户体验极差:同步请求会阻塞主线程。这意味着只有请求发送完成,页面才能关闭或跳转。在弱网环境下,用户会感觉页面“卡死”了。
  2. 浏览器废弃:现代浏览器(如 Chrome)已经明确表示将在页面卸载期间禁用同步 XHR,这种方法迟早失效。

二、 救世主:navigator.sendBeacon

1. 概念

navigator.sendBeacon() 是专门为“页面卸载时发送数据”而设计的 Web API。 它的核心能力是:将数据放入浏览器的发送队列,即使页面已经关闭,浏览器也会在后台默默完成发送。

2. 核心优势

  • 可靠性高:不受页面生命周期影响,确保数据不丢失。
  • 非阻塞:完全异步执行,不会阻塞页面关闭或跳转,用户体验丝滑。
  • 低优先级:浏览器会择机发送(通常是网络空闲时),不争抢关键资源。

3. API 语法

const result = navigator.sendBeacon(url, data);
  • url:请求地址。

  • data:要发送的数据,支持 ArrayBufferArrayBufferViewBlobDOMStringFormDataURLSearchParams

  • result(返回值):布尔值 (true / false)。

    • true:表示数据成功加入传输队列(注意:不代表服务器接收成功)。
    • false:表示队列已满,无法加入。

三、 实战:三种常见发送姿势

1. 发送普通字符串

默认 Content-Typetext/plain

const reportData = (url, data) => {
  // data 可能会被转为字符串 "[object Object]",建议先 stringify
  navigator.sendBeacon(url, JSON.stringify(data));
};

2. 发送 JSON 数据(推荐)

如果你希望后端接收到的 Content-Typeapplication/json 或者 application/x-www-form-urlencoded,需要使用 Blob 来手动指定。

const reportData = (url, data) => {
  // ✅ 正确写法:Blob 的第二个参数才是 options
  const blob = new Blob([JSON.stringify(data)], {
    type: 'application/json; charset=UTF-8' // 或者 application/x-www-form-urlencoded
  });
  navigator.sendBeacon(url, blob);
};

3. 发送 FormData

适用于需要上传文件或模拟表单提交的场景。浏览器会自动设置 Content-Typemultipart/form-data

const reportData = (url, data) => {
  const formData = new FormData();
  Object.keys(data).forEach((key) => {
    let value = data[key];
    // FormData 的 value 只能是字符串或 Blob
    if (typeof value !== 'string' && !(value instanceof Blob)) {
      value = JSON.stringify(value);
    }
    formData.append(key, value);
  });
  navigator.sendBeacon(url, formData);
};

四、 避坑指南(面试考点)

  1. 请求类型固定sendBeacon 只能发送 POST 请求。
  2. 无法读取响应:这是一个“射后不理”的 API,你无法获取服务器返回的数据(状态码、Response Body 等)。
  3. 数据大小限制:虽然标准没有明确规定,但浏览器对队列总大小有限制(通常在 64KB 左右),不适合发送大数据。
  4. Cookie 携带sendBeacon 默认会携带同域的 Cookie。

五、 面试模拟题

Q1:sendBeaconajax (XHR/Fetch) 有什么根本区别?

参考回答:

  • 生命周期:Ajax 请求属于页面上下文,页面关闭时请求会被取消(除非同步);sendBeacon 属于浏览器上下文,页面关闭后依然存活。
  • 交互体验:页面卸载时,同步 Ajax 会阻塞跳转;sendBeacon 是异步非阻塞的。
  • 功能限制sendBeacon 只能 POST,无法自定义 headers(除了 Content-Type),且无法读取响应。

Q2:如果浏览器不支持 sendBeacon 怎么办?

参考回答: 需要做降级处理。

  1. 检测 navigator.sendBeacon 是否存在。
  2. 如果不存在,降级为 同步 XHR 请求(虽然体验差,但得保数据)。
  3. 或者使用 <img> 标签发送 GET 请求(仅限数据量极小且不需要响应的场景)。

Q3:sendBeacon 返回 true 代表数据一定发送成功了吗?

参考回答: 不一定。返回 true 仅代表浏览器成功将数据加入了发送队列。如果网络断开、或者浏览器崩溃,数据依然可能发送失败。但相比于普通 Ajax,它的成功率已经高出了几个数量级。

从零打造 AI 全栈应用(一) : 深度解析 Shadcn UI + Vite + NestJS 的工程化最佳实践

在 AIGC 浪潮席卷开发领域的今天, “会接 AI API”已经不再是核心竞争力,真正拉开差距的,是你是否具备构建可扩展、可维护、可持续演进的全栈工程能力

本文将以 Notes AI 项目为背景,不纠结具体业务实现,而是聚焦于:

一个现代 AI 全栈项目,底层工程化与 UI 架构应该如何设计?


项目蓝图:Notes AI

Notes AI 是一个现代化的 AI 驱动内容平台,核心模块包括:

  • 身份认证:登录 / 注册 / 权限控制
  • 文章系统:内容发布、管理与分发
  • AIGC 能力:AI 辅助写作、内容生成
  • 后端架构:基于 NestJS 的高内聚 API 服务

功能并不复杂,但工程复杂度并不低
真正决定项目质量的,并不是功能列表,而是它的基础设施选择


UI 新范式:为什么选择 Shadcn UI?

在 Notes AI 中,我们并没有选择 Ant Design、MUI 等“传统组件库”,而是选择了 Shadcn UI

Shadcn UI 本质是什么?

一句话概括:

Shadcn UI 不是组件库,而是组件源码生成器。

它和传统 UI 库的核心差异在于:

传统组件库 Shadcn UI
npm install 引入黑盒组件 CLI 拉取源码到本地
覆盖样式成本高 直接改源码
依赖版本升级风险 组件完全由你掌控
往往体积不可控 用多少,引入多少

源码所有权,才是真正的自由

当你执行:

npx shadcn@latest add button

Shadcn 会做一件非常“反直觉”的事情:

把 Button 组件的源码直接拷贝进你的项目中

通常路径是:

src/components/ui/button.tsx

这意味着:

  • 没有“库升级破坏样式”的风险
  • 可以随意修改 Tailwind 样式
  • 可以改交互逻辑、加埋点、加权限

组件不再是“外部依赖”,而是你工程的一部分。

这对中大型项目来说,价值非常大。


路径别名:Shadcn 能好用的前提条件

Shadcn 强烈依赖路径别名(Alias) ,否则组件引入会迅速失控。

没有别名时的灾难

import { Button } from '../../../../components/ui/button'

使用别名后的理想状态

import { Button } from '@/components/ui/button'

这不仅是“写得短”,而是:

  • 项目结构更稳定
  • 重构成本更低
  • 组件层级更清晰

而这一步,Vite + TypeScript 必须同时配置


工程化基石:Vite 配置深度解析

vite.config.ts 的核心职责

Vite 的配置本质上只有两件事:

  1. 插件系统(plugins)
  2. 模块解析规则(resolve)

plugins:构建能力扩展

plugins: [
  react(), // React JSX 支持
]
  • React / Vue 插件是必需的
  • Tailwind 通常通过 postcss.config.js 配置,而非 Vite 插件

resolve:模块解析规则

resolve: {
  alias: {
    '@': path.resolve(__dirname, './src'),
  },
  extensions: ['.js', '.ts', '.jsx', '.tsx', '.json'],
}

重点只有一个:

把 @ 映射到 src 目录

❗为什么需要 @types/node

这里使用了:

  • __dirname
  • path.resolve

它们都属于 Node.js API,而不是浏览器 API。

TypeScript 默认并不认识这些类型,因此必须安装:

npm i -D @types/node

这一步的本质是:

让 TS 在“Node 环境”下正确理解 vite.config.ts

这是非常容易被忽略,但在面试中很加分的细节。


TypeScript 路径映射:tsconfig.app.json

很多人只配了 Vite,然后疑惑:

“为什么编辑器还在报错?”

原因是:

Vite 负责构建,TypeScript 负责类型系统

必须同步配置 tsconfig

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

关键结论

  • vite.config.ts → 运行时解析
  • tsconfig.app.json → 编译期 / IDE 智能提示

二者必须一致,否则一定踩坑。


npx:被严重低估的工程利器

1. npx 是什么?

npxnpm 自带的命令行工具,核心作用是:

无需全局安装,直接执行 npm 包中提供的 CLI 命令

它会在需要时临时下载对应包,执行完成后不干扰本地或全局环境。

2️. 为什么现代项目离不开 npx?

  • 不需要全局安装
  • 用完即走,不污染环境
  • 默认使用最新版本
  • 非常适合脚手架和生成器

3️. Shadcn CLI 的最佳拍档

npx shadcn@latest init

该命令会:

  • 初始化 components.json
  • 配置 Tailwind 风格
  • 设置路径别名
  • 确定组件生成目录

这是 Shadcn 体系的入口,而不是可选步骤。


总结:这不是技术堆砌,而是工程思想

Notes AI 项目背后体现的,并不是某个炫酷技术点,而是一整套现代工程化思维:

  • NestJS:提供稳定、可扩展的后端架构
  • Vite:通过精细化配置,提供极速 DX
  • Shadcn UI:用“源码所有权”取代黑盒组件
  • Tailwind CSS:原子化样式,避免 CSS 失控
  • npx:让工具链更轻、更干净

真正优秀的 AI 应用,拼到最后,拼的是工程能力。

JS-Navigator 对象全方位实战指南

前言

在前端开发中,我们需要了解用户到底在用什么设备、网络状况如何、甚至物理位置在哪里。这时,navigator 对象就是我们派出的“侦探”。它存储了浏览器的版本、操作系统、设备能力等关键信息。今天我们就来盘点 navigator 中那些高频使用的核心技能。

一、 核心身份识别:UserAgent

navigator.userAgent 是实战中用来判断设备类型(iOS/Android/PC)的基石。

// 简单的设备判断函数
const isMobile = /Mobi|Android|iPhone/i.test(navigator.userAgent);
console.log(isMobile ? "当前是移动端" : "当前是 PC 端");

注意:随着隐私保护加强,现代浏览器正在推广 User-Agent Client Hints (navigator.userAgentData) 来逐步替代 userAgent


二、 现代 API 实战(高频场景)

navigator 不仅仅是用来读属性的,它还挂载了许多强大的 API。

1. 剪切板操作:Clipboard API

早期的 document.execCommand 已被废弃,现代复制粘贴使用 navigator.clipboard,它是异步的。

// 复制文本到剪切板
async function copyText(text) {
  try {
    await navigator.clipboard.writeText(text);
    console.log('复制成功!');
  } catch (err) {
    console.error('复制失败: ', err);
  }
}

// 读取剪切板内容
async function readText() {
  const text = await navigator.clipboard.readText(); 
  console.log('剪切板内容:', text); // text为复制到剪切板上内容
}

2. 页面卸载时的“遗言”:sendBeacon

面试必考点:如何在页面关闭(unload)前可靠地发送埋点数据?

使用 XHR 或 Fetch 可能会因为页面关闭而被浏览器取消,使用同步 AJAX 会阻塞页面关闭影响体验。sendBeacon 是最佳解。

  • 特点:异步发送、不阻塞页面关闭、但是不一定保证发送成功!!!
// 页面卸载时发送数据
window.addEventListener('unload', function() {
  const data = JSON.stringify({ event: 'page_close', time: Date.now() });
  
  // 注意:如果后端需要 JSON 格式,建议使用 Blob 设置 Header
  const blob = new Blob([data], { type: 'application/json' });
  
  const result = navigator.sendBeacon('/api/log', blob);
  console.log(result ? "埋点进入发送队列" : "发送队列已满");
});

3. 地理位置:Geolocation

获取用户经纬度,常用于地图或本地服务。

  • 注意:必须在 HTTPS 环境下才能调用,且需要用户授权。
if (navigator.geolocation) {
  navigator.geolocation.getCurrentPosition(
    (pos) => {
      console.log(`维度: ${pos.coords.latitude}`); 
      console.log(`经度: ${pos.coords.longitude}`);
    },
    (err) => {
      console.error("定位失败(可能是用户拒绝或超时):", err.message);
    },
    { timeout: 5000 } // 设置超时时间
  );
}

三、 环境嗅探属性

属性 描述 示例值
language 浏览器首选语言 "zh-CN"
cookieEnabled 是否启用 Cookie true
platform 操作系统平台(已废弃但常用) "Win32", "MacIntel"
hardwareConcurrency CPU 逻辑核心数 8 (常用于决定开启多少 Web Worker)

四、 网络状态的“假象”:onLine

navigator.onLine 返回 true 表示设备连接到了局域网或路由器,并不代表一定能访问互联网(比如连了 wifi 但宽带欠费了)。

因此,更严谨的网络检测通常结合 window 的事件监听:

function updateStatus() {
  const status = document.getElementById('status');
  if (navigator.onLine) {
    console.log("网络已连接(但不一定能上网)");
    // 实际场景中,这里通常会发一个请求 ping 一下服务器来确认真连网
  } else {
    console.log("网络已断开");
  }
}

// 监听网络变化事件
window.addEventListener('online', updateStatus);
window.addEventListener('offline', updateStatus);

五、 面试模拟题

Q1:如何判断当前用户是否处于断网状态?

参考回答:

初步判断可以使用 navigator.onLine 属性,配合 window 的 online 和 offline 事件监听。但 navigator.onLine 存在误报(只检测网卡连接状态),最稳妥的方式是配合一个轻量级的 Ajax 请求(Ping)或者加载一张 1x1 像素的图片来检测实际连通性。

Q2:navigator.sendBeacon 和普通 AJAX 请求有什么区别?

参考回答:

  1. 优先级sendBeacon 是为了解决页面卸载时发送数据的问题设计的,浏览器会将其放入专门的队列,即使页面已关闭,浏览器也会在后台完成发送。
  2. 不阻塞:它完全异步,不会像同步 XHR 那样阻塞页面跳转。
  3. 请求类型:只能发送 POST 请求,且无法读取服务器的响应内容(它是“射后不理”的)。

Q3:如何获取用户的剪切板内容?有什么限制?

参考回答:

使用 navigator.clipboard.readText()。

限制:

  1. 必须在 HTTPS 环境下使用。
  2. 必须由 用户交互(如点击事件)触发,不能自动读取。
  3. 浏览器通常会弹窗询问用户是否允许读取。

JS-深度解密 History API:单页应用(SPA)实现无刷新跳转的底层逻辑

前言

在现代前端开发中,我们习惯了页面不刷新但 URL 却在变化的体验。这背后除了 Hash 模式,最核心的功臣就是 History 对象。它不仅能控制页面的前进后退,还能在不触发请求的情况下修改地址栏。

一、 History 对象:用户的“航迹云”

window.history 存储了用户在当前窗口中访问的所有记录。为了隐私保护,你无法看到具体的 URL 列表,但可以通过它自由穿梭。

1. 基础属性

  • length:历史堆栈中的条目总数(包括当前页)。
  • state:返回当前历史条目的状态对象副本。

2. 基础导航方法

  • back() :后退一页。

  • forward() :前进一页。

  • go(n)

    • n > 0:前进 n 步。
    • n < 0:后退 n 步。
    • n = 0 或不传:刷新当前页面

二、 HTML5 状态管理:无刷新跳转的核心

HTML5 为 history 引入了两个重量级方法:pushStatereplaceState。它们允许我们在不请求服务器的情况下,手动修改浏览器的地址栏。

1. pushState(state, title, url) —— 新增记录

  • 作用:在历史记录栈中添加一个新条目。

  • 参数

    • state:一个 JSON 对象,用于存储自定义信息。
    • title:目前大多数浏览器忽略,传 "" 即可。
    • url:新的 URL 地址,必须与当前页面同源

2. replaceState(state, title, url) —— 替换记录

  • 作用:修改当前的历史记录,而不是创建新的。
  • 影响:调用后 history.length 不会增加。

⚠️ 核心特点(避坑指南):

  • 不刷新:调用这两个方法后,地址栏变了,但浏览器不会检查 URL 是否存在,也不会重新加载页面。
  • 不触发 popstate:手动调用 pushStatereplaceState 不会触发 popstate 事件。

三、 popstate 事件:监听历史变动

虽然 pushState 不会触发 popstate,但浏览器的原生行为会触发它:

  • 点击“后退”或“前进”按钮。
  • 在 JS 中调用 back()forward()go()
window.addEventListener('popstate', (event) => {
    console.log("检测到路径变化,当前关联的状态数据:", event.state);
    // 这里通常是单页路由的核心:根据新的状态更新 UI 组件
});

四、 实战:为什么 Vue/React Router 需要它?

在 History 模式下,路由系统会拦截链接的默认点击行为,改为调用 history.pushState

  1. 用户点击链接 ➜ 拦截默认跳转 ➜ 执行 pushState('/new-path')
  2. 地址栏更新 ➜ 页面不刷新。
  3. 代码主动更新组件 ➜ 渲染新内容。
  4. 用户点击后退 ➜ 触发 popstate ➜ 路由监听到变化 ➜ 切换回旧组件。

五、 面试模拟题

Q1:history.pushStatelocation.href 有什么区别?

参考回答:

  • location.href 会导致浏览器立即向服务器发送请求,触发整页刷新。
  • history.pushState 仅修改浏览器地址栏并增加历史记录,不会触发网络请求,也不会刷新页面,是单页应用无刷新跳转的基础。

Q2:为什么 History 模式在刷新页面时会出现 404?如何解决?

参考回答: 因为 pushState 设置的 URL 是前端虚拟的。用户直接刷新时,浏览器会向服务器请求这个不存在的物理路径。 解决方法:需要后端(Nginx/Node)配合。将所有未命中的静态资源请求统一重定向(Fallback)到 index.html,让前端路由接管后续的解析。

Q3:replaceState 的典型应用场景是什么?

参考回答: 常用于重定向纠正 URL。例如:

  1. 用户登录后,从登录页跳转到个人中心,可以使用 replaceState 替换掉登录页。这样用户在个人中心点击“后退”时,不会再回到登录页,而是回到登录之前的页面。
  2. 在搜索页修改筛选条件时,实时更新 URL 却不希望产生过多的历史记录导致用户后退困难。
❌