普通视图

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

【JavaScript面试题-this 绑定】请说明 `this` 在不同场景下的指向(默认、隐式、显式、new、箭头函数)。

2026年3月18日 15:27

今天我们来聊一聊 JavaScript 中一个既基础又让人头疼的概念——this

一、this 是什么?

简单来说,this 是函数执行时内部自动生成的一个对象,它指向调用该函数的上下文。你可以把它理解为函数内部的“环境变量”,代表了当前函数运行时所处的对象。

一个形象的比喻

想象一下,你有一个“自我介绍”的功能,不同的人调用它时,“我”这个字指向不同的人:

  • 小明说“叫小明”,这里的“我”就是小明。
  • 小红说“叫小红”,这里的“我”就是小红。

在 JavaScript 中,this 就像这句话里的“我”,而那个自我介绍的函数就像一句模板:“我叫 xxx”。这个模板里的 this.name 会根据是谁在调用而自动替换成对应的人名。

用代码表示:

javascript

function introduce() {
  console.log(`我叫 ${this.name}`);
}

const ming = { name: '小明', introduce };
const hong = { name: '小红', introduce };

ming.introduce(); // 我叫 小明(this 指向 ming)
hong.introduce(); // 我叫 小红(this 指向 hong)

这里的 introduce 函数内部的 this 就像“我”一样,随着调用者(ming 或 hong)不同,指向也不同。这就是 this 的动态性——它是在函数执行时,根据调用它的对象确定的。

二、this 能做什么?

理解了 this 是动态上下文,那么它能为我们做什么呢?

  • 让同一个函数服务于不同的对象,实现代码复用;
  • 在构造函数中初始化实例属性
  • 在事件处理中方便地访问触发元素
  • 显式地指定上下文,借用其他对象的方法
  • 在回调函数中优雅地保留外层 this

下面我们就通过一个个实战场景,来体会 this 的妙用。


三、实战场景一网打尽

场景1:对象方法中的 this —— 隐式绑定

假设我们有一个用户对象,需要输出用户的名称:

javascript

const user1 = {
  name: '小明',
  greet() {
    console.log(`大家好,我是 ${this.name}`);
  }
};

user1.greet(); // 大家好,我是 小明

当 greet 作为 user1 的方法被调用时,this 指向 user1,所以能正确访问 name

能做什么:我们可以定义多个类似的对象,使用同一个方法结构,轻松访问各自的数据。

陷阱:如果把方法赋值给一个变量再调用,this 就会丢失:

javascript

const fn = user1.greet;
fn(); // 大家好,我是 undefined (非严格模式下 this 指向 window,没有 name 属性)

解决方法:使用 bind 强制绑定 this,或者用箭头函数(后面会讲)。


场景2:构造函数中的 this —— new 绑定

在面向对象编程中,我们经常用构造函数来创建对象:

javascript

function Person(name, age) {
  this.name = name;
  this.age = age;
  this.intro = function() {
    console.log(`我叫 ${this.name},今年 ${this.age} 岁。`);
  };
}

const p1 = new Person('小红', 20);
p1.intro(); // 我叫 小红,今年 20 岁。

当使用 new 调用 Person 时,this 指向新创建的空对象,然后我们往这个对象上添加属性,最后返回这个对象。

能做什么:轻松批量创建结构相似的对象,并且每个对象的方法都能正确访问自己的属性。

注意:如果忘记写 newthis 会指向全局对象,导致全局变量污染。所以构造函数通常首字母大写,提醒自己用 new 调用。


场景3:DOM 事件处理中的 this

在浏览器中处理事件时,this 通常指向触发事件的 DOM 元素:

html

<button id="myBtn">点我</button>
<script>
  const btn = document.getElementById('myBtn');
  btn.addEventListener('click', function() {
    console.log(this); // <button id="myBtn">点我</button>
    this.textContent = '已点击';
  });
</script>

能做什么:在事件回调中直接通过 this 操作当前元素,非常方便。

注意:如果回调使用箭头函数,this 就会指向外层作用域(比如 window),无法直接操作元素。所以事件回调一般用普通函数。


场景4:显式指定 this —— call / apply / bind

有时候我们需要手动指定函数的 this,比如“借用”其他对象的方法。

javascript

const user2 = { name: '小刚' };
const user3 = { name: '小丽' };

function introduce(hobby) {
  console.log(`我是 ${this.name},喜欢 ${hobby}`);
}

introduce.call(user2, '篮球'); // 我是 小刚,喜欢 篮球
introduce.apply(user3, ['跳舞']); // 我是 小丽,喜欢 跳舞

const introduceXiaoGang = introduce.bind(user2, '足球');
introduceXiaoGang(); // 我是 小刚,喜欢 足球
  • call 和 apply 立即调用函数,区别是传参方式不同。
  • bind 返回一个新函数,永久绑定 this,可用于后续调用。

能做什么:实现函数复用,动态改变上下文;也可以用于“函数借用”,比如数组方法借用给类数组对象。


场景5:回调函数中保持 this —— 箭头函数的妙用

在异步回调或定时器中,我们经常需要访问外层的 this,但普通函数的 this 会指向全局(或 undefined 严格模式),导致无法访问期望的对象。

传统解决方式是用 var self = this 缓存,或者用 bind

javascript

function Counter() {
  this.count = 0;
  setInterval(function() {
    this.count++; // 这里的 this 指向 window,无法更新 count
    console.log(this.count);
  }, 1000);
}
new Counter(); // 输出 NaN 或 undefined

用 bind 修正:

javascript

function Counter() {
  this.count = 0;
  setInterval(function() {
    this.count++;
    console.log(this.count);
  }.bind(this), 1000);
}
new Counter(); // 1 2 3 ...

而箭头函数让这一切变得简单:箭头函数没有自己的 this,它会捕获定义时外层作用域的 this

javascript

function Counter() {
  this.count = 0;
  setInterval(() => {
    this.count++; // 这里的 this 继承自 Counter 实例
    console.log(this.count);
  }, 1000);
}
new Counter(); // 1 2 3 ...

能做什么:在回调、事件监听、Promise 等场景中,优雅地保留外层 this,避免繁琐的 self = this 或 bind

注意:箭头函数的 this 一旦确定,就无法通过 call/apply/bind 改变,所以不能用于动态上下文。


场景6:嵌套函数中的 this 问题

在对象方法内部定义普通函数,这个普通函数的 this 会指向全局(或 undefined),这常常让人困惑:

javascript

const obj = {
  name: 'obj',
  foo() {
    function bar() {
      console.log(this.name);
    }
    bar(); // 非严格模式输出 undefined 或 window.name
  }
};
obj.foo();

如何让 bar 也能访问 obj 的 name?有几种方法:

  • 用箭头函数(推荐):

    javascript

    foo() {
      const bar = () => {
        console.log(this.name);
      };
      bar(); // obj
    }
    
  • 在外层保存 this

    javascript

    foo() {
      const self = this;
      function bar() {
        console.log(self.name);
      }
      bar();
    }
    
  • 用 bind

    javascript

    foo() {
      function bar() {
        console.log(this.name);
      }
      bar.bind(this)();
    }
    

能做什么:保证嵌套函数也能访问外层对象的属性,避免作用域丢失。


四、this 绑定规则优先级(一句话总结)

当多种规则同时适用时,this 的绑定优先级是:

new 绑定 > 显式绑定(call/apply/bind) > 隐式绑定(对象方法) > 默认绑定(独立调用)

箭头函数不参与这个优先级,它完全由外层作用域决定。


五、总结与思考

回到最初的问题:this 能做什么?

  • 它让函数灵活地适应不同的调用对象,实现代码复用;
  • 它在构造函数中帮助我们初始化实例;
  • 它在事件处理中方便操作当前元素;
  • 它通过显式绑定让我们能动态指定上下文;
  • 它配合箭头函数,优雅地解决了回调中的 this 保持问题。

掌握 this 的关键,不是死记硬背规则,而是在写代码时问自己:这个函数是怎么被调用的?  调用方式决定了 this 的指向。

希望这篇文章能帮你从“this 是什么”的困惑,走向“this 能做什么”的熟练应用。如果你有更多关于 this 的实战经验或疑惑,欢迎在评论区留言讨论!


最后留个思考题:下面代码的输出是什么?为什么?

javascript

const length = 10;
function fn() {
  console.log(this.length);
}
const obj = {
  length: 5,
  method(fn) {
    fn();
    arguments[0]();
  }
};
obj.method(fn, 1);

(答案:先输出 10(或 undefined),然后输出 2。因为第一次调用 fn() 是默认绑定,第二次 arguments[0]() 是隐式绑定,this 指向 arguments 对象,其 length 是传入的参数个数,即 2。)

欢迎留言你的答案和理解!我们下期再见。

#前端、#前端面试、#干货

如果这篇这篇文章对您有帮助?关注、点赞、收藏,三连支持一下。
有疑问或想法?评论区见

小程序-下拉刷新不走回调函数

作者 喂_balabala
2026年3月18日 15:15

下拉刷新

配置与回调

  • .json 文件中添加配置开启下拉刷新
{
  "enablePullDownRefresh": true,//开启下拉刷新
  "backgroundTextStyle": "dark" //配置颜色
}
  • onPullDownRefresh 是下拉刷新的回调函数
/**
* 页面相关事件处理函数--监听用户下拉动作
*/
onPullDownRefresh() {
    wx.showNavigationBarLoading();
},
  • stopPullDownRefresh 是自己写的停止下拉动效函数
stopPullDownRefresh() {
    wx.stopPullDownRefresh();
    wx.hideNavigationBarLoading();
},

Question

Q1: 下拉动效出来了,但是没有触发回调函数
原因: 页面问题:页面高度 = 屏幕高度,没有任何可滚动空间
  • 代码里的布局逻辑(必然是有这种结构):
page { height: 100%; }
.container { height: 100vh; }
.full_screen_container { height: 100%; }
  • 这种写法会导致:

  • 页面高度 = 手机屏幕高度 → 页面无法滚动 → 系统认为 “没有下拉动作” → 不触发 onPullDownRefresh 回调

  • 但!系统依然会播放下拉动画(因为配置开着)。

  • 下拉刷新动画是 【系统全局自动触发】 的,只要配置了 enablePullDownRefresh:true,不管页面能不能滚动、不管回调写没写,动画都会出现!

  • onPullDownRefresh ()回调函数是业务逻辑触发,必须满足页面存在可滚动区域 + 页面真的发生了下拉滚动行为才会执行!

解决方案
方案一:
  • 把根容器改成这样
/* 必须去掉固定 100% 高度!!! */
page {
  height: auto; /* 关键 */
  min-height: 100%;
}

.container {
  min-height: 100vh; /* 不能写死 height */
  overflow: visible;
}
方案二:
/* 给页面加一个看不见的高度,强制让页面可滚动 */
page::after {
  content: '';
  display: block;
  height: 1rpx;
}

Python 短信接口高效集成指南:Django/Flask 框架最佳实践

2026年3月18日 15:07

在 Django/Flask 后端开发中,python 短信接口的集成是用户验证码发送、订单状态通知、风控提醒等核心业务的必备环节,但多数开发者常因框架适配逻辑不当、异步处理缺失、参数配置不规范,导致 python 短信接口响应超时、高并发下服务阻塞、错误码排查效率低等问题。本文聚焦 python 短信接口在 Django/Flask 框架的高效集成,拆解不同框架的适配原理,提供同步 / 异步两种实现方案,结合生产级优化技巧,帮助开发者快速落地高可用的短信发送功能。

b-1.jpg

一、Python 短信接口开发核心基础

1.1 短信接口通信原理

python 短信接口本质是基于 HTTP/HTTPS 协议的 RESTful 接口调用,核心逻辑可拆解为三步:1)组装认证参数(account、password)和业务参数(mobile、content);2)向短信服务商 API 地址发送请求;3)解析 JSON/XML 响应并处理业务逻辑。主流 python 短信接口均支持 POST/GET 请求,字符编码为 UTF-8,如互亿无线提供的标准化 python 短信接口文档,明确了框架适配时的参数传递规则,是行业内典型的参考范式。

1.2 Django/Flask 适配核心差异

Django 和 Flask 作为主流 Python Web 框架,集成 python 短信接口的核心差异体现在异步处理和任务调度上,具体对比如下:

表格

框架 异步实现方式 任务调度推荐 适用场景
Flask 基于 aiohttp + 协程 Celery/APScheduler 轻量应用、高并发接口
Django 基于视图异步 / Celery Celery/Django Q 中大型项目、复杂业务

二、Flask 框架集成 Python 短信接口实战

2.1 同步实现(基础版)

同步方案适合低并发的轻量场景,开发成本低、易上手,核心依赖requests库实现 HTTP 请求:

python

运行

from flask import Flask, request, jsonify
import requests

app = Flask(__name__)

@app.route('/send-sms', methods=['POST'])
def send_sms():
    """Flask同步实现Python短信接口调用"""
    # 获取请求参数
    data = request.get_json()
    mobile = data.get('mobile')
    content = data.get('content')
    
    # 手机号脱敏与格式校验(避免完整手机号泄露)
    if not mobile or len(mobile) != 11:
        return jsonify({"code": 406, "msg": "手机号格式不正确"})
    safe_mobile = mobile[:3] + "****" + mobile[7:]
    
    # 短信接口配置(注:需通过注册链接获取APIID/APIKEY:http://user.ihuyi.com/?udcpF6)
    config = {
        "api_url": "https://api.ihuyi.com/sms/Submit.json",
        "account": "你的APIID",  # 替换为实际APIID
        "password": "你的APIKEY"  # 替换为实际APIKEY
    }
    
    # 组装请求参数(符合接口UTF-8编码规范)
    params = {
        "account": config["account"],
        "password": config["password"],
        "mobile": mobile,
        "content": content
    }
    
    try:
        # 发送同步POST请求,设置10秒超时避免阻塞
        response = requests.post(
            config["api_url"],
            data=params,
            headers={"Content-Type": "application/x-www-form-urlencoded"},
            timeout=10
        )
        result = response.json()
        app.logger.info(f"手机号[{safe_mobile}]短信发送结果:{result}")
        return jsonify(result)
    except requests.exceptions.Timeout:
        return jsonify({"code": 0, "msg": "请求超时"})
    except Exception as e:
        return jsonify({"code": 0, "msg": f"发送失败:{str(e)}"})

if __name__ == "__main__":
    app.run(debug=True)

2.2 异步优化(高并发版)

同步方案会阻塞 Flask 主线程,高并发场景下需基于aiohttp协程实现异步调用,核心代码如下:

python

运行

from flask import Flask, request, jsonify
import aiohttp
import asyncio

app = Flask(__name__)

async def async_send_sms(mobile, content):
    """异步调用Python短信接口(非阻塞)"""
    config = {
        "api_url": "https://api.ihuyi.com/sms/Submit.json",
        "account": "你的APIID",
        "password": "你的APIKEY"
    }
    params = {
        "account": config["account"],
        "password": config["password"],
        "mobile": mobile,
        "content": content
    }
    
    async with aiohttp.ClientSession() as session:
        async with session.post(
            config["api_url"],
            data=params,
            headers={"Content-Type": "application/x-www-form-urlencoded"},
            timeout=aiohttp.ClientTimeout(total=10)
        ) as resp:
            return await resp.json()

@app.route('/async-send-sms', methods=['POST'])
def async_send_sms_view():
    """Flask异步短信发送接口"""
    data = request.get_json()
    mobile = data.get('mobile')
    content = data.get('content')
    
    if not mobile or len(mobile) != 11:
        return jsonify({"code": 406, "msg": "手机号格式不正确"})
    
    # 启动协程任务,避免阻塞主线程
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    result = loop.run_until_complete(async_send_sms(mobile, content))
    loop.close()
    
    return jsonify(result)

if __name__ == "__main__":
    app.run(debug=True)

三、Django 框架集成 Python 短信接口最佳实践

3.1 基于视图函数的同步实现

Django 基础版集成方案适配快速开发场景,通过视图函数直接调用 python 短信接口:

python

运行

# sms/views.py
from django.http import JsonResponse
from django.views.decorators.http import require_POST
import requests

@require_POST
def send_sms(request):
    """Django同步实现Python短信接口调用"""
    mobile = request.POST.get('mobile')
    content = request.POST.get('content')
    
    # 手机号校验与脱敏
    if not mobile or len(mobile) != 11:
        return JsonResponse({"code": 406, "msg": "手机号格式不正确"})
    safe_mobile = mobile[:3] + "****" + mobile[7:]
    
    # 接口基础配置
    config = {
        "api_url": "https://api.ihuyi.com/sms/Submit.json",
        "account": "你的APIID",
        "password": "你的APIKEY"
    }
    
    params = {
        "account": config["account"],
        "password": config["password"],
        "mobile": mobile,
        "content": content
    }
    
    try:
        response = requests.post(
            config["api_url"],
            data=params,
            headers={"Content-Type": "application/x-www-form-urlencoded"},
            timeout=10
        )
        result = response.json()
        return JsonResponse(result)
    except Exception as e:
        return JsonResponse({"code": 0, "msg": f"发送失败:{str(e)}"})

# urls.py 配置路由
from django.urls import path
from . import views

urlpatterns = [
    path('send-sms/', views.send_sms, name='send-sms'),
]

demo-python.png

3.2 结合 Celery 的异步发送(生产级)

Django 生产环境中,推荐用 Celery 处理异步任务,避免阻塞 WSGI 进程,核心实现如下:

python

运行

# sms/tasks.py(Celery异步任务)
from celery import shared_task
import requests

@shared_task
def send_sms_task(mobile, content):
    """Celery异步任务:发送短信"""
    config = {
        "api_url": "https://api.ihuyi.com/sms/Submit.json",
        "account": "你的APIID",
        "password": "你的APIKEY"
    }
    
    params = {
        "account": config["account"],
        "password": config["password"],
        "mobile": mobile,
        "content": content
    }
    
    try:
        response = requests.post(
            config["api_url"],
            data=params,
            headers={"Content-Type": "application/x-www-form-urlencoded"},
            timeout=10
        )
        return response.json()
    except Exception as e:
        return {"code": 0, "msg": str(e)}

# sms/views.py(视图调用异步任务)
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from .tasks import send_sms_task

@require_POST
def async_send_sms(request):
    mobile = request.POST.get('mobile')
    content = request.POST.get('content')
    
    if not mobile or len(mobile) != 11:
        return JsonResponse({"code": 406, "msg": "手机号格式不正确"})
    
    # 提交Celery任务(非阻塞,立即返回)
    task = send_sms_task.delay(mobile, content)
    return JsonResponse({"code": 2, "msg": "任务已提交", "task_id": task.id})

四、Python 短信接口常见问题排查与优化

4.1 高频错误码解析

集成 python 短信接口时,以下错误码占比超 80%,对应解决方案如下:

  1. 错误码 405:API ID/KEY 错误 → 核对注册的认证信息,确保无拼写 / 空格错误;
  2. 错误码 4052:访问 IP 与备案 IP 不符 → 在短信服务商后台配置服务器 IP 白名单;
  3. 错误码 4072:内容与模板不匹配 → 严格按审核通过的模板拼接变量,禁止篡改固定内容;
  4. 错误码 4085:单手机号单日验证码超限 → 前端增加 60 秒发送间隔,后端记录发送次数并限制。

4.2 跨框架通用优化技巧

  1. 参数前置校验:对手机号格式(11 位数字)、短信内容长度(≤500 字)做前置校验,减少无效接口调用;
  2. 手机号脱敏:日志 / 返回结果中仅保留脱敏手机号(如 138****1234),避免数据泄露;
  3. 超时控制:所有 python 短信接口调用设置 10 秒超时,防止长时间阻塞框架进程;
  4. 重试机制:对网络波动导致的 4086 错误,设置 1-2 次重试(间隔 1 秒),降低失败率;
  5. 日志埋点:记录请求参数(脱敏)、响应结果、耗时,便于线上问题快速定位。

五、Django vs Flask 集成方案对比

表格

维度 Django 集成方案 Flask 集成方案
异步实现 Celery(成熟稳定) aiohttp 协程(轻量高效)
开发成本 中(需配置 Celery) 低(协程直接调用)
并发能力 高(Celery 分布式) 高(协程非阻塞)
生产适配 适合中大型项目 适合轻量微服务
学习曲线 较陡(Celery 配置) 平缓(协程易理解)

总结

  1. python 短信接口在 Django/Flask 中的集成核心是适配框架的异步特性,Flask 优先用 aiohttp 协程实现轻量异步,Django 生产环境推荐结合 Celery 实现分布式异步发送;
  2. 开发时需重点关注参数校验、手机号脱敏、超时控制,针对 405/4052/4072 等高频错误码做好针对性处理;
  3. 不同框架的集成方案各有优劣,需根据项目规模(轻量 / 中大型)选择适配方式,确保 python 短信接口的高可用与高性能。

TS 入门:给 React 穿上“防弹衣”

作者 玉米Yvmi
2026年3月18日 14:50

前言
JavaScript 像是一位随性的艺术家,自由但易错;TypeScript 则是一位严谨的工程师,用类型系统为我们筑起防线。

很多新手觉得 TS 繁琐,那是还没掌握“正确姿势”。今天,我不讲枯燥理论,直接通过实战场景,带你把 TS 融入 React 的血脉。

一、组件的“身份证”:Props 精准定义

在 JS 中,Props 靠“口头约定”;在 TS 中,Props 必须有“身份证”。

传错参数?漏传必填项?运行时才报错?NO!

使用 interface 定义契约,利用 React.ReactNode 兼容所有内容。

// 第一步:定义契约
interface AaaProps {
  name: string;        // 必填:必须是字符串
  age?: number;        // 可选:注意那个问号 '?'
  content: React.ReactNode; // 万能容器:字符串/JSX/Fragment 都能装
}

// 第二步:应用契约 (推荐写法)
function Aaa({ name, content }: AaaProps) {
  return <div> Hi, {name} | {content}</div>;
}

// 第三步:安全使用
export default function App() {
  // TS 会立刻拦截:如果忘记传 name,或者 content 传了数字,直接标红!
  return <Aaa name="玉米🌽" content={<span>我是内容</span>} />;
}
  • ? 的作用:明确区分“可有可无”和“必须拥有”。
  • React.ReactNode:比 any 安全,比 string 灵活,它是 React 内容的“最大公约数”。
  • 解构赋值:直接在函数参数中解构 { name },代码更清爽,TS 依然能自动推断类型。

二、Hooks 的“导航仪”:泛型让状态不再模糊

useStateuseRef 是 React 的左右手,但在 TS 中,如果不加泛型 <T>,它们就像失去了导航的船。

场景 A:状态初始化

// 没给初始值时,TS 默认它是 undefined
const [num, setNum] = useState<number>(); 
// 类型推断:number | undefined

// 给了初始值,TS 就知道它永远是 number
const [count, setCount] = useState<number>(0); 
// 类型推断:number

场景 B:Ref 的双重身份

useRef 既能抓 DOM,也能存数据。怎么区分?看泛型!

// 身份 1:DOM 捕手
const inputRef = useRef<HTMLInputElement>(null);
// current 可能是 HTMLInputElement 或 null

// 身份 2:数据储物柜 (不触发重渲染)
const storeRef = useRef<{ num: number }>(null);

// 安全赋值
if (storeRef.current) {
  storeRef.current.num = 2; // TS 知道这里有 num 属性
}

泛型就像一个**“模具”**。

  • 倒入 HTMLInputElement,它就是抓 Input 的夹子。
  • 倒入 { num: number },它就变成了存数据的盒子。
  • 如果不指定模具,TS 就只能给你一团模糊的橡皮泥(any 或推断错误)。

三、打通任督二脉:ForwardRef 的类型接力

父组件想操作子组件的 DOM?forwardRef 是桥梁,但 TS 需要知道这座桥通向哪里。

核心三步走

// 定义子组件:明确 Ref 的目标是 input
const Child = forwardRef<HTMLInputElement>((props, ref) => {
  return <input ref={ref} placeholder="请聚焦我" />;
});

// 父组件声明:Ref 类型必须与子组件一致
const parentRef = useRef<HTMLInputElement>(null);

// 安全调用:使用可选链 '?.' 防止 null 报错
useEffect(() => {
  parentRef.current?.focus(); 
}, []);

如果子组件说“我要 Input”,父组件却传了个 div 的 ref,TS 编译器会直接亮红灯。这种端到端的类型检查,彻底杜绝了 Cannot read property 'focus' of null 的低级错误。

四、性能优化的“类型护航”

当项目变大,useReducermemo 登场。TS 能让你的优化逻辑无懈可击。

状态建模:Action 联合类型

这是 TS 最强大的特性之一:判别联合类型

// 定义状态
interface State { result: number; }

// 定义动作 (关键!限制 type 的取值)
type Action = 
  | { type: 'add'; num: number } 
  | { type: 'minus'; num: number };

// Reducer:TS 会自动根据 type 推断 action 的结构
function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'add': 
      // 这里 TS 知道 action 一定有 num
      return { result: state.result + action.num };
    case 'minus':
      return { result: state.result - action.num };
    default:
      return state;
  }
}

配合 Memo 优化

// 缓存计算结果
const count = useMemo(() => res.result * 10, [res.result]);

// 缓存函数引用 (防止子组件无效渲染)
const cb = useCallback(() => 666, []);

// 子组件:只有 props 变了才渲染
const Child = memo(({ count }: { count: number }) => (
  <h2>计算结果:{count}</h2>
));

如果你手误写了 dispatch({ type: 'delete' ... }),TS 会立刻告诉你:“没有 delete 这个动作!”。这在 JS 中可能要等到用户点击按钮报错了才能发现。

结语

TypeScript 初看是束缚,实则是赋予你重构底气的“防弹衣”。它让你从“运行后报错”的被动救火,转向“编写时即知”的主动掌控,将潜在的隐患扼杀在编译阶段。

不必追求一步到位,渐进式地收窄每一个 any,都是在为代码大厦加固地基。当你习惯了类型系统带来的智能提示与安全边界,便真正完成了从“码农”到“工程师”的思维跃迁。

js并发请求,且限制并发请求数量实现方案

2026年3月18日 14:37

说明

  • 前端会遇到这种需求,希望多个请求并发加快前端反应速度,但又要限制同时请求的数量,防止服务器压力过大
  • 以下代码由Trae里的千问模型生成,思路真牛

方案

async function batchQuset(){
      this.$store.commit('openLoading')
      const questNum = this.paramsList.length
      // 每批最多 3 个并发请求
      const BATCH_SIZE = 3
      // 保持顺序的结果数组
      const numResList = new Array(questNum).fill(null) 
      // 并发控制函数
      const runWithConcurrency = async (tasks, concurrency) => {
        const results = []
        // 维护一个正在执行的 Promise 列表(控制并发就靠它)
        const executing = []
        for (const task of tasks) {
          // p 是 then() 方法返回的新 Promise,不是 task() 的原始 Promise
          const p = task().then((result) => {
            // 当task执行完成后,从executing数组中移除
            executing.splice(executing.indexOf(p), 1)
            return result
          })
          results.push(p)
          executing.push(p)
          // 当正在执行的任务数量超过并发限制时,等待任意一个完成
          if (executing.length >= concurrency) {
            // 等有完成的Promise时,外面的for循环才能继续走
            await Promise.race(executing)
          }
        }
        // 走到这里时,仅剩下小于并发数量的Promise还没彻底完成
        return Promise.all(results)
      }
      // 创建所有校验任务[Promise,Promise]
      const tasks = this.paramsList.map(
        (params, index) => async () => {
          const { pass, res } = await questApi(params)
          this.$store.commit(
            'openLoading',
            `进度: ${index + 1}/${questNum}`
          )
          return { index, pass, res }
        }
      )
      // 执行并发校验
      const results = await runWithConcurrency(tasks, BATCH_SIZE)
      // 按原始顺序填充结果
      results.forEach(({ index, pass, res }) => {
        if (!pass) {
          numResList[index] = res
        }
      })
      // 过滤掉 null 值,得到最终的列表
      const resList = numResList.filter((item) => item !== null)
      this.$store.commit('closeLoading')
      // *****请求结果resList进行展示/处理******
}

说明

  • this.$store.commit('openLoading')是自己把element的loading二次封装,方便唤起和改文字
import Vuex from "vuex";
import { Loading } from 'element-ui';
export default new Vuex.Store({
    state: {
    // 全屏loading实例
    loading: null,
    },
    mutations:{
     openLoading(state, str) {
      if (state.loading) {
        state.loading.setText(str)
      } else {
        state.loading = Loading.service({
          lock: true,
          text: str,
          spinner: 'el-icon-loading',
          background: 'rgba(0, 0, 0, 0.7)',
          customClass: 'phone-loading'
        })
      }
    },
    closeLoading(state) {
      state.loading?.close()
      state.loading = null
    },
    }
})
  • then 中 return result 的作用 :
    • 当 then 的回调函数执行完毕后, p 这个 Promise 会被 resolve
    • p 被 resolve 的值就是 return 后面的值
    • 如果 return result ,则 p 解析为 result
    • 如果不 return 或 return undefined ,则 p 解析为 undefined
// 示例 1:return result
const p1 = task().then((result) => {
  console.log('收到 result:', result)  // 假设 result = '任务完成'
  return result  // return '任务完成'
})

p1.then((value) => {
  console.log('p1 的值:', value)  // 输出:'任务完成'
})

// 示例 2:不 return
const p2 = task().then((result) => {
  console.log('收到 result:', result)  // 假设 result = '任务完成'
  // 没有 return 语句
})

p2.then((value) => {
  console.log('p2 的值:', value)  // 输出:undefined
})

// 示例 3:return 其他值
const p3 = task().then((result) => {
  console.log('收到 result:', result)  // 假设 result = '任务完成'
  return '修改后的值'
})

p3.then((value) => {
  console.log('p3 的值:', value)  // 输出:'修改后的值'
})

给 JS穿上铠甲:TypeScript 基础核心概念详解(类型/接口/泛型)

作者 玉米Yvmi
2026年3月18日 13:52

前言

曾经,我沉醉于 JavaScript 的灵活与自由。变量可以随意赋值,函数参数无需声明,一切看起来都那么随心所欲。直到有一天,一个看似简单的 undefined is not a function 错误在生产环境爆发,我才惊觉:这种“自由”有时更像是在没有护栏的悬崖边跳舞。

在深入探索 TypeScript 的过程中,我深刻体会到了它带来的秩序之美。今天,我想结合实战代码,和大家聊聊 TypeScript 的基础,希望能帮同样在转型的你,穿上铠甲,从容前行。

一、混沌与秩序:为什么我们需要类型?

在学习 TypeScript 之前,我们先回看一下 JavaScript 的世界。

在 JavaScript 中,变量就像是一个个没有标签的盒子。你可以把数字放进去,下一秒又可以把它拿出来,换成一个字符串。这种“弱类型”特性虽然开发速度快,但也埋下了隐患。编译器直到代码运行的最后一刻,才知道盒子里装的是什么。如果此时盒子里的东西不是我们预期的,程序就会崩溃。

// JavaScript 的动态类型陷阱
function add(a, b) {
  // 运行时才能发现类型问题
  if (typeof a === 'number' && typeof b === 'number') {
    return a + b
  }
  return undefined; 
}

// 调用时传入了字符串,编译器不会报错,但逻辑可能非预期
const result = add(1, '2'); 
console.log(result); // 输出 undefined,而非报错

相比之下,C 语言等“强类型”语言则要求我们在定义变量时就必须声明类型,一旦类型不匹配,编译直接失败。

TypeScript 正是为了解决 JavaScript 的痛点而生。它给 JavaScript 加上了静态类型的“护栏”。它不改变 JS 的运行机制,而是在代码运行前(编译阶段)就帮我们检查类型是否正确。

看,这是迈向 TypeScript 的第一步:

// TypeScript 的类型注解
let a: number = 1;
// a = '2';  // ❌ 报错!TypeScript 会大声告诉你:'2' 不能赋值给 number 类型的变量
console.log(a);

这就好比给变量贴上了标签。一旦贴上 number 的标签,这个盒子就只能装数字。如果你试图塞进字符串,TS 编译器会立即拦截,将错误扼杀在摇篮里。

二、基础数据类型:构建类型的基石

有了类型注解的概念,我们就可以开始构建更复杂的数据结构了。TS 提供的一系列基础类型,是我们搭建程序的砖瓦。

1. 布尔值与数字

最基础的类型,对应 JS 中的 booleannumber

let isDone: boolean = false;
let count: number = 123;

2. 字符串与字面量类型

除了普通的字符串,TS 还允许我们定义“字面量类型”,即变量只能是某个特定的字符串值。这在做状态管理时非常有用,就像给变量限定了唯一的“身份证号”。

const hello = 'hello';
const a: 'hello' = 'hello'; // ✅ 正确
// const b: 'hello' = 'world'; // ❌ 错误,只能是 'hello'

3. 数组与元组

数组用来存储相同类型的列表,而元组(Tuple)则像是固定长度的“混合容器”,可以存储不同类型的值,但顺序和类型必须严格对应。

// 普通数组:只能装数字
let list: number[] = [1, 2, 3];

// 元组:第一个必须是 number,第二个必须是 string
let tuple: [number, string] = [1, 'hello']; 
// let errorTuple: [number, string] = ['hello', 1]; // ❌ 类型错位,编译器直接红牌罚下

4. 枚举(Enum)

枚举让我们可以定义一组命名的常量,让代码可读性更强。就像给方向定义了名字,而不是使用晦涩的数字。

enum Direction {
  NORTH,
  SOUTH,
  EAST,
  WEST
}
let dir: Direction = Direction.NORTH; // 比直接写 0 更易读,代码自文档化

5. Any 与 Unknown:双刃剑与保险丝

在迁移旧代码时,我们难免会遇到类型不确定的情况。JS 开发者习惯用 any,它意味着“关闭类型检查”。

let notSure: any = 100;
notSure = '123'; // ✅ 随便改,TS 不管了,这里失去了保护

any 用多了,TS 就退化成 JS 了,失去了保护意义。

TS 提供了更安全的 unknown。它和 any 一样可以接收任何类型,但在你使用它之前,必须进行类型判断或断言。这就像是一个带保险丝的电路,虽然通电,但必须先确认安全才能使用。

let value: unknown = 123;
value = '123';

// let abc: string = value; // ❌ 报错!不能直接把 unknown 赋给 string
// 必须先收窄类型,确认安全
if (typeof value === 'string') {
  let abc: string = value; // ✅ 安全了,TS 知道此时 value 一定是 string
}

6. Void, Null, Undefined 与 Symbol

这些类型分别对应无返回值、空值、未定义以及唯一的标识符。特别是 void,常用于没有返回值的函数,明确告诉调用者“别指望我有返回值”。

function warnUser(): void {
  console.log("This is my warning message");
  // 这里不需要 return 任何值,甚至 return undefined 也是允许的
}

三、对象与接口:描绘数据的形状

在实际开发中,我们处理最多的往往是对象。如何描述一个对象的“形状”?TS 提供了 接口类型别名

接口就像是建筑的蓝图,规定了对象必须拥有哪些属性,哪些是可选的。

interface Person {
  name: string;
  age: number;
  sex?: string; // ? 表示可选属性,就像装修时的“预留接口”
}

const p: Person = {
  name: '探长',
  age: 20
  // sex 属性可选,不写也不会报错,系统依然认为它是合法的 Person
};

除了接口,TS 还提供了强大的类型运算。我们可以像搭积木一样组合类型。 使用了交叉类型(&)来合并两个类型,创造出新的形态:

type PartialX = { x: number };

// Point 类型既要有 x,也要有 y,通过 & 将两个类型“焊接”在一起
type Point = PartialX & { y: number };

const p: Point = {
  x: 1,
  y: 2
};

这就像是将两块拼图完美地拼在一起,形成了一个新的、更完整的形状。这种组合能力让 TS 在处理复杂数据结构时游刃有余,避免了重复定义。

四、泛型:类型的“模具”

如果说接口是描述具体对象的蓝图,那么泛型(Generics)就是制造蓝图的模具

想象一下,你要写一个函数,它的功能是“原样返回传入的参数”。

  • 如果传入数字,返回数字;
  • 如果传入字符串,返回字符串。

在没有泛型之前,我们可能要用 any,但这会丢失类型信息,导致调用者不知道返回的是什么。泛型允许我们将类型作为一个参数传递进去,让函数具有“多态”的能力,且保持类型安全。

// T 是一个类型占位符,调用时确定具体是什么类型
// 就像是一个通用的容器,里面装什么,倒出来就是什么
function identity<T>(value: T): T {
  return value;
}

// 调用时指定 T 为 number
const num = identity<number>(100); 
// num 的类型被推断为 number

// 调用时指定 T 为 string
const str = identity<string>('hello');
// str 的类型被推断为 string

泛型还可以同时接受多个类型参数,甚至用于约束数组等复杂结构,极大地提高了代码的复用性:

// 定义一个既可以存 number 也可以存 string 的数组
let arr: Array<number | string> = [1, 2, 3, '1'];

泛型让代码变得更加灵活且安全,它是 TS 进阶的必经之路,也是区分新手与老手的关键标志。

五、类型断言与守卫:掌控不确定性

有时候,我们比编译器更清楚某个变量的类型。比如在处理 DOM 元素或者第三方库返回的数据时。这时,我们可以使用类型断言,告诉编译器:“相信我,我知道我在做什么。”

TS 提供了两种断言方式,推荐使用的是 as 语法:

let someValue: any = 'this is a apple';

// 方式一:as 语法(推荐,兼容性好)
let strLength = (someValue as string).length;

// 方式二:尖括号语法(不能在 JSX/TSX 中使用,容易与 HTML 标签混淆)
// let strLength = (<string>someValue).length;

但断言并非万能,盲目断言可能导致运行时错误。更优雅的方式是使用类型守卫。通过 typeofinstanceof 或自定义判断函数,在代码块内部收窄类型范围。这就像是在迷雾中点亮一盏灯,只有走进灯光范围(if 语句块内),变量的真实面目才会被看清,TS 也会随之放宽限制,允许你访问特定类型的方法。

function printId(id: number | string) {
  if (typeof id === "string") {
    // 在这里,id 的类型被收窄为 string
    console.log(id.toUpperCase());
  } else {
    // 在这里,id 的类型被收窄为 number
    console.log(id);
  }
}

结语:从束缚到自由

回顾这段旅程,我们经历了从“随意赋值”的混乱,到“严格定义”的束缚,最后达到了“类型安全下的自由”。TypeScript 并不是要给 JavaScript 戴上沉重的枷锁,而是为我们提供了一套精密的导航系统。

学习之路漫长,这些基础只是探索 TS 世界的起点。希望这篇文章能帮你理清 TS 的脉络,让你在写代码时多一份底气,少一份 undefined 的惊吓。

WebMCP 时代已至 - Chrome WebMCP 使用指南

作者 flutter
2026年3月18日 13:33

文章同步发布于我的 个人博客

在不久前发布的 Chrome 146 版本中增加了实验性的 WebMCP API, 标志着 WebMCP 时代的到来, WebMCPGoogleMicrosoft 提出的一个用于让 AI Agent 直接操作 Web 页面的 MCP API, 我们来尝试通过编写一个简单的支持 WebMCP 的页面来学习一下 WebMCP API

[!WARNING] 截至文章发布时(2026-03-18), Chrome 146 已经发布, 可以在 Chrome 的设置页面 chrome://settings/help 查看当前浏览器版本: chrome-settings-page.pngChrome 146 虽然已经发布, 但依然属于实验性的 API, 需要访问 chrome://flags/#enable-webmcp-testing 手动开启: chrome-flags-enable-webmcp-testing.png 开启后需要重启 Chrome 才能生效, 验证一下是否生效: chrome-console-model-context.png 在浏览器控制台中执行 navigator.modelContext 如果输出了 ModelContext 对象则说明当前已经支持 WebMCP

[!TIP] 由于 WebMCP API 比较新, LLM 还没有相关的知识, 再加上现在的互联网是绝大部分文章都是 AI 生成的, 互联网上现有的文章和教程全部对 AI Agent 如何调用 WebMCP 避而不谈, 没有任何参考价值, 本文我来尝试一下让 Claude Code 调用 WebMCP, 并提供相关的 Skills

介绍

什么是 WebMCP

WebMCP 是浏览器提供的 MCP API, 它实现了 Web 页面上 声明 MCP ToolsAI Agent 进行调用

我们来详细介绍一下 WebMCP工作原理:

  1. Web 开发者将页面上的功能以 tools 的形式进行公开
  2. AI Agent 调用浏览器打开 Web 页面, 通过 WebMCP API 读取所有的 tools
  3. AI Agent 调用这些 tools 来操作 Web 页面, 然后将信息返回给 AI Agent
  4. AI Agent 根据返回的信息, 继续进行对话或操作网页

webmcp-shopping-example.png

举个简单的例子, 以购物网站为例, 假设我想购买一部手机:

  1. AI Agent 进行对话, 描述我的需求和预算, 例如 我想在某购物网站购买一部手机, 预算在 3000 元左右, 要有高刷, 电池容量要大, ...
  2. AI Agent 调用浏览器相关的 MCP(例如 chrome-devtools-mcp, 使用方式可参考我的 另一篇文章), 访问某购物网站
  3. 从此网站读取所有的 webmcp tools, 调用商品搜索相关的 tool, 并增加筛选条件(例如屏幕刷新率 / 电池容量 / 价格 等条件)和排序
  4. 在页面中已经展示所有符合条件的商品, 并且 AI Agent 也获取到了商品的 JSON 数据
  5. 继续进行对话或浏览网页 ...

WebMCP Tool

本质上 WebMCP API 的实现非常简单, 它只是在当前网页上定义的一系列 Function:

  • navigator.modelContext.registerTool(tool): 注册 tool
  • navigator.modelContext.unregisterTool(name): 删除 tool
  • navigator.modelContext.provideContext(): 注册顶级/应用级别的 tool
  • navigator.modelContext.clearContext(): 删除所有 tool

WebMCPtoolMCP 中的 tool 数据结构一致, 下面是一个简单的 demo:

navigator.modelContext.registerTool({
  name: 'get-page-title',
  description: 'Get the current page title',
  inputSchema: { type: 'object', properties: {} },
  async execute() {
    return {
      content: [{ type: 'text', text: document.title }],
    };
  },
});

其中 inputSchema 使用 JSON Schema 格式描述接受的参数, execute 是调用 tool 时执行的函数

除此之外, 还有另外一个用于调试的 API navigator.modelContextTesting:

  • navigator.modelContextTesting.listTools(): 获取所有注册的 tool
  • navigator.modelContextTesting.executeTool(name, argsJson, options?): 执行一个 tool 并提供参数
  • navigator.modelContextTesting.executeTool(name, source, options?): 执行一个 tool 并提供参数(流式请求)
  • navigator.modelContextTesting.registerToolsChangedCallback(callback): 监听 tool 注册/注销事件
  • navigator.modelContextTesting.getCrossDocumentScriptToolResult(): 以序列化字符串的形式返回跨文档声明式工具的结果

具体使用方式可以参考 Demo

API 参考

你可以在 mcp-b 的文档 中查看 WebMCP 的详细 API 参考, 这也是现有的唯一可以参考的文档, 注意, 这仍然是一个实验性的 API, 未来可能会有变化

为什么要使用 WebMCP

现阶段 AI Agent 调用浏览器操控 Web 页面有以下三种方式

这三种方式存在以下问题:

  • 需要 消耗大量的 token, 因为本质上是完全读取整个页面进行分析
  • 操作的 步骤越多, 耗时会越长
  • 操作不一定准确, 对于复杂的页面无法进行准确的操作, 例如需要滚动才能看到的元素

WebMCP 可以让 Web 开发者直接提供 官方 的操作方式:

  • 无需解析页面, 只需要调用已经注册的 tool
  • 本质上在调用 tool 时执行的是 js 代码, 不会有任何的耗时操作
  • toolsWeb 开发者进行维护, 可以确保操作的准确性和安全性

为什么不直接调用后端 API

WebMCP 的局限性在于, 它必须打开浏览器访问当前网页, 才能调用 tool, 为什么不直接调用 API 接口或者后端提供的 MCP 服务呢?

对于简单的操作当然可以直接调用 API, 网页的优势在于它可以提供给用户更丰富的可视化和更好的交互体验, 这是通过 AI Agent 的聊天窗口无法实现的, 这也是 Web 页面的魅力所在

WebMCP 的缺陷

在以下场景不应该使用 WebMCP

  • 依赖于开发者提供的 tool, 如果当前的任务没有对应的 tool 则无法使用
  • 必须打开浏览器访问当前网页, 才能获取或者调用 tool, 在草案中也提及了这一点, 其实可以通过 声明式方案 来直接获取所有 tools
  • 如果没有健全的权限管理, 很可能被滥用, 例如注册了当前用户没有权限的 tool, 或者本应该移除的 tool 没有被移除
  • 兼容性问题, 这是一个非常新的 API, 目前 WebMCP 只在 Chrome 146 及以上版本中支持, 在 caniuseMDN 中甚至都找不到任何信息; 不过因为其 API 的简单性, 可以使用 @mcp-b/webmcp-polyfill 来在旧版本的 Chrome 中使用 WebMCP

WebMCP 的应用场景与优势

对于用户来说:

  • 填写复杂的表单 的场景, 例如需要填写几十上百个字段的表单
  • 需要经过用户确认的场景, 例如生成数据填充到表单中, 让用户确认

对于 AI Agent:

  • 节省 token, 直接调用 WebMCP tools 完成任务
  • 可以在 WebMCP 的帮助下 100% 正确 的完成复杂的任务

对于软件测试:

  • 基于确定性的 WebMCP tools 调用, 降低测试用例的维护成本
  • 基于封装的 WebMCP 减少测试用例的不稳定性

使用 WebMCP

选型

WebMCP 还处在草案阶段, 应该只有基于 Chromium 的浏览器才会原生支持, 所以就目前来看, polyfill 是必须的, 除此之外我们还可以选择直接使用 mcp-b, 以下是这三者直接的关系

  • 浏览器原生 API: 兼容性极差, 目前只有 这些 API, 但 API 相对稳定可靠
  • @mcp-b/webmcp-polyfill: 仅仅提供原生 APIpolyfill, 不包含 mcp-bAPI
  • mcp-b: 完全兼容原生 WebMCP API, 并在此基础上提供扩展, 提供了额外的功能, 扩展的部分 API 属于非官方 API

[!TIP] 现阶段最佳选择就是 @mcp-b/webmcp-polyfill 方案, 我们也会使用此方案, 关于这三者的对比可参考 原生 API vs Polyfill vs mcp-b 全局运行时

WebMCP 调试工具

webmcp-chrome-extension.png

官方提供了一个浏览器插件 Model Context Tool Inspector 来帮助开发者调试 WebMCP, 它可以查看当前注册的 tools, 以及调用 tools 时的参数和返回值, 推荐安装, 但是感觉以后这个插件可能会集成到 Chrome devtools

我们来扒一下这个 extension 的源码:

  1. 进入扩展的详情页, 复制 ID webmcp-chrome-extension-details.png
  2. 进入 Google Chrome 的插件目录
open ~/Library/Application\ Support/Google/Chrome/Default/Extensions/gbpdfapgefenggkahomfgkhfehlcenpd

文件比较少, 我们先查看 content.js:

webmcp-chrome-extension-flag-enable-error.png

首先监测是否开启了 WebMCP Testing 标志

webmcp-chrome-extension-source.png

这里也是调用了 navigator.modelContextTesting.listTools() 来获取当前注册的 tools

创建一个新项目

接下来我们开始创建一个新项目来演示 WebMCP 的使用:

pnpm create vite@latest my-webmcp-react

.../19cf5f0e3d5-d06b                     |   +1 +
.../19cf5f0e3d5-d06b                     | Progress: resolved 1, reused 0, downloaded 1, added 1, done
│
◇  Select a framework:
│  React
│
◇  Select a variant:
│  TypeScript
│
◇  Install with pnpm and start now?
│  Yes
│
◇  Scaffolding project in /Users/kuidi/projects/my-webmcp-react...
│
◇  Installing dependencies with pnpm...

这里选择 react + typescript, 然后安装 polyfillusewebmcp:

npm install @mcp-b/webmcp-polyfill usewebmcp

然后修改 src/main.tsx, 引入 polyfill:

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { initializeWebMCPPolyfill } from '@mcp-b/webmcp-polyfill';
import './index.css'
import App from './App.tsx'

initializeWebMCPPolyfill()

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

改一下 src/App.tsx:

import { useWebMCP } from 'usewebmcp';
import './App.css'

const INPUT_SCHEMA = {
  type: 'object',
  properties: {
    name: { type: 'string' },
  },
} as const;

function App() {
  const helloTool = useWebMCP({
    name: 'say_hello',
    description: 'Returns a hello message',
    inputSchema: INPUT_SCHEMA,
    execute: async (args) => ({
      content: [{ type: 'text', text: `Hello ${args?.name ?? 'world'}!` }],
    }),
  });

  return (
    <div>
      <h1>My First React WebMCP Tool</h1>
      <p>Tool "say_hello" registered.</p>
      <p>Executions: {helloTool.state.executionCount}</p>
      <p>Last result: {helloTool.state.lastResult
        ? JSON.stringify(helloTool.state.lastResult)
        : 'none'}</p>
      {helloTool.state.error && (
        <p style={{ color: 'red' }}>Error: {helloTool.state.error.message}</p>
      )}
      <button onClick={() => helloTool.execute({ name: 'React' })}>
        Run Tool Locally
      </button>
    </div>
  );
}

export default App

然后我们点击 Run Tool Locally, 可以看到 WebMCP 成功调用了 say_hello 工具, 并返回了 Hello React!

react-webmcp-demo-page.png

AI Agent 调用 WebMCP

至此我们完成了 WebMCP 的声明, 可笑的是网络上大部分教程也都止步于此, 明明 WebMCP 是让 AI Agent 进行调用的啊, 前端自己执行算是怎么回事?

接下来我们来尝试一下在 Claude Code 中调用 WebMCP, 由于 LLM 并不知道 WebMCP API 的存在, 所以我根据 mcp-b 的文档, 编写一个简单的 skills, 我已经写好了, 可以参考 SublimeCT/webmcp-agent, 我们来直接安装它

npx skills add SublimeCT/webmcp-agent

███████╗██╗  ██╗██╗██╗     ██╗     ███████╗
██╔════╝██║ ██╔╝██║██║     ██║     ██╔════╝
███████╗█████╔╝ ██║██║     ██║     ███████╗
╚════██║██╔═██╗ ██║██║     ██║     ╚════██║
███████║██║  ██╗██║███████╗███████╗███████║
╚══════╝╚═╝  ╚═╝╚═╝╚══════╝╚══════╝╚══════╝

┌   skills
│
◇  Source: https://github.com/SublimeCT/webmcp-agent.git
│
◇  Repository cloned
│
◇  Found 1 skill
│
●  Skill: webmcp-agent
│
│  A skill for guiding AI Agents to interact with WebMCP web pages
│
◇  41 agents
◇  Which agents do you want to install to?
│  Amp, Cline, Codex, Cursor, Gemini CLI, GitHub Copilot, Kimi Code CLI, OpenCode, Claude Code, Trae
│
◇  Installation scope
│  Global
│
◇  Installation method
│  Symlink (Recommended)

│
◇  Installation Summary ───────────────────────────────────────╮
│                                                              │
│  ~/.agents/skills/webmcp-agent                               │
│    universal: Amp, Cline, Codex, Cursor, Gemini CLI +3 more  │
│    symlink → Claude Code, Trae                               │
│                                                              │
├──────────────────────────────────────────────────────────────╯
│
◇  Proceed with installation?
│  Yes
│
◇  Installation complete

│
◇  Installed 1 skill ──────────────────────────────────────────╮
│                                                              │
│  ✓ ~/.agents/skills/webmcp-agent                             │
│    universal: Amp, Cline, Codex, Cursor, Gemini CLI +3 more  │
│    symlinked: Claude Code, Trae                              │
│                                                              │
├──────────────────────────────────────────────────────────────╯

│
└  Done!  Review skills before use; they run with full agent permissions.

这里我只安装到 Claude Code 中了, 我们来通过 Claude Code 进入 mcp-b 的测试页面 webmcp.sh 来测试一下:

claude

╭─── Claude Code v2.1.62 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│                                  │ Tips for getting started                                                                         │
│           Welcome back!          │ Run /init to create a CLAUDE.md file with instructions for Claude                                │
│                                  │ ─────────────────────────────────────────────────────────────────                                │
│                                  │ Recent activity                                                                                  │
│              ▐▛███▜▌             │ No recent activity                                                                               │
│             ▝▜█████▛▘            │                                                                                                  │
│               ▘▘ ▝▝              │                                                                                                  │
│   Sonnet 4 · API Usage Billing   │                                                                                                  │
│        ~/projects/isms-ui        │                                                                                                  │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

  /model to try Opus 4.6

❯ 调用 chrome-devtools mcp 进入 https://webmcp.sh/dashboard, 通过 webmcp-agent 获取所有的路由

⏺ 我来帮你使用 chrome-devtools 进入 WebMCP 页面并获取路由信息。

  首先,让我打开浏览器并导航到该页面:

⏺ chrome-devtools - new_page (MCP)(url: "https://webmcp.sh/dashboard")
  ⎿  ## Pages
     1: about:blank
     2: https://webmcp.sh/dashboard [selected]

⏺ 页面已成功打开。现在让我使用 webmcp-agent skill 来获取所有的路由信息:

⏺ Skill(webmcp-agent)
  ⎿  Successfully loaded skill

⏺ 现在让我执行 WebMCP 的 listTools 脚本来获取所有可用的工具/路由:

⏺ chrome-devtools - evaluate_script (MCP)(function: "() => {\n  // 检查 WebMCP 是否可用\n  if (!navigator.modelContextTesting || typeof
                                          navigator.modelContextTesting.listTools !== 'function') {\n    return {\n      error: 'WebMCP
                                          API not available on this page',\n      available: false\n    };\n  }\n  \n  // 调用
                                         listTools 获取所有工具\n  const tools = navigator.modelContextTesting.listTools();\n  return
                                         {\n    available: true,\n    count: tools.length,\n    tools: tools\n  };\n}")
  ⎿  Running…

chrome-devtools-mcp-open-page.png

此时 Claude Code 调用 chrome-devtools MCP 打开了浏览器, 进入了 webmcp.sh/dashboard 页面, 然后顺利的触发了 webmcp-agent skill 来获取所有的路由信息

[!TIP] 经过测试, 在使用 chrome-devtools mcp 时如果不说明使用 webmcp-agent 就不会调用 webmcp-agent skills, 所以我这里说明要调用 webmcp-agent 来获取路由信息

⏺ chrome-devtools - evaluate_script (MCP)(function: "() => {\n  const result =
                                         navigator.modelContextTesting.executeTool('list_all_routes', '{}');\n  return result;\n}")
  ⎿  Script ran on page and returned:
     ```json
     "{\"content\":[{\"type\":\"text\",\"text\":\"=== WEBMCP APPLICATION NAVIGATION ===\\n\\nABOUT THIS APP\\n──────────────\\nWeb
     … +29 lines (ctrl+o to expand)

⏺ 📋 WebMCP 所有路由列表

  WebMCP 是一个内存和知识管理系统,具有以下路由:

  🏠 主要路由

  ┌─────────────────────┬──────────┬──────────────────────────────┐
  │        路由         │   描述   │             功能             │
  ├─────────────────────┼──────────┼──────────────────────────────┤
  │ /                   │ 首页     │ WebMCP 演示概览和文档        │
  ├─────────────────────┼──────────┼──────────────────────────────┤
  │ /dashboard          │ 仪表盘   │ 内存概览、统计图表和快速访问 │
  ├─────────────────────┼──────────┼──────────────────────────────┤
  │ /entities           │ 实体页面 │ 浏览和管理所有内存实体       │
  ├─────────────────────┼──────────┼──────────────────────────────┤
  │ /entities/$entityId │ 实体详情 │ 查看和编辑特定实体           │
  ├─────────────────────┼──────────┼──────────────────────────────┤
  │ /graph              │ 知识图谱 │ 可视化实体关系               │
  ├─────────────────────┼──────────┼──────────────────────────────┤
  │ /memory-blocks      │ 内存块   │ 管理始终在上下文的核心内存   │
  ├─────────────────────┼──────────┼──────────────────────────────┤
  │ /sql-repl           │ SQL REPL │ 执行直接数据库查询           │
  ├─────────────────────┼──────────┼──────────────────────────────┤
  │ /sql-execution-log  │ SQL 日志 │ 查看查询执行历史             │
  ├─────────────────────┼──────────┼──────────────────────────────┤
  │ /about              │ 关于页面 │ 技术栈和架构信息             │
  ├─────────────────────┼──────────┼──────────────────────────────┤
  │ /showcase           │ 展示页面 │ UI 组件演示                  │
  └─────────────────────┴──────────┴──────────────────────────────┘

  🔧 详细功能

  📊 Dashboard (/dashboard)
  - 查看内存块和实体计数
  - 按类别和层级查看令牌使用图表
  - 浏览数据库更改审计日志
  - 快速创建内存块和实体

  📝 Entities (/entities)
  - 在可搜索表格中查看所有实体
  - 按类别筛选(事实、偏好、技能、人员、项目、目标)
  - 创建、编辑和删除实体
  - 按名称或描述搜索实体

  🕸️  Knowledge Graph (/graph)
  - 以交互式 2D 或 3D 图形查看实体
  - 探索实体之间的连接
  - 运行视觉效果(相机漫游、粒子爆发)
  - 直接执行 SQL 查询

  💾 Memory Blocks (/memory-blocks)
  - 在表格中查看所有内存块
  - 创建新内存块
  - 编辑块内容、标签和优先级
  - 按块类型筛选(user_profile、agent_persona、current_goals、context)

  🗄️  SQL REPL (/sql-repl)
  - 对浏览器内数据库运行 SQL 查询
  - 探索数据库架构和表
  - 查看格式化的查询结果

  🚀 导航示例

  // 导航到实体页面
  { "to": "/entities" }

  // 查看特定实体
  { "to": "/entities/$entityId", "params": { "entityId": "abc-123" } }

  // 打开知识图谱
  { "to": "/graph" }

  // 管理内存块
  { "to": "/memory-blocks" }

  这个应用使用了 TanStack Router 进行路由管理,当你导航到不同路由时,会自动激活相应的上下文相关工具。

可以看到 Claude Code 在触发 webmcp-agent 之后正确的调用了名为 list_all_routestool, 然后获取到了所有的路由信息

WebMCP 的未来

human-ai-agent-web.png

在过去和现在, 网页是被设计为让人类进行操作的, 在未来, 网页会更多的被设计为让 AI Agent 进行操作, 例如:

  • 自动填写表单
  • 自动进行购物
  • 自动打网约车

也就是说, 我们可以不必面对复杂的表单, 不必受困于繁琐的操作和一些反人类的交互设计, 直接让 AI Agent 替代我们与网页或程序进行交互, 实际上腾讯已经开始着手这样做了, 腾讯计划在微信中集成一个 AI Agent, 通过 Agent 与小程序进行交互, 腾讯控制着微信小程序这样体量巨大的应用, 包括 打车 / 外卖 / 购物 在内的基本上所有的操作都可以在小程序上完成

2025腾讯Q3财报会上,刘炽平就曾表示,微信的理想蓝图是最终会推出一个AI智能体:“微信的生态系统拥有通信和社交生态系统,使智能体能够理解用户的需求、意图和兴趣;拥有内容生态系统,包括公众号和视频号;拥有小程序生态系统,基本上涵盖了互联网上的大部分用例;拥有商业生态系统,允许人们购买商品,以及支付生态系统,允许人们几乎立即完成支付。所以,这几乎是用户的理想助手,理解用户的需求,并且能够在该生态系统内执行所有任务。

来源: 秘密开发Agent,微信告别AI克制

参考

vue-router v5.x createRouter 是创建路由实例?

作者 米丘
2026年3月18日 13:05

vue-router 初始化方法 createRouter。

image.png

createRouter

1、做了什么?

  • createRouterMatcher 初始化路由匹配系统。
  • 初始化 URL 处理,设置 URL 查询参数的解析和序列化函数。
  • 初始化历史管理器,初始化路由历史管理(Hash/History 模式)。
  • 初始化导航守卫系统,创建全局导航守卫的回调队列,beforeGuards,beforeResolveGuardsafterGuards
  • 初始化路由状态,创建响应式的当前路由状态currentRoute和待处理路由pendingLocation
  • 浏览器寒假配置滚动行为。
  • 定义核心的路由管理、路由导航方法。

2、函数返回?

返回 Router 实例。

/**
 * Creates a Router instance that can be used by a Vue app.
 * 负责组装路由的所有核心能力(路由匹配、导航守卫、历史记录管理、滚动行为、URL 解析 / 生成等),
 * 最终返回一个可安装到 Vue 应用的 Router 实例
 * @param options - {@link RouterOptions}
 */
export function createRouter(options: RouterOptions): Router {
  // 创建路由匹配器:解析 routes 配置,生成匹配规则(核心)
  const matcher = createRouterMatcher(options.routes, options)

  // 初始化 URL 查询参数解析/序列化函数(默认/自定义)
  const parseQuery = options.parseQuery || originalParseQuery
  const stringifyQuery = options.stringifyQuery || originalStringifyQuery

  // 初始化历史管理器(Hash/History 模式),开发环境校验必传
  const routerHistory = options.history

  if (__DEV__ && !routerHistory)
    throw new Error(
      'Provide the "history" option when calling "createRouter()":' +
        ' https://router.vuejs.org/api/interfaces/RouterOptions.html#history'
    )

  // 初始化导航守卫队列(全局前置/解析后/后置守卫)
  const beforeGuards = useCallbacks<NavigationGuardWithThis<undefined>>()
  const beforeResolveGuards = useCallbacks<NavigationGuardWithThis<undefined>>()
  const afterGuards = useCallbacks<NavigationHookAfter>()

  // 初始化当前路由(响应式)和待处理路由
  const currentRoute = shallowRef<RouteLocationNormalizedLoaded>(
    START_LOCATION_NORMALIZED
  )
  // 待处理路由(当前导航目标),初始值为起始路由
  let pendingLocation: RouteLocation = START_LOCATION_NORMALIZED

  // 滚动行为初始化:有自定义 scrollBehavior 时,禁用浏览器默认滚动恢复
  // leave the scrollRestoration if no scrollBehavior is provided
  if (isBrowser && options.scrollBehavior && 'scrollRestoration' in history) {
    history.scrollRestoration = 'manual'
  }

  const normalizeParams = applyToParams.bind(
    null,
    paramValue => '' + paramValue
  )
  // 遍历路由参数对象的所有值,对每个值应用指定的处理函数,并返回新的参数对象
  const encodeParams = applyToParams.bind(null, encodeParam)
  const decodeParams: (params: RouteParams | undefined) => RouteParams =
    // @ts-expect-error: intentionally avoid the type check
    applyToParams.bind(null, decode)

  let removeHistoryListener: undefined | null | (() => void)
  
  let readyHandlers = useCallbacks<_OnReadyCallback>()
  let errorListeners = useCallbacks<_ErrorListener>()
  let ready: boolean
  
  const go = (delta: number) => routerHistory.go(delta)

  let started: boolean | undefined
  const installedApps = new Set<App>()
  
    // NOTE: we need to cast router as Router because the experimental
  // data-loaders add many properties that aren't available here. We might want
  // to add them later on instead of having declare module in experimental
  const router = {
    currentRoute,
    listening: true, // 监听路由

    addRoute,
    removeRoute,
    clearRoutes: matcher.clearRoutes,
    hasRoute,
    getRoutes,
    resolve,
    options,

    push,
    replace,
    go,
    back: () => go(-1),
    forward: () => go(1),

    beforeEach: beforeGuards.add,
    beforeResolve: beforeResolveGuards.add,
    afterEach: afterGuards.add,

    onError: errorListeners.add,
    isReady,

    /**
     * Vue 应用集成(install 方法)
     * @param app
     */
    install(app: App) {
      // 注册全局组件 RouterLink 和 RouterView
      app.component('RouterLink', RouterLink)
      app.component('RouterView', RouterView)

      // 暴露 $router/$route 到全局
      app.config.globalProperties.$router = router as Router
      Object.defineProperty(app.config.globalProperties, '$route', {
        enumerable: true,
        get: () => unref(currentRoute),
      })

      // this initial navigation is only necessary on client, on server it doesn't
      // make sense because it will create an extra unnecessary navigation and could
      // lead to problems
      // 初始化首次导航(客户端)
      if (
        isBrowser &&
        // used for the initial navigation client side to avoid pushing
        // multiple times when the router is used in multiple apps
        !started &&
        currentRoute.value === START_LOCATION_NORMALIZED
      ) {
        // see above
        started = true
        push(routerHistory.location).catch(err => {
          if (__DEV__) warn('Unexpected error when starting the router:', err)
        })
      }

      const reactiveRoute = {} as RouteLocationNormalizedLoaded
      for (const key in START_LOCATION_NORMALIZED) {
        Object.defineProperty(reactiveRoute, key, {
          get: () => currentRoute.value[key as keyof RouteLocationNormalized],
          enumerable: true,
        })
      }

      // 提供路由注入(useRouter/useRoute)
      app.provide(routerKey, router as Router)
      app.provide(routeLocationKey, shallowReactive(reactiveRoute))
      app.provide(routerViewLocationKey, currentRoute)

      const unmountApp = app.unmount
      installedApps.add(app)

      // 应用卸载时清理
      app.unmount = function () {
        installedApps.delete(app)
        // the router is not attached to an app anymore
        if (installedApps.size < 1) {
          // invalidate the current navigation
          pendingLocation = START_LOCATION_NORMALIZED
          removeHistoryListener && removeHistoryListener()
          removeHistoryListener = null
          currentRoute.value = START_LOCATION_NORMALIZED
          started = false
          ready = false
        }
        unmountApp()
      }

      // TODO: this probably needs to be updated so it can be used by vue-termui
      if (
        (__DEV__ || __FEATURE_PROD_DEVTOOLS__) &&
        isBrowser &&
        !__STRIP_DEVTOOLS__
      ) {
        addDevtools(app, router as Router, matcher)
      }
    },
  } satisfies Pick<Router, Extract<keyof Router, string>>
  
  return router as Router
}

参数 options 有哪些属性?

/**
 * Options to initialize a {@link Router} instance.
 */
export interface RouterOptions extends EXPERIMENTAL_RouterOptions_Base {
  /**
   * Initial list of routes that should be added to the router.
   */
  routes: Readonly<RouteRecordRaw[]>
}
/**
 * Options to initialize a {@link Router} instance.
 */
export interface EXPERIMENTAL_RouterOptions_Base extends PathParserOptions {
  /**
   * History implementation used by the router. Most web applications should use
   * `createWebHistory` but it requires the server to be properly configured.
   * You can also use a _hash_ based history with `createWebHashHistory` that
   * does not require any configuration on the server but isn't handled at all
   * by search engines and does poorly on SEO.
   *
   * @example
   * ```js
   * createRouter({
   *   history: createWebHistory(),
   *   // other options...
   * })
   * ```
   */
  history: RouterHistory // 指定路由使用的「历史记录管理器」,决定路由模式(Hash/History)

  /**
   * Function to control scrolling when navigating between pages. Can return a
   * Promise to delay scrolling.
   *
   * @see {@link RouterScrollBehavior}.
   *
   * @example
   * ```js
   * function scrollBehavior(to, from, savedPosition) {
   *   // `to` and `from` are both route locations
   *   // `savedPosition` can be null if there isn't one
   * }
   * ```
   */
  scrollBehavior?: RouterScrollBehavior // 自定义路由切换时的页面滚动行为(如返回顶部、恢复滚动位置)

  /**
   * Custom implementation to parse a query. See its counterpart,
   * {@link EXPERIMENTAL_RouterOptions_Base.stringifyQuery}.
   *
   * @example
   * Let's say you want to use the [qs package](https://github.com/ljharb/qs)
   * to parse queries, you can provide both `parseQuery` and `stringifyQuery`:
   * ```js
   * import qs from 'qs'
   *
   * createRouter({
   *   // other options...
   *   parseQuery: qs.parse,
   *   stringifyQuery: qs.stringify,
   * })
   * ```
   */
  parseQuery?: typeof originalParseQuery // 将 URL 中的查询参数字符串(如 a=1&b=2)解析为对象({ a: '1', b: '2' })

  /**
   * Custom implementation to stringify a query object. Should not prepend a leading `?`.
   * {@link parseQuery} counterpart to handle query parsing.
   * 将查询参数对象({ a: '1', b: '2' })序列化为字符串(a=1&b=2),无需手动加 ?
   */

  stringifyQuery?: typeof originalStringifyQuery

  /**
   * Default class applied to active {@link RouterLink}. If none is provided,
   * `router-link-active` will be applied.
   * 设置 <RouterLink> 「部分匹配激活」时的默认类名(如 /home 匹配 /home/child)
   */
  linkActiveClass?: string

  /**
   * Default class applied to exact active {@link RouterLink}. If none is provided,
   * `router-link-exact-active` will be applied.
   * 设置 <RouterLink> 「精确匹配激活」时的默认类名(仅 /home 匹配 /home)
   */
  linkExactActiveClass?: string

  /**
   * Default class applied to non-active {@link RouterLink}. If none is provided,
   * `router-link-inactive` will be applied.
   * 预留配置,用于设置 <RouterLink> 「非激活状态」的默认类名,当前版本未启用
   */
  // linkInactiveClass?: string
}
/**
 * @internal
 */
export interface _PathParserOptions {
  /**
   * Makes the RegExp case-sensitive.
   * 控制路由路径匹配时是否区分大小写(影响生成的正则表达式是否添加 i 标志)
   * @defaultValue `false` false(不区分大小写,如 /Home 和 /home 视为同一路由)
   */
  sensitive?: boolean

  /**
   * Whether to disallow a trailing slash or not.
   * 控制是否严格匹配路径末尾的斜杠(/)
   * @defaultValue `false` false(允许末尾斜杠,如 /home 和 /home/ 视为同一路由)
   */
  strict?: boolean

  /**
   * Should the RegExp match from the beginning by prepending a `^` to it.
   * @internal
   * 控制生成的路径匹配正则是否添加 ^ 前缀(即是否从字符串开头开始匹配)
   * @defaultValue `true` true(必须从路径开头匹配,符合路由匹配的基本逻辑)
   */
  start?: boolean

  /**
   * Should the RegExp match until the end by appending a `$` to it.
   * 控制生成的路径匹配正则是否添加 $ 后缀(即是否完整匹配路径末尾)
   * @deprecated this option will alsways be `true` in the future. Open a discussion in vuejs/router if you need this to be `false`
   * 已废弃
   * @defaultValue `true`
   */
  end?: boolean
}
export type PathParserOptions = Pick<
  _PathParserOptions,
  'end' | 'sensitive' | 'strict'
>

routes 配置

export type RouteRecordRaw =
  | RouteRecordSingleView // 最基础的路由配置,对应「一个路径匹配一个组件」的场景,无嵌套子路由
  // 基础单视图路由 + 嵌套子路由(对应 <RouterView> 嵌套渲染)
  | RouteRecordSingleViewWithChildren
  // 一个路径匹配多个组件,对应 <RouterView name="xxx"> 命名视图
  | RouteRecordMultipleViews
  // 多视图路由 + 嵌套子路由,是 RouteRecordMultipleViews 的扩展
  | RouteRecordMultipleViewsWithChildren
  // 仅用于路由重定向,无组件 / 视图配置,匹配路径后跳转到目标路由
  | RouteRecordRedirect

1、RouteRecordSingleView

基础单视图,一个路径匹配一个组件。禁止components、children、redirect。

/**
 * Route Record defining one single component with the `component` option.
 */
export interface RouteRecordSingleView extends _RouteRecordBase {
  /**
   * Component to display when the URL matches this route.
   * 指定路由匹配时要渲染的单个组件,是单视图路由的核心标识
   */
  component: RawRouteComponent
  // 明确禁止在单视图路由中使用 components 字段(多视图路由的核心字段)
  components?: never
  // 明确禁止在单视图路由中使用 children 字段(嵌套路由的核心字段)
  children?: never
  // 明确禁止在单视图路由中使用 redirect 字段(重定向路由的核心字段)
  redirect?: never

  /**
   * Allow passing down params as props to the component rendered by `router-view`.
   * 控制是否将路由参数(params/query)作为 props 传递给路由组件,避免组件直接依赖 $route
   */
  props?: _RouteRecordProps
}
// TODO: could this be moved to matcher? YES, it's on the way
/**
 * Internal type for common properties among all kind of {@link RouteRecordRaw}.
 */
export interface _RouteRecordBase extends PathParserOptions {
  /**
   * Path of the record. Should start with `/` unless the record is the child of
   * another record.
   * 路由路径
   * @example `/users/:id` matches `/users/1` as well as `/users/posva`.
   */
  path: string

  /**
   * Where to redirect if the route is directly matched. The redirection happens
   * before any navigation guard and triggers a new navigation with the new
   * target location.
   * 路由重定向选项,用于定义路由跳转目标
   */
  redirect?: RouteRecordRedirectOption

  /**
   * Aliases for the record. Allows defining extra paths that will behave like a
   * copy of the record. Allows having paths shorthands like `/users/:id` and
   * `/u/:id`. All `alias` and `path` values must share the same params.
   * 路由别名数组,用于定义额外的路径
   */
  alias?: string | string[]

  /**
   * Name for the route record. Must be unique.
   * 路由名称,必须唯一
   */
  name?: RouteRecordNameGeneric

  /**
   * Before Enter guard specific to this record. Note `beforeEnter` has no
   * effect if the record has a `redirect` property.
   */
  beforeEnter?:
    | NavigationGuardWithThis<undefined>
    | NavigationGuardWithThis<undefined>[]

  /**
   * Arbitrary data attached to the record.
   * 路由元数据,用于存储自定义信息,如权限、标题等
   */
  meta?: RouteMeta

  /**
   * Array of nested routes.
   * 子路由数组,用于定义嵌套路由结构
   */
  children?: RouteRecordRaw[]

  /**
   * Allow passing down params as props to the component rendered by `router-view`.
   */
  props?: _RouteRecordProps | Record<string, _RouteRecordProps>
}

2、RouteRecordSingleViewWithChildren

单视图嵌套子路由。禁止配置components。

/**
 * Route Record defining one single component with a nested view. Differently
 * from {@link RouteRecordSingleView}, this record has children and allows a
 * `redirect` option.
 */
export interface RouteRecordSingleViewWithChildren extends _RouteRecordBase {
  /**
   * Component to display when the URL matches this route.
   * 指定父路由匹配时渲染的布局组件(需包含 <RouterView> 用于渲染子路由)
   */
  component?: RawRouteComponent | null | undefined
  // 与 RouteRecordSingleView 一致,禁止使用 components(多视图字段),保证父路由为「单视图布局」
  components?: never

  // 定义父路由下的嵌套子路由,是该接口的核心标识(区别于 RouteRecordSingleView)
  children: RouteRecordRaw[]

  /**
   * Allow passing down params as props to the component rendered by `router-view`.
   * 控制是否将父路由的参数传递给父布局组件(而非子路由组件)
   */
  props?: _RouteRecordProps
}

3、RouteRecordMultipleViews

多视图。禁止配置component、children、redirect。

/**
 * Route Record defining multiple named components with the `components` option.
 */
export interface RouteRecordMultipleViews extends _RouteRecordBase {
  /**
   * Components to display when the URL matches this route. Allow using named views.
   * 指定路由匹配时要渲染的多个命名组件,键为「视图名称」,值为「组件」,是多视图路由的核心标识
   * 示例  components: {
            default: () => import('./DashboardMain.vue'), // 对应 <RouterView>(默认视图)
            header: () => import('./DashboardHeader.vue'), // 对应 <RouterView name="header">
            sidebar: () => import('./DashboardSidebar.vue'), // 对应 <RouterView name="sidebar">
          },
   */
  components: Record<string, RawRouteComponent>
  component?: never // 明确禁止使用 component 字段(单视图路由的核心字段)
  // 禁止使用 children 字段,多视图 + 嵌套子路由需使用 RouteRecordMultipleViewsWithChildren 类型
  children?: never
  // 禁止使用 redirect 字段,重定向路由需使用 RouteRecordRedirect 类型
  redirect?: never

  /**
   * Allow passing down params as props to the component rendered by
   * `router-view`. Should be an object with the same keys as `components` or a
   * boolean to be applied to every component.
   * 控制是否将路由参数传递给每个命名视图组件,是单视图 props 字段的多视图扩展
   */
  props?: Record<string, _RouteRecordProps> | boolean
}

4、RouteRecordMultipleViewsWithChildren

多视图嵌套子路由。禁止配置component。

/**
 * Route Record defining multiple named components with the `components` option and children.
 */
export interface RouteRecordMultipleViewsWithChildren extends _RouteRecordBase {
  /**
   * Components to display when the URL matches this route. Allow using named views.
   * 指定父路由匹配时渲染的多命名视图布局组件(需包含多个 <RouterView name="xxx"> 用于渲染子路由);
   * 1、有布局组件:父路由渲染多视图布局(如 header + sidebar + main),子路由可覆盖 / 扩展父视图;
   * 2、无布局组件:父路由仅用于路径分组(如 /admin/* 下的多视图子路由,无可视化布局);
   */
  components?: Record<string, RawRouteComponent> | null | undefined
  // 与 RouteRecordMultipleViews 一致,禁止使用 component 字段(单视图路由的核心字段)
  component?: never

  // 定义父多视图路由下的嵌套子路由,是该接口的核心标识(区别于 RouteRecordMultipleViews)
  children: RouteRecordRaw[]

  /**
   * Allow passing down params as props to the component rendered by
   * `router-view`. Should be an object with the same keys as `components` or a
   * boolean to be applied to every component.
   * 控制是否将父路由的参数传递给父多视图组件(而非子路由组件)
   */
  props?: Record<string, _RouteRecordProps> | boolean
}

路由独享守卫 beforeEnter

beforeEnter 守卫 只在进入路由时触发,不会在 paramsquery 或 hash 改变时触发。

image.png

{
  path: '/dashboard',
  name: 'dashboard',
  component: () => import('@/views/dashboard/DashBoard.vue'),
  meta: {
    title: '看板',
    icon: 'dashboard',
    roles: ['admin', 'user']
  },
  // beforeEnter: (to, from) => {
  //   console.log('beforeEnter-to', to)
  //   console.log('beforeEnter-from', from)
  //   return true
  // },
  beforeEnter: [(to, from) => {
    console.log('beforeEnter-111to', to)
    console.log('beforeEnter-f111rom', from)
    return true
  }, (to, from) => {
    console.log('beforeEnter-222to', to)
    console.log('beforeEnter-222from', from)
    return true
  }]

},

Router 实例有哪些属性?

/**
 * Router instance.
 * 路由实例
 */
export interface Router extends EXPERIMENTAL_Router_Base<RouteRecordNormalized> {
  /**
   * Original options object passed to create the Router
   * 存储创建路由实例时传入的原始配置项
   */
  readonly options: RouterOptions

  /**
   * Add a new {@link RouteRecordRaw | route record} as the child of an existing route.
   * 动态路由方法
   * 重载 1:添加嵌套路由
   * 返回值:一个「移除该动态路由的函数」,调用后可删除本次添加的路由
   * @param parentName - Parent Route Record where `route` should be appended at
   * @param route - Route Record to add
   */
  addRoute(
    // NOTE: it could be `keyof RouteMap` but the point of dynamic routes is not knowing the routes at build
    parentName: NonNullable<RouteRecordNameGeneric>,
    route: RouteRecordRaw
  ): () => void
  /**
   * Add a new {@link RouteRecordRaw | route record} to the router.
   *
   * @param route - Route Record to add
   * 重载 2:添加顶级路由
   * 返回值:一个「移除该动态路由的函数」,调用后可删除本次添加的路由
   */
  addRoute(route: RouteRecordRaw): () => void

  /**
   * Remove an existing route by its name.
   *
   * @param name - Name of the route to remove 路由名称(非空),注意只能通过名称删除,不能通过路径
   * 根据路由名称删除已存在的路由(包括静态路由和动态添加的路由)
   * 
   */
  removeRoute(name: NonNullable<RouteRecordNameGeneric>): void

  /**
   * Delete all routes from the router.
   * 清空路由表中所有路由(包括静态路由和动态添加的路由)
   * 注意:清空后路由表为空,需重新调用 addRoute 添加路由,否则导航会失效
   */
  clearRoutes(): void
}
/**
 * Router base instance.
 *
 * @experimental This version is not stable, it's meant to replace {@link Router} in the future.
 */
export interface EXPERIMENTAL_Router_Base<TRecord> {
  // NOTE: for dynamic routing we need this
  // <TRouteRecordRaw, TRouteRecord>
  /**
   * Current {@link RouteLocationNormalized}
   * 存储当前激活的标准化路由信息(响应式)
   */
  readonly currentRoute: ShallowRef<RouteLocationNormalizedLoaded>

  /**
   * Allows turning off the listening of history events. This is a low level api for micro-frontend.
   * 控制是否监听浏览器历史事件,专为「微前端」场景设计
   */
  listening: boolean

  // TODO: deprecate in favor of getRoute(name) and add it
  /**
   * Checks if a route with a given name exists
   * 根据路由名称判断路由是否存在(静态 / 动态添加的路由均可检测)
   * @param name - Name of the route to check
   */
  hasRoute(name: NonNullable<RouteRecordNameGeneric>): boolean

  /**
   * Get a full list of all the {@link RouteRecord | route records}.
   * 返回路由表中所有标准化路由记录
   */
  getRoutes(): TRecord[]

  /**
   * Returns the {@link RouteLocation | normalized version} of a
   * {@link RouteLocationRaw | route location}. Also includes an `href` property
   * that includes any existing `base`. By default, the `currentLocation` used is
   * `router.currentRoute` and should only be overridden in advanced use cases.
   * 将原始路由地址(如字符串、对象)解析为标准化的路由对象(包含 href、fullPath 等)
   * @param to - Raw route location to resolve
   * @param currentLocation - Optional current location to resolve against
   */
  resolve<Name extends keyof RouteMap = keyof RouteMap>(
    to: RouteLocationAsRelativeTyped<RouteMap, Name>,
    // NOTE: This version doesn't work probably because it infers the type too early
    // | RouteLocationAsRelative<Name>
    currentLocation?: RouteLocationNormalizedLoaded
  ): RouteLocationResolved<Name>
  resolve(
    // not having the overload produces errors in RouterLink calls to router.resolve()
    to: RouteLocationAsString | RouteLocationAsRelative | RouteLocationAsPath,
    currentLocation?: RouteLocationNormalizedLoaded
  ): RouteLocationResolved

  /**
   * Programmatically navigate to a new URL by pushing an entry in the history
   * stack.
   * 通过「新增历史记录」实现无刷新导
   *
   * @param to - Route location to navigate to
   */
  push(to: RouteLocationRaw): Promise<NavigationFailure | void | undefined>

  /**
   * Programmatically navigate to a new URL by replacing the current entry in
   * the history stack.
   * 通过「替换当前历史记录」实现导航(对应 history.replaceState),无历史记录回溯
   *
   * @param to - Route location to navigate to
   */
  replace(to: RouteLocationRaw): Promise<NavigationFailure | void | undefined>

  /**
   * Go back in history if possible by calling `history.back()`. Equivalent to
   * `router.go(-1)`.
   * 历史记录回溯
   */
  back(): void

  /**
   * Go forward in history if possible by calling `history.forward()`.
   * Equivalent to `router.go(1)`.
   * 历史记录回溯
   */
  forward(): void

  /**
   * Allows you to move forward or backward through the history. Calls
   * `history.go()`.
   *
   * @param delta - The position in the history to which you want to move,
   * relative to the current page
   * 历史记录回溯
   */
  go(delta: number): void

  /**
   * Add a navigation guard that executes before any navigation. Returns a
   * function that removes the registered guard.
   *
   * 注册全局前置守卫,导航触发时最先执行(可拦截、重定向导航)
   * @param guard - navigation guard to add
   */
  beforeEach(guard: NavigationGuardWithThis<undefined>): () => void

  /**
   * Add a navigation guard that executes before navigation is about to be
   * resolved. At this state all component have been fetched and other
   * navigation guards have been successful. Returns a function that removes the
   * registered guard.
   *
   * @param guard - navigation guard to add
   * @returns a function that removes the registered guard
   * 在所有组件内守卫、异步路由组件解析完成后,导航确认前执行
   * @example
   * ```js
   * router.beforeResolve(to => {
   *   if (to.meta.requiresAuth && !isAuthenticated) return false
   * })
   * ```
   *
   */
  beforeResolve(guard: _NavigationGuardResolved): () => void

  /**
   * Add a navigation hook that is executed after every navigation. Returns a
   * function that removes the registered hook.
   *
   * 导航完成后(成功 / 失败均执行),无法拦截导航
   *
   * @param guard - navigation hook to add
   * @returns a function that removes the registered hook
   *
   * @example
   * ```js
   * router.afterEach((to, from, failure) => {
   *   if (isNavigationFailure(failure)) {
   *     console.log('failed navigation', failure)
   *   }
   * })
   * ```
   */
  afterEach(guard: NavigationHookAfter): () => void

  /**
   * Adds an error handler that is called every time a non caught error happens
   * during navigation. This includes errors thrown synchronously and
   * asynchronously, errors returned or passed to `next` in any navigation
   * guard, and errors occurred when trying to resolve an async component that
   * is required to render a route.
   * 注册导航错误监听器,捕获导航过程中的所有未处理错误
   *
   * @param handler - error handler to register
   */
  onError(handler: _ErrorListener): () => void

  /**
   * Returns a Promise that resolves when the router has completed the initial
   * navigation, which means it has resolved all async enter hooks and async
   * components that are associated with the initial route. If the initial
   * navigation already happened, the promise resolves immediately.
   *
   * This is useful in server-side rendering to ensure consistent output on both
   * the server and the client. Note that on server side, you need to manually
   * push the initial location while on client side, the router automatically
   * picks it up from the URL.
   */
  isReady(): Promise<void> // 等待初始导航完成

  /**
   * Called automatically by `app.use(router)`. Should not be called manually by
   * the user. This will trigger the initial navigation when on client side.
   * 安装路由到 Vue 应用
   * 由 app.use(router) 自动调用,完成路由的初始化(注册全局组件、注入路由实例、触发初始导航)
   * @internal
   * @param app - Application that uses the router
   */
  install(app: App): void
}

实例方法 router.replace

  function replace(to: RouteLocationRaw) {
    return push(assign(locationAsObject(to), { replace: true }))
  }

实例方法 router.push

  function push(to: RouteLocationRaw) {
    return pushWithRedirect(to)
  }

pushWithRedirect

  /**
   * 负责处理「路由跳转 + 重定向 + 守卫执行 + 历史记录更新 + 错误处理」的全流程
   * @param to 目标路由位置(可以是字符串路径、命名路由对象或路径对象)
   * @param redirectedFrom 重定向来源路由位置(可选)
   * @returns 导航失败原因、成功时无返回值或 undefined
   */
  function pushWithRedirect(
    to: RouteLocationRaw | RouteLocation,
    redirectedFrom?: RouteLocation
  ): Promise<NavigationFailure | void | undefined> {

    // 解析目标路由为标准化 RouteLocation 对象
    const targetLocation: RouteLocation = (pendingLocation = resolve(to))
    const from = currentRoute.value // 获取当前路由(响应式的 currentRoute)

    // 获取历史记录状态(state)
    const data: HistoryState | undefined = (to as RouteLocationOptions).state
    // 获取强制跳转标志(force)
    const force: boolean | undefined = (to as RouteLocationOptions).force
    // to could be a string where `replace` is a function
    // 获取替换标志(replace)
    const replace = (to as RouteLocationOptions).replace === true

    // 检查目标路由是否配置了 redirect,返回重定向后的路由
    const shouldRedirect = handleRedirectRecord(targetLocation, from)

    // 若存在重定向,递归调用 pushWithRedirect 处理重定向后的路由
    if (shouldRedirect)
      return pushWithRedirect(
        // 合并重定向路由与原配置
        assign(locationAsObject(shouldRedirect), {
          state:
            typeof shouldRedirect === 'object'
              ? assign({}, data, shouldRedirect.state)
              : data,
          force,
          replace,
        }),
        // keep original redirectedFrom if it exists
        redirectedFrom || targetLocation
      )

    // if it was a redirect we already called `pushWithRedirect` above
    const toLocation = targetLocation as RouteLocationNormalized // 标准化目标路由

    toLocation.redirectedFrom = redirectedFrom // 标记重定向来源

    let failure: NavigationFailure | void | undefined // 声明导航失败变量

    // 非强制跳转 + 路由完全相同 → 生成重复跳转错误
    if (!force && isSameRouteLocation(stringifyQuery, from, targetLocation)) {
      failure = createRouterError<NavigationFailure>(
        ErrorTypes.NAVIGATION_DUPLICATED,
        {
          to: toLocation,
          from,
        }
      )
      // trigger scroll to allow scrolling to the same anchor
      // 即使重复跳转,仍处理滚动(如锚点 #top)
      handleScroll(
        from,
        from,
        // this is a push, the only way for it to be triggered from a
        // history.listen is with a redirect, which makes it become a push
        true, // push导航 
        // This cannot be the first navigation because the initial location
        // cannot be manually navigated to
        false // 非首次导航,初始路由不能手动跳转
      )
    }

    // 有失败则返回 resolved 的 failure,否则调用 navigate 执行真正的导航
    return (failure ? Promise.resolve(failure) : navigate(toLocation, from))
      .catch((error: NavigationFailure | NavigationRedirectError) =>
        isNavigationFailure(error)
          ? // navigation redirects still mark the router as ready
          // 导航守卫重定向 → 仅返回错误,不标记 ready
            isNavigationFailure(error, ErrorTypes.NAVIGATION_GUARD_REDIRECT)
            ? error
            // 其他导航失败 → 标记 router 为 ready 并返回错误
            : markAsReady(error) // also returns the error
          : // reject any unknown error
          // 未知错误 → 触发全局错误并抛出
            triggerError(error, toLocation, from)
      )
      .then((failure: NavigationFailure | NavigationRedirectError | void) => {
        if (failure) {
          if (
            isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT)
          ) {
            if (
              __DEV__ &&
              // we are redirecting to the same location we were already at
              // 开发环境:检测无限重定向(超过30次)并报警
              isSameRouteLocation(
                stringifyQuery,
                resolve(failure.to),
                toLocation
              ) &&
              // and we have done it a couple of times
              redirectedFrom &&
              // @ts-expect-error: added only in dev
              (redirectedFrom._count = redirectedFrom._count
                ? // @ts-expect-error
                  redirectedFrom._count + 1
                : 1) > 30
            ) {
              warn(
                `Detected a possibly infinite redirection in a navigation guard when going from "${from.fullPath}" to "${toLocation.fullPath}". Aborting to avoid a Stack Overflow.\n Are you always returning a new location within a navigation guard? That would lead to this error. Only return when redirecting or aborting, that should fix this. This might break in production if not fixed.`
              )
              return Promise.reject(
                new Error('Infinite redirect in navigation guard')
              )
            }

            return pushWithRedirect(
              // keep options
              assign(
                {
                  // preserve an existing replacement but allow the redirect to override it
                  replace,
                },
                locationAsObject(failure.to),
                {
                  state:
                    typeof failure.to === 'object'
                      ? assign({}, data, failure.to.state)
                      : data,
                  force,
                }
              ),
              // preserve the original redirectedFrom if any
              redirectedFrom || toLocation
            )
          }
        } else {
          // if we fail we don't finalize the navigation
          // 导航成功 → 最终化导航(更新历史记录/滚动/路由状态)
          failure = finalizeNavigation(
            toLocation as RouteLocationNormalizedLoaded,
            from,
            true,
            replace,
            data
          )
        }
        // 触发 afterEach 后置钩子
        triggerAfterEach(
          toLocation as RouteLocationNormalizedLoaded,
          from,
          failure
        )
        return failure
      })
  }

当待处理路由 与 当前路由完全一致,会出现以下问题

image.png

image.png

image.png

handleRedirectRecord

  /**
   * 「解析目标路由匹配记录中最后一条的 redirect 配置
   *  →标准化重定向目标格式→校验重定向合法性→合并原路由的 query/hash 等参数→返回最终的重定向目标」
   * @param to 目标路由对象
   * @param from 来源路由对象
   * @returns 
   */
  function handleRedirectRecord(
    to: RouteLocation,
    from: RouteLocationNormalizedLoaded
  ): RouteLocationRaw | void {

    const lastMatched = to.matched[to.matched.length - 1] // 获取最后一条匹配记录
    
    if (lastMatched && lastMatched.redirect) {
      const { redirect } = lastMatched // 获取 redirect 配置

      // 解析 redirect,目标重定向位置
      let newTargetLocation =
        typeof redirect === 'function' ? redirect(to, from) : redirect

      // 标准化字符串格式的 redirect → 对象格式
      if (typeof newTargetLocation === 'string') {
        newTargetLocation =
          // 字符串含 ?/# → 解析为完整对象(包含 query/hash)
          newTargetLocation.includes('?') || newTargetLocation.includes('#')
            ? (newTargetLocation = locationAsObject(newTargetLocation))
            : // force empty params
              { path: newTargetLocation }

        // @ts-expect-error: force empty params when a string is passed to let
        // the router parse them again
        // 强制清空 params,避免原路由 params 污染重定向目标
        newTargetLocation.params = {}
      }

      if (
        __DEV__ &&
        newTargetLocation.path == null &&
        !('name' in newTargetLocation)
      ) {
        warn(
          `Invalid redirect found:\n${JSON.stringify(
            newTargetLocation,
            null,
            2
          )}\n when navigating to "${
            to.fullPath
          }". A redirect must contain a name or path. This will break in production.`
        )
        throw new Error('Invalid redirect')
      }

      return assign(
        {
          query: to.query, // 继承原路由的 query 参数
          hash: to.hash, // 继承原路由的 hash 锚点
          // avoid transferring params if the redirect has a path
          // 重定向目标有 path → 清空 params;无 path(用 name 跳转)→ 继承原 params
          params: newTargetLocation.path != null ? {} : to.params,
        },
        newTargetLocation
      )
    }
  }

image.png

handleScroll

  // Scroll behavior
  function handleScroll(
    to: RouteLocationNormalizedLoaded, // 目标路由
    from: RouteLocationNormalizedLoaded, // 来源路由
    isPush: boolean, // 是否为 push 导航
    isFirstNavigation: boolean // 是否是应用首次导航(如页面初始化时的路由)
  ): // the return is not meant to be used
  Promise<unknown> {
  
    const { scrollBehavior } = options
    // 非浏览器环境(如SSR) 或 未配置 scrollBehavior → 直接返回成功 Promise
    if (!isBrowser || !scrollBehavior) return Promise.resolve()

    // 计算初始滚动位置(scrollPosition)
    const scrollPosition: _ScrollPositionNormalized | null =
      // 非 push 跳转(replace/后退)→ 读取保存的滚动位置
      (!isPush && getSavedScrollPosition(getScrollKey(to.fullPath, 0))) ||
      // 首次导航 或 非 push 跳转 → 读取 history.state 中的滚动位置
      ((isFirstNavigation || !isPush) &&
        (history.state as HistoryState) &&
        history.state.scroll) ||
      null // 其他情况 → 无滚动位置

    // 等待 DOM 更新完成后再执行滚动(路由跳转后组件渲染需要时间,避免滚动到未渲染的元素)
    return nextTick()
      // 调用用户配置的 scrollBehavior,获取目标滚动位置
      .then(() => scrollBehavior(to, from, scrollPosition))
      // 若返回了滚动位置,执行实际的滚动操作
      .then(position => position && scrollToPosition(position))
      // 捕获滚动过程中的错误,触发全局错误处理
      .catch(err => triggerError(err, to, from))
  }

scrollToPosition

最终调用原生 API window.scrollTo 实现。

export function scrollToPosition(position: ScrollPosition): void {
  let scrollToOptions: ScrollPositionCoordinates

  // 元素锚点型(包含 el 字段)
  if ('el' in position) {
    const positionEl = position.el
    const isIdSelector =
      typeof positionEl === 'string' && positionEl.startsWith('#')
    /**
     * `id`s can accept pretty much any characters, including CSS combinators
     * like `>` or `~`. It's still possible to retrieve elements using
     * `document.getElementById('~')` but it needs to be escaped when using
     * `document.querySelector('#\\~')` for it to be valid. The only
     * requirements for `id`s are them to be unique on the page and to not be
     * empty (`id=""`). Because of that, when passing an id selector, it should
     * be properly escaped for it to work with `querySelector`. We could check
     * for the id selector to be simple (no CSS combinators `+ >~`) but that
     * would make things inconsistent since they are valid characters for an
     * `id` but would need to be escaped when using `querySelector`, breaking
     * their usage and ending up in no selector returned. Selectors need to be
     * escaped:
     *
     * - `#1-thing` becomes `#\31 -thing`
     * - `#with~symbols` becomes `#with\\~symbols`
     *
     * - More information about  the topic can be found at
     *   https://mathiasbynens.be/notes/html5-id-class.
     * - Practical example: https://mathiasbynens.be/demo/html5-id
     */
    if (__DEV__ && typeof position.el === 'string') {
      // 场景1:是 ID 选择器但对应元素不存在,或不是 ID 选择器
      if (!isIdSelector || !document.getElementById(position.el.slice(1))) {
        try {
          const foundEl = document.querySelector(position.el)
          // 场景1.1:是 ID 选择器但通过 querySelector 找到了元素 → 警告(建议用 getElementById)
          if (isIdSelector && foundEl) {
            warn(
              `The selector "${position.el}" should be passed as "el: document.querySelector('${position.el}')" because it starts with "#".`
            )
            // return to avoid other warnings
            return
          }
        } catch (err) {
           // 场景1.2:选择器语法错误 → 警告(提示转义字符)
          warn(
            `The selector "${position.el}" is invalid. If you are using an id selector, make sure to escape it. You can find more information about escaping characters in selectors at https://mathiasbynens.be/notes/css-escapes or use CSS.escape (https://developer.mozilla.org/en-US/docs/Web/API/CSS/escape).`
          )
          // return to avoid other warnings
          return
        }
      }
    }

    // 查找目标 DOM 元素
    const el =
      typeof positionEl === 'string'
        ? isIdSelector
          ? document.getElementById(positionEl.slice(1)) // ID 选择器:直接用 getElementById
          : document.querySelector(positionEl)  // 其他选择器:用 querySelector
        : positionEl // 非字符串:直接使用传入的 HTMLElement

    // 元素不存在 → 开发环境警告并返回
    if (!el) {
      __DEV__ &&
        warn(
          `Couldn't find element using selector "${position.el}" returned by scrollBehavior.`
        )
      return
    }
    // 计算元素的滚动坐标
    scrollToOptions = getElementPosition(el, position)

    // 坐标型(直接使用)
  } else {
    scrollToOptions = position
  }

  // 浏览器支持平滑滚动(scrollBehavior API)
  // 判断浏览器是否支持 window.scrollTo 的配置项(如 { behavior: 'smooth' })
  if ('scrollBehavior' in document.documentElement.style)
    window.scrollTo(scrollToOptions)

  // 不支持平滑滚动 → 降级使用基础 scrollTo
  else {
    window.scrollTo(
      scrollToOptions.left != null ? scrollToOptions.left : window.scrollX,
      scrollToOptions.top != null ? scrollToOptions.top : window.scrollY
    )
  }
}

finalizeNavigation

  /**
   * - Cleans up any navigation guards
   * - Changes the url if necessary
   * - Calls the scrollBehavior
   */
  /**
   * 导航最终化
   * @param toLocation 目标路由
   * @param from 当前路由
   * @param isPush 是否为 push 导航
   * @param replace 是否为 replace 导航
   * @param data 导航状态数据
   * @returns
   */
  function finalizeNavigation(
    toLocation: RouteLocationNormalizedLoaded,
    from: RouteLocationNormalizedLoaded,
    isPush: boolean,
    replace?: boolean,
    data?: HistoryState
  ): NavigationFailure | void {
    // a more recent navigation took place
    // 校验导航是否被取消(并发导航冲突)
    const error = checkCanceledNavigation(toLocation, from)
    if (error) return error

    // only consider as push if it's not the first navigation
    // 判断是否为首次导航
    const isFirstNavigation = from === START_LOCATION_NORMALIZED
    const state: Partial<HistoryState> | null = !isBrowser ? {} : history.state

    // change URL only if the user did a push/replace and if it's not the initial navigation because
    // it's just reflecting the url
    // 仅在「主动 push 跳转」时更新 URL
    if (isPush) {
      // on the initial navigation, we want to reuse the scroll position from
      // history state if it exists
      // replace 模式 或 首次导航 → 使用 replaceState 更新 URL
      if (replace || isFirstNavigation)
        routerHistory.replace(
          toLocation.fullPath,
          assign(
            {
              scroll: isFirstNavigation && state && state.scroll,
            },
            data
          )
        )
        // 普通 push 跳转 → 使用 pushState 新增历史记录
      else routerHistory.push(toLocation.fullPath, data)
    }

    // accept current navigation
    // 更新响应式的当前路由 → 触发组件重新渲染
    currentRoute.value = toLocation 
    handleScroll(toLocation, from, isPush, isFirstNavigation) // 触发滚动

    markAsReady() // 标记就绪
  }

实例方法 router.resolve

router.resolve 是 Vue Router 提供的路由地址解析 API,用于将任意格式的路由地址(字符串 / 对象 / 命名路由)解析为标准化的 RouteLocationResolved 对象。

  /**
   * 路由地址解析器
   * @param rawLocation 原始路由地址(字符串或对象)
   * @param currentLocation 当前路由状态(可选)
   * @returns 解析后的路由地址对象
   */
  function resolve(
    rawLocation: RouteLocationRaw,
    currentLocation?: RouteLocationNormalizedLoaded
  ): RouteLocationResolved {
    // const resolve: Router['resolve'] = (rawLocation: RouteLocationRaw, currentLocation) => {
    // const objectLocation = routerLocationAsObject(rawLocation)
    // we create a copy to modify it later
    currentLocation = assign({}, currentLocation || currentRoute.value)

    // 解析字符串路由地址(包含 query/hash)
    if (typeof rawLocation === 'string') {
      const locationNormalized = parseURL(
        parseQuery,
        rawLocation,
        currentLocation.path
      )
      const matchedRoute = matcher.resolve(
        { path: locationNormalized.path },
        currentLocation
      )

      const href = routerHistory.createHref(locationNormalized.fullPath)
      if (__DEV__) {
        if (href.startsWith('//'))
          warn(
            `Location "${rawLocation}" resolved to "${href}". A resolved location cannot start with multiple slashes.`
          )
        else if (!matchedRoute.matched.length) {
          warn(`No match found for location with path "${rawLocation}"`)
        }
      }

      // locationNormalized is always a new object
      return assign(locationNormalized, matchedRoute, {
        params: decodeParams(matchedRoute.params),
        hash: decode(locationNormalized.hash),
        redirectedFrom: undefined,
        href,
      })
    }

    // 校验 rawLocation 是否为合法的路由对象(包含 path/name 至少其一)
    if (__DEV__ && !isRouteLocation(rawLocation)) {
      warn(
        `router.resolve() was passed an invalid location. This will fail in production.\n- Location:`,
        rawLocation
      )
      return resolve({})
    }

    let matcherLocation: MatcherLocationRaw

    // path could be relative in object as well
    // 解析对象路由地址(包含 path/params/query/hash)
    // 含 path 的对象路由
    if (rawLocation.path != null) {
      // 开发环境警告:path 与 params 混用(params 会被忽略)
      // path 与 params 不兼容:通过 path 跳转时,params 会被忽略(因 path 已包含参数,如 /user/1)
      if (
        __DEV__ &&
        'params' in rawLocation &&
        !('name' in rawLocation) &&
        // @ts-expect-error: the type is never
        Object.keys(rawLocation.params).length
      ) {
        warn(
          `Path "${rawLocation.path}" was passed with params but they will be ignored. Use a named route alongside params instead.`
        )
      }
      matcherLocation = assign({}, rawLocation, {
        path: parseURL(parseQuery, rawLocation.path, currentLocation.path).path,
      })

      // 解析命名路由地址(包含 name/params)
    } else {
      // remove any nullish param
      const targetParams = assign({}, rawLocation.params)
      for (const key in targetParams) {
         // 移除 null/undefined 的 params(避免匹配错误)
        if (targetParams[key] == null) {
          delete targetParams[key]
        }
      }
      // pass encoded values to the matcher, so it can produce encoded path and fullPath
      matcherLocation = assign({}, rawLocation, {
        params: encodeParams(targetParams),
      })
      // current location params are decoded, we need to encode them in case the
      // matcher merges the params
      currentLocation.params = encodeParams(currentLocation.params)
    }

    const matchedRoute = matcher.resolve(matcherLocation, currentLocation)
    const hash = rawLocation.hash || ''

    // 开发环境警告:hash 未以 # 开头
    if (__DEV__ && hash && !hash.startsWith('#')) {
      warn(
        `A \`hash\` should always start with the character "#". Replace "${hash}" with "#${hash}".`
      )
    }

    // the matcher might have merged current location params, so
    // we need to run the decoding again
    matchedRoute.params = normalizeParams(decodeParams(matchedRoute.params))

    // 生成 fullPath(合并 path/query/hash)
    const fullPath = stringifyURL(
      stringifyQuery,
      assign({}, rawLocation, {
        hash: encodeHash(hash),
        path: matchedRoute.path,
      })
    )

    const href = routerHistory.createHref(fullPath)
    if (__DEV__) {
      if (href.startsWith('//')) {
        warn(
          `Location "${rawLocation}" resolved to "${href}". A resolved location cannot start with multiple slashes.`
        )
      } else if (!matchedRoute.matched.length) {
        warn(
          `No match found for location with path "${
            rawLocation.path != null ? rawLocation.path : rawLocation
          }"`
        )
      }
    }

    return assign(
      {
        fullPath,
        // keep the hash encoded so fullPath is effectively path + encodedQuery +
        // hash
        hash,
        query:
          // if the user is using a custom query lib like qs, we might have
          // nested objects, so we keep the query as is, meaning it can contain
          // numbers at `$route.query`, but at the point, the user will have to
          // use their own type anyway.
          // https://github.com/vuejs/router/issues/328#issuecomment-649481567
          stringifyQuery === originalStringifyQuery
            ? normalizeQuery(rawLocation.query)
            : ((rawLocation.query || {}) as LocationQuery),
      },
      matchedRoute,
      {
        redirectedFrom: undefined,
        href,
      }
    )
  }
{
  path: '/dashboard',
  name: 'dashboard',
  component: () => import('@/views/dashboard/DashBoard.vue'),
  meta: {
    title: '看板',
    icon: 'dashboard',
    roles: ['admin', 'user']
  }
}

router.resolve 支持哪些输入格式?

  • 字符串格式(含绝对 / 相对路径、query/hash)。
  • 对象格式(path 模式),path 模式下传入 params 会被忽略(开发环境会警告)。
  • 对象格式(命名路由模式)。
// 解析 path 模式
console.log('router.resolve', router.resolve({
  path: '/dashboard'
}))

// 解析命名路由
console.log('router.resolve', router.resolve({
  name: 'dashboard'
}))

// 解析路径
console.log('router.resolve', router.resolve('/dashboard'))

image.png

实例方法 addRoute

  /**
   * 新增路由(支持嵌套)
   * 格式 1:addRoute(父路由名称, 子路由配置)
   * 格式 2:addRoute(路由配置)
   * @param parentOrRoute 父路由记录名或路由记录对象
   * @param route 子路由记录(可选)
   * @returns 移除路由的函数
   */
  function addRoute(
    parentOrRoute: NonNullable<RouteRecordNameGeneric> | RouteRecordRaw,
    route?: RouteRecordRaw
  ) {
    let parent: Parameters<(typeof matcher)['addRoute']>[1] | undefined
    let record: RouteRecordRaw

    // 判断第一个参数是否为「路由名称」(而非路由配置对象)
    if (isRouteName(parentOrRoute)) {
      // 根据路由名称从底层匹配器中获取对应的「路由记录匹配器」
      parent = matcher.getRecordMatcher(parentOrRoute)
      if (__DEV__ && !parent) {
        warn(
          `Parent route "${String(parentOrRoute)}" not found when adding child route`,
          route
        )
      }
      record = route!
    } else {
      record = parentOrRoute
    }

    return matcher.addRoute(record, parent)
  }

实例方法 removeRoute

  /**
   * 删除路由(根据路由记录名)
   * @param name 路由记录名称
   */
  function removeRoute(name: NonNullable<RouteRecordNameGeneric>) {
    const recordMatcher = matcher.getRecordMatcher(name)
    if (recordMatcher) {
      matcher.removeRoute(recordMatcher)
    } else if (__DEV__) {
      warn(`Cannot remove non-existent route "${String(name)}"`)
    }
  }

实例方法 getRoutes

  /**
   * 获取所有路由记录
   * @returns
   */
  function getRoutes() {
    return matcher.getRoutes().map(routeMatcher => routeMatcher.record)
  }

实例方法 hasRoute

  /**
   * 判断路由是否存在
   * @param name
   * @returns
   */
  function hasRoute(name: NonNullable<RouteRecordNameGeneric>): boolean {
    return !!matcher.getRecordMatcher(name)
  }

vue-router 是如何安装的?

router 实例的 install 是一个函数,vue 利用 vue 实例 app app.use(router) 引入 vue-router 。

image.png

vue-router 全局路由守卫有哪些?

image.png

beforeEach(guard: NavigationGuardWithThis<undefined>): () => void
beforeResolve(guard: _NavigationGuardResolved): () => void
afterEach(guard: NavigationHookAfter): () => void

v5 版本,已废弃 next() 写法,建议使用 return 返回替代。

// 已废弃写法
// 全局前置守卫
router.beforeEach((to, from, next) => {
  console.log('router.beforeEach-to', to)
  console.log('router.beforeEach-from', from)
  next()
})

// 全局解析守卫
router.beforeResolve((to, from, next) => {
  console.log('router.beforeResolve-to', to)
  console.log('router.beforeResolve-from', from)
  next()
})

image.png

// 建议写法
// 全局前置守卫
router.beforeEach((to, from) => {
  console.log('router.beforeEach-to', to)
  console.log('router.beforeEach-from', from)
  return true
})

// 全局解析守卫
router.beforeResolve((to, from) => {
  console.log('router.beforeResolve-to', to)
  console.log('router.beforeResolve-from', from)
  return true
})
export interface NavigationGuardWithThis<T> {
  (
    this: T,
    to: RouteLocationNormalized, // 目标路由对象
    from: RouteLocationNormalizedLoaded, // 来源路由对象
    /**
     * @deprecated Return a value from the guard instead of calling `next(value)`.
     * The callback will be removed in a future version of Vue Router.
     * 未来版本将移除对 `next(value)` 的调用,建议直接返回值。
     */
    next: NavigationGuardNext // 导航守卫回调函数
  ): _Awaitable<NavigationGuardReturn>
}

export interface _NavigationGuardResolved {
  (
    this: undefined,
    to: RouteLocationNormalizedLoaded,
    from: RouteLocationNormalizedLoaded,
    /**
     * @deprecated Return a value from the guard instead of calling `next(value)`.
     * The callback will be removed in a future version of Vue Router.
     */
    next: NavigationGuardNext
  ): _Awaitable<NavigationGuardReturn>
}

export interface NavigationHookAfter {
  (
    to: RouteLocationNormalizedLoaded,
    from: RouteLocationNormalizedLoaded,
    failure?: NavigationFailure | void
  ): unknown
}

router.push 接收参数的 3 种方式

/**
 * Route location that can be passed to `router.push()` and other user-facing APIs.
 */
export type RouteLocationRaw<Name extends keyof RouteMap = keyof RouteMap> =
  RouteMapGeneric extends RouteMap
    ?
        | RouteLocationAsString // 字符串路径(如 "/home")
        | RouteLocationAsRelativeGeneric // 命名路由泛型对象(如 { name: 'Home' })
        | RouteLocationAsPathGeneric  // 路径对象泛型(如 { path: '/home' })
    : // 强类型约束(开启 TS 强校验)
        | _LiteralUnion<RouteLocationAsStringTypedList<RouteMap>[Name], string>
        | RouteLocationAsRelativeTypedList<RouteMap>[Name]
        | RouteLocationAsPathTypedList<RouteMap>[Name]
const handleClick = () => {
  // 命名路由
  router.push({
    name: "user-list",
  });
};

const handleClick2 = () => {
  // 对象路由(path模式)
  router.push({
    path: "/user/123",
  });
};

const handleClick3 = () => {
  // 字符路由
  router.push("/data-view");
};

最后

  1. 源码阅读:github.com/hannah-lin-…

《Vue3 watch详情:deep/immediate/flush/once 全用法 + 踩坑总结》

作者 cmd
2026年3月18日 12:13

本文全面解析 Vue3 watch 所有用法,包含监听基础类型、引用类型、多个数据源、停止监听、深度监听、新旧值获取、与 watchEffect 区别,适合前端开发日常使用与面试准备。

《Vue3 watch详情:deep/immediate/flush/once 全用法 + 踩坑总结》

1. API介绍

watch(WatcherSource, Callback, [WatchOptions])

type WatcherSource<T> = Ref<T> | (() => T) 

interface WatchOptions extends WatchEffectOptions {
    deep?: boolean // 默认:false 
    immediate?: boolean // 默认:false 
    flush?: string // 默认:'pre'
}

参数说明:

WatcherSource: 用于指定要侦听的响应式数据源。侦听器数据源可以是返回值的 getter 函数,可以直接 是 ref reactive

callback : 执行的回调函数,可依次接受 newValue , oldValue 作为参数。

watchOptions: deep immediate flush once(3.4新增) 可选

  • 当需要对响应式对象进行深度监听时,设置 deep: true

  • 默认情况下watch是惰性的,当我们设置 immediate: true 时,watch会在初始化时立即执行回调函数

  • flush 选项可以更好地控制回调的时间。它可设置为 pre、post 或 sync

    • 默认值是 pre,指定的回调应该在DOM渲染前被调用。
    • post 值是可以用来将回调推迟到DOM渲染之后的。如果回调需要通过 $refs 访问更新的 DOM 或子组件,那么则使用该值。
    • 如果 flush 被设置为 sync,一旦值发生了变化,回调将被同步调用(少用,影响性能)。
  • once: true : 一次性侦听器;只生效一次(3.4新增参数)

WatchSource必须是引用对象;因此它的写法有两种;

  • 如果是响应式的引用对象,如ref,reactive; 直接写变量名即可;
  • 如果是基础数据,需要使用getter函数;

getter函数的使用除了上面的情况还有一个就是获取引用对象新旧值的时候会用到;

2. 侦听单个数据源及停止侦听

<script setup>
  import { watch, ref, reactive } from 'vue'
  // 侦听一个 getter
  const person = reactive({name: '小松菜奈'})
  watch(
    () => person.name,
    (value, oldValue) => {
      console.log(value, oldValue)
    }, {immediate:true}
  )
  person.name = '有村架纯'

  // 直接侦听ref  停止侦听
  const ageRef = ref(16)
  const stopAgeWatcher = watch(ageRef, (value, oldValue) => {
    console.log(value, oldValue)
    if (value > 18) {
      stopAgeWatcher() // 当ageRef大于18,停止侦听
    }
  })

  const changeAge = () => {
    ageRef.value += 1
  }
</script>

现象

配置了immediate:truewatch,在初始化时触发了一次watch的回调。我们连续点击增加年龄,当年龄 的当前值大于18时,watch停止了侦听。

结论

侦听器数据源可以是返回值的 getter 函数,也可以直接是 refwatch函数是有返回值的,返回值是停止器,然后通 过执行停止器() 函数来停止侦听。

3. 监听多个数据源

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

  const name = ref('小松菜奈')
  const age = ref(25)

  watch([name, age], ([name, age], [prevName, prevAge]) => {
    console.log('newName', name, 'oldName', prevName)
    console.log('newAge', age, 'oldAge', prevAge)
  })

  // 如果你在同一个函数里同时改变这些被侦听的来源,侦听器只会执行一次
  const change1 = () => {
    name.value = '有村架纯'
    age.value += 2
  }

  // 用 nextTick 等待侦听器在下一步改变之前运行,侦听器执行了两次
  const change2 = async () => {
    name.value = '新垣结衣'
    await nextTick()
    age.value += 2
  }
</script>

现象

以上,当我们在同一个函数里同时改变nameage两个侦听源,watch的回调函数只触发了一次;当我们 在nameage的改变之间增加了一个nextTickwatch回调函数触发了两次。

结论

我们可以通过watch侦听多个数据源的变化。如果在同一个函数里同时改变这些被侦听的来源,侦听器只会 执行一次。若要使侦听器执行多次,我们可以利用 nextTick ,等待侦听器在下一步改变之前运行。

4. 侦听引用对象

<template>
  <div>
    <div>ref定义数组:{{arrayRef}}</div>
    <div>reactive定义数组:{{arrayReactive}}</div>
  </div>
  <div>
    <button @click="changeArrayRef">改变ref定义数组第一项</button>
    <button @click="changeArrayReactive">改变reactive定义数组第一项</button>
  </div>
</template>

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

  const arrayRef = ref([1, 2, 3, 4])
  const arrayReactive = reactive([1, 2, 3, 4])

  // ref not deep, 不能深度侦听
  const arrayRefWatch = watch(arrayRef, (newValue, oldValue) => {
    console.log('newArrayRefWatch', newValue, 'oldArrayRefWatch', oldValue)
  })

  // ref deep, 深度侦听,新旧值一样
  const arrayRefDeepWatch = watch(arrayRef, (newValue, oldValue) => {
    console.log('newArrayRefDeepWatch', newValue, 'oldArrayRefDeepWatch', oldValue)
  }, {deep: true})

  // ref deep, getter形式 , 新旧值不一样
  const arrayRefDeepGetterWatch = watch(() => [...arrayRef.value], (newValue, oldValue) => {
    console.log('newArrayRefDeepGetterWatch', newValue, 'oldArrayRefDeepGetterWatch', oldValue)
  })

  // reactive,默认深度监听,可以不设置deep:true, 新旧值一样
  const arrayReactiveWatch = watch(arrayReactive, (newValue, oldValue) => {
    console.log('newArrayReactiveWatch', newValue, 'oldArrayReactiveWatch', oldValue)
  })

  // reactive,getter形式 , 新旧值不一样
  const arrayReactiveGetterWatch = watch(() => [...arrayReactive], (newValue, oldValue) => {
    console.log('newArrayReactiveFuncWatch', newValue, 'oldArrayReactiveFuncWatch', oldValue)
  })

  const changeArrayRef = () => {
    arrayRef.value[0] = 3
  }
  const changeArrayReactive = () => {
    arrayReactive[0] = 6
  }
</script>

现象

  • 当将引用对象采用ref形式定义时,如果不加上deep:true watch侦听不到值的变化的;而加 deep:truewatch可以侦听到数据的变化,但是当前值和先前值一样,即不能获取旧值。
  • 当将引用对象采用 reactive形式定义时,不作任何处理,watch可以侦听到数据的变化,但是当前值和旧值一样。
  • 两种定义下,把watch的数据源写成getter函数的形式并进行深拷贝返回,可以在watch回调中同时获得当前值和旧值。
    const objReactive = reactive({user: {name: 'aa', age: '18'}, brand: 'Channel'});
    
    /** 对象深度监听的最佳实践- reactive且源采用函数式返回,返回深拷贝后的数据 */
    watch(() => _.cloneDeep(objReactive), (newVal, oldVal) => {
      console.log('newVal', newVal);
      console.log('oldVal', oldVal);
    })

结论: 当我们使用watch侦听引用对象时

  • 若使用ref定义的引用对象:
    • 只要获取当前值,watch第一个参数直接写成数据源,另外需要加上deep:true选项
    • 若要获取当前值和旧值,需要把数据源写成getter函数的形式,并且需对数据源进行深拷贝
  • 若使用 reactive定义的引用对象:
    • 只要获取当前值,watch第一个参数直接写成数据源,可以不加deep:true选项
    • 若要获取当前值和旧值,需要把数据源写成getter函数的形式,并且需对数据源进行深拷贝

5. watchEffect

watchEffect(callback, options): 只有两个参数,第一个是回调函数,第二个是配置项,配置项参数与watch一样;

watchEffect会立即执行,不像watch是惰性的;当然也可以通过watch配置项加{immediate: true}实现;

const number = reactive({ count: 0 });
const countAdd = () => {
  number.count++;
};
watchEffect(()=>{
  console.log("新的值:", number.count);
})

TIP

watchEffect 仅会在其同步执行期间,才追踪依赖。在使用异步回调时,只有在第一个 await 正常工作前访问到的属性才会被追踪。

以便dom更新之后运行watchEffect, 有个简单写法

watchPostEffect(() => {
  /* 在 Vue 更新后执行 */
})

6. watch, watchEffect的区别

  1. watchwatchEffect 都能监听响应式数据的变化,不同的是它们监听数据变化的方式不同。
  2. watch 会明确监听某一个响应数据,而 watchEffect则是隐式的监听回调函数中响应数据。
  3. watch 在响应数据初始化时是不会执行回调函数的,watchEffect 在响应数据初始化时就会立即执行回调函数。

7. FAQ

通常来说,我们的一个组件被销毁或者卸载后,监听器也会跟着被停止,并不需要我们手动去关闭监听器。但是总是有一些特殊情况,即使组件卸载了,但是监听器依然存在,这个时候其实式需要我们手动关闭它的,否则容易造成内存泄漏。

比如下面这中写法,我们就需要手动停止监听器:

<script setup>
import { watchEffect } from 'vue'
// 它会自动停止
watchEffect(() => {})
// ...这个则不会!
setTimeout(() => {
  watchEffect(() => {})
}, 100)
</script>

上段代码中我们采用异步的方式创建了一个监听器,这个时候监听器没有与当前组件绑定,所以即使组件销毁了,监听器依然存在。

关闭方法很简单,代码如下:

const unwatch = watchEffect(() => {})
// ...当该侦听器不再需要时
unwatch()

感谢您抽出宝贵的时间观看本文;本文是 Vue3 核心 API 系列的第 1 篇,后续会持续更新 computed、ref/reactive、生命周期等实战内容,同时正在整理「Vue3 完整项目实战小册」(包含从 0 到 1 开发小程序 / 管理系统的全流程),欢迎关注~

Flutter InkWell与GestureDetector

作者 木子雨廷
2026年3月18日 11:31

Flutter 中点击与手势:从 InkWell、GestureDetector 到事件机制

前言

在 Flutter 里做「可点区域」「手势识别」时,最先接触的往往是 GestureDetectorMaterial 体系下的 InkWell。二者都能响应点击,但语义、视觉效果和命中区域并不相同;再往下还有 ListenerMouseRegionRawGestureDetector 等。理清「用什么组件」和「事件怎么从屏幕传到回调」,能少踩很多坑(例如:点了没反应、水波纹不出现、和滚动冲突、透明区域也能点等)。


一、InkWell 与 GestureDetector:该用谁?

1. GestureDetector:纯手势识别,不负责 Material 反馈

GestureDetector 本质是**手势竞技场(Gesture Arena)**的封装:把指针序列识别成 onTaponLongPressonPanUpdate 等,然后调你的回调。

特点简要归纳:

维度 说明
视觉反馈 没有 Material 水波纹 / 高亮,除非你自己DecoratedBox 或在外层再套 Material
子组件 通常包在非空 child 上;child 没有尺寸时可能无法命中(见后文「命中测试」)
手势种类 onTaponDoubleTaponLongPressonVerticalDrag*onHorizontalDrag*onScale* 等,较全
与滚动 列表里横向滑动手势容易和 Vertical scroll 抢手势,需 behavior 或改用手势组合策略

典型写法:


GestureDetector(

onTap: () {},

child: Text('点我'),

)

2. InkWell:在 Material 上提供「水波纹」式点击反馈

InkWell 必须放在 Material(或带 Material 祖先,如 CardMaterial)里,否则水波纹不显示或行为异常。

特点简要归纳:

维度 说明
视觉反馈 点击有 splash(水波纹)、可配 highlightColor
形状 常用 borderRadius + InkWellborderRadius子组件圆角一致,否则波纹会「方角溢出」
子组件 同样依赖子树的布局尺寸;无 child 时需配合 SizedBox.expand
手势 以点击类为主(onTaponLongPress 等),不如 GestureDetector 的拖动手势全

典型写法:


Material(

color: Colors.white,

borderRadius: BorderRadius.circular(8),

child: InkWell(

borderRadius: BorderRadius.circular(8),

onTap: () {},

child: Padding(

padding: const EdgeInsets.all(16),

child: Text('带波纹的按钮'),

),

),

)

选型小结:

  • Material 点击反馈InkWell(或 InkResponseIconButton 等)。

  • 只要 回调、不要波纹,或要 复杂拖动手势GestureDetector(或 Listener + 自处理)。


二、容易混淆的「兄弟组件」

1. InkResponse

与 InkWell 类似,但可更细调水波纹形状(如圆形),适合图标按钮外层。

2. Listener

底层指针事件onPointerDown / onPointerMove / onPointerUp不参与手势竞技场语义封装,适合:

  • 只要原始指针、不要「点一下」语义;

  • 和手势系统解耦(例如自定义绘制、调试命中区域)。

注意:Listenerbehavior 同样影响命中;要独占事件需配合 HitTestBehavior

3. MouseRegion / Hover

桌面 / Web 上悬停、光标样式(cursor),和移动端「点击」互补。

4. AbsorbPointer / IgnorePointer

  • AbsorbPointer:子树吸收事件,下层兄弟收不到。

  • IgnorePointer:子树不参与命中,事件穿透到下层(只读蒙层误用会导致「点透」)。

5. MergeSemantics / ExcludeSemantics

无障碍与语义树相关,影响读屏,与「可点区域」产品语义常一起考虑。

6. RawGestureDetector

需要自定义 GestureRecognizer、多 recognizer 精细组合时使用,一般业务少用。


三、细节:为什么「点了没反应」?

1. HitTestBehavior(GestureDetector / Listener)

子组件没有尺寸(如空的 Container 无宽高)时,命中区域可能为 0。可设:


GestureDetector(

behavior: HitTestBehavior.opaque, // 或 translucent

onTap: () {},

child: Container(color: Colors.red, width: 16, height: 16),

)

取值 含义
deferToChild 默认;只在 child 报告命中的区域响应
opaque 整块区域参与命中,挡住下面,适合蒙层拦截
translucent 参与命中,可与下层同时参与部分命中测试(具体仍受竞技场影响)

2. 子组件超出父布局的命中区

若子控件用负 margin / 负 Positioned 画出父布局外,父级未扩大时,触点可能落在父级 HitTest 范围外,表现为「点了没反应」。修复:扩大父级可点区域(如更大的 SizedBox)或把按钮移回命中区内。

3. 与 ScrollView 的手势冲突

竖向 ListView 里若 onTap 不触发,常见原因是拖动手势在竞技场中胜出。可尝试:缩小识别区域、用 Listener、或调整 ScrollPhysics / ScrollBehavior

4. InkWell 与 Clip

水波纹画在 Material 的 ink layer 上,若子组件裁切不当,会出现波纹被裁掉;Material 与 InkWell 的 borderRadius 建议一致


四、事件响应机制原理(简述)

1. 命中测试(Hit Test)

手指按下后,从根节点向下做 Hit Test

  • 每个 RenderObject 根据几何判断触点是否落在自己范围内;

  • 得到一条从根到最内层可命中节点的路径。

未参与命中的节点不会收到后续指针事件。

2. 指针事件(PointerEvent)

命中路径上的节点会收到:

PointerDownEventPointerMoveEventPointerUpEvent / PointerCancelEvent

Listener 监听的就是这一层。

3. 手势识别与竞技场(Gesture Arena)

GestureDetector 内部注册 GestureRecognizer(如 TapGestureRecognizer)。多个 recognizer 可能同时收到同一串指针,但最终通常只有一个手势胜出

  • Gesture Arena:从 PointerDown 开始角逐;例如「轻点」和「拖动」竞争,赢的回调触发,输的取消。

因此:onTap 不触发 ≠ 没点到,也可能是被别手势抢走

4. InkWell 的路径

InkWell 同样建立在手势识别之上,额外把点击反馈交给 MaterialInkController 画水波纹;因此需要 Material 祖先

5. 与经典 DOM 冒泡的差异

Flutter 不是经典 DOM 的冒泡模型,而是:

命中路径分发指针 → 手势层竞技场决出胜者。

理解这一点可解释:透明上层挡住下层、IgnorePointer 点透、以及「扩大 SizedBox 修复关闭钮不响应」等实际问题。


五、实践注意点

注意点 建议
可点区域过小 至少保证约 40×40 命中区(可用 SizedBox + Center 包小图标)
只要拦截点击 HitTestBehavior.opaque 的 GestureDetector 盖一层
列表 + 点击 优先 InkWell / ListTile,注意与滚动手势竞争
调试 debugPaintPointersEnabled、或临时加半透明背景看命中范围

六、小结

组件 典型用途 反馈 / 行为
GestureDetector 通用点击、拖动、缩放 无内置 Material 波纹;手势类型全
InkWell / InkResponse 列表项、卡片点击 水波纹;需 Material 祖先
Listener 原始指针 不经「点击」语义封装
IgnorePointer / AbsorbPointer 只读蒙层、穿透/拦截 改变是否参与命中
事件机制 命中测试 → Pointer → Recognizer → Arena 解释「点了没反应」与手势冲突

选型时先想:要不要 Material 反馈要不要复杂手势命中区域是否足够;遇到异常再从 Hit Test + Gesture Arena 两条线排查即可。

QT中自定义标题栏

作者 寒鸦飞尽
2026年3月18日 10:56

QT中自定义标题栏

在 Qt 桌面开发里,自定义标题栏是一个很常见的需求。比如你想统一品牌风格、做更轻的窗口外观,或者需要让标题栏和业务界面融为一体,这时候系统默认标题栏往往就不够用了。

但真正动手之后会发现,这件事远不止“把标题栏画出来”这么简单。因为一旦把系统标题栏去掉,随之消失的还有一整套窗口能力:

  • 窗口拖动
  • 双击最大化/还原
  • 四边八角缩放
  • 最大化后的拖动恢复
  • Windows 下的系统阴影、圆角、细边框
  • 最大化时窗口位置偏移等细节问题

这篇文章结合一个实际 Qt 6 项目的实现,完整讲清楚如何在 QML + Qt + Win32/DWM 的组合下,做出一套可用、接近原生体验的自定义标题栏方案。

一、先说结论:自定义标题栏要分三层做

如果想把这件事做好,建议拆成三层:

  1. QML 负责界面
    • 标题栏布局
    • 最小化、最大化、关闭按钮
    • 鼠标 hover、图标切换
  2. Qt 负责窗口交互
    • startSystemMove()
    • startSystemResize()
    • 窗口状态切换
    • 普通状态与最大化状态之间的恢复逻辑
  3. Windows 原生层负责非客户区细节
    • 系统阴影
    • 圆角
    • 细边框
    • 非客户区裁剪
    • 最大化偏移修正

这样分层后,代码结构会清晰很多,而且不会把所有逻辑都塞进一个 QML 文件里。

二、去掉系统标题栏

第一步是把系统标题栏去掉,让窗口变成无边框窗口。

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window

Window {
    id: window

    flags: Qt.Window | Qt.FramelessWindowHint

    width: 1200
    height: 800
    minimumWidth: 800
    minimumHeight: 600
    visible: true
    title: ""

    color: "transparent"

    Rectangle {
        anchors.fill: parent
        color: "#f5f7fb"

        ColumnLayout {
            anchors.fill: parent
            spacing: 0

            WindowTitleBar {
                id: titleBar
                Layout.fillWidth: true
                title: "我是标题"
                logoSource: "qrc:/icons/logo.png"
                windowStateController: windowStateController
            }

            Item {
                Layout.fillWidth: true
                Layout.fillHeight: true
            }
        }
    }

    WindowStateController {
        id: windowStateController
        window: window
    }

    WindowResizeHandles {
        anchors.fill: parent
        windowStateController: windowStateController
    }
}

这里最关键的是:

flags: Qt.Window | Qt.FramelessWindowHint

只要加上 Qt.FramelessWindowHint,系统标题栏就没了。接下来窗口顶部就可以完全由我们自己接管。

三、实现一个自定义标题栏组件

接下来开始做标题栏 UI。这个标题栏需要具备几个最基本的能力:

  • 展示标题、Logo
  • 最小化按钮
  • 最大化 / 还原按钮
  • 关闭按钮
  • 鼠标按下时开始拖动窗口
  • 双击时最大化 / 还原

下面是一份比较完整的 WindowTitleBar.qml

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window

Item {
    id: root

    property var windowStateController
    property string title: "我是标题"
    property color backgroundColor: "#f7f8fa"
    property color borderColor: "#d9dde4"
    property color textColor: "#111827"
    property color buttonHoverColor: "#e9edf3"
    property color closeHoverColor: "#e81123"
    property alias logoSource: logo.source

    height: 40

    function toggleMaximize() {
        if (!windowStateController) {
            return;
        }
        windowStateController.toggleMaximize();
    }

    Rectangle {
        anchors.fill: parent
        color: root.backgroundColor
    }

    Rectangle {
        anchors.left: parent.left
        anchors.right: parent.right
        anchors.bottom: parent.bottom
        height: 1
        color: root.borderColor
    }

    MouseArea {
        anchors.fill: parent
        acceptedButtons: Qt.LeftButton
        propagateComposedEvents: true

        onPressed: function(mouse) {
            if (mouse.button === Qt.LeftButton && windowStateController) {
                windowStateController.startMove(root, mouse.x, mouse.y);
            }
        }

        onDoubleClicked: function(mouse) {
            if (mouse.button === Qt.LeftButton) {
                root.toggleMaximize();
            }
        }
    }

    RowLayout {
        anchors.fill: parent
        anchors.leftMargin: 12
        spacing: 8

        Image {
            id: logo
            Layout.alignment: Qt.AlignVCenter
            Layout.preferredWidth: 18
            Layout.preferredHeight: 18
            sourceSize.width: 18
            sourceSize.height: 18
            fillMode: Image.PreserveAspectFit
            smooth: true
        }

        Text {
            Layout.alignment: Qt.AlignVCenter
            text: root.title
            color: root.textColor
            font.family: "Microsoft YaHei"
            font.pixelSize: 13
            elide: Text.ElideRight
        }

        Item {
            Layout.fillWidth: true
        }

        Rectangle {
            Layout.preferredWidth: 46
            Layout.preferredHeight: parent.height
            color: minimizeArea.containsMouse ? root.buttonHoverColor : "transparent"

            Image {
                anchors.centerIn: parent
                width: 18
                height: 18
                source: "qrc:/icons/minimize.png"
                fillMode: Image.PreserveAspectFit
            }

            MouseArea {
                id: minimizeArea
                anchors.fill: parent
                hoverEnabled: true
                onClicked: {
                    if (windowStateController) {
                        windowStateController.minimize();
                    }
                }
            }
        }

        Rectangle {
            Layout.preferredWidth: 46
            Layout.preferredHeight: parent.height
            color: maximizeArea.containsMouse ? root.buttonHoverColor : "transparent"

            Image {
                anchors.centerIn: parent
                width: 18
                height: 18
                source: windowStateController && windowStateController.maximized
                        ? "qrc:/icons/exitFullScreen.png"
                        : "qrc:/icons/fullScreen.png"
                fillMode: Image.PreserveAspectFit
            }

            MouseArea {
                id: maximizeArea
                anchors.fill: parent
                hoverEnabled: true
                onClicked: {
                    root.toggleMaximize();
                }
            }
        }

        Rectangle {
            Layout.preferredWidth: 46
            Layout.preferredHeight: parent.height
            color: closeArea.containsMouse ? root.closeHoverColor : "transparent"

            Image {
                anchors.centerIn: parent
                width: 18
                height: 18
                source: "qrc:/icons/close.png"
                fillMode: Image.PreserveAspectFit
            }

            MouseArea {
                id: closeArea
                anchors.fill: parent
                hoverEnabled: true
                onClicked: {
                    if (Window.window) {
                        Window.window.close();
                    }
                }
            }
        }
    }
}

四、拖动窗口时,尽量别自己算位移

很多人第一次做自定义标题栏,会在 MouseArea 里记录鼠标按下位置,然后自己计算 window.xwindow.y。这能跑,但体验一般,而且容易在多屏、贴边、最大化恢复时出 bug。

更稳妥的方式是直接使用 Qt 提供的系统能力:

window.startSystemMove()

这会把拖动行为交还给系统窗口管理器。吸附、贴边、拖动体验都会更接近原生。

所以在标题栏里,不应该自己移动窗口,而是交给一个统一的状态控制器处理。

五、抽一个 WindowStateController,统一管理窗口状态

无边框窗口最容易出问题的地方,就是最大化与还原之间的状态切换。

建议把这些逻辑统一放进一个 WindowStateController.qml。下面是完整实现:

import QtQuick
import QtQuick.Window

Item {
    id: root

    required property Window window
    visible: false
    width: 0
    height: 0

    property bool maximized: false
    property rect normalGeometry: Qt.rect(0, 0, 0, 0)

    function availableGeometry() {
        if (window && window.screen) {
            return window.screen.availableGeometry;
        }
        return Qt.rect(0, 0, Screen.width, Screen.height);
    }

    function rememberNormalGeometry() {
        if (!window || maximized || window.visibility === Window.Minimized || window.visibility === Window.FullScreen) {
            return;
        }

        normalGeometry = Qt.rect(window.x, window.y, window.width, window.height);
    }

    function applyGeometry(geometry) {
        if (!window) {
            return;
        }

        window.x = Math.round(geometry.x);
        window.y = Math.round(geometry.y);
        window.width = Math.round(geometry.width);
        window.height = Math.round(geometry.height);
    }

    function maximize() {
        if (!window) {
            return;
        }

        rememberNormalGeometry();
        maximized = true;
        window.showMaximized();
        applyGeometry(availableGeometry());
    }

    function restore() {
        if (!window) {
            return;
        }

        maximized = false;
        window.showNormal();
        if (normalGeometry.width > 0 && normalGeometry.height > 0) {
            applyGeometry(normalGeometry);
        }
    }

    function toggleMaximize() {
        if (maximized) {
            restore();
        } else {
            maximize();
        }
    }

    function minimize() {
        if (!window) {
            return;
        }

        window.showMinimized();
    }

    function startMove(item, mouseX, mouseY) {
        if (!window || !item) {
            return;
        }

        if (!maximized) {
            window.startSystemMove();
            return;
        }

        const globalPoint = item.mapToGlobal(mouseX, mouseY);
        const preservedWidth = window.width;
        const preservedHeight = window.height;

        maximized = false;
        window.showNormal();

        Qt.callLater(function() {
            window.width = preservedWidth;
            window.height = preservedHeight;
            window.x = Math.round(globalPoint.x - mouseX);
            window.y = Math.round(globalPoint.y - mouseY);
            window.startSystemMove();
        });
    }

    function startResize(item, mouseX, mouseY, edges) {
        if (!window || !item) {
            return;
        }

        if (!maximized) {
            window.startSystemResize(edges);
            return;
        }

        const globalPoint = item.mapToGlobal(mouseX, mouseY);
        const preservedWidth = window.width;
        const preservedHeight = window.height;

        maximized = false;
        window.showNormal();

        Qt.callLater(function() {
            window.width = preservedWidth;
            window.height = preservedHeight;

            if (edges & Qt.LeftEdge) {
                window.x = Math.round(globalPoint.x);
            } else if (edges & Qt.RightEdge) {
                window.x = Math.round(globalPoint.x - preservedWidth);
            }

            if (edges & Qt.TopEdge) {
                window.y = Math.round(globalPoint.y);
            } else if (edges & Qt.BottomEdge) {
                window.y = Math.round(globalPoint.y - preservedHeight);
            }

            window.startSystemResize(edges);
        });
    }

    Component.onCompleted: {
        rememberNormalGeometry();
    }

    Connections {
        target: window

        function onXChanged() {
            root.rememberNormalGeometry();
        }

        function onYChanged() {
            root.rememberNormalGeometry();
        }

        function onWidthChanged() {
            root.rememberNormalGeometry();
        }

        function onHeightChanged() {
            root.rememberNormalGeometry();
        }
    }
}

这段代码里有几个关键点。

1. 记录普通状态下的窗口尺寸

property rect normalGeometry: Qt.rect(0, 0, 0, 0)

最大化之前,先把正常窗口的位置和大小保存下来,还原时才能回到原来的状态。

2. 最大化后拖动,要先恢复再开始拖

这是最容易漏掉的细节。

如果窗口已经最大化,此时用户在标题栏按住往下拖,正确体验应该是:

  • 先从最大化状态恢复
  • 再让窗口跟着鼠标继续拖动

这就是 startMove() 里这段逻辑的意义:

if (!maximized) {
    window.startSystemMove();
    return;
}

而不是在最大化状态下直接调用 startSystemMove()

3. 最大化后从边缘缩放,也要先恢复

同理,最大化窗口通常不能直接以当前状态进入边缘缩放,所以这里先恢复,再启动系统缩放:

window.showNormal();
Qt.callLater(function() {
    ...
    window.startSystemResize(edges);
});

Qt.callLater() 在这里很有用,它能让窗口先完成一次状态切换,再去执行后续几何调整。

六、四边八角缩放:透明热区比手写缩放逻辑更稳

标题栏能拖了,接下来还要把缩放能力补回来。

最简单可控的做法,是在窗口四周加一层透明热区,把边和角都覆盖到。下面是一份完整的 WindowResizeHandles.qml

import QtQuick
import QtQuick.Window

Item {
    id: root

    property var windowStateController
    property int handleSize: 6
    property bool active: Window.window
                          && Window.window.visibility !== Window.FullScreen

    function startResize(edges, mouseArea, mouse) {
        if (root.active && windowStateController) {
            windowStateController.startResize(mouseArea, mouse.x, mouse.y, edges);
        }
    }

    MouseArea {
        id: topHandle
        anchors.left: parent.left
        anchors.right: parent.right
        anchors.top: parent.top
        height: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeVerCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.TopEdge, topHandle, mouse); }
    }

    MouseArea {
        id: bottomHandle
        anchors.left: parent.left
        anchors.right: parent.right
        anchors.bottom: parent.bottom
        height: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeVerCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.BottomEdge, bottomHandle, mouse); }
    }

    MouseArea {
        id: leftHandle
        anchors.left: parent.left
        anchors.top: parent.top
        anchors.bottom: parent.bottom
        width: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeHorCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.LeftEdge, leftHandle, mouse); }
    }

    MouseArea {
        id: rightHandle
        anchors.right: parent.right
        anchors.top: parent.top
        anchors.bottom: parent.bottom
        width: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeHorCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.RightEdge, rightHandle, mouse); }
    }

    MouseArea {
        id: topLeftHandle
        anchors.left: parent.left
        anchors.top: parent.top
        width: root.handleSize
        height: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeFDiagCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.LeftEdge | Qt.TopEdge, topLeftHandle, mouse); }
    }

    MouseArea {
        id: topRightHandle
        anchors.right: parent.right
        anchors.top: parent.top
        width: root.handleSize
        height: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeBDiagCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.RightEdge | Qt.TopEdge, topRightHandle, mouse); }
    }

    MouseArea {
        id: bottomLeftHandle
        anchors.left: parent.left
        anchors.bottom: parent.bottom
        width: root.handleSize
        height: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeBDiagCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.LeftEdge | Qt.BottomEdge, bottomLeftHandle, mouse); }
    }

    MouseArea {
        id: bottomRightHandle
        anchors.right: parent.right
        anchors.bottom: parent.bottom
        width: root.handleSize
        height: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeFDiagCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.RightEdge | Qt.BottomEdge, bottomRightHandle, mouse); }
    }
}

这里的核心思路是:

  • 边缘和角分别放 MouseArea
  • 根据不同区域传不同的 Qt.Edge
  • 缩放时最终仍然走 window.startSystemResize(edges)

这比自己去改窗口宽高稳定得多。

七、只做到这里还不够:Windows 下往往会丢系统阴影

做到前面这些,窗口已经“能用了”,但视觉上通常还是不够像原生应用。最常见的问题是:

  • 没有系统阴影
  • 圆角丢失
  • 无边框后显得很薄、很飘
  • 最大化后可能有位置偏移

这些问题仅靠 QML 很难优雅解决,Windows 下最好下沉到原生层处理。

八、在 Windows 层接管非客户区

下面这部分是整个方案的关键。思路是:

  • 拿到 Qt 主窗口对应的 HWND
  • 自定义窗口过程
  • 拦截 WM_NCCALCSIZE
  • 让整个窗口区域作为客户区
  • 同时继续借助 DWM 提供阴影、圆角、边框

1. 自定义窗口过程

#ifdef Q_OS_WIN
#include <dwmapi.h>
#include <windows.h>

#ifndef DWMWA_BORDER_COLOR
#define DWMWA_BORDER_COLOR 34
#endif

namespace
{
    constexpr DWORD kDwmWindowCornerPreferenceAttribute = 33;
    constexpr DWORD kDwmWindowCornerPreferenceRound = 2;
    constexpr wchar_t kMainWindowOriginalWndProcProperty[] = L"WujieAgentMainWindowOriginalWndProc";
    constexpr COLORREF kThinBorderColor = RGB(0xD9, 0xDD, 0xE4);

    LRESULT CALLBACK mainWindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
    {
        const auto originalProc = reinterpret_cast<WNDPROC>(GetPropW(hwnd, kMainWindowOriginalWndProcProperty));

        switch (message)
        {
        case WM_NCCALCSIZE:
            return 0;

        case WM_NCDESTROY:
            if (originalProc)
            {
                SetWindowLongPtrW(hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(originalProc));
                RemovePropW(hwnd, kMainWindowOriginalWndProcProperty);
            }
            break;

        default:
            break;
        }

        if (originalProc)
        {
            return CallWindowProcW(originalProc, hwnd, message, wParam, lParam);
        }
        return DefWindowProcW(hwnd, message, wParam, lParam);
    }
}
#endif

WM_NCCALCSIZE 返回 0 的作用,是告诉系统:不要再给我单独计算传统标题栏和边框的客户区了,我要把整个窗口都当作内容区来用。

这样 QML 标题栏才能真正贴到窗口最上方。

2. 安装新的窗口过程

#ifdef Q_OS_WIN
void installMainWindowProc(QWindow *window)
{
    if (!window)
    {
        return;
    }

    const HWND hwnd = reinterpret_cast<HWND>(window->winId());
    if (!hwnd)
    {
        return;
    }

    if (GetPropW(hwnd, kMainWindowOriginalWndProcProperty))
    {
        return;
    }

    const auto previousProc =
        reinterpret_cast<WNDPROC>(SetWindowLongPtrW(hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(&mainWindowProc)));
    if (previousProc)
    {
        SetPropW(hwnd, kMainWindowOriginalWndProcProperty, reinterpret_cast<HANDLE>(previousProc));
    }
}
#endif

这里把原始 WndProc 存在窗口属性里,窗口销毁时再恢复,避免资源泄漏和消息链断裂。

九、补回系统阴影、圆角和细边框

仅仅接管 WM_NCCALCSIZE 还不够,还要主动调用 DWM 接口恢复视觉效果。

#ifdef Q_OS_WIN
void enableSystemShadow(QWindow *window)
{
    if (!window)
    {
        return;
    }

    const HWND hwnd = reinterpret_cast<HWND>(window->winId());
    if (!hwnd)
    {
        return;
    }

    BOOL compositionEnabled = FALSE;
    if (FAILED(DwmIsCompositionEnabled(&compositionEnabled)) || !compositionEnabled)
    {
        return;
    }

    const DWMNCRENDERINGPOLICY policy = DWMNCRP_ENABLED;
    DwmSetWindowAttribute(hwnd, DWMWA_NCRENDERING_POLICY, &policy, sizeof(policy));

    const COLORREF borderColor = kThinBorderColor;
    DwmSetWindowAttribute(hwnd, DWMWA_BORDER_COLOR, &borderColor, sizeof(borderColor));

    const DWORD cornerPreference = kDwmWindowCornerPreferenceRound;
    DwmSetWindowAttribute(hwnd,
                          kDwmWindowCornerPreferenceAttribute,
                          &cornerPreference,
                          sizeof(cornerPreference));

    const MARGINS margins{1, 1, 1, 1};
    DwmExtendFrameIntoClientArea(hwnd, &margins);

    SetWindowPos(hwnd,
                 nullptr,
                 0,
                 0,
                 0,
                 0,
                 SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
}
#endif

这段代码分别做了几件事:

  • 启用非客户区渲染
  • 设置一圈很薄的边框颜色
  • 指定圆角风格
  • 把 DWM frame 延伸进客户区
  • 通知系统刷新窗口 frame

其中这一句非常关键:

const MARGINS margins{1, 1, 1, 1};
DwmExtendFrameIntoClientArea(hwnd, &margins);

它能帮助系统继续在窗口边缘绘制阴影和相关效果。没有它时,无边框窗口经常显得“糊在屏幕上”。

十、隐藏系统标题和图标,避免残留

有时候即便你已经用了无边框窗口,Windows 某些状态下仍可能残留标题、图标或系统边框表现。可以额外做一层清理:

#ifdef Q_OS_WIN
void hideWindowsCaptionIconAndTitle(QWindow *window)
{
    if (!window)
    {
        return;
    }

    const HWND hwnd = reinterpret_cast<HWND>(window->winId());
    if (!hwnd)
    {
        return;
    }

    const LONG exStyle = GetWindowLongW(hwnd, GWL_EXSTYLE);
    SetWindowLongW(hwnd, GWL_EXSTYLE, exStyle | WS_EX_DLGMODALFRAME);

    SendMessageW(hwnd, WM_SETICON, ICON_SMALL, 0);
    SendMessageW(hwnd, WM_SETICON, ICON_BIG, 0);
    SetWindowTextW(hwnd, L"");

    SetWindowPos(hwnd,
                 nullptr,
                 0,
                 0,
                 0,
                 0,
                 SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
}
#endif

这一步的目标不是“美化”,而是避免系统残留元素干扰我们自己的标题栏。

十一、修正最大化后的窗口偏移

无边框窗口在 Windows 上有一个很常见的问题:最大化之后,窗口左上角可能和工作区不完全对齐,出现 1~几像素的偏移。

可以在窗口可见性变化后做一次修正:

#ifdef Q_OS_WIN
void correctMaximizedWindowOffset(QWindow *window)
{
    if (!window || window->visibility() != QWindow::Maximized)
    {
        return;
    }

    QScreen *screen = window->screen();
    if (!screen)
    {
        return;
    }

    const QRect availableGeometry = screen->availableGeometry();
    const QPoint expectedTopLeft = availableGeometry.topLeft();

    if (window->position() == expectedTopLeft)
    {
        return;
    }

    const HWND hwnd = reinterpret_cast<HWND>(window->winId());
    if (!hwnd)
    {
        window->setPosition(expectedTopLeft);
        return;
    }

    SetWindowPos(hwnd,
                 nullptr,
                 expectedTopLeft.x(),
                 expectedTopLeft.y(),
                 0,
                 0,
                 SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
}
#endif

这段代码的思路很直接:

  • 读取当前屏幕的 availableGeometry
  • 取它的左上角作为最大化后应有的位置
  • 如果窗口没有对齐,就用 SetWindowPos 修正

十二、在应用启动时统一应用这些 tweak

最后,把这些原生能力统一挂到主窗口上。比如在 QQmlApplicationEngine 加载完成后执行:

void applyMainWindowTweaks(QQmlApplicationEngine &engine)
{
#ifdef Q_OS_WIN
    if (engine.rootObjects().isEmpty())
    {
        return;
    }

    auto *mainWindow = qobject_cast<QWindow *>(engine.rootObjects().constFirst());
    if (!mainWindow)
    {
        return;
    }

    mainWindow->setTitle(QString());
    installMainWindowProc(mainWindow);
    hideWindowsCaptionIconAndTitle(mainWindow);
    enableSystemShadow(mainWindow);

    QObject::connect(mainWindow, &QWindow::visibilityChanged, mainWindow, [mainWindow]() {
        if (mainWindow->isVisible())
        {
            hideWindowsCaptionIconAndTitle(mainWindow);

            if (mainWindow->visibility() == QWindow::Windowed)
            {
                enableSystemShadow(mainWindow);
            }

            QTimer::singleShot(0, mainWindow, [mainWindow]() {
                correctMaximizedWindowOffset(mainWindow);
            });
        }
    });

    QObject::connect(mainWindow, &QWindow::windowTitleChanged, mainWindow, [mainWindow](const QString &title) {
        if (!title.isEmpty())
        {
            mainWindow->setTitle(QString());
            hideWindowsCaptionIconAndTitle(mainWindow);

            if (mainWindow->visibility() == QWindow::Windowed)
            {
                enableSystemShadow(mainWindow);
            }
        }
    });
#else
    Q_UNUSED(engine);
#endif
}

这里有两个处理很有必要:

  • visibilityChanged 后重新应用阴影和标题栏隐藏逻辑
  • windowTitleChanged 后强制把系统标题清掉

因为某些状态切换后,Windows 可能会重新套回部分非客户区表现,不补这一层很容易出现“偶发性异常”。

十三、这套方案为什么比“纯 QML 手搓窗口管理”更稳

很多实现方案都会走到一个误区:既然标题栏自己画了,那拖动、缩放、最大化逻辑也都自己算。

理论上可行,实际上问题很多:

  • 鼠标位移要自己维护
  • 多显示器切换容易错
  • 最大化恢复点不好算
  • 贴边、吸附、系统手势体验差
  • Windows 阴影和边框很难补完整

而这套方案的核心原则是:

外观自己画,窗口行为尽量交给系统。

也就是:

  • UI 用 QML 自定义
  • 窗口拖拽用 startSystemMove()
  • 窗口缩放用 startSystemResize()
  • 阴影圆角边框交给 DWM
  • 自己只补系统默认标题栏拿走后丢失的那一层胶水逻辑

这样代码量不一定最少,但稳定性和体验会明显更好。

十四、落地时最容易踩的几个坑

1. 只去掉标题栏,不补缩放热区

结果就是窗口看起来没问题,但用户拉不动边缘。

2. 最大化后直接 startSystemMove()

这样拖动标题栏时会出现跳动,或者不能正确恢复到普通窗口状态。

3. 自己算拖拽位移,不用系统拖动

短期看能用,长期看问题最多,尤其是多屏和贴边。

4. 只写 QML,不处理 Win32/DWM

这会导致窗口没有阴影、圆角异常、边框层次不足。

5. 最大化后不修正 offset

某些机器上会出现窗口边缘错位,视觉上很明显。

十五、总结

Qt 自定义标题栏真正难的地方,从来不是“把按钮画出来”,而是把系统标题栏拿掉以后,如何把窗口该有的行为和质感补回来。

比较稳的一条路线是:

  • Qt.FramelessWindowHint 去掉系统标题栏
  • 用 QML 重建顶部标题栏 UI
  • startSystemMove()startSystemResize() 保留原生交互
  • 用独立控制器管理最大化、还原和普通窗口几何
  • 在 Windows 层通过 WM_NCCALCSIZE + DWM 补回阴影、圆角、细边框和位置修正

如果只是做一个“看起来像标题栏”的 UI,这件事并不难;但如果目标是做出一个真正可用、体验接近原生的桌面窗口,这些细节基本都绕不过去。

用 TypeScript 的 infer 搓一个类型安全的深层路径访问工具

2026年3月18日 10:40

用 TypeScript 的 infer 搓一个类型安全的深层路径访问工具

你写过 get(obj, 'a.b.c') 吗?

Lodash 的 _.get 应该是前端用得最多的工具函数之一了。好用是好用,但它返回的类型是 any。你传个 'a.b.c',TypeScript 完全不知道这条路径是不是真的存在,更不知道取出来的值是什么类型(这个说法其实不太严谨)。

import _ from 'lodash'

const config = {
  db: {
    host: 'localhost',
    port: 5432,
    pool: { max: 10, min: 2 }
  }
}

const host = _.get(config, 'db.host')
// host 的类型:any
// 你拼错成 'db.hoost' 也不会报错,运行时才炸

const max = _.get(config, 'db.pool.max')
// max 的类型:还是 any
// 你把它当 string 用,TypeScript 不拦你

这事困扰了我挺久。后来 TypeScript 4.1 加了模板字面量类型,再配合 infer 和递归条件类型,终于可以让路径访问变得类型安全了。

这篇就聊聊怎么一步步把这个工具类型搓出来。

约束路径:只允许合法路径

这一步是整个方案里最有意思的部分。要生成一个对象所有合法路径的联合类型。

type AllPaths<T, Prefix extends string = ''> =
  T extends object
    ? {
        [K in keyof T & string]:
          | `${Prefix}${K}`                              // 当前层的 key
          | AllPaths<T[K], `${Prefix}${K}.`>             // 递归下一层
      }[keyof T & string]
    : never

type ConfigPaths = AllPaths<Config>
// 'db' | 'db.host' | 'db.port' | 'db.pool' | 'db.pool.max' | 'db.pool.min'
// | 'redis' | 'redis.host' | 'redis.ttl'

这个类型做的事情:遍历对象的每一层,把所有可能的路径拼成字符串字面量的联合类型。

现在改造一下 deepGet

function deepGet<T, P extends AllPaths<T> & string>(
  obj: T,
  path: P
): DeepGet<T, P> {
  return path.split('.').reduce((acc: any, key) => acc?.[key], obj) as any
}

deepGet(config, 'db.host')       //  正常
deepGet(config, 'db.pool.max')   //  正常
deepGet(config, 'db.hoost')      //  编译报错!'db.hoost' 不在合法路径里

写错路径直接标红。编辑器自动补全也能用了——输入 'db.' 会提示 hostportpool

这体验比 Lodash 的 _.get 好太多了。

处理数组和可选属性

上面的版本遇到数组就歇菜了。真实业务里对象嵌数组太常见了,得处理。

type Config2 = {
  servers: Array<{
    host: string
    port: number
    tags: string[]
  }>
  metadata?: {
    version: string
  }
}

数组怎么办?一般有两种思路:

思路一:用 [number] 语法表示数组索引

路径写成 'servers.[number].host',类型层面识别 [number] 并取数组元素类型。

type DeepGetV2<T, P extends string> =
  P extends `${infer Key}.${infer Rest}`
    ? Key extends keyof T
      ? DeepGetV2<T[Key], Rest>
      : Key extends `[number]`                    // 命中 [number]
        ? T extends Array<infer Item>             // T 是数组吗?
          ? DeepGetV2<Item, Rest>                 // 是 → 取元素类型继续递归
          : never
        : never
    : P extends keyof T
      ? T[P]
      : P extends `[number]`
        ? T extends Array<infer Item> ? Item : never
        : never

思路二:自动穿透数组

遇到数组类型自动取元素,路径里不用写 [number]。路径写 'servers.host' 就能拿到 string

我个人更倾向思路一。虽然写起来啰嗦点,但语义更明确——你一眼就知道这里穿过了一个数组。思路二在类型层面倒是简洁,但读代码的人可能会困惑:servers 明明是个数组,怎么直接 .host 了?

可选属性的处理相对简单,DeepGet 递归下去自然会带上 undefined

type Config3 = {
  metadata?: { version: string }
}

type V = DeepGet<Config3, 'metadata.version'>
// string | undefined (因为 metadata 可能不存在)

这里 TypeScript 的行为其实符合直觉,不用额外处理。

AllPaths 的性能问题

AllPaths 有个坑:对象属性越多、嵌套越深,生成的联合类型就越庞大。

假设一个对象每层 10 个属性,嵌套 4 层。AllPaths 生成的路径数量大概是 10 + 10×10 + 10×10×10 + 10×10×10×10 ≈ 11110 个字符串字面量。TypeScript 编译器处理这么大的联合类型,编辑器会明显卡顿。

之前在一个项目里给一个比较大的配置对象加了 AllPaths 约束——先别急着反驳,VSCode 的 TS Server 直接转圈了好几秒。后来只好妥协,只对核心配置做路径约束,其他的还是用 string

几个缓解思路:

// 1. 限制递归深度,只生成前 N 层的路径
type ShallowPaths<T, Depth extends any[] = []> =
  Depth['length'] extends 3 ? never :  // 只展开 3 层
  T extends object
    ? { [K in keyof T & string]:
        | K
        | `${K}.${ShallowPaths<T[K], [...Depth, any]>}`
      }[keyof T & string]
    : never

// 2. 拆分类型,对子树单独约束
// 不要 AllPaths<WholeConfig>,而是 AllPaths<Config['db']>
function getDbConfig<P extends AllPaths<Config['db']>>(path: P) {
  return deepGet(config.db, path)
}

说实话这块没有完美方案。类型安全和编译性能之间得做取舍。

聊到这

infer + 模板字面量类型 + 递归条件类型,这三个东西组合起来能做的事情比想象中多很多。路径访问只是其中一个典型应用。

不过也别上头。也行。类型体操写得越复杂,维护成本越高。一个新人看到五六层嵌套的条件类型,大概率直接懵。我的经验是:工具类型可以复杂,但暴露给使用者的 API 要简单。把复杂度藏在工具类型内部,让调用方只需要写 deepGet(obj, 'a.b.c') 就够了。

好吧这个问题比我想的复杂。

还有一点,TypeScript 的类型系统本身是图灵完备的,理论上啥都能算。但"能做"和"该做"是两回事。等等,其实"和"该做"是两回事。如果一个类型写了超过 20 行,先想想是不是设计上能简化。

从 Webpack 迁移到 Rspack 后,循环依赖为什么炸了?一个 const vs var 引发的血案

2026年3月18日 10:32

背景

最近在做公司 Electron 项目的构建工具迁移——从 Webpack 切换到 Rspack。Rspack 号称"基于 Rust 的高性能 JavaScript 打包工具",API 与 Webpack 高度兼容,大部分配置可以直接平迁。

迁移过程确实很顺利,配置几乎原样搬过来,构建速度也有了明显提升。但打包后一运行,主进程直接白屏崩溃:

ReferenceError: Cannot access '__rspack_default_export' before initialization

诡异的是,完全相同的业务代码,用 Webpack 打包就没有任何问题。

定位过程

1. 确认循环依赖链

根据报错堆栈,定位到了一条循环依赖链:

product.ts → log/index.ts → xbotLog.ts → remoteLog.ts → product.ts

remoteLog.ts 顶层 import 了 product

import product, { isOversea, isPrivate, isStandalone } from '@root/common/product';

product.ts 又通过 log 模块间接依赖了 remoteLog.ts,形成了闭环。

2. 但 Webpack 为什么没事?

这条循环依赖不是新写的,一直存在,Webpack 下跑了很久都没出过问题。两边的配置几乎一模一样:

  • 同样的 babel-loader + @babel/preset-typescript
  • 同样的 splitChunks 配置
  • 同样的 target: 'electron-main'
  • 同样的 output.libraryTarget: 'commonjs'

那差异到底在哪?

3. 对比构建产物

把两边的构建产物拉出来一看,答案就在眼前。

Webpack 生成的模块导出代码:

__webpack_require__.d(__webpack_exports__, {
  "default": () => (__WEBPACK_DEFAULT_EXPORT__)
});
// ...
var __WEBPACK_DEFAULT_EXPORT__ = product;

Rspack 生成的模块导出代码:

__webpack_require__.d(__webpack_exports__, {
  "default": () => (__rspack_default_export)
});
// ...
/* export default */ const __rspack_default_export = (product);

看到了吗?一个用 var,一个用 const

根因:Temporal Dead Zone(暂时性死区)

这不是什么配置差异,而是 JavaScript 语言层面 varconst/let 的本质区别。

var 的行为

console.log(a); // undefined(不报错)
var a = 1;

var 声明会被提升(hoisting) 到函数作用域顶部,变量在声明前就已存在,值为 undefined

const/let 的行为

console.log(b); // ReferenceError: Cannot access 'b' before initialization
const b = 1;

const/let 虽然也会被提升,但在赋值语句执行之前处于暂时性死区(TDZ),任何访问都会抛 ReferenceError

循环依赖下的连锁反应

理解了 TDZ,再来看循环依赖场景下发生了什么。

两个打包器都使用 __webpack_require__.d 来注册模块导出,本质是定义了一个 getter:

// 运行时源码(Webpack 和 Rspack 一致)
__webpack_require__.d = (exports, definition) => {
  for (var key in definition) {
    Object.defineProperty(exports, key, {
      enumerable: true,
      get: definition[key]  // ← 这是一个惰性 getter
    });
  }
};

当注册 "default": () => (__rspack_default_export) 时,它只是定义了一个 getter 函数,并不会立即求值。真正求值发生在别的模块访问 .default 的时候。

循环依赖的执行流程

1. product.ts 开始执行
2.import log → log 开始执行
3.import xbotLog → xbotLog 开始执行
4.import remoteLog → remoteLog 开始执行
5.import product → 发现 product 正在执行中,返回当前(未完成的)exports
6.         → 访问 product.default → 触发 getter → 读取 __rspack_default_export
7.         → 💥 const 还没赋值,处于 TDZ → 报错!

如果是 var

6.         → 访问 product.default → 触发 getter → 读取 __WEBPACK_DEFAULT_EXPORT__
7.         → var 已提升,值为 undefined → 不报错,后续 product 执行完毕后值会正确

修复

知道了根因,修复方案就很简单了。Rspack 提供了 output.environment 配置来控制生成代码使用的 ES 特性级别:

// rspack.main.js
module.exports = {
  output: {
    path: outputFilePath,
    filename: '[name].js',
    libraryTarget: 'commonjs',
    environment: {
      const: false,  // 使用 var 代替 const/let
    },
  },
  // ... 其他配置不变
};

设置 environment.const: false 后,Rspack 会将生成代码中所有的 const/let 退化为 var,与 Webpack 行为一致。

修改后重新构建,产物中的:

/* export default */ const __rspack_default_export = (product);

变成了:

/* export default */ var __rspack_default_export = (product);

循环依赖不再报错,问题解决。只改了 3 行配置,零业务代码改动。

思考

谁对谁错?

严格来说,Rspack 的做法更"正确"。ES Module 规范中,导入绑定(import binding)本身就不应该在模块初始化完成前被访问。使用 const 能让这类隐患在运行时尽早暴露。

而 Webpack 用 var 的做法更"宽容"——循环依赖下虽然不报错,但你拿到的是 undefined,如果代码恰好在初始化阶段就用了这个值去做判断或调用方法,可能会产生更隐蔽的 bug。

治本之道

output.environment.const: false 是一个低成本的兼容方案,但循环依赖本身才是根源。长远来看,更好的做法是:

  1. 延迟引用:将产生循环的 import 改为函数内 require(),只在真正需要时才加载
  2. 解耦模块:重新组织模块结构,打破依赖环路
  3. 检测工具:在 CI 中集成 circular-dependency-pluginmadge 等工具,把循环依赖扼杀在 MR 阶段

总结

对比项 Webpack Rspack
default export 声明 var __WEBPACK_DEFAULT_EXPORT__ const __rspack_default_export
循环依赖行为 提升为 undefined,静默通过 TDZ 直接报错
兼容修复 output.environment.const: false

从 Webpack 迁移到 Rspack 时,如果遇到 Cannot access '__rspack_default_export' before initialization 错误,大概率是已有的循环依赖被 Rspack 更严格的代码生成方式暴露了出来。加一行 environment.const: false 可以快速恢复,但别忘了回头清理那些循环依赖——它们本来就不应该存在。

Next.js 初学者核心知识点

作者 lcy453
2026年3月18日 10:31

基于本项目(海克斯大乱斗攻略站)讲解,结合实际代码理解概念。


一、服务端组件 vs 客户端组件

这是 Next.js App Router 最核心的概念,没搞清楚这个,后面全是坑。

服务端组件(默认)

app/ 目录下的组件默认都是服务端组件,特点:

  • 可以直接 async/await,在服务器上跑
  • 可以直接访问数据库、调接口,不暴露给浏览器
  • 不能用 useStateuseEffectonClick 等浏览器 API
// app/page.tsx —— 服务端组件,直接 async
export default async function Home() {
  const data = await fetch("https://api.example.com/data");
  const json = await data.json();

  return <div>{json.title}</div>;
}

客户端组件

顶部加 "use client" 声明,特点:

  • 可以用 React hooks(useState、useEffect 等)
  • 可以绑定事件(onClick、onChange)
  • 不能直接 async/await 请求(需要用 useEffect 或 SWR)
// components/SearchBox.tsx —— 客户端组件
"use client";

import { useState } from "react";

export default function SearchBox() {
  const [value, setValue] = useState("");
  return (
    <input value={value} onChange={(e) => setValue(e.target.value)} />
  );
}

本项目的例子

服务端组件:app/page.tsx、app/builds/page.tsx、app/champions/[id]/page.tsx
客户端组件:components/ChampionGrid.tsx、app/augments/page.tsx

最佳实践:尽量让父组件(页面)是服务端组件负责取数据,把数据作为 props 传给客户端组件负责交互。


二、fetch 还是 axios?

直接结论:Next.js 服务端推荐用原生 fetch,不推荐 axios

原因是 Next.js 对原生 fetch 做了扩展增强,加了缓存和重新验证功能,这是 axios 没有的:

// Next.js 扩展的 fetch,支持缓存控制
fetch(url, {
  next: {
    revalidate: 86400, // ISR:缓存 24 小时后重新请求
  },
});

fetch(url, {
  cache: "no-store", // 每次请求都不缓存(相当于 SSR)
});

fetch(url, {
  cache: "force-cache", // 永久缓存(相当于 SSG)
});

本项目里就用了 revalidate: 86400,英雄数据每天只请求一次 Riot 服务器,其余时间直接用缓存,性能很好。

axios 能用吗?

能用,但有限制:

  • 客户端组件里完全可以用 axios(和普通 React 一样)
  • 服务端组件里用 axios 可以发请求,但失去了 Next.js 的缓存能力
// 客户端组件里用 axios —— 完全没问题
"use client";
import axios from "axios";
import { useEffect, useState } from "react";

export default function MyComponent() {
  const [data, setData] = useState(null);

  useEffect(() => {
    axios.get("/api/data").then((res) => setData(res.data));
  }, []);

  return <div>{data}</div>;
}

总结:服务端用原生 fetch + Next.js 缓存,客户端交互请求用 axios 或 fetch 都行。


三、路由系统

文件即路由

app/
├── page.tsx              →  /
├── about/
│   └── page.tsx          →  /about
├── champions/
│   ├── page.tsx          →  /champions
│   └── [id]/
│       └── page.tsx      →  /champions/任意值(动态路由)
└── blog/
    └── [...slug]/
        └── page.tsx      →  /blog/任意/层级/路径(捕获所有路由)

动态路由取参数

// app/champions/[id]/page.tsx
interface Props {
  params: Promise<{ id: string }>;
}

export default async function ChampionPage({ params }: Props) {
  const { id } = await params; // Next.js 15+ params 是 Promise
  // id 就是 URL 里的值,比如访问 /champions/Jinx,id = "Jinx"
}

编程式跳转(客户端)

"use client";
import { useRouter } from "next/navigation";

export default function MyButton() {
  const router = useRouter();

  return (
    <button onClick={() => router.push("/champions")}>
      去英雄列表
    </button>
  );
}

Link 组件(推荐用于导航)

import Link from "next/link";

// 比 <a> 标签好,支持预加载
<Link href="/champions/Jinx">查看金克斯</Link>

四、路由守卫(权限控制)

Next.js 没有 Vue Router 那种内置的 beforeEach 守卫,但有几种方式实现。

方式一:middleware.ts(推荐,最强大)

在项目根目录创建 middleware.ts,它会在请求到达页面之前执行,适合做登录验证:

// middleware.ts(放在项目根目录,和 src/ 同级)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const token = request.cookies.get("token")?.value;
  const isLoginPage = request.nextUrl.pathname === "/login";

  // 没有 token 且不是登录页,跳转到登录页
  if (!token && !isLoginPage) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  // 已登录还访问登录页,跳转首页
  if (token && isLoginPage) {
    return NextResponse.redirect(new URL("/", request.url));
  }

  return NextResponse.next(); // 放行
}

// 配置哪些路由需要走 middleware(不写默认全部)
export const config = {
  matcher: ["/dashboard/:path*", "/profile/:path*", "/login"],
};

方式二:服务端组件里直接判断

// app/dashboard/page.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth"; // 你自己的获取 session 函数

export default async function DashboardPage() {
  const session = await getSession();

  if (!session) {
    redirect("/login"); // 服务端直接重定向,用户看不到页面内容
  }

  return <div>欢迎,{session.user.name}</div>;
}

方式三:客户端组件里判断

"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";

export default function ProtectedPage() {
  const router = useRouter();

  useEffect(() => {
    const token = localStorage.getItem("token");
    if (!token) {
      router.push("/login");
    }
  }, []);

  return <div>受保护的内容</div>;
}

三种方式对比:

方式 执行时机 适合场景
middleware 请求到达前 全局权限控制,性能最好
服务端组件 redirect 服务器渲染时 单个页面的权限判断
客户端 useEffect 浏览器渲染后 简单场景,会有短暂闪烁

推荐用 middleware,一次配置,全局生效,用户甚至看不到页面内容就被重定向了。


五、API 路由(后端接口)

Next.js 可以在同一个项目里写后端接口,不需要单独起一个服务。

app/
└── api/
    └── champions/
        └── route.ts    →  GET/POST /api/champions
// app/api/champions/route.ts
import { NextResponse } from "next/server";

// 处理 GET 请求
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const name = searchParams.get("name");

  const data = { champions: ["Jinx", "Lux", "Darius"] };
  return NextResponse.json(data);
}

// 处理 POST 请求
export async function POST(request: Request) {
  const body = await request.json();
  // 处理 body...
  return NextResponse.json({ success: true }, { status: 201 });
}

前端调用:

const res = await fetch("/api/champions?name=Jinx");
const data = await res.json();

六、数据获取的三种模式

理解这三种模式,就理解了 Next.js 的核心价值。

SSG(静态生成)—— 构建时生成

fetch(url, { cache: "force-cache" }); // 或者不传 cache 参数
  • 构建时请求一次,生成静态 HTML
  • 适合:不常变化的内容(博客文章、文档)
  • 优点:最快,CDN 可以缓存

ISR(增量静态再生)—— 定时刷新

fetch(url, { next: { revalidate: 3600 } }); // 每小时刷新
  • 先返回缓存内容,后台定时重新生成
  • 适合:数据偶尔更新(本项目的英雄数据)
  • 优点:兼顾性能和数据新鲜度

SSR(服务端渲染)—— 每次请求都重新获取

fetch(url, { cache: "no-store" });
  • 每次用户访问都重新请求数据
  • 适合:实时数据(用户个人信息、股票价格)
  • 优点:数据永远最新

七、Image 组件

Next.js 内置的 <Image> 比普通 <img> 强很多:

import Image from "next/image";

<Image
  src="https://ddragon.leagueoflegends.com/cdn/15.8.1/img/champion/Jinx.png"
  alt="金克斯"
  width={64}
  height={64}
  className="rounded-md"
/>

自动做的事情:

  • 懒加载(滚动到才加载)
  • 自动转换为 WebP 格式(更小)
  • 防止布局偏移(需要指定 width/height)

注意:加载外部域名的图片需要在 next.config.js 里配置白名单:

// next.config.js
module.exports = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "ddragon.leagueoflegends.com",
      },
    ],
  },
};

八、环境变量

# .env.local(本地开发,不提交 git)
DATABASE_URL=mysql://localhost:3306/mydb
NEXT_PUBLIC_API_URL=https://api.example.com  # NEXT_PUBLIC_ 前缀才能在浏览器用
SECRET_KEY=abc123  # 没有前缀,只能在服务端用

使用:

// 服务端(任何地方都能用)
const secret = process.env.SECRET_KEY;

// 客户端(只能用 NEXT_PUBLIC_ 开头的)
const apiUrl = process.env.NEXT_PUBLIC_API_URL;

九、常见错误和解决方法

错误1:在服务端组件里用了 useState

Error: useState is not a function

解决:组件顶部加 "use client",或者把有状态的部分拆成单独的客户端组件。

错误2:外部图片不显示

Error: Invalid src prop, hostname not configured

解决:在 next.config.jsimages.remotePatterns 里加上对应域名。

错误3:hydration 错误

Error: Hydration failed because the initial UI does not match

原因:服务端渲染的 HTML 和客户端渲染的结果不一致(比如用了 Math.random()Date.now())。

解决:把这类逻辑放到 useEffect 里,确保只在客户端执行。

错误4:params 需要 await(Next.js 15+)

Error: params should be awaited before using its properties

解决:

// 错误写法
const { id } = params;

// 正确写法
const { id } = await params;

十、项目目录约定速查

文件/目录 作用
app/layout.tsx 全局布局,所有页面的外壳
app/page.tsx 首页(/
app/loading.tsx 页面加载中的 UI
app/error.tsx 页面报错时的 UI
app/not-found.tsx 404 页面
app/api/*/route.ts API 接口
middleware.ts 请求中间件(路由守卫)
public/ 静态资源,直接通过 /文件名 访问
.env.local 本地环境变量
next.config.js Next.js 配置文件

十一、开发常用命令

pnpm dev        # 启动开发服务器(热更新)
pnpm build      # 构建生产版本
pnpm start      # 启动生产服务器(需要先 build)
pnpm lint       # 检查代码规范

构建时 Next.js 会告诉你每个页面是 SSG、SSR 还是 ISR,输出类似:

Route (app)                Size    First Load JS
┌ ○ /                      2.5 kB  88.3 kB
├ ○ /augments              1.2 kB  87.0 kB
├ ○ /builds                3.1 kB  88.9 kB
└ ƒ /champions/[id]        1.8 kB  87.6 kB

○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

表示静态,ƒ 表示动态(每次请求都渲染)。

前端重连机制

作者 山_雨
2026年3月18日 10:14

在前端的重连机制方案中,最暴力的就是直接使用 window.location.reload() ,但是这可能会导致用户陷入无限刷新的死循环(比如用户断网了,刷新多少次也加载不出来),同时也非常消耗性能和用户流量。

我们可以从重试机制、状态反馈、错误拦截三个维度来进行优化。下面以 mapbox 的底图加载失败为案例进行测试。

1. 指数退避的自动重试

不要立即刷新,而是尝试在代码层面重新加载地图资源。如果重试多次失败,再引导用户手动刷新。

let retryCount = 0; // vue 组件中的 script 只会在 setup 阶段渲染一次,不是响应式的状态可以直接用 let,react 每次渲染的时候,函数体都会重新执行,需要用 ref 维持
const MAX_RETRIES = 3;

this.mapInstance.on('error', (error) => {
  console.error('地图加载失败', error);

  if (retryCount < MAX_RETRIES) {
    retryCount++;
    const delay = Math.pow(2, retryCount) * 1000; // 2s, 4s, 8s 后重试
    
    console.warn(`正在进行第 ${retryCount} 次尝试重连...`);
    setTimeout(() => {
      // 尝试重新设置样式或触发加载逻辑,而不是刷新整个页面
      this.mapInstance.setStyle(this.currentStyleUrl); 
    }, delay);
  } else {
    // 超过次数后,显示 UI 提示而不是硬刷新
    this.showErrorUI('网络状况不佳,请检查网络后手动刷新');
  }
});

2. 添加 UI 状态遮罩|UI降级(防止白屏感)

与其让页面白着,不如给用户一个明确的反馈。在初始化地图时显示一个 Loading,如果加载失败,切换为“刷新按钮”。

  • 加载中: 显示骨架屏或 Loading 动画。
  • 失败后: 移除 Loading,显示“加载失败,点击重试”按钮。这样避免了自动刷新的突兀感。

3. 检测特定错误类型

MapBox 的 error 事件会捕获各种错误(包括样式错误、Tile 请求失败等)。有些错误不值得刷新页面:

this.mapInstance.on('error', (e) => {
  // 如果只是某个瓦片加载 404,不应该刷新页面
  if (e.error?.status === 404) return;

  // 如果是鉴权问题,刷新也没用
  if (e.error?.status === 401) {
    console.error('MapBox Token 无效');
    return;
  }

  // 针对核心资源加载失败的处理
  this.handleCriticalError();
});

4. 离线/断网检测

利用浏览器的原生事件,在网络恢复的第一时间尝试重载。

window.addEventListener('online', () => {
  if (this.mapLoadFailed) {
    this.mapInstance.setStyle(this.currentStyleUrl);
  }
});

5. 综合方案

将逻辑封装成一个状态机

  1. 状态 A (Loading): 页面中心显示转圈。

  2. 状态 B (Success): 监听到 loadidle 事件,隐藏转圈。

  3. 状态 C (Error): 监听到 error

    • 记录重试次数。
    • 若次数未满:微调样式 URL 触发重新请求(或调用 map.remove() 后重新 new Mapboxgl.Map())。
    • 若次数已满:弹出一个浮窗提示:“地图资源加载超时 [重试按钮]”。
// 状态配置
const MAP_CONFIG = {
  maxRetries: 3,         // 最大自动重试次数
  retryDelay: 3000,      // 基础重试延迟 (ms)
  isRecovering: false,   // 是否正在恢复中
  retryCount: 0
};

this.mapInstance.on('error', (e) => {
  // 1. 过滤掉非致命错误(例如某个瓦片 404 不应该导致重刷)
  if (e.error?.status === 404 || e.error?.status === 403) {
    console.warn('忽略非致命瓦片加载错误');
    return;
  }

  console.error('地图核心资源加载失败:', e.error);

  // 2. 自动重试逻辑
  if (MAP_CONFIG.retryCount < MAP_CONFIG.maxRetries) {
    MAP_CONFIG.retryCount++;
    
    // 使用简单的指数退避,避免频繁冲击服务器
    const delay = MAP_CONFIG.retryCount * MAP_CONFIG.retryDelay;
    
    console.log(`将在 ${delay}ms 后尝试第 ${MAP_CONFIG.retryCount} 次自动修复...`);
    
    setTimeout(() => {
      // 核心技巧:重新触发 style 加载通常比 reload 页面更轻量
      // 如果 mapInstance 还没坏死,尝试 resetStyle
      this.mapInstance.setStyle(this.currentStyleUrl);
    }, delay);

  } else {
    // 3. 最终降级处理:弹出友好的提示层而非强制刷新
    this.showMapErrorOverlay();
  }
});

// 4. 监听网络恢复
window.addEventListener('online', () => {
  if (MAP_CONFIG.retryCount >= MAP_CONFIG.maxRetries) {
    // 网络恢复了,自动尝试最后一次
    this.mapInstance.setStyle(this.currentStyleUrl);
    // 隐藏错误遮罩
    this.hideMapErrorOverlay();
  }
});

更加完善的内部封装

class MapManager {
  constructor() {
    this.mapInstance = null;
    this.retryCount = 0;
    this.maxRetries = 3;
    this.styleUrl = 'mapbox://styles/mapbox/streets-v11';
  }

  // 显示错误遮罩
  showErrorOverlay(message) {
    const container = document.getElementById('map'); // 地图容器ID
    let overlay = container.querySelector('.map-error-overlay');
    
    if (!overlay) {
      overlay = document.createElement('div');
      overlay.className = 'map-error-overlay';
      overlay.innerHTML = `
        <p class="error-msg">${message}</p>
        <button class="map-error-btn">立即重试</button>
      `;
      overlay.querySelector('button').onclick = () => window.location.reload();
      container.appendChild(overlay);
    } else {
      overlay.classList.remove('is-hidden');
      overlay.querySelector('.error-msg').innerText = message;
    }
  }

  // 隐藏错误遮罩
  hideErrorOverlay() {
    const overlay = document.querySelector('.map-error-overlay');
    if (overlay) overlay.classList.add('is-hidden');
  }

  initMap() {
    // ... 初始化 mapInstance 的代码 ...

    this.mapInstance.on('error', (e) => {
      // 过滤掉瓦片层级的 404 错误
      if (e.error?.status === 404) return;

      if (this.retryCount < this.maxRetries) {
        this.retryCount++;
        console.warn(`地图加载异常,第 ${this.retryCount} 次重试...`);
        
        // 尝试重置样式来恢复,而不是刷新页面
        setTimeout(() => {
          this.mapInstance.setStyle(this.styleUrl);
        }, this.retryCount * 2000);
      } else {
        // 达到上限,显示手动重试 UI
        this.showErrorOverlay('网络连接超时,请检查网络后重试');
      }
    });

    this.mapInstance.on('load', () => {
      this.retryCount = 0; // 加载成功,重置计数
      this.hideErrorOverlay();
    });
  }
}

6. 偶发网络问题手动重现

模拟各种 error 情况是至关重要的,如果是网络偶发问题,不需要去拔网线,通过修改关键key(将mapbox的style和token修改为错误的)和在浏览器开发者工具(F12)操作模拟网络问题就能覆盖 90% 的场景。

1. 模拟网络彻底断开 (Offline)

这是测试“重试逻辑”和“网络恢复自动加载”最直接的方法。

  • 操作步骤

    1. 打开 Chrome 开发者工具 -> Network (网络) 标签页。
    2. 找到 No Throttling (不限速) 下拉菜单。
    3. 选择 Offline (离线)
  • 结果:此时地图的所有瓦片请求和样式请求都会立即失败,触发 error 事件。

2. 模拟特定资源加载失败 (404/403/500)

如果你想模拟 Mapbox 的 Token 失效(401)或者某个图层样式找不到了(404),可以使用Request Blocking

  • 操作步骤

    1. 在开发者工具中,按下 Ctrl + Shift + P (Mac: Cmd + Shift + P)。

    2. 输入 Blocking,选择 Show Network Request Blocking

    3. 点击 + 号,添加过滤规则或者选中某个请求右键进行block,刷新后会针对性的block

      • 输入 api.mapbox.com:拦截所有地图数据请求。
      • 输入 *.png*.pbf:只拦截瓦片请求。
  • 结果:被拦截的请求会显示为 (blocked:devtools),触发地图的错误回调。

3. 模拟弱网/高延迟 (Latency)

模拟地图加载极其缓慢,导致超时的场景。

  • 操作步骤

    1. Network 标签页的下拉菜单中选择 Slow 3G
    2. 或者点击 Add... 自定义一个配置(例如延迟 10000ms)。
  • 结果:可以观察你的 Loading 遮罩层是否能正常显示,以及在长时间无响应后是否会触发你写的超时重试逻辑。

4. 代码层面手动触发 (Manual Trigger)

如果你只是想调试 UI 样式(比如看那个错误遮罩层好不好看),不需要真的制造网络错误,直接在控制台调你的实例方法:

  • 操作步骤: 在 Console 中输入:
// 假设你的 mapInstance 挂载在 window 下或者你能访问到
mapInstance.fire('error', { error: { message: '模拟错误', status: 401 } });
  • 结果:这会手动触发你绑定的 .on('error', ...) 监听函数。

5. 模拟地理位置权限失败

如果你的地图涉及用户定位:

  • 操作步骤

    1. 点击地址栏左侧的 锁图标
    2. 位置信息 (Location) 设置为 屏蔽 (Block)
  • 结果:测试 geolocate 插件报错时的 UI 反馈。

@vue-office依赖报错

2026年3月18日 10:03

背景

这是一个已有项目,之前 install 后一切正常。但某天我重新拉取仓库到新目录,准备在新仓库上继续开发时,却发现 build 和运行都报错了。

报错信息如下:

09:15:03 [vite] Pre-transform error: Failed to resolve entry for package "@vue-office/docx". The package may have incorrect main/module/exports specified in its package.json.  
09:15:04 [vite] Internal server error: Failed to resolve entry for package "@vue-office/docx". The package may have incorrect main/module/exports specified in its package.json.  
Plugin: vite:import-analysis  
File: E:/rag-op-admin-web/src/views/application/create/components/FilePreviewDialog.vue  
at packageEntryFailure (file:///E:/rag-op-admin-web/node_modules/.pnpm/vite@5.4.1_@types+node@20.16.1_sass@1.77.8/node_modules/vite/dist/node/chunks/dep-Cy9twKMn.js:46533:15)  
at resolvePackageEntry (file:///E:/rag-op-admin-web/node_modules/.pnpm/vite@5.4.1_@types+node@20.16.1_sass@1.77.8/node_modules/vite/dist/node/chunks/dep-Cy9twKMn.js:46530:3)  
at tryNodeResolve (file:///E:/rag-op-admin-web/node_modules/.pnpm/vite@5.4.1_@types+node@20.16.1_sass@1.77.8/node_modules/vite/dist/node/chunks/dep-Cy9twKMn.js:46346:16)

8609AC17-BCE2-401f-B15C-2BE50EF3CF5C.png

或是找不到对应的 CSS 文件。

奇怪的是,旧的项目目录(一直没动过)却能正常运行。对比发现,旧项目的 node_modules/@vue-office/excel/lib/ 目录下存在 index.js 和 index.css,而新拉的仓库里却没有这些文件。

明明配置文件没改过,node 和 pnpm 版本也在兼容范围内,为什么新建的仓库总是缺少文件呢?

解决办法

经过排查,最终通过以下几个步骤解决了问题。下面分享排查和解决的过程。

1. 检查 package.json 中的 ignoredBuiltDependencies

"ignoredBuiltDependencies": [
  "@vue-office/docx",
  "@vue-office/excel",
  "@vue-office/pdf",
  "@vue-office/pptx",
  "esbuild",
  "vue-demi"
]

这是 pnpm 用于在安装时忽略某些依赖的构建步骤和 postinstall 脚本的配置。由于 @vue-office 的所有包都被列在这里,它们的 postinstall 脚本就被跳过了。

2. 检查 .npmrc 文件

enable-pre-post-scripts=false

这个配置禁用了 npm/pnpm 的 pre 和 post 脚本执行。这意味着所有包的 postinstall 脚本都不会自动运行,自然也包括 @vue-office 包的 postinstall 脚本。所以要把它改成true

3. 手动执行 postinstall 脚本

即使检查了上面两项,我还是没能立即解决问题。最终我手动执行了各个包的 postinstall.js 脚本:

node node_modules/@vue-office/excel/lib/script/postinstall.js
node node_modules/@vue-office/docx/lib/script/postinstall.js
node node_modules/@vue-office/pdf/lib/script/postinstall.js
node node_modules/@vue-office/pptx/lib/script/postinstall.js

执行完毕后,再去查看 node_modules/@vue-office/excel/lib/ 目录,发现 index.js 和 index.css 文件已经生成,项目也恢复正常了。

4. 更彻底的解决方式

手动执行脚本虽然能临时解决问题,但下次重新安装依赖时又会丢失。为了避免这种情况,可以:

  • 移除 ignoredBuiltDependencies 中的相关包(如果确实不需要忽略的话)

  • 删除 .npmrc 中的 enable-pre-post-scripts=false,或者将其改为 true

  • 使用 pnpm 的 approve-builds 命令(适用于 pnpm 10.x):
    运行 pnpm approve-builds,然后根据提示选择允许这些包的构建脚本(注意这是交互式操作,适合手动构建时使用)

  • 在项目级别创建一个自定义的 postinstall 脚本,在 package.json 的 scripts 中添加:

    json

    "postinstall": "node node_modules/@vue-office/excel/lib/script/postinstall.js && node node_modules/@vue-office/docx/lib/script/postinstall.js && ..."
    

分析原因

为什么会出现这个问题?为什么旧项目没问题,新拉取的仓库却出问题?

1. @vue-office 包需要运行 postinstall 脚本

@vue-office 是一个同时支持 Vue 2 和 Vue 3 的库。为了兼容两个版本,它在安装后需要通过 postinstall 脚本自动检测项目中的 Vue 版本,并将对应版本的代码复制到 lib 目录下(生成 index.js 和 index.css)。

如果 postinstall 没有执行,lib 目录下就会缺少这些文件,导致项目在引入时报模块找不到的错误。

2. 为什么新拉取的仓库 postinstall 没有执行?

  • pnpm 10.x 引入了新的安全特性,默认会忽略构建脚本
  • 需要使用pnpm approve-builds 来批准这些包的构建脚本,但这需要交互式操作
  • 或者可以创建一个自定义的 postinstall 脚本在项目级别

总结

遇到类似“模块找不到”的问题,尤其是在重新拉取项目后出现,可以按以下思路排查:

  1. 检查项目中的 .npmrc 和 package.json 中与脚本执行相关的配置(如 enable-pre-post-scriptsignoredBuiltDependencies)。
  2. 确认依赖包是否需要 postinstall 脚本(查看包的文档或源码)。
  3. 尝试手动执行 postinstall 脚本,看是否能生成缺失的文件。
  4. 考虑 pnpm 版本的安全策略,必要时使用 pnpm approve-builds 或自定义 postinstall 脚本。

通过以上方法,可以确保项目在任何环境下都能正确安装依赖并运行。

原文链接:@vue-office依赖报错 | 1Z5K

easy-model 在数据可视化仪表板中的应用

作者 张一凡93
2026年3月18日 09:56

数据可视化仪表板需要处理大量数据、图表配置、用户交互等复杂逻辑。easy-model 的模型驱动架构能有效组织这些功能,提升开发效率。本文通过实际用例展示 easy-model 在仪表板开发中的优势。

图表配置模型

图表是仪表板的核心。我们创建 ChartModel 封装图表逻辑:

import { useModel } from "@e7w/easy-model"

class ChartModel {
  config = {
    type: "bar" as "bar" | "line" | "pie",
    data: [] as Array<{ label: string; value: number }>,
    title: "",
    showLegend: true
  };

  constructor(initialConfig: typeof this.config) {
    this.config = initialConfig;
  }

  updateData(newData: typeof this.config.data) {
    this.config.data = newData;
  }

  toggleLegend() {
    this.config.showLegend = !this.config.showLegend;
  }

  getMaxValue() {
    return Math.max(...this.config.data.map(d => d.value));
  }
}

function ChartComponent({ chartId }: { chartId: string }) {
  const params = useMemo(() => [{
    type: "bar",
    data: [
      { label: "Jan", value: 100 },
      { label: "Feb", value: 150 },
      { label: "Mar", value: 200 }
    ],
    title: "月销售额",
    showLegend: true
  }])
  const chartModel = useModel(ChartModel, params);

  return (
    <div>
      <h3>{chartModel.config.title}</h3>
      <p>最大值: {chartModel.getMaxValue()}</p>
      <button onClick={() => chartModel.toggleLegend()}>
        {chartModel.config.showLegend ? "隐藏图例" : "显示图例"}
      </button>
      {/* 渲染图表逻辑 */}
    </div>
  );
}

模型封装图表业务逻辑,如数据更新、配置切换等。

仪表板布局管理

仪表板布局需要跨组件共享:

import { useModel } from "@e7w/easy-model"

class DashboardModel {
  widgets: Array<{ id: string; type: string; position: { x: number; y: number } }> = [];
  dashboardId: string;

  constructor(dashboardId: string) {
    this.dashboardId = dashboardId;
  }

  addWidget(widget: typeof this.widgets[0]) {
    this.widgets.push(widget);
  }

  moveWidget(id: string, newPosition: { x: number; y: number }) {
    const widget = this.widgets.find(w => w.id === id);
    if (widget) widget.position = newPosition;
  }

  getWidgetCount() {
    return this.widgets.length;
  }
}

function Dashboard({ dashboardId }: { dashboardId: string }) {
  const dashboard = useModel(DashboardModel,[dashboardId]);

  return (
    <div>
      <h2>仪表板 ({dashboard.getWidgetCount()} 个组件)</h2>
      {dashboard.widgets.map(widget => (
        <div key={widget.id} style={{
          position: 'absolute',
          left: widget.position.x,
          top: widget.position.y
        }}>
          {/* 渲染组件 */}
        </div>
      ))}
    </div>
  );
}

useModel 支持布局状态共享。

数据异步加载

仪表板数据通常来自API:

import { loader, useLoader, useModel } from "@e7w/easy-model"

class DataSourceModel {
  data: any[] = [];
  sourceId: string;

  constructor(sourceId: string) {
    this.sourceId = sourceId;
  }

  @loader.load(true)
  async loadData() {
    // 模拟API调用
    const response = await fetch(`/api/data/${this.sourceId}`);
    this.data = await response.json();
  }
}

function DataWidget({ sourceId }: { sourceId: string }) {
  const dataModel = useModel(DataSourceModel, [sourceId]);
  const { isLoading } = useLoader();

  return (
    <div>
      {isLoading(dataModel.loadData) ? "加载中..." : (
        <ul>
          {dataModel.data.map((item, i) => <li key={i}>{item.name}</li>)}
        </ul>
      )}
      <button onClick={dataModel.loadData} disabled={isLoading(dataModel.loadData)}>
        刷新数据
      </button>
    </div>
  );
}

异步数据加载,状态自动管理。

测试保证质量

模型类易于测试:

describe("ChartModel", () => {
  it("should update data", () => {
    const model = new ChartModel({
      type: "bar",
      data: [],
      title: "Test",
      showLegend: true,
    });
    const newData = [{ label: "A", value: 10 }];
    model.updateData(newData);
    expect(model.config.data).toEqual(newData);
  });

  it("should calculate max value", () => {
    const model = new ChartModel({
      type: "bar",
      data: [
        { label: "A", value: 10 },
        { label: "B", value: 20 },
      ],
      title: "Test",
      showLegend: true,
    });
    expect(model.getMaxValue()).toBe(20);
  });
});

测试确保图表逻辑正确。

总结

easy-model 在数据可视化仪表板中表现出色:模型封装图表逻辑、状态共享布局、异步数据处理。结合测试,提升开发质量。试用 easy-model,让仪表板开发更简单!

项目地址:GitHub

Windows 前端环境进化论:告别 nvm,拥抱 Rust 编写的 fnm!

作者 JuliusDeng
2026年3月18日 09:51

前言:在 Windows 上开发,你是否忍受过 nvm-windows 切换版本时的缓慢?甚至因为环境变量没配对导致 node -v 毫无反应?今天推荐大家更换为 fnm (Fast Node Manager) 。它基于 Rust 开发,主打一个“快”字,且对 Windows 原生支持极佳。


🛑 第一步:彻底清理旧环境 (卸载 nvm)

为了避免多个 Node 管理器抢夺 PATH 变量,建议先送走 nvm-windows

  1. 卸载程序:打开“控制面板” -> “程序和功能”,找到 nvm-windows 并点击卸载。

  2. 清理残留:删除文件夹 C:\Users\你的用户名\AppData\Roaming\nvm(如果有)。

  3. 清理环境变量

    • 右键“此电脑” -> “属性” -> “高级系统设置” -> “环境变量”。
    • 用户变量中,删除 NVM_HOMENVM_SYMLINK
    • Path 变量中,删除包含 %NVM_HOME%%NVM_SYMLINK% 的行。
  4. 验证:打开 PowerShell,输入 node -v。如果提示“不是内部或外部命令”,说明清理干净了。


🚀 第二步:安装 fnm

在 Windows 上,最推荐使用 Winget(Win10/11 自带的包管理器),无需手动下载安装包。

打开 PowerShell (推荐管理员模式),运行:

PowerShell

winget install Schniz.fnm

如果你习惯用 Scoop,也可以执行:scoop install fnm


⚙️ 第三步:配置环境变量 (核心避坑区)

安装完 fnm 必须手动激活环境,否则执行 fnm use 会报错。

1. 创建并打开配置文件

在 PowerShell 中输入:

PowerShell

# 如果提示找不到路径,请先运行这一行创建文件
if (!(Test-Path -Path $PROFILE)) { New-Item -ItemType File -Path $PROFILE -Force }

# 打开配置文件
notepad $PROFILE

2. 写入激活代码

在打开的记事本末尾,粘贴以下代码并保存(Ctrl+S):

PowerShell

fnm env --use-on-cd | Out-String | Invoke-Expression

🔐 第四步:解决“禁止运行脚本”报错 (必看)

重启终端后,你可能会看到一片红字报错:“在此系统上禁止运行脚本”。这是因为 Windows 默认限制了脚本权限。

解决方法:

  1. 管理员身份 运行 PowerShell。

  2. 执行以下命令:

    PowerShell

    Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
    
  3. 输入 Y 并回车确认。

  4. 重启终端,报错消失。

📌 第五步:设置默认 Node 版本

安装好 fnm 后,你需要下载并指定一个全局默认版本:

PowerShell

# 1. 安装最新的 LTS 版本 (或者指定版本号,如 fnm install 20)
fnm install --lts

# 2. 将该版本设为全局默认
fnm default 20

# 3. 验证
node -v

📦 第六步:进阶 —— 找回丢失的 Yarn/Pnpm

首选 pnpm?

  • 速度极快:比 npm/yarn 快 2-3 倍。
  • 节省空间:基于内容寻址存储,相同的依赖在全局只存一份,不会重复占用磁盘。
  • 严格模式:解决“幽灵依赖”问题,让你的项目更稳健。

安装 pnpm (推荐方式)

在 Windows 环境下,如果你已经按照前文配置好了 fnm,直接用 corepack 激活是最优雅的:

PowerShell

# 1. 激活 Node 内置的 corepack
corepack enable

# 2. 准备 pnpm
corepack prepare pnpm@latest --activate

# 3. 验证
pnpm -v

image.png

注意:如果你的项目里已经有 pnpm-lock.yamlcorepack 会自动根据项目要求的版本进行切换,非常省心。

切换 Node 版本后,全局安装的 yarn 会“消失”。推荐使用 Node 官方内置的 Corepack 来解决,无需重复安装。

方法 A:使用内置 Corepack (推荐)

Node v16.13+ 官方内置了 corepack,可以无感激活 Yarn:

PowerShell

# 激活 Node 内置的包管理工具管理器
corepack enable

# 验证 Yarn 是否可用
yarn -v

这种方式最稳妥,它会根据你项目里的 packageManager 字段自动匹配 Yarn 版本。

方法 B:手动全局安装

如果你更喜欢传统方式:

Bash

fnm use 20  # 先切换到你要用的版本
npm install -g yarn

🛠️ Windows 常用指令速查

命令 说明
fnm ls-remote 列出所有可下载的 Node 版本
fnm list 查看本地已安装的版本
fnm install <version> 安装指定版本
fnm use <version> 临时切换版本
fnm default <version> 永久设置默认版本

⚠️ Windows 专属避坑指南

  1. 权限报错:如果在执行 fnm defaultfnm use 时报符号链接(Symlink)错误,请以管理员身份运行 PowerShell,或者在系统设置中开启“开发者模式”。
  2. 终端选择:强烈建议配合 Microsoft Terminal 使用,配合 oh-my-posh 视觉体验更佳。
  3. 彻底卸载官方版 Node:如果你之前是从 Node.js 官网下载的 .msi 安装包安装的 Node,请务必先去控制面板卸载它,否则它会占用系统路径,导致 fnm 失效。

Windows 开发效率:一键添加“右键在此处打开 PowerShell (管理员)”全攻略

在 Windows 上做前端开发,频繁切换目录是常态。虽然 Win11 自带了终端右键菜单,但很多时候我们需要管理员权限,或者需要原生 PowerShell


🚀 核心脚本:一键导入

新建一个记事本,将以下内容粘贴进去,保存并重命名为 add_powershell.reg(确保后缀是 .reg)。

代码段

Windows Registry Editor Version 5.00

; --- 1. 普通权限版 ---
[HKEY_CURRENT_USER\Software\Classes\directory\Background\shell\PowerShell]
@="在此处打开 PowerShell"
"Icon"="powershell.exe"

[HKEY_CURRENT_USER\Software\Classes\directory\Background\shell\PowerShell\command]
@="powershell.exe -noexit -command "Set-Location -LiteralPath '%V'""

; --- 2. 管理员权限版 ---
[HKEY_CURRENT_USER\Software\Classes\directory\Background\shell\PowerShellAdmin]
@="在此处打开 PowerShell (管理员)"
"Icon"="powershell.exe"

[HKEY_CURRENT_USER\Software\Classes\directory\Background\shell\PowerShellAdmin\command]
@="powershell.exe -Command "Start-Process powershell -ArgumentList '-noexit','-command',\"Set-Location -LiteralPath '%V'\" -Verb RunAs""

后悔药:如何卸载?

如果不想要了,新建 .reg 运行以下内容即可清理干净:

代码段

Windows Registry Editor Version 5.00

[-HKEY_CURRENT_USER\Software\Classes\directory\Background\shell\PowerShell]
[-HKEY_CURRENT_USER\Software\Classes\directory\Background\shell\PowerShellAdmin]

⚠️ 必看:避坑指南(三大生存法则)

如果你直接保存运行,可能会遇到以下三个问题,请务必对号入座:

1. 解决中文乱码(最重要!)

现象:右键菜单显示一串看不懂的符号。 对策:保存 .reg 文件时,点击“另存为”,在底部的 【编码】 下拉框中选择 ANSIUTF-16 LE。千万不要选默认的 UTF-8

结语

从 nvm 迁移到 fnm 后,最直观的感受是打开终端再也没有那 0.5 秒的延迟了。作为追求效率的前端开发,这波“装备升级”绝对值得!

觉得有用的话,点个赞支持一下吧!评论区欢迎交流 Windows 环境配置心得。

TypeScript 模板字面量类型高级用法

作者 兆子龙
2026年3月18日 09:48

一、模板字面量类型是什么

模板字面量类型是 TypeScript 4.1 引入的强大特性,它允许你在类型层面使用类似 JavaScript 模板字符串的语法。通过这个特性,你可以基于已有的字符串类型构造出新的字符串类型,实现精确的类型推导和约束。

在介绍具体用法之前,让我们先理解模板字面量类型的基本语法:

type Greeting = `Hello, ${string}`;
const greeting1: Greeting = 'Hello, World';  // ✓ 正确
const greeting2: Greeting = 'Hi, there';     // ✗ 错误,不匹配模式

Greeting 类型表示所有以「Hello, 」开头的字符串。只有符合这个模式的字符串才能赋值给 Greeting 类型的变量。

二、模板字面量类型的基本用法

2.1 字符串拼接类型

模板字面量类型最基础的用法是拼接字符串:

type Part1 = 'Hello';
type Part2 = 'World';
type Greeting = `${Part1} ${Part2}`;
// Greeting 类型为 "Hello World"

2.2 使用联合类型

模板字面量类型可以与联合类型配合使用,生成所有可能的组合:

type Direction = 'top' | 'bottom' | 'left' | 'right';
type MarginProperty = `margin${Capitalize<Direction>}`;
// MarginProperty 类型为 "marginTop" | "marginBottom" | "marginLeft" | "marginRight"

type BorderProperty = `border${Capitalize<Direction>}-width`;
// BorderProperty 类型为 "borderTop-width" | "borderBottom-width" | ...

注意这里使用了 TypeScript 内置的 Capitalize 工具类型,它可以将字符串类型的首字母转为大写。

2.3 内置工具类型

TypeScript 提供了几个与模板字面量类型配合使用的内置工具类型:

  • Uppercase<StringType>:将字符串转为大写
  • Lowercase<StringType>:将字符串转为小写
  • Capitalize<StringType>:将首字母转为大写
  • Uncapitalize<StringType>:将首字母转为小写
type EventName = 'click' | 'hover' | 'focus';
type UpperEventName = `${Uppercase<EventName>}`;
// "CLICK" | "HOVER" | "FOCUS"

type HandlerName = `on${Capitalize<EventName>}`;
// "onClick" | "onHover" | "onFocus"

三、实战:CSS 属性类型生成

让我们通过一个实际场景来深入理解模板字面量类型的用法。假设我们需要为 CSS 属性生成对应的类型定义:

// 定义所有可能的 CSS 属性前缀
type CssPropertyPrefix = '' | 'webkit' | 'moz' | 'ms' | 'o';

// 定义属性名
type CssPropertyName = 
  | 'transform' | 'transition' | 'animation'
  | 'flex' | 'grid';

// 生成完整的 CSS 属性名
type CssProperty = `${CssPropertyPrefix}${Capitalize<CssPropertyName>}`;
// 结果:"" | "WebkitTransform" | "MozTransform" | "MsTransform" | "OTransform" | ...

更复杂的例子,生成带值的 CSS 属性类型:

type CssValue = 'auto' | '0' | '100%' | '1px' | '2px';
type CssPropertyWithValue = `${CssProperty} ${CssValue}`;
// 例如:"transform auto" | "transform 0" | "WebkitTransform 100%" | ...

四、模板字面量类型与infer

模板字面量类型最强大的用法是配合 infer 关键字进行模式匹配和类型提取:

4.1 提取字符串中的特定部分

// 提取事件名称
type ExtractEventName<T> = T extends `on${infer Name}` ? Name : never;

type ClickEvent = ExtractEventName<'onClick'>;      // "Click"
type HoverEvent = ExtractEventName<'onHover'>;      // "Hover"
type CustomEvent = ExtractEventName<'onCustom'>;    // "Custom"

4.2 提取路径中的文件名

type ExtractFileName<T> = T extends `${string}/${infer Name}` ? Name : T;

type FileName1 = ExtractFileName<'/src/components/Button.tsx'>;  // "Button.tsx"
type FileName2 = ExtractFileName<'index.ts'>;                    // "index.ts"

4.3 提取驼峰命名并转换

type CamelToKebab<T extends string> = 
  T extends `${infer First}${infer Rest}`
    ? First extends Uppercase<First>
      ? `-${Lowercase<First>}${CamelToKebab<Rest>}`
      : `${First}${CamelToKebab<Rest>}`
    : T;

type KebabCase = CamelToKebab<'backgroundColor'>;  // "background-color"
type KebabCase2 = CamelToKebab<'borderRadius'>;    // "border-radius"

4.4 提取并转换 URL 参数

type ExtractQueryParams<T extends string> = 
  T extends `${string}?${infer Query}`
    ? Query extends `${infer Key}=${infer Value}&${infer Rest}`
      ? { [K in Key]: Value } & ExtractQueryParams<`?${Rest}`>
      : Query extends `${infer Key}=${infer Value}`
        ? { [K in Key]: Value }
        : {}
    : {};

type Params = ExtractQueryParams<'/api/users?id=1&name=jack&age=25'>;
// { id: "1" } & { name: "jack" } & { age: "25" }

五、模板字面量类型在React中的应用

5.1 组件 Props 类型推导

type Size = 'small' | 'medium' | 'large';
type Color = 'primary' | 'secondary' | 'danger';

type ButtonVariant = `${Size}-${Color}`;
// "small-primary" | "small-secondary" | "small-danger" | ...

interface ButtonProps {
  variant: ButtonVariant;
  onClick: `handle${Capitalize<ButtonVariant>}`;
// 例如:handleSmallPrimary | handleSmallSecondary | ...
}

function createHandler(variant: ButtonVariant): ButtonProps['onClick'] {
  return `handle${variant}` as ButtonProps['onClick'];
}

5.2 状态更新函数类型

type Setter<T extends string> = `set${Capitalize<T>}`;

interface State {
  name: string;
  age: number;
  email: string;
}

type StateSetters = {
  [K in keyof State as Setter<string & K>]: (value: State[K]) => void
};

// Setter<"name"> = "setName"
// 结果类型:{ setName: (value: string) => void; setAge: (value: number) => void; ... }

5.3 路由参数类型

type RouteParams<T extends string> = 
  T extends `${string}:${infer Param}/${infer Rest}`
    ? { [K in Param]: string } & RouteParams<`/${Rest}`>
    : T extends `${string}:${infer Param}`
      ? { [K in Param]: string }
      : {};

type BlogRoute = RouteParams<'/blog/:id/:slug'>;
// { id: string } & { slug: string }

type UserRoute = RouteParams<'/users/:id'>;
// { id: string }

六、模板字面量类型与映射类型

模板字面量类型可以与映射类型结合,批量生成相关的类型:

type APIResponse = {
  code: number;
  message: string;
  data: unknown;
};

type APIAction = 'login' | 'register' | 'logout' | 'profile';

type APIEndpoint = `/${APIAction}`;

type APIResponses = {
  [K in APIAction as `${K}${string}`]: APIResponse;
};

// 结果:
// {
//   login: APIResponse;
//   loginSuccess: APIResponse;
//   loginFailed: APIResponse;
//   register: APIResponse;
//   ...
// }

更实用的例子,生成表单验证规则类型:

type ValidationRule = 'required' | 'minLength' | 'maxLength' | 'pattern';
type FieldName = 'email' | 'password' | 'username' | 'phone';

type ValidationConfig = {
  [F in FieldName as `${F}${Capitalize<ValidationRule>}`]: 
    F extends 'email' 
      ? { pattern: RegExp } | { required: true }
      : F extends 'password'
        ? { minLength: number } | { required: true }
        : { required: true } | { minLength: number; maxLength: number }
};

// emailRequired: { pattern: RegExp } | { required: true }
// passwordMinLength: { minLength: number } | { required: true }
// usernameRequired: { required: true } | { minLength: number; maxLength: number }

七、模板字面量类型的性能考虑

模板字面量类型在编译时计算,过度复杂的类型推导可能会影响 TypeScript 的编译性能。以下是一些优化建议:

首先,避免在模板字面量类型中使用过深的递归。深度递归的类型推导会显著增加类型检查的时间。

// 不推荐:深层递归
type DeepCamelToKebab<T extends string, Acc extends string = ''> = 
  T extends `${infer First}${infer Rest}`
    ? First extends Uppercase<First>
      ? DeepCamelToKebab<Rest, `${Acc}-${Lowercase<First>}`>
      : DeepCamelToKebab<Rest, `${Acc}${First}`>
    : Acc;

// 推荐:限制递归深度或使用条件类型简化
type SimpleCamelToKebab<T extends string> = 
  T extends `${infer A}${infer B}`
    ? A extends Uppercase<A>
      ? `-${Lowercase<A>}${SimpleCamelToKebab<B>}`
      : `${A}${SimpleCamelToKebab<B>}`
    : T;

其次,使用 extends 约束来缩小类型范围,减少类型计算量:

// 不推荐:无约束的类型推导
type ExtractFromString<T> = T extends `${string}${infer Rest}` ? Rest : T;

// 推荐:添加约束
type ExtractFromString<T extends string> = 
  T extends `${string}${infer Rest}` ? Rest : T;

第三,对于复杂的类型计算,考虑使用类型别名缓存结果:

// 缓存计算结果
type CachedKebabCase<T extends string> = KebabCaseMap[T];

// 预先计算映射
type KebabCaseMap = {
  backgroundColor: 'background-color';
  borderRadius: 'border-radius';
  color: 'color';
  // ... 更多预计算的类型
};

八、模板字面量类型的调试技巧

复杂的模板字面量类型往往难以调试。以下是几个实用的调试技巧:

第一个技巧是使用中间类型别名来分解复杂类型:

// 原始复杂类型
type ComplexType = `${Prefix}${Capitalize<Name>}${Suffix}`;

// 分解为多个中间类型
type Step1 = Capitalize<Name>;
type Step2 = `${Prefix}${Step1}`;
type Step3 = `${Step2}${Suffix}`;

第二个技巧是使用条件类型产生错误信息:

// 通过错误信息查看实际类型
type Debug<T> = T extends string ? never : T;
type Result = Debug<YourComplexType>;  // 错误信息会显示实际类型

第三个技巧是使用 typeof 和模板字面量类型结合:

const config = {
  apiUrl: 'https://api.example.com',
  version: 'v1',
} as const;

type Endpoint = `${typeof config.apiUrl}/${typeof config.version}/${string}`;
// "https://api.example.com/v1/${string}"

九、常见问题与解决方案

9.1 模板字面量类型不生效

确保你使用的是 TypeScript 4.1 或更高版本。在较旧的 TypeScript 版本中,模板字面量类型可能不被支持。

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020"],
    "typescriptVersion": ">=4.1.0"
  }
}

9.2 联合类型在模板字面量中的行为

当模板字面量类型中的某部分是一个联合类型时,结果会是所有可能组合的联合:

type A = 'x' | 'y';
type B = '1' | '2';
type C = `${A}${B}`;
// "x1" | "x2" | "y1" | "y2"

9.3 大小写转换工具类型

TypeScript 的内置大小写转换工具类型只对 ASCII 字符有效。如果你需要处理其他语言的字符,可能需要自定义实现:

type UppercaseFirst<T extends string> = 
  T extends `${infer First}${infer Rest}`
    ? `${Uppercase<First>}${Rest}`
    : T;

type LowercaseFirst<T extends string> = 
  T extends `${infer First}${infer Rest}`
    ? `${Lowercase<First>}${Rest}`
    : T;

十、总结

模板字面量类型是 TypeScript 类型系统中最强大的特性之一。它不仅可以让你的类型定义更加精确和类型安全,还能在编译时捕获许多潜在的类型错误。通过掌握模板字面量类型,你可以写出更加健壮、可维护的 TypeScript 代码。

如果这篇文章对你有帮助,欢迎点赞、收藏和关注。

❌
❌