普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月28日技术

fsck Cheatsheet

Basic Usage

Common fsck command forms.

Command Description
fsck /dev/sdb1 Check a filesystem
fsck -n /dev/sdb1 Check only, do not write changes
fsck -f /dev/sdb1 Force a check even if the filesystem looks clean
fsck -t ext4 /dev/sdb1 Check only filesystems of the given type
fsck -A Check filesystems listed in /etc/fstab

Safe Repair Workflow

Use this sequence before making repairs.

Command Description
lsblk -f Identify the device, mount point, and filesystem type
mount | grep /dev/sdb1 Confirm whether the device is mounted
sudo umount /dev/sdb1 Unmount the filesystem before repair
sudo fsck -n /dev/sdb1 Run a read-only check first
sudo fsck -p /dev/sdb1 Auto-fix safe problems without prompts

Repair Modes

Choose how interactive the repair should be.

Option Description
-n Do not make changes, useful for a safe first pass
-p Automatically repair safe problems
-y Answer yes to all prompts
-r Prompt before each repair
-f Force checking even if the filesystem appears clean

Filesystem Selection

Target one filesystem, one type, or all entries in fstab.

Command Description
sudo fsck /dev/nvme0n1p2 Check one device directly
sudo fsck -t ext4 /dev/nvme0n1p2 Check one device as ext4
sudo fsck -A Check all eligible filesystems in /etc/fstab
sudo fsck -AR Check all except the root filesystem
sudo fsck.ext4 /dev/nvme0n1p2 Run the filesystem-specific checker directly

Root Filesystem Recovery

fsck cannot repair the mounted root filesystem on a running system.

Method Description
Recovery mode Boot into recovery and choose the filesystem check option
Live USB Boot a live system, identify the root partition, then run fsck there
fsck.mode=force Kernel parameter to force a check during boot on systemd systems
fsck.repair=yes Kernel parameter to approve repairs during boot
sudo touch /forcefsck Older non-systemd pattern to force a boot-time check

tune2fs Scheduling

Control when ext filesystems are checked automatically.

Command Description
sudo tune2fs -l /dev/sdb1 | grep -i 'last checked|mount count' Show last check time and mount counters
sudo tune2fs -c 25 /dev/sdb1 Run a check after every 25 mounts
sudo tune2fs -c -1 /dev/sdb1 Disable mount-count-based checks
sudo tune2fs -i 1m /dev/sdb1 Run a check at most once per month
sudo tune2fs -i 0 /dev/sdb1 Disable time-based checks

fstab Pass Values

The sixth /etc/fstab column controls boot-time check order.

Value Description
0 Do not check this filesystem at boot
1 Check first, usually the root filesystem
2 Check after root, for other local filesystems

Example: /dev/sda2 /home ext4 defaults 0 2

Exit Codes

Use exit codes to understand what fsck found.

Code Description
0 No errors
1 Filesystem errors corrected
2 System should be rebooted
4 Filesystem errors left uncorrected
8 Operational error
16 Usage or syntax error
32 Checking canceled by user
128 Shared-library error

Other Filesystems

Some filesystems use tools other than fsck.

Filesystem Tool
XFS xfs_repair
Btrfs btrfs check or btrfs scrub
NTFS ntfsfix
FAT/VFAT fsck.vfat
Ext2/3/4 fsck.ext2, fsck.ext3, fsck.ext4

Related Guides

Use these articles for the full workflow around filesystem repair.

Guide Description
Fsck Command in Linux (Repair Filesystem) Full fsck guide with examples
How to Check Disk Space in Linux Using the df Command Check mounted filesystems and free space
How to Mount and Unmount File Systems in Linux Unmount a filesystem before repair
Sudo Command in Linux: Run Commands as Root Run fsck with the required privileges

为什么很多复杂跳转,最后都得先回首页?

2026年3月28日 18:15

首页真正开始变重,往往不是 tab 能不能动态,而是它一边要处理远端配置,一边要处理登录态差异和多入口跳转。用户从不同地方进来,默认先到哪一页、旧状态要不要保、资源位和不同内容往哪儿走,都会一起变。走到这一步,首页处理的就不只是页面显示,而是整套跳转和页面安排。

我这次重新看首页相关代码,最先让我停住的,不是动态 tab,也不是某个配置字段,而是一段跳转逻辑。

很多入口并不是直接去目标页,而是要先退回首页,再继续往下走:

if (currentRoute != AppRoutes.initial && currentRoute != AppRoutes.splash) {
  Get.until((route) => route.settings.name == AppRoutes.initial);
}
Get.toNamed(AppRoutes.gameDetail, arguments: args);

如果首页真的只是一个普通页面,这种写法其实很奇怪。

你明明可以直接跳去详情页,为什么还要先回首页?

只有一种解释说得通:

首页在这个项目里,早就不只是首页了。

它已经成了很多复杂跳转最后都要借一下力的固定落点。

顺着这段逻辑再往下看,后面很多原本零散的问题就会重新连起来:

  • 为什么同样是首页,从不同地方进来体验不一样
  • 为什么某些 tab 一改,旧状态就开始乱
  • 为什么资源位、Deep Link、小程序和首页会互相牵扯
  • 为什么启动阶段很多初始化最后也会绕回首页这套逻辑里

所以这篇文章真正想讨论的,不是“首页怎么做成配置化”,而是首页什么时候开始从一个页面,慢慢变成一个要负责承接跳转、安排入口、重新组织内容的总入口。

1. 首页一旦成了很多跳转共同的落脚点,角色就已经变了

项目继续长一段时间以后,首页很容易悄悄长出一种新角色:

  • 不是用户点一下就进来的首页
  • 而是别的流程收不住时,要先退回来的那个位置

这件事比“tab 能不能动态”更值得注意。

因为它说明系统已经默认把首页当成:

  • 一个稳定基点
  • 一个统一入口
  • 一个可以继续展开下一步跳转的地方

这类角色一旦成立,首页真正开始关心的就不再只是页面怎么画、tab 怎么排,而会变成:

  • 哪些跳转应该先回首页
  • 哪些目标页可以直接进
  • 哪些入口需要先做登录校验
  • 哪些内容得先回到首页这一层再继续往下分

也就是说,首页最先变复杂的地方,往往不是页面显示,而是很多流程最后都要借它走一遍。

2. 真正把首页拖复杂的,不是配置字段本身,而是不同地方进来后要不要当成同一个首页处理

首页一旦开始接很多不同入口,页面本身就会变得没那么“固定”。

项目里 fromTaskPage 这种开关很小,但代表性特别强。它说明同样都是回首页,系统还得继续区分:

  • 你是正常点进来的
  • 还是从任务页跳回来的
  • 是从资源位带进来的
  • 还是从 Deep Link 直达后又退回来的
  • 甚至是不是从 WebView、小程序退出以后再回到首页这套逻辑里

一旦这些路径同时存在,“首页默认长什么样”这个问题就已经不够用了。

更接近真实的问题反而是:

  • 这次默认应该落在哪一页
  • 之前的状态还要不要保
  • 这次回来的用户,应该看到原来的首页,还是改过组织后的首页

所以首页复杂度真正开始长出来,不是因为页面本身变花了,而是因为同样叫首页,从不同地方进来的其实已经不是同一种体验。

3. 配置化后面最重的,不是 tab 显示,而是老页面、旧状态和默认先到哪一页怎么一起调整

不少人会先把首页配置化理解成:

  • tab 从写死改成接口返回
  • 某些频道能开关
  • 用户 A 和用户 B 看到的顺序不一样

这些当然都算配置化,但还不是最重的地方。

真正把事情变复杂的,是首页调整以后,老页面和旧状态还在不在。

DynamicTabController 这段逻辑就很能说明问题:

final Map<String, Widget> existingViews = {};
final oldTabs = _previousTabs.toList();

for (int i = 0; i < oldTabs.length && i < tabViews.length; i++) {
  existingViews[_normalizeTabName(oldTabs[i])] = tabViews[i];
}

for (String tab in tabs) {
  final normalizedTab = _normalizeTabName(tab);

  if (existingViews.containsKey(normalizedTab)) {
    tabViews.add(existingViews[normalizedTab]!);
  } else {
    ...
  }
}

这段代码真正在回答的,不是“tab 对不对”,而是:

  • 老页面要不要继续沿用
  • 老状态要不要保
  • 页面切回来时该不该刷新

DynamicPageConfig 这一层又把另一个问题抬了出来。

它已经不只是在说“显示不显示”,而是在决定:

  • 谁能看到什么
  • 某个页面什么时候出现
  • 默认先落在哪个 tab
  • 从不同入口回来时,首页要不要换一种页面安排

比如这段默认配置:

static const List<TabConfig> defaultTabs = [
  TabConfig(
    name: '论坛',
    key: 'forum',
    requireAuth: false,
    requireConfig: false,
    order: 0,
  ),
  TabConfig(
    name: '关注',
    key: 'follow',
    requireAuth: true,
    requireConfig: false,
    order: 1,
  ),
  ...
];

static const String defaultSelectedTab = 'discover';
static bool fromTaskPage = false;

这些字段一旦组合在一起,首页真正要处理的就不再只是“显示哪些 tab”,而是:

  • 入口筛选
  • 默认先到哪一页
  • 登录态差异
  • 配置差异
  • 老状态怎么保留下来

所以首页配置化真正变重的地方,常常不是 UI,而是页面重新调整以后,旧东西和新入口怎么一起处理。

4. 当资源位、H5、小程序都接进来以后,首页已经在替整套页面关系决定去向了

首页会继续变重,还有一个经常被低估的原因:

它后面接的内容类型越来越杂了。

项目里这几层一接进来,首页就已经很难只被当成一个普通页面看了:

  • 资源位服务会主动预加载
  • 小程序控制器会在启动阶段预热默认小程序
  • 本地 LocalServer 会被拉起来组织小程序内容
  • 远端配置 game_suport_show 还会影响小程序页要不要显示

这说明首页最后要处理的,已经不只是几个原生模块,而是一整套内容能力:

  • 原生页
  • H5 页面
  • 小程序页
  • 资源位驱动内容

这时候,很多看起来像“首页问题”的事情,最后都会被拉成另一类问题:

  • 内容该落在哪一页
  • 不同内容进来以后首页怎么接
  • 跳转规则怎么统一
  • 初始化时机怎么安排

走到这里,首页其实已经在替整套页面关系决定去向了。

所以我现在再看“配置化首页”这类需求,已经不会先去想“tab 怎么写”,而是:

  • 这个首页最后要处理多少种内容
  • 它是不是已经成了很多跳转共同的落脚点
  • 它是不是已经在替整套页面关系决定入口怎么走

只要这些问题开始出现,首页就已经不只是首页了。

JavaScript 严格模式下 arguments 的区别

2026年3月28日 17:37

标签:#前端 #JavaScript #严格模式 #arguments #学习笔记

一、arguments 是什么?

arguments 是函数内部的类数组对象(Array-like),包含了函数调用时传入的所有实参。

function foo() {
  console.log(arguments);       // [1, 2, 3]
  console.log(arguments[0]);    // 1
  console.log(arguments.length); // 3
}
foo(1, 2, 3);

⚠️ 它不是真正的数组,没有 pushmapforEach 等方法(除非用 Array.from() 转换)。

二、如何开启严格模式

// 全局严格模式
'use strict';

// 函数级严格模式
function bar() {
  'use strict';
  // 此函数内启用严格模式
}

三、严格模式 vs 非严格模式的核心区别

🔴 区别一:arguments 不可被修改来影响命名参数(最重要)

非严格模式(二者联动)

function foo(a, b) {
  console.log(a, b);        // 1, 2
  arguments[0] = 100;
  console.log(a, b);        // 100, 2  ← a 被改变了!
}
foo(1, 2);

非严格模式下,修改 arguments[n] 会同步修改对应的命名参数。

严格模式(二者独立)

'use strict';
function foo(a, b) {
  console.log(a, b);        // 1, 2
  arguments[0] = 100;
  console.log(a, b);        // 1, 2  ← a 没变!
}
foo(1, 2);

严格模式下,arguments 和命名参数完全独立,互不影响。

🔴 区别二:arguments.callee 被禁用

非严格模式(可用)

function factorial(n) {
  if (n <= 1) return 1;
  return n * arguments.callee(n - 1);  // ✅ 正常执行
}
console.log(factorial(5)); // 120

arguments.callee 指向当前正在执行的函数本身,常用于匿名函数递归。

严格模式(报错)

'use strict';
function factorial(n) {
  if (n <= 1) return 1;
  return n * arguments.callee(n - 1);  // ❌ TypeError!
}

严格模式下访问 arguments.callee 会直接抛出 TypeError

✅ 替代方案

// 方案1:命名函数直接调用自身
function factorial(n) {
  if (n <= 1) return 1;
  return n * factorial(n - 1);
}

// 方案2:使用箭头函数 + 函数名(尾递归友好)
const factorial = (n) => {
  if (n <= 1) return 1;
  return n * factorial(n - 1);
};

// 方案3:用函数表达式赋给变量
const factorial = function f(n) {
  if (n <= 1) return 1;
  return n * f(n - 1);
};

🔴 区别三:arguments.caller 被禁用

非严格模式

function inner() {
  console.log(arguments.caller);  // 返回调用 inner 的外部函数
}
function outer() {
  inner();
}
outer();

严格模式

'use strict';
function inner() {
  console.log(arguments.caller);  // ❌ TypeError!
}

arguments.callerarguments.callee 在严格模式下都被禁止,原因是它们存在安全隐患(可以访问调用栈)。

🟡 区别四:arguments 不会追踪剩余参数(Spread Rest)

严格模式引入了 ...rest 语法,它与 arguments 的行为完全不同:

'use strict';

function foo(a, ...rest) {
  console.log(arguments.length); // 实参个数
  console.log(rest.length);      // 剩余参数个数
}

foo(1, 2, 3, 4);
// arguments.length → 4(所有实参)
// rest.length → 3(去掉 a 之后的部分:[2, 3, 4])

关键区别

  • arguments:类数组,包含所有实参
  • ...rest真正的数组,只包含未匹配命名参数的部分

四、完整对比表

特性

非严格模式

严格模式

修改 arguments[n] 影响命名参数

✅ 会影响

❌ 不影响

arguments.callee

✅ 可用

❌ TypeError

arguments.caller

✅ 可用

❌ TypeError

arguments 与 rest 参数共存

✅ 可共存

✅ 可共存(但行为独立)

五、为什么严格模式要限制 arguments?

1. 性能优化

非严格模式下,JS 引擎必须维护 arguments 和参数之间的双向绑定关系,这导致无法对函数参数进行某些优化。严格模式下二者独立,引擎可以更高效地处理参数。

2. 安全性

arguments.calleearguments.caller 允许访问调用栈,存在被利用来进行安全攻击的风险。

3. 代码可读性

现代 JS 推荐使用命名函数rest 参数替代 arguments 的各种黑魔法,代码更清晰。

六、现代推荐写法

'use strict';

// ❌ 旧写法:依赖 arguments
function sum() {
  let total = 0;
  for (let i = 0; i < arguments.length; i++) {
    total += arguments[i];
  }
  return total;
}

// ✅ 新写法:使用 rest 参数
function sum(...nums) {
  return nums.reduce((acc, n) => acc + n, 0);
}

// ✅ 新写法:rest + 命名参数结合
function log(prefix, ...messages) {
  messages.forEach(msg => console.log(prefix, msg));
}
log('[INFO]', 'hello', 'world');

总结

严格模式的核心改变:将 arguments 从一个"神奇的对象"变成了一个普通的类数组,切断了它与命名参数的隐式绑定,并禁用了不安全的 callee/caller 属性。在现代 JS 开发中,推荐使用 ...rest 参数替代 arguments

如何借助Github pages部署React+vite静态前端项目

作者 苍舒墨
2026年3月27日 14:46

这里只针对静态页面啊,需要向后端服务器访问数据的不行,所以还是有点受限了。

在部署时遇到了许多问题,所以想和大家分享一下我的解决方案。

我自己也去找了许多教程,下面贴出一些个人觉得有帮助的:

来认真学一下,项目部署到 github pages-腾讯云开发者社区-腾讯云

在 GitHub Pages 上部署 React 应用|极客教程

【‌轻松上手:React Vite 应用快速部署至 Github Pages】www.bilibili.com/video/BV1ch…

【免费部署一个静态网站!在GitHub上部署静态网站教程】www.bilibili.com/video/BV1D2…

虽然啊,这些都没有解决我的问题,但是讲的还是不错的。其中有些是使用git命令推到Github的仓库上,但是不知道为什么输命令的话连不上github

报错如下:

fatal: unable to access 'https://github.com/仓库名/项目名.git/': Failed to connect to github.com port 443 after 21143 ms: Couldn't connect to server

遇到过404的问题即访问不到资源,也遇到过不报错但全白屏的情况。其实都是路径不对的原因,接下来进行分析。

GitHub Pages 的两种部署方式

  1. 用户/组织站点

    • 仓库名:username.github.io
    • 访问地址:https://username.github.io/
    • 部署在根路径,base 配置为 /
  2. 项目站点

    • 仓库名:任意名称(如 myWeb
    • 访问地址:https://username.github.io/myWeb/
    • 部署在子路径,需要特殊配置

关键配置项

  • base:Vite 配置,控制静态资源(JS/CSS/图片)的引用路径
  • basename:React Router 配置,控制路由的基础路径

失败原因分析

问题 1:文件结构错误

现象: 404 错误,资源找不到

原因: 上传时把 dist 文件夹本身拖到 GitHub,导致结构变成:

仓库根目录/
  └── dist/
      ├── index.html
      └── assets/

实际访问路径变成 https://xxx.github.io/myWeb/index.html,而 HTML 里的资源路径是 /myWeb/assets/...,路径不匹配。

解决方案:

  • 进入 dist 文件夹内部
  • 全选里面的文件和文件夹(不选 dist 本身)
  • 拖到 GitHub 仓库根目录

正确结构:

仓库根目录/
  ├── index.html
  ├── assets/
  └── vite.svg

问题 2:base 配置错误

现象: 资源 404,或者图标能显示但 JS/CSS 加载失败

原因: base 配置不正确导致资源路径错误

尝试过的配置:

  1. base: '/' (默认)

    <script src="/assets/index.js"></script>
    

    实际访问:https://xxx.github.io/assets/index.js

    正确路径:https://xxx.github.io/myWeb/assets/index.js

  2. base: '/visual-editor/' (绝对路径)

    <script src="/myWeb/assets/index.js"></script>
    

    生产环境:✅ 正确

    本地开发:❌ npm run devnpm run preview 都会失败

  3. base: './' (相对路径)

    <script src="./assets/index.js"></script>
    

    看似完美,但遇到了问题 3...

问题 3:React Router 路由匹配失败

现象:

  • 资源加载成功(304 /200 状态码)
  • 控制台无报错
  • 页面白屏
  • DOM 中只有 <div id="root"><div id="app"></div></div>,app 内部为空

原因: BrowserRouter 在子路径下路由匹配失败

技术细节:

  • GitHub Pages 访问地址:https://xxx.github.io/myWeb/
  • BrowserRouter 默认 basename 是 /
  • 当前实际路径是 /myWeb/
  • 路由配置的 path="/" 匹配不上实际路径 /myWeb/
  • React 渲染了 <div id="app"> 但 Routes 内没有匹配的组件

为什么本地 preview 也白屏?

  • npm run preview 运行在 http://localhost:4173/
  • 如果 base 是 /myWeb/,资源路径变成 http://localhost:4173/myWeb/assets/...
  • 但实际文件在 http://localhost:4173/assets/...
  • 路径不匹配导致加载失败或路由失败

问题 4:开发/生产环境冲突

矛盾点:

  • 开发环境(npm run dev)需要 base 为 /
  • 生产环境(GitHub Pages)需要 base 为 /myWeb/
  • 写死任何一个值都会导致另一个环境失败

最终解决方案

1. 动态配置 base(vite.config.ts)

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

export default defineConfig(({ mode }) => ({
  plugins: [react()],
  resolve: {
    alias: {
      '@': '/src',
    },
  },
  server: {
    port: 3000,
  },
  base: mode === 'production' ? '/myWeb/' : '/',
}));

原理:

  • mode 参数由 Vite 自动传入
  • npm run dev:mode = 'development',base = '/'
  • npm run build:mode = 'production',base = '/visual-editor/'

2. 动态配置 basename(src/main.tsx)

import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './styles/base.css';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <BrowserRouter basename={import.meta.env.BASE_URL}>
      <App />
    </BrowserRouter>
  </React.StrictMode>
);

原理:

  • import.meta.env.BASE_URL 是 Vite 内置环境变量
  • 自动读取 vite.config.ts 中的 base 值
  • 开发环境:basename = '/'
  • 生产环境:basename = '/myWeb/'

3. 部署流程

1. 构建项目
npm run build

2. 上传文件
打开本地 dist 文件夹
全选里面的所有内容(index.html、assets 文件夹等)
拖到 GitHub 仓库根目录上传

3. 配置 GitHub Pages
仓库 Settings → Pages
Source: Deploy from a branch
Branch: main
Folder: / (root)
Save

4. 等待 1-2 分钟后访问
https://username.github.io/myWeb/

常见问题排查

白屏无报错

  1. 检查 DOM 结构,看 <div id="root"> 里有没有内容
  2. 如果只有空的 <div id="app">,是路由问题
  3. 确认 BrowserRouter 的 basename 配置

资源 404

  1. F12 → Network 面板查看失败的资源路径
  2. 对比实际文件在 GitHub 上的位置
  3. 检查 vite.config.ts 的 base 配置

缓存问题

  1. 强制刷新:Ctrl + Shift + R(Windows)
  2. 无痕模式访问
  3. 清除浏览器缓存
  4. URL 加随机参数:?v=123(没试过有没有用)

本地预览失败

npm run preview

如果白屏,说明配置有问题,不要急着部署,先在本地解决。

总结

部署 Vite + React Router 到 GitHub Pages 的核心是:

  1. 理解路径差异:开发环境根路径 vs 生产环境子路径
  2. 两处配置:静态资源路径(base)+ 路由基础路径(basename)
  3. 环境区分:使用 Vite 的 mode 参数动态配置
  4. 正确上传:上传 dist 内容,不是 dist 文件夹

配置正确后,开发和部署都能正常工作,无需手动切换配置。

如果网络上实在找不到解决方法,可以去问ai的,现在大模型越来越强,将问题和目标清楚地描述给ai,大概率可以解决问题。

🔥 前端人必看:浏览器安全核心知识点全解析(XSS/CSRF/DDoS)

作者 小金鱼Y
2026年3月28日 17:03

作为前端开发者,我们每天都在和浏览器打交道,但你真的了解浏览器背后的安全隐患吗?从 XSS 脚本注入到 CSRF 跨站请求伪造,再到 DDoS 流量攻击,这些威胁时刻潜伏在我们的代码里。

本文将用最通俗的语言,拆解浏览器安全的核心知识点,让你轻松掌握防御手段,写出更安全的 Web 应用。

一、同源策略:浏览器安全的第一道防线

同源策略是浏览器最核心的安全基石,它规定了:协议、域名、端口必须完全一致,两个页面才属于 “同源”,才能自由读取对方的资源。

  • 举个例子:

    • https://juejin.cnhttps://juejin.cn:8080 → 端口不同,不同源
    • https://juejin.cnhttps://m.juejin.cn → 子域名不同,不同源
    • http://juejin.cnhttps://juejin.cn → 协议不同,不同源
  • 作用:同源策略限制了不同源之间的脚本、Cookie、DOM 等资源的访问,从根源上防止恶意网站窃取用户数据。如果没有同源策略,恶意网站可以轻易读取你在其他网站的登录状态、个人信息,后果不堪设想。

二、XSS 攻击:藏在网页里的 “隐形脚本”

1. 什么是 XSS?

XSS(Cross-Site Scripting,跨站脚本攻击)是指黑客在目标网站的网页中嵌入恶意脚本,当用户访问该网站时,浏览器会执行这些脚本,导致用户信息泄露、网站被劫持等严重后果。

2. XSS 的三种注入方式

  • 反射型 XSS:用户在不知情的情况下,将恶意脚本作为参数传递给网站请求,服务器会把参数原封不动返回给用户,浏览器会解析执行这段脚本。

    • 示例:https://example.com/search?keyword=<script>stealCookie()</script>
  • 存储型 XSS:黑客将恶意脚本直接存储在目标网站的数据库中,当其他用户访问相关页面时,脚本会被加载执行。

    • 示例:在评论区、留言板提交包含恶意脚本的内容
  • DOM 型 XSS:黑客通过篡改页面 DOM 结构,劫持 Web 资源,直接在前端执行恶意代码,不需要服务器参与。

3. 如何防御 XSS?

  1. 关键字符过滤:对用户输入的内容进行严格过滤,转义掉 <>&"' 等特殊字符,防止浏览器将其解析为 HTML 标签。

  2. CSP(内容安全策略) :在响应头中添加 Content-Security-Policy 字段,限制浏览器只能加载指定来源的资源,防止 XHR 请求加载恶意脚本。

    • 示例:Content-Security-Policy: default-src 'self'
  3. HttpOnly Cookie:在设置 Cookie 时添加 HttpOnly 属性,禁止 JavaScript 访问 Cookie,即使被 XSS 攻击也无法窃取用户登录凭证。

    • 示例:Set-Cookie: sessionId=xxx; HttpOnly

三、CSRF 攻击:披着 “合法外衣” 的恶意请求

1. 什么是 CSRF?

CSRF(Cross-Site Request Forgery,跨站请求伪造)是指黑客在恶意网站中嵌入表单或脚本,当用户登录过目标网站并访问恶意网站时,浏览器会自动携带目标网站的 Cookie,发起伪造的请求(比如转账、修改密码)。

2. CSRRF 的攻击方式

  • 自动发起 GET 请求:通过 <img><link> 等标签,在页面加载时自动发起请求。
  • 自动发起 POST 请求:通过隐藏表单,在页面加载时自动提交表单。

3. 如何防御 CSRF?

  1. 验证码:在关键操作(如转账、修改密码)前添加验证码,要求用户手动输入,防止机器人自动提交。

  2. CSRF 令牌:在表单中添加随机生成的令牌,服务器验证令牌有效性后才处理请求,确保请求来自合法页面。

  3. SameSite Cookie:在设置 Cookie 时添加 SameSite 属性,限制 Cookie 只能在同站请求中携带,防止跨站请求携带 Cookie。

    • Strict:完全禁止跨站携带 Cookie
    • Lax:允许部分安全的跨站请求携带 Cookie(如 GET 请求)

四、DDoS 攻击:压垮服务器的 “流量洪水”

1. 什么是 DDoS?

DDoS(Distributed Denial of Service,分布式拒绝服务攻击)是指黑客通过控制大量被感染的设备,向目标网站发送海量请求,耗尽服务器的带宽、CPU、内存等资源,导致网站无法正常响应合法用户的请求。

2. 如何防御 DDoS?

  1. 防火墙:在服务器端部署防火墙,对进入的流量进行过滤,只允许合法流量通过,拦截恶意请求。
  2. 负载均衡:通过负载均衡器将流量分发到多台服务器,避免单台服务器被流量压垮,提升系统的抗攻击能力。
  3. CDN 加速:将静态资源缓存到全球各地的 CDN 节点,分散流量压力,同时隐藏源服务器的真实 IP。
  4. 流量清洗:使用专业的 DDoS 防护服务,对流量进行清洗,过滤掉恶意请求,只将合法流量转发给源服务器。

五、浏览器系统安全:从底层筑牢防线

除了上述攻击类型,浏览器本身也提供了一系列安全机制,帮助我们保护用户数据:

  • 沙箱机制:将浏览器的不同进程(如渲染进程、插件进程)隔离在沙箱中,防止恶意代码突破进程边界,影响系统安全。
  • 自动更新:浏览器会定期更新安全补丁,修复已知的漏洞,建议始终保持浏览器为最新版本。
  • 安全浏览:主流浏览器会内置安全浏览功能,拦截已知的恶意网站、钓鱼网站,提醒用户注意风险。
  • 权限控制:浏览器会对网站的权限(如摄像头、麦克风、地理位置)进行严格控制,用户可以手动授权或拒绝。

六、总结

浏览器安全是 Web 开发中不可忽视的一环,从同源策略到 XSS、CSRF、DDoS 攻击,每一个知识点都关系到用户数据的安全。作为前端开发者,我们不仅要写出功能完善的代码,更要具备安全意识,在开发过程中主动采取防御措施,为用户打造更安全的上网环境。

React如何远程加载组件

作者 前端fun
2026年3月28日 16:13

前言

做过低代码开发的应该都遇到过组件加载的问题,一开始我们是把组件和整个项目放在同一个仓库中,打包的时候每个组件都是一个单独的chunk,这样加载的时候按需加载。但是随着项目越来越大,组件越来越多,甚至一些自定义的等等,这一套逻辑就不能支持下去。

而远程组件的好处就是,每个开发都能编写组件直接提交到一个公共的地方,那么我们的项目在获取组件列表的时候就能自动拿到以及使用。

构想

既然我们需要加载远程组件,就需要实现一个模块,需要处理从远端加载我们的组件然后进行渲染。

1.加载远端js
2.缓存组件(避免多次加载)
3.获取组件
4. 渲染

低代码通常会返回一个配置项,描述了内部有哪些组件以及他们的层级关系,这些我们不需要关注,默认我们现在有这么一个配置项。

首先要加载远程组件,然后把他放到react中渲染,类似下面的结构:

  const json = {
  components: [
  {
"id": "remote-button-a",
"url": "https://cdn.jsdelivr.net/npm/@luke358/remote-component@latest/dist/umd/button.umd.js",
"globalName": "RemoteUI",
"exportName": "Button",
"componentProps": {
"children": "远程按钮 A"
},
"loadingText": "远程按钮 A 加载中",
"clickMessage": "Remote UMD button A is working."
},
  ]
  }

这里我们定义了一些简单的结构。

  • import 远程的js
  • name 我们最终把这个组件挂载到window上,用于存取组件

实现

远程组件的构建和打包

这里主要关注组件的实现、以及通过配置远程加载组件,这里我们需要把远程组件打包为 umd 或者 iife。

我们新建一个项目来存放我们的组件。直接使用vite+react+ts脚手架搭建即可。

我们把所有的组件都放在 src/components 下。

主要的打包配置如下

import { existsSync } from "node:fs";
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import { build } from "vite";
import react from "@vitejs/plugin-react";

const rootDir = process.cwd();
const srcComponentsDir = resolve(rootDir, "src/components");
const outDir = resolve(rootDir, "dist/umd");
const entryExtensions = [".tsx", ".ts", ".jsx", ".js"];
const globalNamespace = "RemoteUI";

function toKebabCase(value) {
  return value
    .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
    .replace(/[_\s]+/g, "-")
    .toLowerCase();
}

async function getComponentEntries() {
  const dirents = await readdir(srcComponentsDir, { withFileTypes: true });
  const entries = [];

  for (const dirent of dirents) {
    if (!dirent.isDirectory()) {
      continue;
    }

    const componentName = basename(dirent.name);
    const entry = entryExtensions
      .map((extension) => resolve(srcComponentsDir, componentName, `index${extension}`))
      .find((entryPath) => existsSync(entryPath));

    if (entry) {
      entries.push({
        componentName,
        entry,
        fileName: `${toKebabCase(componentName)}.umd.js`,
        globalName: globalNamespace
      });
    }
  }

  if (entries.length === 0) {
    throw new Error("No component entry files were found under src/components.");
  }

  return entries;
}

async function buildComponent(entryConfig) {
  await build({
    configFile: false,
    plugins: [
      react({
        jsxRuntime: "classic"
      })
    ],
    resolve: {
      alias: {
        "@": resolve(rootDir, "src")
      }
    },
    build: {
      outDir,
      emptyOutDir: false,
      sourcemap: true,
      lib: {
        entry: entryConfig.entry,
        name: entryConfig.globalName,
        formats: ["umd"],
        fileName: () => entryConfig.fileName
      },
      rollupOptions: {
        external: ["react", "react-dom"],
        output: {
          extend: true,
          globals: {
            react: "React",
            "react-dom": "ReactDOM"
          }
        }
      }
    }
  });
}

async function main() {
  const entries = await getComponentEntries();

  await rm(outDir, { recursive: true, force: true });
  await mkdir(outDir, { recursive: true });

  for (const entry of entries) {
    await buildComponent(entry);
  }

  await writeFile(
    resolve(outDir, "manifest.json"),
    JSON.stringify(
      entries.map(({ componentName, fileName, globalName }) => ({
        componentName,
        fileName,
        globalName,
        accessPath: `${globalName}.${componentName}`
      })),
      null,
      2
    )
  );

  console.log(`Built ${entries.length} UMD bundle(s) into ${outDir}`);
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});

主要关注的就是 formats, globalNameexternal.

我们不需要把 React 和 ReactDOM 打包进来,到时候全部都适用主应用的,所以后续需要在主应用把 React 和 ReactDOM 挂载到 window 上。

然后编写一个简单的 button 组件

import React from "react";

export interface ButtonProps {
  children: React.ReactNode;
  type?: "button" | "submit" | "reset";
  disabled?: boolean;
  onClick?: () => void;
}

const buttonStyle: React.CSSProperties = {
  display: "inline-flex",
  alignItems: "center",
  justifyContent: "center",
  padding: "12px 18px",
  border: "none",
  borderRadius: "999px",
  background:
    "linear-gradient(135deg, rgba(15, 23, 42, 1) 0%, rgba(37, 99, 235, 1) 100%)",
  color: "#ffffff",
  fontSize: "14px",
  fontWeight: 600,
  cursor: "pointer",
  boxShadow: "0 12px 30px rgba(37, 99, 235, 0.24)"
};

const disabledStyle: React.CSSProperties = {
  opacity: 0.45,
  cursor: "not-allowed",
  boxShadow: "none"
};

export function Button({
  children,
  type = "button",
  disabled = false,
  onClick
}: ButtonProps) {
  return (
    <button
      type={type}
      disabled={disabled}
      onClick={onClick}
      style={{
        ...buttonStyle,
        ...(disabled ? disabledStyle : undefined)
      }}
    >
      {children}
    </button>
  );
}

执行打包之后 "build:umd": "node ./scripts/build-umd.mjs" 就会在dist/umd 下看到一个 button.umd.js 文件。

之后把这个文件上传到 oss 或者其他地方就能进行远程加载。

远程组件的加载使用

添加React到全局对象

// main.ts
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";

declare global {
  interface Window {
    React?: typeof React;
    ReactDOM?: typeof ReactDOM;
  }
}

window.React = React;
window.ReactDOM = ReactDOM;

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

这样在渲染远程组件的时候就能确保 React 能正确的使用

RemoteComponentLoader 实现


function loadRemoteScript(url: string) {
  const cachedPromise = scriptPromiseCache.get(url);

  if (cachedPromise) {
    return cachedPromise;
  }

  const scriptPromise = new Promise<void>((resolve, reject) => {
    const existingScript = document.querySelector<HTMLScriptElement>(`script[data-remote-src="${url}"]`);

    if (existingScript) {
      if (existingScript.dataset.loaded === "true") {
        resolve();
        return;
      }

      existingScript.addEventListener("load", () => resolve(), { once: true });
      existingScript.addEventListener("error", () => reject(new Error(`Load failed: ${url}`)), {
        once: true
      });
      return;
    }

    const script = document.createElement("script");
    script.src = url;
    script.async = true;
    script.dataset.remoteSrc = url;
    script.onload = () => {
      script.dataset.loaded = "true";
      resolve();
    };
    script.onerror = () => reject(new Error(`Load failed: ${url}`));
    document.head.appendChild(script);
  }).catch((error) => {
    scriptPromiseCache.delete(url);
    removeRemoteScript(url);
    throw error;
  });

  scriptPromiseCache.set(url, scriptPromise);
  return scriptPromise;
}

export function loadRemoteComponent<TProps extends Record<string, unknown>>(
  config: RemoteComponentConfig
) {
  const cacheKey = getCacheKey(config);
  const resolvedComponent = resolvedComponentCache.get(cacheKey);

  if (resolvedComponent) {
    return Promise.resolve(resolvedComponent as RemoteComponentType<TProps>);
  }

  const pendingComponent = componentPromiseCache.get(cacheKey);

  if (pendingComponent) {
    return pendingComponent as Promise<RemoteComponentType<TProps>>;
  }

  const componentPromise = loadRemoteScript(config.url)
    .then(() => {
      const remoteComponent = resolveRemoteExport(config);
      resolvedComponentCache.set(cacheKey, remoteComponent);
      return remoteComponent;
    })
    .catch((error) => {
      componentPromiseCache.delete(cacheKey);
      resolvedComponentCache.delete(cacheKey);
      throw error;
    });

  componentPromiseCache.set(cacheKey, componentPromise);
  return componentPromise as Promise<RemoteComponentType<TProps>>;
}

这里主要是展示怎么通过配置加载我们远端的组件。

后续使用只需要:


const [ResolvedComponent, setResolvedComponent] = React.useState<RemoteComponentType<TProps> | null>(

useEffect(() => {
loadRemoteComponent<TProps>(config)
      .then((component) => {
        if (!disposed) {
          setResolvedComponent(() => component);
        }
      })
      .catch((error: Error) => {
        if (!disposed) {
          setLoadError(error);
        }
      });

    return () => {
      disposed = true;
    };
}, [])

return <div> {ResolvedComponent ? 'loading' : <ResolvedComponent />} </div>

这里只是给了个简单的例子。

正常情况我们需要考虑组件的并发加载处理,缓存,错误捕获避免导致应用全部崩溃等一些细节问题。

具体实现代码(主要由AI生成):
github.com/luke358/tes…

安装依赖后,执行 npm run dev 可以直接看到效果

解决大数据渲染卡顿:Vue3 虚拟列表组件的完整实现方案

作者 起风了___
2026年3月28日 15:54

文章简介

本文介绍的是 vue3 中虚表组件的实现方式。当需要展示的数据量达到几百上千条时就需要使用虚表,否则大量组件的渲染会导致页面卡顿甚至卡死。 备注:本文介绍的虚表只支持固定且高度相同的数据元素。

实现原理

滚动容器
┌─────────────────────────────┐
│                             │
│    ~~~~~~~~~~~~~~~~~~~      │
│    ~  未渲染的虚拟行  ~       │
│    ~~~~~~~~~~~~~~~~~~~      │
│  ┌─────────────────────┐    │
│  │  实际渲染区域     │    │
│  │  (visibleRows)      │    │
│  └─────────────────────┘    │
│    ~~~~~~~~~~~~~~~~~~~      │
│    ~  未渲染的虚拟行  ~       │
│    ~~~~~~~~~~~~~~~~~~~      │
│                             │
└─────────────────────────────┘
  • 虚表由 3 个元素组成,分别为有固定高度的根元素(滚动容器)提供数据滚动能力、用于撑开根容器的占位元素、用于展示信息的区域渲染元素。
  • 渲染区域在根元素内部使用绝对定位 position: absolute; 脱离文档流。
    • 实时计算需要渲染的元素行。
    • 备注:当需要渲染的元素发生变化时,通过 transform: translateY(100px); 属性对渲染区域进行偏移,确保渲染连续。
  • 根元素使用相对定位 position: relative; 使渲染元素在根元素内部定位、滚动。
  • 占位元素只用来撑开根元素内部空间,让根元素提供滚动能力。
    • 备注:占位元素的高度计算方式:数据量 * 数据展示元素高度。

外部属性定义

  • items: 使用虚表的父组件传入的所有要展示数据源。
  • itemHeight:每个数据元素的展示行高
  • width、height:可由父组件传入固定数值,默认撑满父组件。
  • space:展示元素之间的间距
  • bufferSize:渲染区域上下缓冲区大小
const props = defineProps({
  // 数据源 (必须)
  items: {
    type: Array,
    required: true,
  },
  // 行高 (必须,单位px)
  itemHeight: {
    type: Number,
    required: true,
  },
  // 容器宽度 (可选,未指定则撑满父元素)
  width: {
    type: [String, Number],
    default: "100%",
  },
  // 容器高度 (可选,未指定则撑满父元素)
  height: {
    type: [String, Number],
    default: "100%",
  },
  // item 间距 (可选,默认5px)
  space: {
    type: Number,
    default: 8,
  },
  // 上下缓冲区行数,避免快速滚动白屏 (默认5)
  bufferSize: {
    type: Number,
    default: 5,
  },
});

插槽定义

主要用于定义数据展示元素插槽的数据类型,否则使用虚表的父组件在定义数据展示元素时会飘红

defineSlots<{
  item(props: { item: any; index: number }): void;
}>();

html 部分

  • viewportRef 绑定根元素对象,用于获取实际视口高度,视口高度会用来计算可展示元素数量
  • containerStyle: 用于设置父组件传递的根容器宽高,或设置默认值
  • virtual-viewport:根元素 css 属性
  • virtual-phantom:占位块 css 属性
  • totalHeight:虚表需要展示的总数据占位高度
  • virtual-content:渲染区 css 属性
  • offsetY:渲染区偏移量
  • visibleRows:实际渲染元素
  • itemHeight:插槽定义的数据展示元素高度,由使用虚表的父组件通过属性传入
  • itemActualHeight:渲染元素实际高度 = 插槽定义的数据展示元素高度(itemHeight) + 元素间隔(space)
<template>
  <!-- 虚拟滚动视口 -->
  <div
    ref="viewportRef"
    class="virtual-viewport"
    :style="containerStyle"
    @scroll="onScroll"
  >
    <!-- 占位块,撑开滚动空间 -->
    <div class="virtual-phantom" :style="{ height: totalHeight + 'px' }" />

    <!-- 渲染内容区,绝对定位跟随滚动 -->
    <div
      class="virtual-content"
      :style="{ transform: `translateY(${offsetY}px)` }"
    >
      <div
        v-for="row in visibleRows"
        :key="row.index"
        :style="{ height: itemActualHeight + 'px' }"
      >
        <!-- 通过插槽让外部自定义每一项的渲染内容 -->
        <slot
          name="item"
          :item="row.data"
          :index="row.index"
          :style="{ height: itemHeight + 'px' }"
        >
          <!-- 默认渲染,当外部没有提供自定义插槽时使用 -->
          <div>
            <span>#{{ row.index + 1 }}</span>
            <span>{{ row.data }}</span>
          </div>
        </slot>
      </div>
    </div>
  </div>
</template>

css 部分

<style scoped>
.virtual-viewport {
  position: relative;
  display: flex;
  flex-direction: column;
  overflow-y: auto;
}

.virtual-phantom {
  width: 100%;
  pointer-events: none;
}

.virtual-content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  will-change: transform;
}
</style>

实时计算变量实现

核心逻辑

  1. 实时计算父组件设置的根元素宽高
  2. 虚表组件挂载后获得视口高度,并订阅根元素的大小变化。
  3. 监听展示数据的变化,超出滚动范围时修正滚动范围。
  4. 实时计算每项元素实际高度
  5. 实时计算占位元素总高度
  6. 实时计算起始结束索引
  7. 实时计算实际渲染的数据行
  8. 实时计算偏移量
  • 实时计算使用 vue3 的 computed() 方法

// 引用
const viewportRef = ref<any>(null);
// 实际容器高度 (动态计算)
const viewportHeight = ref(0);
// 滚动位置
const scrollTop = ref(0);
// 滚动事件
const emit = defineEmits<{
  (e: "scroll", scrollTop: number): void;
}>();
const onScroll = (e: any) => {
  scrollTop.value = e.target.scrollTop;
  emit("scroll", scrollTop.value);
};

// 容器样式
const containerStyle = computed(() => {
  const style: any = {};
  style.width =
    typeof props.width === "number" ? `${props.width}px` : props.width;
  style.height =
    typeof props.height === "number" ? `${props.height}px` : props.height;
  return style;
});
// 更新容器高度
const updateViewportHeight = () => {
  if (viewportRef.value) {
    const height = viewportRef.value.clientHeight;
    if (viewportHeight.value !== height) {
      viewportHeight.value = height;
    }
  }
};
// 使用ResizeObserver监听视口尺寸变化
let resizeObserver: any = null;
onMounted(() => {
  // 初始化容器高度
  updateViewportHeight();
  // 监听容器大小变化
  resizeObserver = new ResizeObserver(() => {
    updateViewportHeight();
  });
  if (viewportRef.value) {
    resizeObserver.observe(viewportRef.value);
  }
});
onBeforeUnmount(() => {
  if (resizeObserver) {
    resizeObserver.disconnect();
    resizeObserver = null;
  }
});
// 监听数据变化,确保滚动位置有效
watch(
  () => props.items,
  () => {
    // 如果数据变化后总高度变小,且当前滚动位置超出范围,修正滚动
    nextTick(() => {
      if (viewportRef.value) {
        const maxScroll = Math.max(
          0,
          totalHeight.value - viewportRef.value.clientHeight,
        );
        if (viewportRef.value.scrollTop > maxScroll) {
          viewportRef.value.scrollTop = maxScroll;
        }
      }
    });
  },
);

// 每项实际高度
const itemActualHeight = computed(() => {
  return props.itemHeight + props.space;
});

// 计算总数据量
const totalItems = computed(() => props.items.length);

// 总高度
const totalHeight = computed(() => totalItems.value * itemActualHeight.value);

// 计算起始索引 (基于scrollTop)
const startIndex = computed(() => {
  const rawStart = Math.floor(scrollTop.value / itemActualHeight.value);
  return Math.max(0, rawStart - props.bufferSize);
});

// 计算结束索引
const endIndex = computed(() => {
  const rawEnd = Math.ceil(
    (scrollTop.value + viewportHeight.value) / itemActualHeight.value,
  );
  return Math.min(totalItems.value - 1, rawEnd + props.bufferSize);
});

// 实际需要渲染的行数据
const visibleRows = computed(() => {
  const start = startIndex.value;
  const end = endIndex.value;
  return props.items
    .map((item, index) => ({
      index: start + index,
      data: item,
    }))
    .slice(start, end + 1);
});
// 偏移量
const offsetY = computed(() => startIndex.value * itemActualHeight.value);

对外暴露滚动事件、滚动距离

// 滚动位置
const scrollTop = ref(0);
// 滚动事件
const emit = defineEmits<{
  (e: "scroll", scrollTop: number): void;
}>();
const onScroll = (e: any) => {
  scrollTop.value = e.target.scrollTop;
  emit("scroll", scrollTop.value);
};

对外暴露根容器

defineExpose({
  $el: viewportRef,
});

使用虚表的父组件可以通过 ref 绑定虚表的根元素。

假设父组件通过 ref="parent" 绑定虚表根元素,通过父组件控制虚表滚动的方法为

parent.value.$el.scrollTop = 100;

父组件使用

<virtual-table
  ref="parent"
  :items="data"
  :space="8"
  :itemHeight="150"
  @scroll="(value: number) => (scrollTop = value)"
>
  <template #item="{ item, index }">
    <div>序号:{{ index }}</div>
    <div>内容:{{ item }}</div>
  </template>
</virtual-table>

附源码

<script setup lang="ts">
import {
  ref,
  computed,
  onMounted,
  onBeforeUnmount,
  watch,
  nextTick,
} from "vue";

const props = defineProps({
  // 数据源 (必须)
  items: {
    type: Array,
    required: true,
  },
  // 行高 (必须,单位px)
  itemHeight: {
    type: Number,
    required: true,
  },
  // 容器宽度 (可选,未指定则撑满父元素)
  width: {
    type: [String, Number],
    default: "100%",
  },
  // 容器高度 (可选,未指定则撑满父元素)
  height: {
    type: [String, Number],
    default: "100%",
  },
  // item 间距 (可选,默认8px)
  space: {
    type: Number,
    default: 8,
  },
  // 上下缓冲区行数,避免快速滚动白屏 (默认5)
  bufferSize: {
    type: Number,
    default: 5,
  },
});
defineSlots<{
  item(props: { item: any; index: number }): void;
}>();

// 引用
const viewportRef = ref<any>(null);
// 实际容器高度 (动态计算)
const viewportHeight = ref(0);
// 滚动位置
const scrollTop = ref(0);
// 滚动事件
const emit = defineEmits<{
  (e: "scroll", scrollTop: number): void;
}>();
const onScroll = (e: any) => {
  scrollTop.value = e.target.scrollTop;
  emit("scroll", scrollTop.value);
};

// 容器样式
const containerStyle = computed(() => {
  const style: any = {};
  style.width =
    typeof props.width === "number" ? `${props.width}px` : props.width;
  style.height =
    typeof props.height === "number" ? `${props.height}px` : props.height;
  return style;
});
// 更新容器高度
const updateViewportHeight = () => {
  if (viewportRef.value) {
    const height = viewportRef.value.clientHeight;
    if (viewportHeight.value !== height) {
      viewportHeight.value = height;
    }
  }
};
// 使用ResizeObserver监听视口尺寸变化
let resizeObserver: any = null;
onMounted(() => {
  // 初始化容器高度
  updateViewportHeight();
  // 监听容器大小变化
  resizeObserver = new ResizeObserver(() => {
    updateViewportHeight();
  });
  if (viewportRef.value) {
    resizeObserver.observe(viewportRef.value);
  }
});
onBeforeUnmount(() => {
  if (resizeObserver) {
    resizeObserver.disconnect();
    resizeObserver = null;
  }
});
// 监听数据变化,确保滚动位置有效
watch(
  () => props.items,
  () => {
    // 如果数据变化后总高度变小,且当前滚动位置超出范围,修正滚动
    nextTick(() => {
      if (viewportRef.value) {
        const maxScroll = Math.max(
          0,
          totalHeight.value - viewportRef.value.clientHeight,
        );
        if (viewportRef.value.scrollTop > maxScroll) {
          viewportRef.value.scrollTop = maxScroll;
        }
      }
    });
  },
);

// 每项实际高度
const itemActualHeight = computed(() => {
  return props.itemHeight + props.space;
});

// 计算总数据量
const totalItems = computed(() => props.items.length);

// 总高度
const totalHeight = computed(() => totalItems.value * itemActualHeight.value);

// 计算起始索引 (基于scrollTop)
const startIndex = computed(() => {
  const rawStart = Math.floor(scrollTop.value / itemActualHeight.value);
  return Math.max(0, rawStart - props.bufferSize);
});

// 计算结束索引
const endIndex = computed(() => {
  const rawEnd = Math.ceil(
    (scrollTop.value + viewportHeight.value) / itemActualHeight.value,
  );
  return Math.min(totalItems.value - 1, rawEnd + props.bufferSize);
});

// 实际需要渲染的行数据
const visibleRows = computed(() => {
  const start = startIndex.value;
  const end = endIndex.value;
  return props.items
    .map((item, index) => ({
      index: start + index,
      data: item,
    }))
    .slice(start, end + 1);
});
// 偏移量
const offsetY = computed(() => startIndex.value * itemActualHeight.value);

defineExpose({
  $el: viewportRef,
});
</script>

<template>
  <!-- 虚拟滚动视口 -->
  <div
    ref="viewportRef"
    class="virtual-viewport"
    :style="containerStyle"
    @scroll="onScroll"
  >
    <!-- 占位块,撑开滚动空间 -->
    <div class="virtual-phantom" :style="{ height: totalHeight + 'px' }" />

    <!-- 渲染内容区,绝对定位跟随滚动 -->
    <div
      class="virtual-content"
      :style="{ transform: `translateY(${offsetY}px)` }"
    >
      <div
        v-for="row in visibleRows"
        :key="row.index"
        :style="{ height: itemActualHeight + 'px' }"
      >
        <!-- 通过插槽让外部自定义每一项的渲染内容 -->
        <slot
          name="item"
          :item="row.data"
          :index="row.index"
          :style="{ height: itemHeight + 'px' }"
        >
          <!-- 默认渲染,当外部没有提供自定义插槽时使用 -->
          <div>
            <span>#{{ row.index + 1 }}</span>
            <span>{{ row.data }}</span>
          </div>
        </slot>
      </div>
    </div>
  </div>
</template>

<style scoped>
.virtual-viewport {
  position: relative;
  display: flex;
  flex-direction: column;
  overflow-y: auto;
}

.virtual-phantom {
  width: 100%;
  pointer-events: none;
}

.virtual-content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  will-change: transform;
}
</style>

聊聊我逃离前端开发的思考

作者 华洛
2026年3月28日 15:50

我在22年底chatGPT出现后的第一时间选择了从前端转型,并精准预测了25年AI产品、agent工程师岗位的诞生,以及26年将会是AI代替人类岗位的元年。

回头想一下,我能做出这些预测,并及时调整我的人生轨迹,全因为我的思考方式:像规划企业一样规划我的人生。

这个思考方式确实让我少走了非常多弯路,早在23年4月份,我写下图中的思考,而这份思考也是我放弃前端选择转型的基础逻辑。

cc99ba4232d28d3ff3b2196882a3d28.jpg

像规划企业一样规划我们的人生

如何像规划企业一样规划我们的人生?

首先大家要对我们的参与社会工作的人生阶段有一个概念:

从24岁大学毕业开始工作到65岁退休,足足有41年。

要知道2026年我们建国才77年;

中华老字号(创立50年以上)认证的企业也只有1455家;

倒闭了多少家企业才有了这1455的老字号。

所以,各位认为选择一个行业之后,能干满40年概率有多大?

干满40年一个行业,需要极大的运气与实力才可以的。

所以,今天我们所面对的,本就是这个世界应该发生的事情,大可不必过于担心焦虑。

比尔·盖茨强调企业需保持"离破产仅18个月"的危机意识。

保持这个意识的企业为了活下去, 都在不停地想办法赚钱、扩展业务:

  • 要不停地迭代产品功能、服务,建立企业护城河;
  • 要不停地找新到新的业务方向、新的客户、新的合作者;
  • 要不停审视市场环境、政策变化、竞争对手,决定进入\离开某个市场。
  • 等等.....

企业面临着市场缩小、政策变化、竞争变多、扩张业务等因素,都在不断研究方向,研究战略,生怕走错一步被彻底淘汰。

但是很多人却从不给自己做未来规划,直到事情发生才后知后觉,然后开始怨天尤人。

殊不知,个人面临着年龄变大、精力衰退、技能落后、新人顶替等等因素,被淘汰的风险一点都不比企业小。

所以个人也应该随时保持距离被辞退仅18个月的风险意识,尤其是现在身处AI的年代,这个时间被压缩的更少了;

我们要不断地审视自己:

  • 是否处于同行业较高水平?
  • 是否存在被淘汰的风险?风险在哪?
  • 是否要选择进入\退出某个岗位\行业?
  • 等等......

试一下吧,现在开始,审视一下你自己,规划一下你自己,像规划一家企业一样。

结语

最后送给读者一句话:

when the facts change, I change my mind ——凯恩斯

这也正应了咱们那句老话:君子审时度势,顺势而为。

Lexical依赖版本冲突与标题渲染

作者 im_AMBER
2026年3月28日 13:52

一、概述

昨天主要修复了两个bug:一是 PDF/Doc 导出时的依赖冲突导致的崩溃问题,二是文档标题更新后的 UI 同步问题。本文记录问题现象、排查过程、根因分析及解决方案,并对过程中的技术选型与排查方法进行复盘总结。

二、文件导出

导出文件PDF Doc 格式的时候存在id冲突,文件导出失败。

是因为Lexical编辑器基于节点框架设计,存在依赖冲突或多次初始化导致的问题。

依赖库版本不一致,后续也证实了是这个原因,BlockNote官方开源库已经明确说明,依赖版本要保持唯一、保持一致。 多节点重复使用选择 JSON ID · 问题 #1718 · TypeCellOS/BlockNote --- Duplicate use of selection JSON ID multiple-node · Issue #1718 · TypeCellOS/BlockNote 在这里插入图片描述

关于这个问题的bug追踪,我学会了通过F12控制台查看调用堆栈,分析错误具体出现在哪几层,事件是如何冒泡的。 在这里插入图片描述从下往上看(按时间顺序):

  • 用户点击:在菜单上点了一下(onInternalClick -> onMenuClick)。
  • 进入代码:程序运行到了 useFileExport.ts 的第 43 行,进入了 exportFile 函数。
  • 触发报错:在执行导出逻辑时,代码内部调用了 Lexical 的选择器逻辑(MultipleNodeSelection.ts)。
  • 报错根源:Selection.jsonID 发现 multiple-node 这个 ID 已经被注册过了。

项目可能同时加载了两个不同版本的 @lexical/selection 或相关插件包。当它们各自尝试注册 MultipleNodeSelection 时,ID 就会发生冲突。

Ctrl+P 查找相关文件,查看编译产物是否存在多份。

如果存在多份,可能是依赖版本冲突,也可能是模块解析路径不一致、代码分割策略或构建配置导致的重复打包。需要进一步查看产物中的版本号或模块路径来确认根本原因。

BlockNote 是基于 Lexical 构建的开源富文本编辑器框架,在 Lexical 基础上做了业务层封装。其底层的Lexical编辑器版本可能存在细微差异,在文档导出时,节点对应的ID发生了冲突。各自注册multiple selection相关包时,ID出现冲突,导致无法正确匹配对应实例。

我排查后发现,是之前引入AI库时做了全局配置分发,没有校验BlockNote开源库的版本一致性,这里涉及到依赖配置问题。 在这里插入图片描述 同时pnpm在打包编译和预加载时,也必须保证依赖版本统一。直接采用了pnpm overrides。

因为BlockNote是多层封装的开源库,所以在package.json里无法直接找到对应的Lexical编辑器依赖,需要通过命令行做深度检索和依赖输出。

根本原因:

  • 依赖树分析: 由于 pnpm 的依赖提升(hoisting)机制,以及部分包通过动态 import 懒加载, 导致 @lexical/selection 在不同模块中被解析到了不同的版本路径。 虽然 package.json 中版本范围一致,但实际安装时存在版本漂移或重复实例。

  • 打包逻辑分析: 在 Vite 构建时,由于模块解析(module resolution)的路径规则, 主包和 exporter 模块中的 Lexical 依赖被解析到了不同的 node_modules 路径, 导致运行时加载了多个 Lexical 实例。

  • 运行时分析: 导出操作触发了动态加载 -> 加载了第二个 Lexical 实例 -> 第二个实例尝试在全局注册已存在的 ID -> 崩溃

这次的收获主要是学会看堆栈信息,也考虑一下报错的更多原因。判断编译文件是否存在多个版本,从而快速定位错误。也思考了为什么出现报错的,除了排查代码逻辑,还要思考一下版本冲突等问题。

关于版本管理的问题,比较好的做法我认为应该在CI流程中加入脚本,检查所有 @blocknote/* 包的版本号是否一致。但是现在我还没有着手做这个。目前的package.json也比较少,现在看来自己维护也不是很麻烦。

三、文档标题提取

我一开始认为应该创建一个hook,然后在对应的wikiList(UI层的渲染)里调用这个hook。

但其实不是所有逻辑都适合用hook,写一个纯工具函数也可以。我当时也没太分清hook和utils的区别,自定义hook和utils呢,我个人感觉是有没有涉及到状态变更,像utils其实是没有动态变化的状态要管理的,更多是直接处理数据层。

React 官方文档在 Building Your Own Hooks 中强调:

Custom Hooks let you share stateful logic, not state itself. 自定义 Hook 让你共享有状态的逻辑,而不是状态本身。

关键在"有状态的逻辑"——这意味着自定义 Hook 的本质是封装那些涉及状态管理和副作用的行为。

核心还是要做到UI和逻辑解耦。

然后wiki list要怎么获取当前动态更新的数据呢?

 useEffect(() => {
    const handleUpdate = (e: any) => {
      const { id, title } = e.detail;
      setDocs((prevDocs) =>
        prevDocs.map((doc) =>
          doc._id === id ? { ...doc, title: title } : doc,
        ),
      );
    };

    window.addEventListener("WIKI_TITLE_UPDATED", handleUpdate);
    return () => window.removeEventListener("WIKI_TITLE_UPDATED", handleUpdate);
  }, []);

这里用了事件广播,把事件监听挂载到window对象上。这个做法因为直接挂载在浏览器web上,其实不是太优雅,但是胜在简单。

我觉得需要注意的一点是前后端与数据库的交互逻辑:新建了一个标题title之后,title是怎么存入数据库的,页面刷新之后也要保证逻辑正常。

 updateDocument(currentDocId, {
          title: note.title,
          content: note.content,
        }).catch(() => {});

新增了title字段。这里bug的验证是打印控制台,看看是否存入后端了,当然应该也可以直接看database的GUI界面。

四、小结

本次修复的两个问题虽然规模不大,但涉及依赖管理、架构设计、技术选型等多个维度。日常开发中,应保持对报错信息的敏感度,深入排查根因而非仅修复表象,同时注重代码的可维护性与可扩展性。

特别声明:本次代码实现仅仅是能跑通功能,并不是优雅的做法,存在设计等层面的缺陷,还请见谅。限于个人经验,文中若有疏漏,还请不吝赐教。

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

作者 SmalBox
2026年3月28日 13:40

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

在 Unity URP Shader Graph 中,Texture 3D Asset 节点是一个基础且重要的资源定义节点,它专门用于在着色器程序中定义和引用三维纹理资源。与传统的 2D 纹理不同,3D 纹理在三个维度上都具有纹理数据,这使得它在处理体积数据、复杂材质过渡和动态效果方面具有独特优势。该节点本身并不直接执行纹理采样操作,而是作为纹理资源的声明和引用点,为后续的 Sample Texture 3D 节点提供必要的纹理数据源。

三维纹理在计算机图形学中通常被称为体积纹理,它将纹理数据组织在三维空间中的规则网格上,每个纹素都有对应的(x, y, z)坐标。这种结构使得 3D 纹理特别适合表示体积数据,如医学成像中的 CT 扫描数据、云层和烟雾的密度场、金属材质的微观结构,或者是复杂材质的内部变化。在实时渲染中,3D 纹理的使用能够创造出更加丰富和真实的视觉效果,特别是在需要表现材质内部结构或复杂过渡效果的场景中。

Texture 3D Asset 节点的核心作用是建立 Shader Graph 与项目中 3D 纹理资源之间的连接桥梁。通过这个节点,开发者可以将项目中导入或创建的 3D 纹理资源引入到着色器图中,并使其可用于后续的纹理采样和处理操作。这种设计符合现代着色器开发中的资源与逻辑分离原则,使得同一个 3D 纹理资源可以在不同的采样节点中以不同的参数重复使用,提高了资源的利用率和着色器开发的灵活性。

在 Unity 的渲染管线中,3D 纹理的处理方式与 2D 纹理有所不同。由于包含额外的维度,3D 纹理通常需要更多的内存和计算资源,因此在性能优化方面需要特别关注。Texture 3D Asset 节点通过统一的接口抽象了这些底层细节,使得开发者可以专注于材质的外观和效果,而不必过多担心底层的实现复杂性。

描述

Texture 3D Asset 节点的主要功能是定义在着色器中使用的常量 3D 纹理资源。这里的"常量"意味着在着色器执行过程中,该纹理资源本身不会发生变化,尽管通过不同的采样参数可以从同一纹理中提取不同的信息。这种设计模式符合现代图形编程的最佳实践,即尽可能将资源和计算逻辑分离,从而提高代码的可维护性和执行效率。

在 Shader Graph 的工作流程中,Texture 3D Asset 节点承担着资源绑定的关键角色。当在项目中创建或导入 3D 纹理后,需要通过此节点将其引入到着色器图中。节点本身不包含任何采样逻辑,它仅仅是对纹理资源的引用声明。这种设计使得同一个纹理资源可以在着色器图中的多个位置被重复使用,每个使用位置都可以应用不同的采样参数和变换操作,而无需在内存中创建多个纹理副本。

3D 纹理与 2D 纹理在内部结构上存在显著差异。2D 纹理可以看作是平面上的像素网格,而 3D 纹理则是三维空间中的体素网格。每个体素不仅包含颜色信息,还可以存储其他数据,如密度、材质属性或其他自定义信息。这种结构使得 3D 纹理特别适合用于表示体积效果和复杂材质的内部结构变化。例如,在模拟大理石材质时,可以使用 3D 纹理来存储石材内部的矿物分布和脉络结构,通过适当的采样方式,可以渲染出具有真实内部结构的大理石表面。

要对 3D 纹理资源进行实际采样,必须将其与 Sample Texture 3D 节点结合使用。这两个节点的分工明确:Texture 3D Asset 节点负责声明纹理资源,而 Sample Texture 3D 节点负责执行具体的采样操作。这种分离设计的优势在于提高了资源的可复用性——使用单个 Texture 3D Asset 节点时,可以使用不同的参数对 3D 纹理进行多次采样,无需对 3D 纹理本身进行多次定义。这不仅减少了内存占用,也使得着色器结构更加清晰和易于维护。

在实际应用场景中,3D 纹理的用途十分广泛。它们可以用于创建复杂的材质效果,如木材的年轮、石材的纹理、金属的晶粒结构等。在特效制作中,3D 纹理常用于模拟体积效果,如烟雾、云层、火焰等。此外,在科技可视化领域,3D 纹理也常用于显示医学影像数据、地质勘探数据、流体模拟结果等科学数据。

Unity 对 3D 纹理的支持包括了一系列优化特性,如 mipmapping、纹理压缩和流式加载等。这些特性通过 Texture 3D Asset 节点对开发者透明地提供,使得开发者可以专注于材质效果的实现,而不必过多关注底层的纹理管理和优化细节。同时,Unity 还提供了完善的工具链支持,包括 3D 纹理的导入设置、预览和调试工具,进一步简化了 3D 纹理的使用流程。

端口

输出端口

Texture 3D Asset 节点仅包含一个输出端口,这个端口的设计反映了节点的单一职责原则——专注于提供 3D 纹理资源的引用。

Out 输出端口是该节点的唯一输出接口,其数据类型被明确指定为"3D 纹理"。这一数据类型不仅包含了纹理本身的图像数据引用,还封装了与纹理相关的元数据信息,如纹理尺寸、格式、mipmap 级别等。在 Shader Graph 的内部数据流中,这个输出端口传递的实际上是一个纹理对象的句柄或引用,而不是纹理数据本身。这种设计确保了数据传输的高效性,避免了不必要的数据复制操作。

输出端口的连接规则需要特别注意。Out 端口只能连接到接受 3D 纹理类型输入的节点,最典型的就是 Sample Texture 3D 节点。如果尝试将其连接到不兼容的节点类型,Shader Graph 会显示连接错误,防止不合理的节点连接组合。这种类型安全检查机制确保了着色器图的正确性和稳定性。

从数据流的角度来看,Out 端口输出的 3D 纹理引用在着色器执行过程中保持不变。这意味着在渲染一帧的过程中,纹理资源是恒定的,不会发生改变。这种不变性使得 Unity 的渲染管线能够进行各种优化,如纹理数据的预取、缓存策略的优化等。同时,这也符合现代图形 API 的设计原则,即在绘制调用之间尽可能保持资源状态的稳定。

在实际使用中,Out 端口的连接方式直接影响着着色器的性能和效果。如果同一个 Texture 3D Asset 节点的 Out 端口被连接到多个 Sample Texture 3D 节点,这意味着同一纹理资源将被多次采样。这种情况下,Unity 的渲染后端通常会识别出这种模式并进行优化,比如通过纹理绑定点的复用减少 API 调用开销。但是,开发者仍需注意这种用法可能带来的性能影响,特别是在性能敏感的移动平台上。

输出端口的特性还包括其对动态分支的支持。在包含动态分支的着色器中,即使某个分支路径上的 Sample Texture 3D 节点实际上不会被执行,但只要它连接到了 Texture 3D Asset 节点的 Out 端口,对应的纹理资源仍然会被绑定到着色器上。这种行为确保了着色器执行的一致性,但同时也意味着需要注意纹理资源的绑定数量,避免超出目标平台的限制。

控件

对象字段控件

Texture 3D Asset 节点的核心控件是一个 3D 纹理对象字段,这个控件提供了与 Unity 项目资源系统的直接交互接口。对象字段的设计遵循了 Unity 编辑器的通用模式,使得熟悉 Unity 的开发者能够快速上手使用。

对象字段控件在节点界面中显示为一个资源选择区域,通常包含纹理预览、资源名称和选择按钮等元素。开发者可以通过多种方式为此字段指定 3D 纹理资源:直接拖拽项目窗口中的 3D 纹理资源到字段区域、点击字段右侧的选择按钮从资源选择窗口中选取,或者通过字段的上下文菜单进行操作。这种灵活的资源指定方式适应了不同的工作流程和习惯。

对象字段的验证机制确保了只有合适的资源类型可以被指定。当尝试分配非 3D 纹理资源时,系统会拒绝该操作并给出相应的错误提示。这种类型安全机制防止了错误的资源分配,减少了调试时间。此外,字段还会对纹理资源的导入设置进行检查,确保其符合 3D 纹理的使用要求,如正确的纹理尺寸、格式和 mipmap 设置等。

在资源管理方面,对象字段维护着对项目中 3D 纹理资源的引用。这种引用关系在 Shader Graph 序列化时会被保存,确保了着色器材质在重新加载时能够正确恢复与纹理资源的关联。同时,这种引用机制也使得资源的重命名、移动等操作能够被正确跟踪,减少了资源丢失的风险。

对象字段还提供了快速的资源访问和导航功能。通过字段的上下文菜单,开发者可以快速在项目窗口中定位当前指定的纹理资源、重新导入纹理、或者打开纹理导入设置界面。这些便捷功能大大提高了着色器开发的工作效率,特别是在需要频繁调整纹理设置的工作流程中。

对于团队协作和资源管理,对象字段的引用机制确保了 Shader Graph 与纹理资源之间的依赖关系能够被 Unity 的资产数据库正确跟踪。这使得资源打包、依赖分析和内存管理等功能能够正常工作。当构建项目时,所有通过 Texture 3D Asset 节点引用的 3D 纹理资源都会被自动包含在构建中,无需手动管理这些依赖关系。

对象字段的另一个重要特性是它对默认资源的支持。在创建新的 Texture 3D Asset 节点时,字段初始状态为空,此时节点不会输出有效的纹理引用。开发者必须显式地指定一个 3D 纹理资源,否则在着色器编译时会生成错误。这种显式的要求确保了着色器行为的明确性,避免了因缺少资源而导致的意外行为。

生成的代码示例

当 Shader Graph 编译为实际的着色器代码时,Texture 3D Asset 节点会生成对应的 HLSL 代码。理解这些生成的代码对于深入掌握节点的工作原理和进行高级着色器开发具有重要意义。

基础代码结构

典型的 Texture 3D Asset 节点会生成如下形式的 HLSL 代码:

HLSL

TEXTURE3D(_Texture3DAsset);
SAMPLER(sampler_Texture3DAsset);

这段代码包含两个关键部分:纹理声明和采样器声明。TEXTURE3D(_Texture3DAsset) 宏声明了一个 3D 纹理对象,其中的 _Texture3DAsset 是纹理的标识符名称。这个标识符在默认情况下由系统自动生成,但也可以根据命名规则进行预测。在实际编译过程中,Unity 会根据 Shader Graph 的整体结构和设置来确定最终的标识符命名。

SAMPLER(sampler_Texture3DAsset) 声明了与纹理关联的采样器状态。采样器定义了纹理采样时的各种参数,如过滤模式、寻址模式等。在现代图形 API 中,纹理和采样器通常是分离的,这种设计允许多个纹理共享同一个采样器状态,提高了资源的灵活性。

编译时处理

在着色器编译过程中,Unity 会对 Texture 3D Asset 节点进行一系列处理和优化。首先,系统会检查指定的 3D 纹理资源是否存在且有效。如果资源丢失或类型不正确,编译器会生成错误并停止编译。这种严格的验证确保了最终着色器的可靠性。

对于纹理的导入设置,如 mipmap、压缩格式、各向异性过滤等,Unity 会在编译时考虑这些设置并生成相应的采样代码。例如,如果纹理启用了 mipmap,生成的采样代码会自动包含 mipmap 级别的计算和选择逻辑。这些细节对 Shader Graph 用户是透明的,但了解其背后的机制有助于更好地优化纹理设置。

运行时行为

在运行时,生成的着色器代码通过 Unity 的材质系统与实际的纹理资源进行绑定。当材质被渲染时,Unity 的渲染管线会确保在绘制调用之前,所有引用的纹理资源都被正确设置到对应的纹理单元中。这个过程是自动管理的,开发者通常不需要关心具体的实现细节。

对于不同的渲染管线和平台,Unity 可能会生成不同的底层代码。例如,在支持 Bindless Texture 的平台上,可能会使用更高效的纹理绑定方式。这些平台特定的优化由 Unity 自动处理,确保了着色器在不同环境下都能获得最佳性能。

高级用法代码模式

在复杂的着色器中,可能会遇到多个 Texture 3D Asset 节点协同工作的情况。这种情况下生成的代码会包含多个纹理和采样器声明:

HLSL

// 第一个3D纹理
TEXTURE3D(_Texture3DAsset);
SAMPLER(sampler_Texture3DAsset);

// 第二个3D纹理
TEXTURE3D(_SecondaryVolumeTexture);
SAMPLER(sampler_SecondaryVolumeTexture);

这种模式允许在同一个着色器中使用多个 3D 纹理,比如一个用于基础颜色,另一个用于法线或高度信息。在性能方面需要注意,同时使用多个 3D 纹理可能会增加内存带宽需求,特别是在移动设备上需要谨慎使用。

与 Properties 的关联

虽然 Texture 3D Asset 节点在 Shader Graph 中表现为常量资源引用,但在某些情况下,开发者可能希望将 3D 纹理暴露为材质的可配置属性。这种情况下,需要使用 Texture 3D 类型的 Property 节点,而不是 Texture 3D Asset 节点。两者的代码生成有所不同:

HLSL

// 通过Property暴露的3D纹理
TEXTURE3D(_CustomVolumeTexture);
SAMPLER(sampler_CustomVolumeTexture);
float4 _CustomVolumeTexture_ST; // 自动生成的缩放偏移参数

Property 节点会额外生成纹理的缩放偏移参数(_ST),这使得材质可以在运行时动态调整纹理的变换参数。理解这种区别对于选择正确的节点类型非常重要。

优化考虑

从生成的代码角度来看,有几个性能优化的考虑点。首先,尽可能复用同一个 Texture 3D Asset 节点,而不是创建多个引用相同资源的节点。这减少了着色器中纹理声明的数量,可能会带来微小的性能提升。

其次,注意纹理的采样器设置。通过 Unity 的采样器状态管理,可以确保相似的采样设置共享采样器状态,减少状态切换的开销。在 Shader Graph 中,这通常通过采样器节点的设置来控制。

最后,考虑到不同平台的特性,生成的代码可能会有所差异。在编写跨平台着色器时,应该测试在不同设备上的表现,确保性能特征符合预期。Unity 提供的帧调试器和渲染诊断工具可以帮助分析纹理相关的性能问题。

实际应用示例

基础体积材质创建

创建一个简单的体积材质是理解 Texture 3D Asset 节点用法的良好起点。假设我们需要创建一个具有内部结构的大理石材质,可以使用 3D 纹理来模拟石材内部的矿物分布。

首先在项目中准备或创建一个合适的 3D 纹理资源。这个纹理应该包含大理石内部结构的体积数据,通常可以通过程序化生成或从真实数据扫描获得。在 Unity 中导入这个纹理时,需要确保纹理类型设置为"3D Texture",并根据需要配置 mipmap、压缩格式等导入设置。

在 Shader Graph 中创建新的着色器图,添加 Texture 3D Asset 节点。通过节点的对象字段控件指定刚才导入的 3D 纹理资源。此时节点的 Out 端口已经可以输出对该纹理的引用。接下来添加 Sample Texture 3D 节点,将其 Texture 输入端口连接到 Texture 3D Asset 节点的输出。Sample Texture 3D 节点的 UVW 输入需要提供三维纹理坐标,这通常来自物体的世界位置或物体空间位置经过适当变换后的结果。

将 Sample Texture 3D 节点的 RGB 输出连接到主着色器的 Base Color 输入,就可以看到 3D 纹理对物体着色的基础效果。通过调整 Sample Texture 3D 节点的采样参数和 UVW 输入的变换方式,可以控制纹理在物体表面的表现方式和密度。

复杂效果组合

3D 纹理的真正威力在于与其他着色器功能的组合使用。例如,可以结合使用多个 Texture 3D Asset 节点来创建复杂的多层体积效果。

考虑一个高级的云层渲染示例。首先需要两个 3D 纹理:一个用于云层的基本密度分布,另一个用于云层的细节扰动。创建两个 Texture 3D Asset 节点分别引用这两个纹理资源。对基础密度纹理进行采样得到基本的云层形状,对细节纹理进行采样并使用时间变量进行动画化,然后将两者按照适当的比例混合。

这种技术的核心在于理解 3D 纹理采样坐标的变换和组合。基础纹理可以使用较大尺度的世界坐标,而细节纹理使用较小尺度的坐标并加上时间变量。通过适当的混合函数(如加法、乘法或屏幕混合)组合两个采样结果,可以创建出动态且富有细节的云层效果。

进一步地,可以将结果与光照计算结合。使用 3D 纹理采样结果作为体积散射计算的输入,结合方向光信息计算光照衰减和散射效果。这种技术能够产生非常逼真的体积光照,适用于烟雾、云层和其他参与介质效果的渲染。

性能优化实践

在使用 Texture 3D Asset 节点时,性能优化是一个重要的考虑因素。3D 纹理由于包含更多的数据量,通常比同等分辨率的 2D 纹理需要更多的内存和采样开销。

一个关键的优化策略是合理选择 3D 纹理的分辨率。对于不需要高频细节的体积数据,可以使用较低的分辨率。例如,64×64×64 的 3D 纹理在大多数情况下已经能够提供不错的质量,而内存占用只有同等 2D 纹理的 1/64(相对于 4096×4096 的 2D 纹理)。

另一个重要的优化是 mipmap 的使用。对于在深度方向有较大变化的体积效果,启用 mipmap 可以显著提高采样的缓存效率。但是需要注意 mipmap 会增加约 33% 的内存占用,需要在质量和性能之间做出权衡。

在 Shader Graph 中,可以通过适当组织节点结构来优化性能。例如,如果多个 Sample Texture 3D 节点使用相同的纹理但不同的采样参数,应该让它们共享同一个 Texture 3D Asset 节点,而不是每个采样节点都连接自己独立的纹理资源节点。这种共享减少了着色器中纹理绑定的数量,可能带来性能提升。

对于移动平台,还需要特别注意纹理压缩和格式选择。ASTC 压缩格式对 3D 纹理通常有较好的支持,可以在保持可接受质量的同时显著减少内存占用。同时,应该避免在片段着色器中进行过于复杂的 3D 纹理采样操作,特别是在低端移动设备上。

调试和问题排查

在使用 Texture 3D Asset 节点时可能会遇到各种问题,掌握有效的调试方法非常重要。一个常见的问题是纹理显示为粉色,这通常表示纹理资源丢失或类型不匹配。检查 Texture 3D Asset 节点的对象字段是否正确指定了 3D 纹理资源,并确认该资源在项目中确实存在且导入设置正确。

另一个常见问题是纹理采样结果不符合预期。这可能是由于采样坐标不正确造成的。可以通过可视化采样坐标来调试这个问题——将 UVW 坐标的各个分量分别输出为颜色,检查坐标范围是否合理。正常情况下,采样坐标应该在[0,1]范围内,超出这个范围的行为取决于纹理的 Wrap Mode 设置。

性能问题也是需要关注的重点。如果发现使用 3D 纹理后帧率显著下降,可以使用 Unity 的 Profiler 工具分析渲染耗时。特别关注纹理采样指令的数量和耗时,以及纹理内存的占用情况。如果发现问题,可以考虑降低纹理分辨率、优化采样次数或使用更高效的纹理格式。

对于高级用户,还可以使用 RenderDoc 等图形调试工具深入分析着色器的执行情况。这些工具可以显示每个绘制调用中纹理的实际绑定状态和采样结果,帮助定位复杂问题的根本原因。

最佳实践和高级技巧

资源管理策略

有效的资源管理是成功使用 Texture 3D Asset 节点的关键。首先,建立统一的 3D 纹理命名和组织规范。由于 3D 纹理在项目中可能不像 2D 纹理那样常见,清晰的组织结构可以避免混淆和提高工作效率。

在内存管理方面,注意 3D 纹理的加载和卸载时机。大型 3D 纹理可能会占用显著的内存空间,应该通过 Unity 的资源管理系统确保它们只在需要时加载。可以使用 Addressables 系统或传统的 Resources 文件夹来管理 3D 纹理的加载生命周期。

对于需要动态生成的 3D 纹理,Unity 提供了 Texture3D.Create 方法和支持 Compute Shader 的更新方式。这些高级用法允许在运行时生成或修改 3D 纹理。


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

Vue3 路由实战 | Vue Router 从 0 到 1 搭建权限管理系统

作者 代码煮茶
2026年3月28日 13:23

Vue3 路由实战 | Vue Router 从 0 到 1 搭建权限管理系统

零、为什么路由权限是企业级项目的“灵魂”?

你有没有遇到过这样的场景:

// 用户A登录后,看到了“用户管理”菜单
// 用户B登录后,菜单栏里没有“用户管理”

// 更离谱的是:用户B虽然看不到菜单,但直接输入URL:
// /user/manage
// 页面居然能打开!——这是巨大的安全漏洞!

企业级项目的核心诉求:用户能看到什么,取决于他有什么权限。这不只是UI层面的隐藏,更是路由层面的拦截。

今天,我们就来搭建一个完整的权限路由系统,包含:

  • 登录拦截
  • 动态路由生成
  • 菜单权限控制
  • 按钮级权限

一、路由基础:从0到1的快速回顾

1.1 安装与基础配置

npm install vue-router@4
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'

// 静态路由(任何人都能访问)
export const constantRoutes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/Login.vue'),
    meta: { title: '登录', requiresAuth: false }
  },
  {
    path: '/404',
    name: 'NotFound',
    component: () => import('@/views/error/404.vue'),
    meta: { title: '404', requiresAuth: false }
  },
  {
    path: '/',
    redirect: '/dashboard',
    meta: { requiresAuth: true }
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: () => import('@/views/Dashboard.vue'),
    meta: { 
      title: '仪表盘', 
      icon: 'dashboard',
      requiresAuth: true,
      permissions: ['dashboard:view']  // 需要的权限
    }
  }
]

// 动态路由(根据权限动态添加)
export const asyncRoutes: RouteRecordRaw[] = [
  {
    path: '/user',
    name: 'User',
    component: () => import('@/layout/index.vue'),
    meta: { title: '用户管理', icon: 'user', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'UserList',
        component: () => import('@/views/user/List.vue'),
        meta: { 
          title: '用户列表', 
          permissions: ['user:list'],
          requiresAuth: true 
        }
      },
      {
        path: 'role',
        name: 'RoleList',
        component: () => import('@/views/user/Role.vue'),
        meta: { 
          title: '角色管理', 
          permissions: ['role:list'],
          requiresAuth: true 
        }
      }
    ]
  },
  {
    path: '/product',
    name: 'Product',
    component: () => import('@/layout/index.vue'),
    meta: { title: '商品管理', icon: 'product', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'ProductList',
        component: () => import('@/views/product/List.vue'),
        meta: { 
          title: '商品列表', 
          permissions: ['product:list'],
          requiresAuth: true 
        }
      },
      {
        path: 'category',
        name: 'CategoryList',
        component: () => import('@/views/product/Category.vue'),
        meta: { 
          title: '分类管理', 
          permissions: ['category:list'],
          requiresAuth: true 
        }
      }
    ]
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes: constantRoutes,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    } else {
      return { top: 0 }
    }
  }
})

export default router

1.2 路由元信息(meta)的妙用

// 定义路由元信息类型
declare module 'vue-router' {
  interface RouteMeta {
    title?: string          // 页面标题
    icon?: string           // 菜单图标
    requiresAuth?: boolean  // 是否需要登录
    permissions?: string[]  // 需要的权限列表
    hidden?: boolean        // 是否在菜单中隐藏
    keepAlive?: boolean     // 是否缓存
    breadcrumb?: boolean    // 是否显示面包屑
    activeMenu?: string     // 高亮的菜单(用于详情页)
  }
}

二、路由守卫:权限控制的守门员

2.1 全局前置守卫

// src/router/index.ts
import { useUserStore } from '@/stores/modules/user'
import { ElMessage } from 'element-plus'

// 白名单:不需要登录就能访问的页面
const whiteList = ['/login', '/404', '/register', '/forget-password']

router.beforeEach(async (to, from, next) => {
  // 设置页面标题
  document.title = to.meta.title ? `${to.meta.title} - 后台管理系统` : '后台管理系统'
  
  const userStore = useUserStore()
  const hasToken = userStore.token
  
  // 1. 如果有 token
  if (hasToken) {
    if (to.path === '/login') {
      // 已登录,访问登录页 → 重定向到首页
      next({ path: '/' })
    } else {
      // 检查是否已经获取过用户信息
      if (userStore.userInfo === null) {
        try {
          // 获取用户信息
          await userStore.fetchUserInfo()
          
          // 根据权限生成动态路由
          const accessRoutes = await generateRoutes(userStore.permissions)
          accessRoutes.forEach(route => {
            router.addRoute(route)
          })
          
          // 解决动态路由刷新后404问题
          next({ ...to, replace: true })
        } catch (error) {
          // token 无效,清除并跳转登录
          await userStore.logout()
          ElMessage.error('登录已过期,请重新登录')
          next(`/login?redirect=${to.path}`)
        }
      } else {
        // 已有用户信息,直接放行
        next()
      }
    }
  } 
  // 2. 没有 token
  else {
    if (whiteList.includes(to.path)) {
      // 在白名单中,直接放行
      next()
    } else {
      // 不在白名单,跳转登录页
      next(`/login?redirect=${to.path}`)
    }
  }
})

2.2 全局后置守卫

// 路由跳转完成后
router.afterEach((to, from) => {
  // 关闭页面加载动画
  // 上报页面访问数据
  // 等等...
  
  // 滚动到顶部(除了需要保持滚动位置的情况)
  if (to.hash) {
    const element = document.querySelector(to.hash)
    if (element) element.scrollIntoView()
  } else {
    window.scrollTo(0, 0)
  }
})

2.3 路由独享守卫

// 在路由配置中单独配置
{
  path: '/settings',
  component: () => import('@/views/Settings.vue'),
  beforeEnter: (to, from, next) => {
    // 检查用户是否有权限访问设置页面
    const userStore = useUserStore()
    if (userStore.userRole === 'admin') {
      next()
    } else {
      next('/403')
    }
  }
}

三、动态路由:根据权限生成菜单

3.1 生成动态路由的核心逻辑

// src/router/utils/dynamicRoutes.ts
import type { RouteRecordRaw } from 'vue-router'
import { asyncRoutes } from '@/router'

/**
 * 根据权限过滤路由
 * @param routes 路由列表
 * @param permissions 用户权限列表
 */
export function filterRoutesByPermissions(
  routes: RouteRecordRaw[],
  permissions: string[]
): RouteRecordRaw[] {
  return routes.filter(route => {
    // 检查当前路由是否需要权限
    if (route.meta?.permissions) {
      // 判断用户是否有任一所需权限
      const hasPermission = route.meta.permissions.some(perm => 
        permissions.includes(perm)
      )
      if (!hasPermission) return false
    }
    
    // 递归过滤子路由
    if (route.children) {
      route.children = filterRoutesByPermissions(route.children, permissions)
      // 如果子路由全部被过滤掉,则当前路由也不显示
      if (route.children.length === 0 && route.meta?.permissions) {
        return false
      }
    }
    
    return true
  })
}

/**
 * 将后端返回的权限树转换为路由
 * @param menus 后端返回的菜单树
 */
export function convertMenusToRoutes(menus: any[]): RouteRecordRaw[] {
  return menus.map(menu => {
    const route: RouteRecordRaw = {
      path: menu.path,
      name: menu.name,
      component: loadComponent(menu.component),
      meta: {
        title: menu.title,
        icon: menu.icon,
        permissions: menu.permissions,
        hidden: menu.hidden
      }
    }
    
    if (menu.children && menu.children.length > 0) {
      route.children = convertMenusToRoutes(menu.children)
    }
    
    return route
  })
}

/**
 * 懒加载组件
 */
function loadComponent(componentPath: string) {
  // 返回一个函数,Vue Router 会异步加载
  return () => import(`@/views/${componentPath}.vue`)
}

3.2 在路由守卫中生成动态路由

// src/router/index.ts
let hasAddedDynamicRoutes = false

router.beforeEach(async (to, from, next) => {
  const userStore = useUserStore()
  const hasToken = userStore.token
  
  if (hasToken) {
    if (to.path === '/login') {
      next({ path: '/' })
    } else {
      if (!hasAddedDynamicRoutes && userStore.userInfo) {
        try {
          // 方式一:前端定义路由,根据权限过滤
          const accessRoutes = filterRoutesByPermissions(
            asyncRoutes, 
            userStore.permissions
          )
          
          // 方式二:后端返回路由,动态添加
          // const accessRoutes = convertMenusToRoutes(userStore.menus)
          
          // 添加动态路由
          accessRoutes.forEach(route => {
            router.addRoute(route)
          })
          
          // 添加404路由(必须放在最后)
          router.addRoute({
            path: '/:pathMatch(.*)*',
            name: 'NotFound',
            component: () => import('@/views/error/404.vue')
          })
          
          hasAddedDynamicRoutes = true
          
          // 重新跳转,确保路由已添加
          next({ ...to, replace: true })
        } catch (error) {
          console.error('生成动态路由失败:', error)
          await userStore.logout()
          next(`/login?redirect=${to.path}`)
        }
      } else {
        next()
      }
    }
  } else {
    // 没有 token 的处理...
    if (whiteList.includes(to.path)) {
      next()
    } else {
      next(`/login?redirect=${to.path}`)
    }
  }
})

3.3 根据路由生成菜单

<!-- components/SidebarMenu.vue -->
<template>
  <el-menu
    :default-active="activeMenu"
    :collapse="isCollapse"
    :unique-opened="true"
    background-color="#304156"
    text-color="#bfcbd9"
    active-text-color="#409eff"
    router
  >
    <template v-for="route in menuRoutes" :key="route.path">
      <!-- 单级菜单 -->
      <el-menu-item 
        v-if="!route.children || route.children.length === 0"
        :index="route.path"
      >
        <el-icon><component :is="route.meta?.icon" /></el-icon>
        <template #title>
          <span>{{ route.meta?.title }}</span>
        </template>
      </el-menu-item>
      
      <!-- 多级菜单(递归) -->
      <el-sub-menu 
        v-else
        :index="route.path"
      >
        <template #title>
          <el-icon><component :is="route.meta?.icon" /></el-icon>
          <span>{{ route.meta?.title }}</span>
        </template>
        <sidebar-menu-item 
          v-for="child in route.children"
          :key="child.path"
          :route="child"
        />
      </el-sub-menu>
    </template>
  </el-menu>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAppStore } from '@/stores/modules/app'
import { useUserStore } from '@/stores/modules/user'
import type { RouteRecordRaw } from 'vue-router'

const route = useRoute()
const appStore = useAppStore()
const userStore = useUserStore()

const isCollapse = computed(() => appStore.sidebarCollapsed)
const activeMenu = computed(() => {
  const { path, meta } = route
  // 如果路由有 activeMenu 配置,则高亮指定菜单
  if (meta.activeMenu) {
    return meta.activeMenu
  }
  return path
})

// 获取需要显示的菜单路由
const menuRoutes = computed(() => {
  // 从 router 中获取动态添加的路由
  const routes = router.getRoutes()
  
  // 过滤掉不需要在菜单中显示的路由
  return routes.filter(route => {
    return route.meta?.title && !route.meta?.hidden
  })
})
</script>

四、路由懒加载:让首屏飞起来

4.1 基础懒加载

// 标准写法
const UserList = () => import('@/views/user/List.vue')

// 带 loading 的写法
const UserList = () => ({
  component: import('@/views/user/List.vue'),
  loading: LoadingComponent,
  error: ErrorComponent,
  delay: 200,
  timeout: 3000
})

4.2 路由分组(chunk)

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // 将 Vue 相关打包在一起
          'vendor-vue': ['vue', 'vue-router', 'pinia'],
          // 将 UI 库单独打包
          'vendor-element': ['element-plus'],
          // 将工具库打包
          'vendor-utils': ['axios', 'dayjs', 'lodash-es'],
          // 将路由页面按模块分组
          'routes-user': [
            './src/views/user/List.vue',
            './src/views/user/Role.vue'
          ],
          'routes-product': [
            './src/views/product/List.vue',
            './src/views/product/Category.vue'
          ]
        }
      }
    }
  }
})

4.3 预加载策略

<!-- index.html 中添加预加载链接 -->
<link rel="prefetch" href="/assets/js/dashboard.xxx.js">
// 使用 webpack/vite 的魔法注释
const UserList = () => import(
  /* webpackChunkName: "user-list" */
  /* webpackPrefetch: true */
  '@/views/user/List.vue'
)

五、实战:后台管理系统完整路由模块

5.1 项目结构

src/
├── router/
│   ├── index.ts                 # 路由主文件
│   ├── modules/                 # 路由模块
│   │   ├── user.ts              # 用户模块路由
│   │   ├── product.ts           # 商品模块路由
│   │   └── order.ts             # 订单模块路由
│   ├── guards/                  # 路由守卫
│   │   ├── auth.ts              # 认证守卫
│   │   ├── permission.ts        # 权限守卫
│   │   └── progress.ts          # 进度条守卫
│   └── utils/                   # 路由工具
│       ├── dynamicRoutes.ts     # 动态路由生成
│       └── permissions.ts       # 权限过滤
├── layout/
│   ├── index.vue                # 主布局
│   ├── Sidebar.vue              # 侧边栏
│   └── Header.vue               # 头部
└── views/
    ├── login/
    │   └── index.vue
    ├── dashboard/
    │   └── index.vue
    ├── user/
    │   ├── List.vue
    │   └── Role.vue
    └── error/
        ├── 401.vue
        ├── 403.vue
        └── 404.vue

5.2 完整路由配置

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { Router, RouteRecordRaw } from 'vue-router'
import { useUserStore } from '@/stores/modules/user'
import { useAppStore } from '@/stores/modules/app'
import { ElMessage } from 'element-plus'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'

// 配置进度条
NProgress.configure({ showSpinner: false })

// 静态路由
export const constantRoutes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/login/index.vue'),
    meta: { title: '登录', requiresAuth: false }
  },
  {
    path: '/401',
    name: 'Unauthorized',
    component: () => import('@/views/error/401.vue'),
    meta: { title: '未授权', requiresAuth: false }
  },
  {
    path: '/403',
    name: 'Forbidden',
    component: () => import('@/views/error/403.vue'),
    meta: { title: '无权限', requiresAuth: false }
  },
  {
    path: '/404',
    name: 'NotFound',
    component: () => import('@/views/error/404.vue'),
    meta: { title: '页面不存在', requiresAuth: false }
  },
  {
    path: '/',
    component: () => import('@/layout/index.vue'),
    redirect: '/dashboard',
    meta: { requiresAuth: true },
    children: [
      {
        path: 'dashboard',
        name: 'Dashboard',
        component: () => import('@/views/dashboard/index.vue'),
        meta: { 
          title: '仪表盘', 
          icon: 'Odometer',
          affix: true,
          requiresAuth: true 
        }
      }
    ]
  }
]

// 动态路由(需要权限)
export const asyncRoutes: RouteRecordRaw[] = [
  {
    path: '/user',
    component: () => import('@/layout/index.vue'),
    meta: { title: '用户管理', icon: 'User', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'UserList',
        component: () => import('@/views/user/List.vue'),
        meta: { 
          title: '用户列表', 
          permissions: ['user:list'],
          keepAlive: true,
          requiresAuth: true 
        }
      },
      {
        path: 'role',
        name: 'RoleList',
        component: () => import('@/views/user/Role.vue'),
        meta: { 
          title: '角色管理', 
          permissions: ['role:list'],
          requiresAuth: true 
        }
      },
      {
        path: 'permission',
        name: 'PermissionList',
        component: () => import('@/views/user/Permission.vue'),
        meta: { 
          title: '权限管理', 
          permissions: ['permission:list'],
          requiresAuth: true 
        }
      }
    ]
  },
  {
    path: '/product',
    component: () => import('@/layout/index.vue'),
    meta: { title: '商品管理', icon: 'Goods', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'ProductList',
        component: () => import('@/views/product/List.vue'),
        meta: { 
          title: '商品列表', 
          permissions: ['product:list'],
          keepAlive: true,
          requiresAuth: true 
        }
      },
      {
        path: 'category',
        name: 'CategoryList',
        component: () => import('@/views/product/Category.vue'),
        meta: { 
          title: '分类管理', 
          permissions: ['category:list'],
          requiresAuth: true 
        }
      },
      {
        path: 'detail/:id',
        name: 'ProductDetail',
        component: () => import('@/views/product/Detail.vue'),
        meta: { 
          title: '商品详情', 
          hidden: true,  // 不在菜单中显示
          activeMenu: '/product/list', // 高亮商品列表菜单
          requiresAuth: true 
        }
      }
    ]
  },
  {
    path: '/order',
    component: () => import('@/layout/index.vue'),
    meta: { title: '订单管理', icon: 'Document', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'OrderList',
        component: () => import('@/views/order/List.vue'),
        meta: { 
          title: '订单列表', 
          permissions: ['order:list'],
          keepAlive: true,
          requiresAuth: true 
        }
      },
      {
        path: 'refund',
        name: 'RefundList',
        component: () => import('@/views/order/Refund.vue'),
        meta: { 
          title: '退款管理', 
          permissions: ['order:refund'],
          requiresAuth: true 
        }
      }
    ]
  },
  {
    path: '/settings',
    component: () => import('@/layout/index.vue'),
    meta: { title: '系统设置', icon: 'Setting', requiresAuth: true, roles: ['admin'] },
    children: [
      {
        path: 'profile',
        name: 'Profile',
        component: () => import('@/views/settings/Profile.vue'),
        meta: { title: '个人设置', requiresAuth: true }
      },
      {
        path: 'account',
        name: 'Account',
        component: () => import('@/views/settings/Account.vue'),
        meta: { title: '账号管理', roles: ['admin'], requiresAuth: true }
      }
    ]
  }
]

// 创建路由实例
const router = createRouter({
  history: createWebHistory(),
  routes: constantRoutes,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    } else {
      return { top: 0 }
    }
  }
})

// 标记是否已添加动态路由
let hasAddedRoutes = false

// 生成动态路由
async function generateDynamicRoutes(permissions: string[], roles: string[]) {
  // 根据权限过滤路由
  const filterRoutes = (routes: RouteRecordRaw[]): RouteRecordRaw[] => {
    return routes.filter(route => {
      // 检查角色权限
      if (route.meta?.roles && !route.meta.roles.some((role: string) => roles.includes(role))) {
        return false
      }
      
      // 检查按钮权限
      if (route.meta?.permissions) {
        const hasPermission = route.meta.permissions.some((perm: string) => 
          permissions.includes(perm)
        )
        if (!hasPermission) return false
      }
      
      // 递归过滤子路由
      if (route.children) {
        route.children = filterRoutes(route.children)
        if (route.children.length === 0 && route.meta?.permissions) {
          return false
        }
      }
      
      return true
    })
  }
  
  const accessibleRoutes = filterRoutes(asyncRoutes)
  
  // 动态添加路由
  accessibleRoutes.forEach(route => {
    router.addRoute(route)
  })
  
  // 添加404兜底路由
  router.addRoute({
    path: '/:pathMatch(.*)*',
    name: 'NotFound',
    component: () => import('@/views/error/404.vue')
  })
  
  return accessibleRoutes
}

// 全局前置守卫
router.beforeEach(async (to, from, next) => {
  // 开始进度条
  NProgress.start()
  
  const userStore = useUserStore()
  const appStore = useAppStore()
  const hasToken = userStore.token
  
  // 设置页面标题
  if (to.meta.title) {
    document.title = `${to.meta.title} - ${appStore.siteTitle}`
  }
  
  if (hasToken) {
    // 已登录
    if (to.path === '/login') {
      // 跳转到首页
      next({ path: '/' })
      NProgress.done()
    } else {
      // 检查是否已获取用户信息
      if (userStore.userInfo === null) {
        try {
          // 获取用户信息
          await userStore.fetchUserInfo()
          
          // 生成动态路由
          const routes = await generateDynamicRoutes(
            userStore.permissions,
            userStore.roles
          )
          
          // 保存路由到 store(用于生成菜单)
          userStore.setRoutes(routes)
          
          // 解决动态路由刷新后404问题
          next({ ...to, replace: true })
        } catch (error) {
          console.error('路由初始化失败:', error)
          await userStore.logout()
          next(`/login?redirect=${to.path}`)
          NProgress.done()
        }
      } else {
        // 检查路由权限
        if (to.meta.requiresAuth) {
          // 检查角色权限
          if (to.meta.roles && !to.meta.roles.some(role => userStore.roles.includes(role))) {
            next('/403')
            NProgress.done()
            return
          }
          
          // 检查按钮权限
          if (to.meta.permissions) {
            const hasPermission = to.meta.permissions.some(perm => 
              userStore.permissions.includes(perm)
            )
            if (!hasPermission) {
              next('/403')
              NProgress.done()
              return
            }
          }
        }
        next()
      }
    }
  } else {
    // 未登录
    if (to.meta.requiresAuth) {
      next(`/login?redirect=${to.path}`)
      NProgress.done()
    } else {
      next()
    }
  }
})

// 全局后置守卫
router.afterEach(() => {
  // 结束进度条
  NProgress.done()
})

// 重置路由(用于退出登录)
export function resetRouter() {
  // 获取所有动态添加的路由
  const routes = router.getRoutes()
  routes.forEach(route => {
    const name = route.name as string
    // 排除静态路由
    if (!constantRoutes.some(r => r.name === name)) {
      router.removeRoute(name)
    }
  })
  hasAddedRoutes = false
}

export default router

5.3 登录页面实现

<!-- views/login/index.vue -->
<template>
  <div class="login-container">
    <el-form
      ref="loginFormRef"
      :model="loginForm"
      :rules="loginRules"
      class="login-form"
    >
      <h3 class="title">后台管理系统</h3>
      
      <el-form-item prop="username">
        <el-input
          v-model="loginForm.username"
          placeholder="用户名"
          :prefix-icon="User"
          size="large"
        />
      </el-form-item>
      
      <el-form-item prop="password">
        <el-input
          v-model="loginForm.password"
          type="password"
          placeholder="密码"
          :prefix-icon="Lock"
          size="large"
          show-password
          @keyup.enter="handleLogin"
        />
      </el-form-item>
      
      <el-form-item>
        <el-button
          :loading="loading"
          type="primary"
          size="large"
          class="login-btn"
          @click="handleLogin"
        >
          登录
        </el-button>
      </el-form-item>
      
      <div class="tips">
        <span>测试账号:admin / 123456</span>
        <span class="ml-10">普通账号:user / 123456</span>
      </div>
    </el-form>
  </div>
</template>

<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/modules/user'

const router = useRouter()
const route = useRoute()
const userStore = useUserStore()

const loginForm = reactive({
  username: 'admin',
  password: '123456'
})

const loginRules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
  ]
}

const loginFormRef = ref()
const loading = ref(false)

const handleLogin = async () => {
  if (!loginFormRef.value) return
  
  await loginFormRef.value.validate(async (valid: boolean) => {
    if (!valid) return
    
    loading.value = true
    try {
      const success = await userStore.login(loginForm)
      if (success) {
        const redirect = route.query.redirect as string || '/'
        router.push(redirect)
        ElMessage.success('登录成功')
      }
    } catch (error) {
      console.error('登录失败:', error)
    } finally {
      loading.value = false
    }
  })
}
</script>

<style scoped lang="scss">
.login-container {
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  
  .login-form {
    width: 400px;
    padding: 40px;
    background: #fff;
    border-radius: 8px;
    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
    
    .title {
      text-align: center;
      margin-bottom: 30px;
      color: #333;
    }
    
    .login-btn {
      width: 100%;
    }
    
    .tips {
      text-align: center;
      color: #999;
      font-size: 12px;
      
      span {
        display: inline-block;
      }
      
      .ml-10 {
        margin-left: 10px;
      }
    }
  }
}
</style>

5.4 按钮级权限指令

// src/directives/permission.ts
import type { App, Directive } from 'vue'
import { useUserStore } from '@/stores/modules/user'

// 权限指令 v-permission="['user:add']"
const permissionDirective: Directive = {
  mounted(el, binding) {
    const { value } = binding
    const userStore = useUserStore()
    
    if (value && Array.isArray(value) && value.length > 0) {
      const hasPermission = value.some(perm => 
        userStore.permissions.includes(perm)
      )
      
      if (!hasPermission) {
        el.parentNode?.removeChild(el)
      }
    }
  }
}

export function setupPermissionDirective(app: App) {
  app.directive('permission', permissionDirective)
}
<!-- 在组件中使用 -->
<template>
  <div>
    <!-- 只有拥有 user:add 权限才能看到添加按钮 -->
    <el-button v-permission="['user:add']" type="primary">
      添加用户
    </el-button>
    
    <!-- 拥有任一权限即可 -->
    <el-button v-permission="['user:edit', 'user:delete']">
      操作
    </el-button>
  </div>
</template>

六、进阶:路由缓存与标签页

6.1 多标签页功能

// stores/modules/tabs.ts
import { defineStore } from 'pinia'
import type { RouteLocationNormalized } from 'vue-router'

interface TabItem {
  name: string
  title: string
  path: string
  query?: Record<string, any>
  params?: Record<string, any>
}

export const useTabsStore = defineStore('tabs', {
  state: () => ({
    visitedTabs: [] as TabItem[],
    activeTab: ''
  }),
  
  actions: {
    addTab(route: RouteLocationNormalized) {
      // 过滤掉不需要缓存的路由
      if (route.meta?.hidden || route.meta?.noCache) return
      
      const tab: TabItem = {
        name: route.name as string,
        title: route.meta?.title as string,
        path: route.path,
        query: route.query,
        params: route.params
      }
      
      const exists = this.visitedTabs.some(item => item.path === tab.path)
      if (!exists) {
        this.visitedTabs.push(tab)
      }
      
      this.activeTab = tab.path
    },
    
    removeTab(path: string) {
      const index = this.visitedTabs.findIndex(tab => tab.path === path)
      if (index > -1) {
        this.visitedTabs.splice(index, 1)
      }
      
      // 如果删除的是当前激活的标签,跳转到上一个标签
      if (this.activeTab === path) {
        const lastTab = this.visitedTabs[index - 1] || this.visitedTabs[0]
        if (lastTab) {
          this.activeTab = lastTab.path
          return lastTab
        }
      }
      return null
    },
    
    closeOtherTabs(path: string) {
      this.visitedTabs = this.visitedTabs.filter(tab => tab.path === path)
      this.activeTab = path
    },
    
    closeAllTabs() {
      this.visitedTabs = []
      this.activeTab = ''
    }
  }
})

七、常见问题与解决方案

7.1 动态路由刷新后404

// 问题:刷新页面后,动态添加的路由丢失
// 解决:在路由守卫中重新添加

router.beforeEach(async (to, from, next) => {
  // ... 省略其他代码
  
  if (!hasAddedRoutes && userStore.userInfo) {
    // 重新添加动态路由
    await generateDynamicRoutes(userStore.permissions, userStore.roles)
    // 关键:replace 当前路由,重新触发守卫
    next({ ...to, replace: true })
    return
  }
  
  next()
})

7.2 路由权限缓存

// 使用 sessionStorage 缓存用户路由
const cacheKey = `user-routes-${userStore.userId}`

// 保存
sessionStorage.setItem(cacheKey, JSON.stringify(accessibleRoutes))

// 恢复
const cachedRoutes = sessionStorage.getItem(cacheKey)
if (cachedRoutes) {
  const routes = JSON.parse(cachedRoutes)
  routes.forEach(route => router.addRoute(route))
}

八、总结

一个完整的权限路由系统包含:

  1. 静态路由:登录页、404页等公共页面
  2. 动态路由:根据权限动态添加
  3. 路由守卫:登录拦截、权限校验
  4. 菜单生成:根据路由自动生成侧边栏
  5. 权限指令:按钮级权限控制
  6. 路由缓存:标签页、keep-alive

核心代码量统计

  • 路由配置文件:~200行
  • 动态路由逻辑:~100行
  • 路由守卫:~150行
  • 菜单组件

Tauri 2 Linux 上 asset://localhost 访问返回 403 避坑指南

作者 ssshooter
2026年3月28日 12:27

很多人在 Tauri v2(尤其是 Linux 系统)中使用 convertFileSrc()asset://localhost 协议加载本地图片、视频、音频等资源时,经常遇到 403 Forbidden 错误。Windows/macOS 可能正常,Linux 却直接翻车。

本文把整个坑的来龙去脉、根本原因、glob 匹配规则彻底讲清楚,并给出最稳的配置方案,帮助大家一次性避坑。

一、问题现象

  • 使用 convertFileSrc(fullPath) 生成的 URL 在 <img><video><audio> 等标签中加载失败
  • 浏览器控制台报 403
  • 终端(Rust 侧)日志提示类似:
    asset protocol not configured to allow the path: /home/user/.local/share/xxx/xxx.png
    
  • 尤其容易出现在 隐藏目录(以 . 开头的目录)下:.local/share.cache.config

二、根本原因:Tauri 的 Glob Scope + Linux 隐藏目录规则

Tauri v2 的 assetProtocol.scope 使用的是 Rust globset 库实现的 glob 模式来做安全校验。只有路径匹配 scope 里的 glob,才允许浏览器通过 asset 协议访问。

最坑的一点在于,Linux(Unix-like 系统)下:

通配符 *?**默认不会匹配以 . 开头的路径(dotfiles / dotdirs),除非你在 glob 模式里字面写出 .

所以即使你写了最宽松的 "**/*",它也进不了 .local.cache 等隐藏目录,导致 403。

这不是 bug,而是 Tauri 为了安全故意设计的(和 Linux shell 的 ls * 默认不显示隐藏文件一样)。

三、Glob 模式最容易搞混的两个写法:**/ vs **/*

glob 写法 含义 能匹配什么 在 assetProtocol.scope 里的实际效果 推荐程度
**/* 递归匹配所有文件 文件(如 a.pngsub/b.mp4 ✅ 强烈推荐 ★★★★★
**/ 递归匹配所有目录 纯目录路径(如 images/sub/ ❌ 几乎没用(scope 要的是文件路径) ★☆☆☆☆

一句话总结

  • **/* = “递归所有文件”(你 99% 的情况都需要这个)
  • **/ = “递归所有目录”(基本不要单独写在 scope 里)

正确写法是 你的路径/**/* 或直接 **/*

四、正确配置(一步到位)

1. 主配置(推荐同时加 Linux 专属配置)

src-tauri/tauri.conf.json(全局):

{
  "app": {
    "security": {
      "csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost; video-src 'self' asset: http://asset.localhost; audio-src 'self' asset: http://asset.localhost; style-src 'self' 'unsafe-inline';",
      "assetProtocol": {
        "enable": true,
        "scope": [
          "**/*",
          "**/.local/share/**/*",
          "**/.cache/**/*",
          "$CACHE/**",
          "$CONFIG/**",
          "$HOME/**"
        ]
      }
    }
  }
}

src-tauri/tauri.linux.conf.json(Linux 专属,强烈建议):

{
  "app": {
    "security": {
      "assetProtocol": {
        "enable": true,
        "scope": [
          "**/*",
          "**/.local/share/**/*",
          "**/.cache/**/*",
          "$CACHE/**",
          "$CONFIG/**"
        ]
      }
    }
  }
}

这样 Windows/macOS 不会被多余的 scope 影响。

2. 代码侧使用(不变)

import { convertFileSrc } from '@tauri-apps/api/core';

const assetUrl = convertFileSrc(absoluteFilePath);

五、操作流程

  1. 按上面修改配置文件
  2. (推荐)cargo clean
  3. pnpm tauri dev(或 npm run tauri dev)测试
  4. 还是 403?看终端日志,把报错里提示的路径对应的 glob 补进去

六、额外避坑小贴士

  • 用 Tauri 内置变量 $CACHE$CONFIG 最香,自动处理平台差异
  • 如果是用户通过 dialog.open() 选择的路径,Tauri 会自动扩展 scope,但持久化路径仍需写进配置
  • 打包进 bundle 的资源不需要 assetProtocol,走 frontendDist 即可
  • Rust 版本建议 ≥ 1.77,Tauri CLI 保持最新

总结
Tauri 2 的 asset 403 坑,99% 是因为 Linux 下 glob 默认不匹配 . 开头的隐藏目录。只要把 **/* + **/.local/share/**/* + $CACHE/** 写全,问题基本秒解。

把这篇配置直接复制到你的项目里,基本不会再踩这个坑了。

希望这篇文章能帮到更多 Tauri 开发者少走弯路!
如果你还有其他 Tauri v2 的奇葩问题,欢迎继续留言~

React 性能优化(下):useCallback 与 useTransition 实战

作者 Csvn
2026年3月28日 12:00

引言

在 React 应用性能优化中,useCallbackuseTransition 是两个强大但常被误解的 Hook。本文将深入探讨它们的正确使用场景、常见陷阱以及实际代码示例,帮助你写出更高效的 React 应用。

useCallback:避免不必要的函数重建

核心原理

useCallback 返回一个记忆化的回调函数,只有在依赖项变化时才会重新创建。这对于避免子组件不必要的重新渲染至关重要。

基础用法

import React, { useState, useCallback } from 'react';

function ParentComponent() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  // ❌ 错误:每次渲染都会创建新函数
  const handleClick = () => {
    console.log('Count:', count);
  };

  // ✅ 正确:使用 useCallback 记忆化
  const handleMemoizedClick = useCallback(() => {
    console.log('Count:', count);
  }, [count]);

  return (
    <div>
      <button onClick={handleMemoizedClick}>点击</button>
      <input value={text} onChange={e => setText(e.target.value)} />
    </div>
  );
}

配合 React.memo 使用

import React, { useState, useCallback, memo } from 'react';

const ChildComponent = memo(({ onIncrement, value }) => {
  console.log('Child rendered');
  return (
    <button onClick={onIncrement}>
      Count: {value}
    </button>
  );
});

function ParentComponent() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  // ✅ 只有 count 变化时,onIncrement 才会变化
  const handleIncrement = useCallback(() => {
    setCount(prev => prev + 1);
  }, []);

  return (
    <div>
      <ChildComponent onIncrement={handleIncrement} value={count} />
      <input value={text} onChange={e => setText(e.target.value)} />
    </div>
  );
}

常见陷阱

// ❌ 陷阱 1:依赖项过多导致失去优化效果
const handler = useCallback(() => {
  doSomething(a, b, c, d, e);
}, [a, b, c, d, e]); // 几乎每次都会重新创建

// ✅ 解决:只依赖真正需要的变量
const handler = useCallback(() => {
  doSomething(a, b);
}, [a, b]);

// ❌ 陷阱 2:在 useCallback 内部使用非稳定引用
const handler = useCallback(() => {
  config.doSomething(); // config 每次都是新对象
}, [config]);

// ✅ 解决:依赖稳定的值
const handler = useCallback(() => {
  configRef.current.doSomething();
}, []);

useTransition:优化耗时更新

核心概念

useTransition 允许你将某些状态更新标记为"过渡"更新,让 UI 保持响应式,避免阻塞用户交互。

基础用法

import React, { useState, useTransition } from 'react';

function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  const handleSearch = (value) => {
    setQuery(value);
    
    // ✅ 将耗时的过滤操作标记为过渡更新
    startTransition(() => {
      const filtered = heavyFilter(value);
      setResults(filtered);
    });
  };

  return (
    <div>
      <input 
        value={query}
        onChange={e => handleSearch(e.target.value)}
        placeholder="搜索..."
      />
      {isPending && <span>加载中...</span>}
      <ul>
        {results.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

function heavyFilter(query) {
  // 模拟耗时操作
  const data = largeDataset.filter(item => 
    item.name.includes(query)
  );
  return data;
}

实际场景:标签切换

import React, { useState, useTransition } from 'react';

function TabComponent() {
  const [activeTab, setActiveTab] = useState('posts');
  const [isPending, startTransition] = useTransition();

  const tabs = {
    posts: <PostsList />,
    comments: <CommentsList />,
    analytics: <AnalyticsPanel />
  };

  const handleTabChange = (tab) => {
    startTransition(() => {
      setActiveTab(tab);
    });
  };

  return (
    <div>
      <nav>
        {Object.keys(tabs).map(tab => (
          <button
            key={tab}
            onClick={() => handleTabChange(tab)}
            disabled={isPending}
          >
            {tab}
          </button>
        ))}
      </nav>
      {isPending && <div className="spinner">切换中...</div>}
      <main>
        {tabs[activeTab]}
      </main>
    </div>
  );
}

与 Suspense 配合

import React, { useState, useTransition, Suspense } from 'react';

function Dashboard() {
  const [activeView, setActiveView] = useState('overview');
  const [isPending, startTransition] = useTransition();

  const handleViewChange = (view) => {
    startTransition(() => {
      setActiveView(view);
    });
  };

  return (
    <div>
      <TabNav onChange={handleViewChange} active={activeView} />
      <Suspense fallback={<LoadingSkeleton />}>
        <ViewContent view={activeView} />
      </Suspense>
    </div>
  );
}

性能对比实测

// 未优化的版本
function UnoptimizedList({ items }) {
  const [filter, setFilter] = useState('');
  
  // 每次输入都会重新渲染整个列表
  const filtered = items.filter(item => 
    item.name.includes(filter)
  );

  return (
    <div>
      <input onChange={e => setFilter(e.target.value)} />
      <List data={filtered} />
    </div>
  );
}

// 优化后的版本
function OptimizedList({ items }) {
  const [filter, setFilter] = useState('');
  const [displayItems, setDisplayItems] = useState(items);
  const [isPending, startTransition] = useTransition();

  const handleChange = (e) => {
    const value = e.target.value;
    setFilter(value);
    
    startTransition(() => {
      const filtered = items.filter(item => 
        item.name.includes(value)
      );
      setDisplayItems(filtered);
    });
  };

  return (
    <div>
      <input value={filter} onChange={handleChange} />
      {isPending && <LoadingIndicator />}
      <List data={displayItems} />
    </div>
  );
}

最佳实践总结

useCallback 使用指南

  1. 优先优化子组件:只有当函数作为 prop 传递给 React.memo 组件时才需要
  2. 避免过早优化:不是所有函数都需要 useCallback
  3. 注意依赖项:确保依赖项稳定且必要
  4. 配合 useMemo:复杂计算场景结合使用

useTransition 使用指南

  1. 识别耗时更新:列表过滤、大数据渲染、复杂计算
  2. 提供加载反馈:使用 isPending 显示加载状态
  3. 区分优先级:用户输入立即响应,数据更新可延迟
  4. 避免滥用:简单更新不需要过渡

总结

useCallbackuseTransition 是 React 性能优化的利器,但需要正确使用:

  • useCallback 解决的是函数引用稳定性问题
  • useTransition 解决的是更新优先级问题

记住:性能优化应该基于实际测量,而非猜测。使用 React DevTools Profiler 找出真正的瓶颈,再针对性地应用这些 Hook。

这 5 个 Elements 小技巧,真的能提高调试效率

作者 isArray
2026年3月28日 11:56

大家好,我是小贺。

如果你是前端,浏览器开发者工具大概已经是每天都要打交道的老朋友了。

但问题也恰恰在这里。我相信很多人会打开 F12,却只会最基础的查看元素、改两行样式、看看报错。真正能让调试变轻松的功能,反而常常被忽略。

这篇不讲太多原理,直接分享 5 个我觉得非常实用的 Elements 面板小技巧。它们不复杂,但一旦用顺手,确实能帮你少走不少弯路。


1. 选中即消失?“强制状态”拯救你

你一定遇到过这种情况:想调试一个鼠标悬浮(hover)后才出现的二级菜单,结果鼠标刚一移过去,菜单就没了,根本来不及查看。

这时候不用和手速较劲。

Elements 面板里,右键点击触发菜单的元素,选择 Force state -> :hover

这样元素就会被强制锁定在悬浮状态。除了 :hover,常见的 :active:focus 这些状态也都能手动模拟。

很多“选中就消失”的交互问题,本质上都可以靠这个功能解决。

强制状态.gif


2. 只有高手才知道的“H 键大法”

有时候你只是想临时看一眼页面效果,比如:

  • 这个 Banner 去掉之后页面会不会更干净?
  • 这个弹窗先隐藏,下面的布局会不会乱?
  • 这个模块不显示时,留白是不是太大?

这时其实不用手动改 display: none

直接在 Elements 面板里选中元素,按一下键盘上的 H

元素会立刻隐藏,再按一次又能恢复。

如果你想进一步测试,可以直接按 Delete 把节点删掉;删错了也不用慌,Ctrl + Z 在开发者工具里同样可以撤回。

这个功能特别适合快速试布局,不用来回改代码、刷新页面。

H 键大法.gif


3. $0:Elements 与控制台的“传送门”

假设你已经在 Elements 面板里选中了一个很深层的 div,这时候想在 Console 里看一下它的 offsetHeight,或者直接调用它的方法。

如果还要自己手写一遍 document.querySelector(...),就太费劲了。

Esc 呼出底部控制台,直接输入 $0

$0 就是你当前在 Elements 面板里选中的那个元素。

比如你可以直接这样用:

$0.offsetHeight;
$0.classList;
$0.getBoundingClientRect();

更方便的是,浏览器还会记住你的选择历史:$1 是上一次选中的元素,$2 是上上次。

调试复杂页面时,这个功能真的非常顺手。

$0.gif


4. 截图不用描边,自带“像素级”捕获

有时候你想把某个按钮、卡片、弹窗单独截出来发给设计师,或者临时拿去做文档、做演示。

这时候最省事的方法,不是手动截图框选,而是直接让浏览器截这个节点。

操作步骤很简单:

  1. Elements 面板里选中目标元素
  2. Ctrl + Shift + P 打开命令面板
  3. 输入 screenshot截图
  4. 选择 Capture node screenshot

这样导出的通常是一个干净的 PNG,元素边缘也会比手动框选更完整。对前端、设计协作、写文档的人来说,这个功能都很好用。

截图.gif


彩蛋:一键开启“整页可编辑”模式

有时候你只是想临时改改页面上的文字,看看排版效果,又不想在 Elements 里逐个节点去双击编辑。

在 Console 里输入:

document.designMode = 'on';

执行之后,整个页面会进入一种近似“可编辑文档”的状态。很多文字内容都可以直接点进去修改、删除、试排版。

它当然不能代替真正改代码,但在演示想法、快速验证文案展示效果时,非常方便。

编辑模式.gif


5. 元素找不到了?试试“滚动到视图”

页面一长,DOM 结构一复杂,经常会出现一种情况:

你在 Elements 面板里明明看到了某个节点,但一时找不到它到底对应页面上的哪一块。

这时可以直接在对应的 HTML 标签上右键,选择 Scroll into view滚动到视图)。

浏览器会自动把页面滚动到这个元素所在的位置。

这个小功能看起来不显眼,但在排查长页面、复杂列表、深层组件时,能省下很多来回找位置的时间。

滚动到视图.gif


写在最后

开发者工具里真正有用的,很多都不是“高级功能”,而是这些平时不太起眼、但一旦掌握就会频繁用到的小能力。

它们不会让你一夜之间变成高手,但会让你少做很多重复劳动。

如果你现在就在电脑前,不妨打开浏览器按一次 F12,先试试 $0H 键,基本马上就能体会到差别。

这里是《你真的会使用浏览器么?》系列的第一篇。如果大家喜欢我们后续继续更新。

ps:文章演示网站为-(小贺的博客)

做了一个AI聊天应用后,我决定试试这个状态管理库

作者 张一凡93
2026年3月28日 11:29

AI应用前端最大的坑,不是LLM调用,而是状态管理

背景

最近做了个AI聊天应用,类似ChatGPT的那种。

本来想用Redux,毕竟老牌方案,结果被毒打了一遍。

Redux的痛

痛点1:状态类型爆炸

// AI聊天应用需要管理的状态
interface ChatState {
  // 对话
  messages: Message[];

  // 流式响应
  streamingText: string;
  isStreaming: boolean;

  // 工具调用
  toolCalls: ToolCall[];
  currentToolCall: ToolCall | null;
  toolResults: Record<string, any>;

  // 上下文
  contextWindow: Message[];
  longTermMemory: MemoryItem[];

  // 用户意图
  currentIntent: Intent | null;
  intentHistory: Intent[];

  // 执行状态
  currentStep: number;
  stepResults: Record<number, any>;

  // 错误处理
  errors: Error[];
  retryQueue: RetryItem[];

  // ...
}

这还只是一个聊天模块的状态。

痛点2:action type能写死你

// 光是状态更新action就能写30个
const ADD_MESSAGE = "chat/ADD_MESSAGE";
const UPDATE_STREAMING = "chat/UPDATE_STREAMING";
const APPEND_STREAMING = "chat/APPEND_STREAMING";
const START_TOOL_CALL = "chat/START_TOOL_CALL";
const COMPLETE_TOOL_CALL = "chat/COMPLETE_TOOL_CALL";
const UPDATE_CONTEXT = "chat/UPDATE_CONTEXT";
const ADD_INTENT = "chat/ADD_INTENT";
// ... 还有几十个

痛点3:跨组件同步难

// Chat组件
const messages = useSelector((s) => s.chat.messages);

// Status组件
const toolCalls = useSelector((s) => s.chat.toolCalls);

// 怎么保证两个组件状态一致?靠redux-thunk?middleware?

然后我用了easy-model

// 一个类搞定AI聊天全状态
class AIChatModel {
  // 对话
  messages: Message[] = [];

  // 流式响应
  streamingText = "";
  isStreaming = false;

  // 工具调用
  toolCalls: ToolCall[] = [];
  currentToolCall: ToolCall | null = null;
  toolResults: Map<string, any> = new Map();

  // 上下文
  contextWindow: Message[] = [];
  longTermMemory: MemoryItem[] = [];

  // 用户意图
  currentIntent: Intent | null = null;
  intentHistory: Intent[] = [];

  // 执行
  currentStep = 0;
  stepResults: Map<number, any> = new Map();

  // 错误
  errors: Error[] = [];

  // 方法
  @loader.load()
  async sendMessage(content: string) {
    this.messages.push({ role: "user", content });
    this.isStreaming = true;

    const response = await llm.streamChat(this.messages);

    for await (const chunk of response) {
      this.streamingText += chunk;
    }

    this.messages.push({ role: "assistant", content: this.streamingText });
    this.streamingText = "";
    this.isStreaming = false;
  }

  async executeToolCall(tool: Tool, params: any) {
    this.currentToolCall = { tool, params, status: "running" };
    this.toolCalls.push(this.currentToolCall);

    const result = await tool.execute(params);

    this.currentToolCall.status = "completed";
    this.currentToolCall.result = result;
    this.toolResults.set(tool.name, result);
    this.currentToolCall = null;
  }
}

一个类,200行代码搞定Redux 500行都搞不定的事。

还能解决什么问题?

1. 撤销重做,调试AI回复

const chat = useModel(AIChatModel, []);
const history = useModelHistory(chat);

// 用户想撤回AI的上一次回复?
history.back();

// 想重做?
history.forward();

2. 跨组件状态共享

// 聊天区域
function ChatArea() {
  const chat = useModel(AIChatModel, ["main"]);
  return <MessageList messages={chat.messages} />;
}

// 状态显示
function StatusPanel() {
  const chat = useModel(AIChatModel, ["main"]);
  return <StatusBadge isStreaming={chat.isStreaming} />;
}

// 工具调用面板
function ToolPanel() {
  const chat = useModel(AIChatModel, ["main"]);
  return <ToolList calls={chat.toolCalls} />;
}

// 三个组件,状态自动同步

3. 深度监听

// 监听任意状态变化
watch(chat, (keys, prev, next) => {
  console.log(`状态变化: ${keys.join(".")}`, prev, "→", next);

  // "messages.5.content" - 第6条消息内容变了
  // "toolCalls.0.status" - 第一个工具调用状态变了
  // "streamingText" - 流式文本更新了
});

对比其他方案

特性 Redux Zustand MobX easy-model
类模型
无装饰器
依赖注入
撤销重做
深度监听 ⚠️
TypeScript友好 ⚠️ ⚠️

结论

AI应用前端,状态管理选easy-model就对了。


GitHub: github.com/ZYF93/easy-…

npm: pnpm add @e7w/easy-model

做AI应用前端,状态管理别再踩坑了,点个⭐️!

详解github workflows流

作者 怪兽同学
2026年3月28日 11:26

Workflows允许你在 GitHub(文章以此为例) 仓库中自动化构建、测试、部署等软件开发流程

1. 什么是Workflows

  • GitHub Actions 是 GitHub 提供的 CI/CD(持续集成/持续部署)平台,可以自动化执行软件开发工作流。基本上代码管理都支持actions,百用不赖
  • Workflow 是 Actions 中的一个可配置的自动化过程,由一个或多个 job 组成,由特定事件触发。每个 workflow 是一个独立的 YAML 文件,存放在仓库的 .github/workflows 目录下。

2. 基本概念

概念 说明
Workflow 一个自动化流程,由一个 YAML 文件定义。
Event 触发 workflow 运行的特定活动,如 pushpull_requestschedule 等。
Job workflow 中的一个任务,由多个 step 组成。一个 workflow 可以包含多个 job,它们可以并行或串行运行。
Step job 中的单个任务,可以是 shell 命令或一个 action
Action 可复用的最小单元,可以封装常用操作(如检出代码、设置 Node.js 环境等)。
Runner 运行 workflow 的服务器。GitHub 提供托管运行器(如 ubuntu-latestwindows-latest),也可自托管。

3. 简单示例

在仓库根目录下创建 .github/workflows 目录,然后放入一个或多个 .yml 或 .yaml 文件 实例如下

name: CI  # 名称

on:  # 触发条件
  push:
    branches: [ main ]

jobs: # 定义作业
  build:
    runs-on: ubuntu-latest  # 运行环境
    steps:
      - name: Checkout code
        uses: actions/checkout@v4  # 使用官方 checkout action
      - name: Run a one-line script
        run: echo Hello, world!

4. 触发事件 (on)

on 字段定义 workflow 何时运行。可以是单个事件、事件列表,或带有条件的事件。

4.1 基本事件

  • push 代码推送时触发
  • pull_request PR 相关事件
  • workflow_dispatch 手动触发(需要在 GitHub 界面点击按钮)
  • schedule 定时触发(cron)
  • 其他事件:如 releaseissueswatchfork见 👉🏻 详细文档地址

下面是详细的事件类型,贯穿仓库管理中的各个过程和节点。


image.png

4.2 多事件与条件过滤

可以组合多个事件,并利用 branchespathstags 等过滤器。

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main
    paths-ignore:
      - 'docs/**'

5. Jobs 与 Steps

Jobs: 定义一个任务

Steps: 每个 step 可以是一个 run(执行 shell 命令)或 uses(引用一个 action)。

jobs:
  job1: 
    runs-on: ubuntu-latest # 指定运行器镜像。常用值:`ubuntu-latest`, `windows-latest`, `macos-latest`,也可以指定版本如 `ubuntu-22.04`。
    steps:
      - name: Step 1 # 可选,显示在日志中,推荐使用。
        if: github.ref == 'refs/heads/main'  # 使用 `if` 条件,支持表达式。
        run: echo "Step 1"
        with:
            node-version: '22'
  job2:
    runs-on: windows-latest
    needs: job1   # job2 在 job1 完成后运行;(定义依赖关系,默认所有 job 并行运行,加 `needs` 后变为串行)
    steps:
      - name: Step 2
        run: echo "Step 2"

6. Actions 的使用

Actions 是可复用的代码单元,可以从 GitHub Marketplace 获取或自定义。

6.1 使用官方或第三方 Action

格式:{owner}/{repo}@{ref} 或 {owner}/{repo}/{path}@{ref}

- name: Upload artifact
  uses: actions/upload-artifact@v4
  with:
    name: my-artifact
    path: dist/

6.2 自定义 Action

可以创建 Docker 容器 Action 或 JavaScript Action,放在仓库中本地引用:

- name: My custom action
  uses: ./.github/actions/my-action

7. 环境变量与 Secrets

7.1 环境变量

可以在 workflow、job 或 step 级别设置环境变量。

env:
  NODE_ENV: production

jobs:
  build:
    env:
      API_URL: https://api.example.com
    steps:
      - name: Use env
        run: echo ${{ env.API_URL }}

7.2 Secrets

敏感信息如密码、token 应存储在 GitHub 仓库的 Settings → Secrets and variables → Actions 中,然后在 workflow 中通过 ${{ secrets.SECRET_NAME }} 引用。

- name: Deploy
  run: npm run deploy -- --token ${{ secrets.DEPLOY_TOKEN }}

注意:Secrets 不会出现在日志中,但需小心避免将 secret 作为命令行参数输出(如 echo ${{ secrets.TOKEN }} 会暴露)。

8. 矩阵策略 (Matrix)

矩阵允许你使用变量组合并行运行多个 job,常用于测试多个操作系统、Node.js 版本等。

yaml

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node-version: [16, 18, 20]
      fail-fast: false  # 是否在某个组合失败时取消所有
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test
  • matrix:定义变量组合。
  • include/exclude:可以添加或排除特定组合。

9. 缓存依赖

通过 actions/cache 可以缓存依赖,加速构建。

- name: Cache npm
  uses: actions/cache@v3
  with:
    path: ~/.npm
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

更常见的是使用针对特定生态的缓存 action,如 actions/setup-node 已内置缓存:

- uses: actions/setup-node@v4
  with:
    node-version: '18'
    cache: 'npm'

10. 工件 (Artifacts) 与部署

10.1 上传/下载工件

可以在 job 间传递文件。

- name: Upload build
  uses: actions/upload-artifact@v4
  with:
    name: build
    path: dist/

- name: Download build
  uses: actions/download-artifact@v4
  with:
    name: build
    path: dist/

10.2 部署到 GitHub Pages

- name: Deploy to GitHub Pages
  uses: peaceiris/actions-gh-pages@v3
  with:
    github_token: ${{ secrets.GITHUB_TOKEN }}
    publish_dir: ./dist

GITHUB_TOKEN 是 GitHub 自动提供的临时 token,具有仓库写入权限。

11. 条件判断与上下文

11.1 条件执行

使用 if 条件,支持表达式。

- name: Only on main branch
  if: github.ref == 'refs/heads/main'
  run: echo "Deploying..."

11.2 上下文

GitHub Actions 提供丰富的上下文变量,如 githubenvsecretsmatrix 等。

- name: Print event name
  run: echo ${{ github.event_name }}

常用上下文:

  • github:包含仓库信息、触发者等。
  • runner:运行器信息。
  • env:环境变量。
  • secrets:仓库机密。
  • steps:上一步的输出。

12. 工作流嵌套与复用

12.1 调用其他 workflow

使用 workflow_call 事件,可以在一个 workflow 中调用另一个 workflow。

被调用的 workflow 需定义 on: workflow_call,并可定义输入和输出。

# .github/workflows/build.yml
name: Build
on:
  workflow_call:
    inputs:
      node-version:
        required: true
        type: string
    secrets:
      npm_token:
        required: true
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
      - run: npm ci
      - run: npm run build

调用方:

name: Main Workflow
on: push
jobs:
  call-build:
    uses: ./.github/workflows/build.yml
    with:
      node-version: '18'
    secrets:
      npm_token: ${{ secrets.NPM_TOKEN }}

13. 运行器组与自托管运行器

如果不想使用 GitHub 托管运行器,可以添加自托管运行器(Self-hosted runner)。

jobs:
  build:
    runs-on: self-hosted

可以为自托管运行器指定标签,如 runs-on: [self-hosted, linux]

14. 调试与日志

  • 启用 step 调试:在 workflow 中设置 ACTIONS_STEP_DEBUG secret 为 true,会输出更详细的日志。
  • 启用 runner 调试:设置 ACTIONS_RUNNER_DEBUG secret 为 true,会输出 runner 级别的调试信息。
  • 手动重试:在 GitHub Actions 页面,可以重新运行失败的 job。

15. 安全最佳实践

  • 最小权限原则:使用 permissions 字段限制 workflow 的 token 权限。

    permissions:
      contents: read
      issues: write
    
  • 避免在日志中打印 secrets

  • 使用环境 (Environment) :对部署等敏感操作,可配置环境并设置保护规则。

    environment: production
    
  • 审查第三方 Action:优先使用官方或经过验证的 Action,固定版本 tag 或 commit SHA。

参考资料:

useTemplateRef 详解

2026年3月28日 11:18

最近升级 Vue3.5 后,发现了 useTemplateRef 这个宝藏 API,直接解决了之前用传统 ref 封装 DOM 逻辑时的痛点 —— 终于能把「获取 DOM + 操作 DOM」的逻辑彻底抽离,全项目复用了!

之前写业务的时候总遇到这种情况:多个组件需要自动聚焦、监听元素尺寸,用传统 ref 封装 Hook 时特别别扭,要么得让组件里的 ref 变量名和 Hook 里保持一致,要么就得写一堆冗余代码传参。直到用了 useTemplateRef 才发现,原来 DOM 逻辑复用可以这么丝滑。

先说说核心区别:为啥传统 ref 复用起来那么麻烦?

之前用 ref(null) 封装 Hook 时,踩过很多坑。比如想写个自动聚焦的通用逻辑,Hook 里定义了 const inputEl = ref(null),那使用这个 Hook 的组件,模板里的 input 必须绑定 :ref="inputEl"—— 这就意味着组件得知道 Hook 内部的变量名,完全没法灵活复用。

而且组件里还得手动接收 Hook 导出的变量,代码又冗余又耦合。如果多个组件用这个 Hook,一旦想改 Hook 里的变量名,所有组件都得跟着改,维护成本太高了。

而 useTemplateRef 最妙的地方在于,不用管组件里的变量名,直接在 Hook 里固定一个字符串标识,组件模板只要对应加上这个 ref 名就行,逻辑完全解耦。

实战两个常用 Hook:看完直接抄去用

分享两个我最近封装的实战 Hook,都是业务中高频用到的,现在全项目直接复用,不用写重复代码。

1. 自动聚焦 Hook:useAutoFocus

之前每个需要自动聚焦的输入框,都得写一遍 onMounted + ref,现在封装一次就行:

// useAutoFocus.js
import { useTemplateRef, onMounted } from 'vue'
export function useAutoFocus() {
  // 直接在 Hook 里指定 ref 名:'auto-focus'
  const inputEl = useTemplateRef('auto-focus')
  onMounted(() => {
    // 挂载后自动聚焦,可选链避免报错
    inputEl.value?.focus()
  })
  return { inputEl }
}

用的时候特别简单,组件里不用写任何逻辑,只要给 input 加个对应的 ref 就行:

<script setup> // 直接引入复用,不用写任何ref、聚焦逻辑 
  import { useAutoFocus } from './useAutoFocus' 
  useAutoFocus() 
</script> 
<template> 
  <!-- 只需要给元素加 ref="auto-focus" --> 
  <input ref="auto-focus" placeholder="自动聚焦" /> 
</template>

不管是登录页、搜索框还是表单输入框,只要引入这个 Hook,加个 ref="auto-focus",立马实现自动聚焦,完全不用关心内部逻辑。

2. DOM 尺寸监听 Hook:useElementSize

监听元素宽高变化也是个高频需求,比如响应式布局、图表自适应,之前每次都要写监听 resize 事件、清理监听,现在封装后直接复用:

// useElementSize.js
import { useTemplateRef, ref, onMounted, onUnmounted } from 'vue'
export function useElementSize() {
  // 绑定 ref 标识:'resize-el'
  const el = useTemplateRef('resize-el')
  const width = ref(0)
  const height = ref(0)
  // 更新元素尺寸的方法
  const updateSize = () => {
    if (el.value) {
      width.value = el.value.offsetWidth
      height.value = el.value.offsetHeight
    }
  }
  onMounted(() => {
    // 初始获取一次尺寸
    updateSize()
    // 监听窗口 resize 事件
    window.addEventListener('resize', updateSize)
  })
  onUnmounted(() => {
    // 组件卸载时清理监听,避免内存泄漏
    window.removeEventListener('resize', updateSize)
  })
  return { width, height }
}

组件使用时,只需要给要监听的元素加个 ref="resize-el",直接获取宽高变量:

<script setup>
import { useElementSize } from './useElementSize'
// 直接复用DOM尺寸监听
const { width, height } = useElementSize()
</script>

<template>
  <!-- 只需标记 ref="resize-el" -->
  <div ref="resize-el">
    宽度:{{ width }} / 高度:{{ height }}
  </div>
</template>

窗口缩放时,宽高会自动更新,不用在组件里写任何监听逻辑,清爽多了。

用 useTemplateRef 实现复用的小技巧

其实核心就 3 个点,记住就能灵活封装:

  1. Hook 内部用字符串固定 ref 标识,比如 'auto-focus'、'resize-el',不用暴露变量;
  1. 组件模板里给目标元素加对应的 ref="标识名",不用管 Hook 内部逻辑;
  1. 所有 DOM 操作、事件监听都写在 Hook 里,组件只负责引入和使用结果,零侵入。

这样封装出来的 Hook 才是真正可复用的 —— 不管哪个组件用,都不用改 Hook 代码,也不用在组件里写额外逻辑。

最后总结下使用感受

useTemplateRef 最让我惊喜的是「彻底解耦」:之前用传统 ref 封装的 Hook,组件和 Hook 之间还得通过变量名关联,现在完全不用管这些,Hook 负责处理逻辑,组件负责展示,边界特别清晰。

而且它是 Vue3.5+ 官方支持的写法,TypeScript 类型推断也很友好,不用手动声明类型。现在我把项目里所有操作 DOM 的逻辑都用这种方式封装了,比如滚动监听、点击 outside 关闭、图片懒加载,一次封装全项目复用,效率提升太多了。

如果你的项目还在 Vue3.5 以上,强烈试试这个 API,能少写很多重复代码~

for...of 的秘密:迭代器与可迭代对象,你也能创造“可循环”的东西

作者 kyriewen
2026年3月28日 10:53

为什么数组可以用for...of循环?为什么对象不行?今天我们来揭开JS里“可循环”的秘密——迭代器(Iterator)和可迭代对象(Iterable)。弄懂它们,你就能让自己的对象也支持for...of,甚至还能写出像Python生成器那样优雅的代码。

前言

你有没有好奇过,为什么数组可以用for...of遍历,而对象不行?为什么...扩展运算符可以展开数组,却不能直接展开对象?这背后其实是迭代器协议在起作用。

今天我们就来彻底搞懂这套机制,然后亲手造一个可以for...of遍历的对象。看完你会感叹:原来JS的循环还有这么多骚操作!

一、什么是可迭代对象?

如果一个对象实现了可迭代协议,它就是可迭代对象。可迭代协议要求对象有一个[Symbol.iterator]方法,这个方法返回一个迭代器

简单来说:可迭代对象 = 有一个能返回迭代器的方法

数组、字符串、Map、Set、arguments、NodeList等都是原生可迭代对象。所以你可以:

for (let item of [1,2,3]) { console.log(item); } // 数组
for (let char of 'hello') { console.log(char); } // 字符串
for (let [key,val] of new Map([[1,2]])) { } // Map

对象不是可迭代对象,所以for...of直接遍历对象会报错。

二、迭代器长什么样?

迭代器是一个对象,它有一个next()方法。每次调用next(),会返回一个对象:{ value: 任意值, done: boolean }done表示是否遍历结束。

比如手动创建一个数组的迭代器:

const arr = ['a', 'b', 'c'];
const iterator = arr[Symbol.iterator]();

console.log(iterator.next()); // { value: 'a', done: false }
console.log(iterator.next()); // { value: 'b', done: false }
console.log(iterator.next()); // { value: 'c', done: false }
console.log(iterator.next()); // { value: undefined, done: true }

你看,这个迭代器就像个“读取器”,每次取一个值,直到取完。

三、自己实现一个可迭代对象

现在我们来造一个可以for...of遍历的对象。比如一个范围对象,能遍历从start到end的所有整数。

const range = {
  start: 1,
  end: 5,
  [Symbol.iterator]() {
    let current = this.start;
    const end = this.end;
    return {
      next() {
        if (current <= end) {
          return { value: current++, done: false };
        } else {
          return { value: undefined, done: true };
        }
      }
    };
  }
};

for (let num of range) {
  console.log(num); // 1,2,3,4,5
}

就这么简单!只要对象有[Symbol.iterator]方法,并且返回一个带有next的对象,它就能被for...of遍历。

四、扩展运算符、解构赋值背后的迭代器

很多JS语法都依赖迭代器:

  • ...扩展运算符:把可迭代对象展开成元素列表
  • 数组解构:[a, b, ...rest] = iterable
  • Array.from():把可迭代对象转成数组
  • for...of循环
  • Promise.all()Promise.race()的参数也是可迭代对象

所以,只要你的对象是可迭代的,它就能享受这些语法糖。

const numbers = [...range]; // [1,2,3,4,5]
const [first, second, ...rest] = range; // first=1, second=2, rest=[3,4,5]

五、生成器函数:迭代器的快捷方式

还记得昨天的Generator吗?生成器函数返回的就是迭代器!所以我们可以用Generator来简化上面的代码:

const range = {
  start: 1,
  end: 5,
  *[Symbol.iterator]() {
    for (let i = this.start; i <= this.end; i++) {
      yield i;
    }
  }
};

是不是简洁多了?*[Symbol.iterator]()就是Generator方法,每次yield一个值,for...of会自动调用next

六、无限迭代器:永不停止的循环

迭代器可以无限进行下去,比如生成斐波那契数列:

const fibonacci = {
  *[Symbol.iterator]() {
    let a = 0, b = 1;
    while (true) {
      yield a;
      [a, b] = [b, a + b];
    }
  }
};

const fib = fibonacci[Symbol.iterator]();
console.log(fib.next().value); // 0
console.log(fib.next().value); // 1
console.log(fib.next().value); // 1
// 想取多少取多少

但注意:用for...of遍历无限迭代器会死循环,所以要手动控制。

七、提前终止迭代器:return方法

如果迭代器被提前终止(比如for...of中遇到break,或者解构只取前几个值),JS会调用迭代器的return方法(如果有的话)。这可以用来做清理工作。

const specialIterable = {
  [Symbol.iterator]() {
    let i = 0;
    return {
      next() {
        if (i < 3) return { value: i++, done: false };
        return { done: true };
      },
      return() {
        console.log('提前终止了');
        return { done: true };
      }
    };
  }
};

for (let x of specialIterable) {
  console.log(x);
  if (x === 1) break; // 触发return
}
// 输出:0,1, 然后打印“提前终止了”

八、实际应用:让对象可迭代

假设你有一个用户列表对象,你想让它支持for...of直接遍历用户:

const userList = {
  users: [
    { name: '张三', age: 18 },
    { name: '李四', age: 20 },
    { name: '王五', age: 22 }
  ],
  *[Symbol.iterator]() {
    for (let user of this.users) {
      yield user;
    }
  }
};

for (let user of userList) {
  console.log(user.name); // 张三 李四 王五
}

这样,你的自定义对象就能像数组一样优雅地遍历了。

九、总结:迭代器无处不在

  • 可迭代对象:实现了[Symbol.iterator]方法,返回一个迭代器。
  • 迭代器:实现了next()方法,返回{ value, done }
  • 生成器函数:是迭代器最便捷的实现方式。
  • 很多JS语法(for...of、扩展运算符、解构)都依赖迭代器协议。

理解了这套机制,你就能:

  • 让自定义对象支持for...of
  • 创建无限序列
  • 深入理解JS语法糖背后的原理

下次你写for...of时,脑子里可以浮现出迭代器一步步next的画面——这才是真正掌握了JS的底层。

明天我们将进入DOM操作与事件流,从JS的核心走向与页面的交互。如果你觉得今天的文章够“可迭代”,点个赞让更多人看到。我们明天见!

TypeScript 学习系列(初):充分认识 TypeScript

作者 luckyCover
2026年3月28日 10:25

什么是 TypeScript?

听到 TypeScript 这个名字时,你的第一反应会联想到 JavaScript,因为它们在名字上特别相近。当然,它们之间确实存在着某种关系。

TypeScript 是 JavaScript 的超集,所谓超集,并不意味着它要比 JavaScript 更高或更强,而是以 JavaScript 为基础,在其拥有的语法、特性等层面上去定义和扩展。那么定义和扩展什么呢?根据它的名字,Type + Script,其核心就是类型(Type)。

那么基于它和 JavaScript 的关系,以及其核心的特性。我们可以以一个全局的视角来了解 TypeScript:TypeScript 是在 JavaScript 的语言层面上所建立起的一套丰富的类型系统。

为什么需要 TypeScript ,它主要解决哪些问题?

现在大型项目都会选择引入 TypeScript 来作为常规的技术栈,我们平常用的 Vue 和 React 框架也都使用 TypeScript,可见它的欢迎度与实用性。

在早期没有 TypeScript 时的痛点:

  • 变量赋值与其实际类型不符:当我们声明一个变量且期望它是一个字符串类型的值,但后续可能因某些误操作将其重新赋值为其他的类型,这时候期望原来字符串类型变量的地方就会出现问题:
let str = "hello" // 期望类型

str = {a: 1} // 误操作

function myStr(s1) {
   console.log(`${str},${s1}`); // 从 hello,world 变成 [object Object],world
}

myStr("world")

上边代码中 myStr 认为 str 就是期望的字符串类型,但并不知道在代码其他地方被赋值为其他类型,导致函数内容结果不符合预期。

  • 没有类型提示,需要频繁切换上下文:声明一个变量并赋予初始值,在没有 TypeScript 前我们是不知道它是什么类型的,可能需要凭借我们对这个变量的记忆或者通过滑动代码来查找变量定义,这样效率其实是较低的。

  • 运行时检测错误,效率低下

let num = 1 // 期望类型

num = undefined // 误操作

function sum(a, b) {
    return a + b
}

console.log(sum(num, 2)); // 期望结果是 3,而实际为 NaN(undefined + 2)

sum 函数对接收的两个参数进行求和运算,由于中间某些操作导致 num 从数字类型的值变为其他类型值,最后求和为 NaN,而 JavaScript 编译时是没任何提示的。我们只有运行代码才能看到结果输出,这时候我们才会去排查问题,更多时候我们会选择在函数开头先进行类型判断。

  • 代码维护性不好:随着项目规模逐步增大,一个组件内就会出现众多的变量定义,经常需要频繁的进行读写操作,状态管理会逐渐变得困难。同时我们很难保证一个变量仅在代码的一处地方使用,很多时候是多个地方同时存在对一个变量的依赖。其实是存在很强的耦合性的,我们需要精准了解每个地方对该变量的操作逻辑。

我们再来看下 TypeScript 解决的痛点,也是它的优势点。

  • 类型安全:使用 TypeScript 后,变量在声明时就可以指定其类型,这样后续对该变量所赋予的值都得是这个类型,其他类型 Typescript 会给予报错提示,这极大程度的防止了变量误操作导致的类型不符问题,大大提高类型安全。
  • 编译时错误检测:在编写代码时,如果出现变量赋值与变量实际定义时的类型不符,TypeScript 会立即检测到并在出异常的代码划上上红色波浪,如下图所示:

image.png

鼠标移上去还可以看到错误原因:

image.png

不需要我们运行代码才能发现问题,这也是 TypeScript 的核心优势。

  • 更好的 IDE 支持:不管是 vscode、webstorm 等编辑器,都具备对 TypeScript 的良好支持,我们可以轻松的引入 TypeScript。
  • 代码的可维护性:TypeScript 的类型安全使得我们不会对一个变量进行无效的赋值,同时它还提供良好的类型提示,鼠标移到指定变量身上,可以看到它所限定的类型,这些点都让代码的可维护性得到了充分的保障。

如何学好 TypeScript?

像学习 JavaScript 一样,我们不会一上来就学习它的函数语法,而是先从最小单位开始,基础类型和引用类型。那么 TypeScript 也是一样,它的核心是类型,同样也可分为基础类型和高级类型。所以想学好 TypeScript 就得先从它的基础类型开始,再到高阶类型,慢慢过渡到组合使用的复杂类型,保持循序渐进,每个阶段都充分理解、充分实践,最后肯定能学好的。

总结

TypeScript 是在 JavaSript 语法层面上提供的一套丰富的类型系统,它的核心优势是类型安全、类型提示、编译时检测、良好的 IDE 支持、提高代码的可维护性。学好 TypeScript 同样需要从基础类型开始、然后到高阶类型,慢慢过渡到组合使用的复杂类型,保持循序渐进,充分理解与实践。

我是 luckyCover,接下来我会持续更新 TypeScript 学习系列的文章,欢迎大家一起讨论学习呀~

❌
❌