阅读视图

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

用scss设计一下系统主题有什么方案吗

在了解方案前,我们多多少少应该见过这样类型的样式--primary-color,这个是什么东东?其实它就是一个css变量,也可叫css自定义属性,它是一种css规范,它一般定义在样式类里,如下

.theme{
  --primary-color:grey;
}

那么问题来了,它有什么用?一般用来实现自定义主题,实时调整修改样式。既然知道了它的用处了,那么它应该如何用?这就需要var


background-color: var(--primary-color);

既然我们用过--primary-color这种规范,相信$primary-color应该也用过,它就是一个预处理器scss的变量,简称scss变量,它与css变量区别是它不需要定义在样式类里,一般用来定义一个值而在多个地方使用

$primary-color:grey;

:root{
  --primary-color: $primary-color
}

那么问题来了,既然我们都初步了解了$var和--var,那么它们的区别究竟在哪里?

特性 Sass 变量 ($var) CSS 变量 (--var)
编译时机 编译时处理 运行时处理
本质 在代码被编译成 CSS 之前就被替换成对应的值。最终的 CSS 文件中不存在 $variable 是浏览器引擎能够识别的真实属性。最终的 CSS 文件中保留着 var(--variable)
作用域 遵循 Sass 的代码作用域(文件、嵌套块)。 遵循 CSS 的级联和继承规则(DOM 结构)。
JavaScript 操作 无法通过 JS 访问或修改,编译后不存在了。 可以通过 JS 动态读取和修改。
能力 可以被用于选择器名、属性名、注释等(得益于编译过程)。 只能用作属性值
兼容性 编译后变成普通 CSS,兼容所有浏览器。 现代浏览器支持良好(IE 基本不支持)。

我们可能疑问,上面两种变量跟主题有关系吗,当然有,如果不用它们,实现主题就不那么完美了,好了废话不多说,我们一起看例子

CSS类名切换

CSS类名切换:通过切换不同类名进行主题的切换

  • 样式文件可以写在一个文件下,然后在根样式中引入
  • 样式文件也可以写在多个不同主题文件下,然后全部根引入
.theme__default{ // 主题一
    --primary-color:grey;
    --text-color:#000; 
}
.theme__blue{ // 主题二
    --primary-color:blue;
    --text-color:blue;
}
.theme__red{ // 主题三
    --primary-color:red;
    --text-color:red;
}
.bgc{
    background-color: var(--primary-color);
}
.text{
    color: var(--text-color);
}

通过声明对应的样式,然后再响应的html进行切换,切换内容如下

<el-select v-model="currentTheme" placeholder="Select" style="width: 180px">
    <el-option
    v-for="item in options"
    :key="item.value"
    :label="item.label"
    :value="item.value"
    />
</el-select>
<div class="mt-4" :class="`theme__${currentTheme}`">
    <div class="bgc h-20 w-20"></div>
    <p class="text">路灯下的光</p>
</div>

切换示例如下:

2025082301.gif

SCSS/SASS变量

SCSS/SASS变量:使用预处理器变量结合不同的类名生成多套样式

方式一:定义多个主题文件,通过scss的minxin语法定义主题样式

/* theme-mixin.scss */
@mixin theme($name) {
  .theme-#{$name} {
    --bg-color: #{$bg-color};
    --text-color: #{$text-color};

  }
}

在每一个主题文件中引入并使用minxin函数进行相应的主题样式定义

/* themes/light.scss */
$bg-color: #fff;
$text-color: #333;
@use "theme-mixin";
@include theme(light);

/* themes/dark.scss */
$bg-color: #121212;
$text-color: #fafafa;
@use "theme-mixin";
@include theme(dark);

在根样式中显式引入所有主题文件样式

/* src/styles/index.scss */
@use "themes/light";
@use "themes/dark";

方式二:在同一个文件下定义样式变量,并用自定义属性data-theme实现主题样式定义

我们可能疑问data-theme是啥,其实它就是一个自定义属性,为html属性提供一个主题标识,原生html支持这种通过data-来定义标签内的属性。在使用这样的前提必须先定义一些主题映射,如下

@use './variables/base-color.scss'; // 引入样式变量

// 定义主题映射
$themes: (
  "light": (
    primary-color: var(--oc-blue-6),
    secondary-color: var(--oc-blue-0),
    bg-color: var(--oc-white),
    text-color: var(--oc-blue-5),
    border-color: var(--oc-blue-5)
  ),
  "dark": (
    primary-color: var(oc-gray-9),
    secondary-color:  var(oc-gray-0),
    bg-color: var(--oc-gray-6), // #1a1a1a
    text-color: var(--oc-gray-4),
    border-color: var(--oc-gray-5)
  ),
);

// 将SCSS变量转换为CSS变量
:root {
  @each $theme, $map in $themes { // 遍历所有主题,$theme主题,$map键值对
    &[data-theme="#{$theme}"] {  // 属性选择器
      @each $key, $value in $map { // 遍历当前主题的所有变量
        --#{$key}: #{$value};
      }
    }
  }
}

既然我们定义了theme那么在html中是如何使用的呢?

 document.documentElement.setAttribute('data-theme', theme);

关键就在于给documentElement设置data-theme属性

CSS-in-JS方案

安装一些插件,使其支持主题切换,以vue为例,我们需要安装pnpm i @vueuse/styles来支持主题,例如以下通过定义主题hooks,然后在全局的app.vue里引入并改变就可以实现主题

// theme.ts
import { createGlobalState } from '@vueuse/core';

export const useTheme = createGlobalState(() => {
  const isDark = ref(false);

  const theme = computed(() =>
    isDark.value
      ? {
          '--primary': '#e74c3c',
          '--bg': '#2c3e50',
          '--text': '#ecf0f1',
        }
      : {
          '--primary': '#3498db',
          '--bg': '#ffffff',
          '--text': '#333333',
        }
  );

  return { isDark, theme };
});

第三方库实现

@vueuse/color-scheme(vue3)

import { useColorMode } from '@vueuse/color-scheme'
const mode = useColorMode()  // 'light' | 'dark' | 'auto'
mode.value = 'dark'          // 自动同步到 <html class="dark">

vuetify

安装:

npm i vuetify@next

在入口文件定义

// main.ts
import { createVuetify } from 'vuetify'

const vuetify = createVuetify({
  theme: {
    themes: {
      light: { colors: { primary: '#1976D2' } },
      dark : { colors: { primary: '#BB86FC' } }
    }
  }
})
app.use(vuetify)

在需要变更的地方使用

import { useTheme } from 'vuetify'
const theme = useTheme()
theme.global.name.value = 'dark'

鸿蒙grid-hybrid项目UI滚动联动

grid-hybrid项目 gitee.com/harmonyos_s…

此项目交互场景相对复杂,可作为实际应用交互场景的草稿,进一步完善。譬如百度文库的UI交互。

一、GridNestListIndex页面交互

-- 多种控件的Scroller滚动联动

项目中GridNestListIndex 页面通过以下方式实现了滚动联动效果:


1. 核心组件与状态

  • Scroll 组件:用于页面的整体滚动。
  • List 组件:用于展示网格项列表。
  • Scroller 对象
    • scrollerForScroll:控制页面整体滚动。
    • scrollerForList:控制列表滚动。
    • scrollerForTitle:控制标题栏的横向滚动。
  • 状态变量
    • listPosition:记录列表的滚动位置(顶部、中间、底部)。
    • scrollPosition:记录页面的滚动位置(顶部、中间、底部)。
    • currentIndex:记录当前显示的列表项的索引。

2. 滚动联动实现逻辑

(1) 列表滚动触发标题栏联动

  • onScrollIndex 事件
    • 当列表滚动时,通过 onScrollIndex 获取当前显示的第一个子组件的索引 start
    • 更新 currentIndex 状态,并调用 scrollerForTitle.scrollToIndex 同步标题栏的滚动位置。

(2) 标题栏点击触发列表联动

  • 标题栏点击事件
    • 点击标题栏时,调用 scrollerForList.scrollToIndex 滚动列表到对应索引。
    • 同时调用 scrollerForScroll.scrollEdge(Edge.Bottom) 确保页面滚动到底部(避免标题栏遮挡列表)。
    • 更新 currentIndex 状态。

(3) 页面滚动与列表滚动联动

  • onScrollFrameBegin 事件
    • 当列表滚动时,通过 onScrollFrameBegin 计算偏移量 offset
    • 如果页面已滚动到底部(scrollPosition === ScrollPosition.end),且列表不在顶部或向上滚动,则允许列表继续滚动。
    • 否则,通过 scrollerForScroll.scrollBy 同步页面的滚动。

(4) 页面滚动状态同步

  • onWillScrollonScrollEdge 事件
    • 监听页面滚动方向,更新 scrollPosition 状态(顶部、中间、底部)。
    • 当页面滚动到顶部或底部时,禁止进一步滚动(通过 onScrollFrameBegin 返回 offsetRemain: 0)。

3. 关键代码片段

  • 列表滚动索引同步

    .onScrollIndex((start: number) => {
      this.currentIndex = start;
      this.scrollerForTitle.scrollToIndex(this.currentIndex);
    })
    
  • 标题栏点击联动

    .onClick(() => {
      this.scrollerForList.scrollToIndex(index);
      this.scrollerForScroll.scrollEdge(Edge.Bottom);
      this.scrollerForTitle.scrollToIndex(index);
      this.currentIndex = index;
    })
    
  • 页面与列表滚动同步

    .onScrollFrameBegin((offset: number) => {
      if (this.scrollPosition === ScrollPosition.end && (this.listPosition != ScrollPosition.start || offset > 0)) {
        return { offsetRemain: offset };
      } else {
        this.scrollerForScroll.scrollBy(0, offset);
        return { offsetRemain: offset };
      }
    })
    

4. 总结

通过 Scroller 对象和状态变量的协同工作,GridNestListIndex 实现了以下联动效果:

  1. 列表滚动时,标题栏自动同步到对应位置。
  2. 点击标题栏时,列表滚动到对应项,并确保页面布局正确。
  3. 页面滚动时,动态调整列表的滚动行为,避免冲突。

这种设计确保了用户体验的流畅性和一致性。

-- Grid的样式设置

GridComponent.ets 文件中,Grid 组件的行列数、宽高和布局是通过以下方式控制的:


1. 行列数控制

(1) 列数控制

  • columnsTemplate 属性
    • 通过 CommonConstants.GRID_COLUMNS_TEMPLATE 定义列模板。
    • 例如:'1fr 1fr 1fr 1fr' 表示 4 列,每列宽度均分剩余空间。

(2) 行数动态计算

  • 动态计算
    • 行数由 gridData.gridItemList.length 和列数决定。
    • 例如:Math.ceil(this.gridData.gridItemList.length / 4) 表示每行 4 个项,计算总行数。

2. 宽高控制

(1) 宽度

  • width 属性
    • 默认继承父容器宽度(CommonConstants.FULL_PERCENT)。
    • 可通过 margin 调整左右边距:
      .margin({
        left: $r('app.float.grid_left_margin'),
        right: $r('app.float.grid_right_margin')
      })
      

(2) 高度

  • height 属性
    • 通过 getGridHeight() 方法动态计算:
      .height(this.getGridHeight())
      
    • 计算逻辑
      • 每行高度固定为 68(网格项高度)。
      • 行间距为 8
      • 上下边距为 12 * 2
      • 公式:
        gridHeight += Math.ceil(this.gridData.gridItemList.length / 4) * 68; // 行高
        gridHeight += (Math.ceil(this.gridData.gridItemList.length / 4) - 1) * 8; // 行间距
        gridHeight += 12 * 2; // 上下边距
        

3. 间距与对齐

(1) 行间距

  • rowsGap 属性
    • 通过 $r('app.float.grid_rows_gap_size') 设置行间距。

(2) 内边距

  • padding 属性
    • 通过 $r('app.float.grid_padding_size') 设置内边距。

(3) 圆角

  • borderRadius 属性
    • 通过 $r('app.float.grid_border_radius') 设置圆角。

4. 关键代码片段

Grid() {
  ForEach(this.gridData.gridItemList, (item: string) => {
    GridItemComponent({ itemName: item })
  })
}
.columnsTemplate(CommonConstants.GRID_COLUMNS_TEMPLATE) // 列模板
.margin({ left: $r('app.float.grid_left_margin'), right: $r('app.float.grid_right_margin') }) // 左右边距
.height(this.getGridHeight()) // 动态高度
.rowsGap($r('app.float.grid_rows_gap_size')) // 行间距
.padding($r('app.float.grid_padding_size')) // 内边距
.borderRadius($r('app.float.grid_border_radius')) // 圆角

5. 总结

  • 列数:由 columnsTemplate 定义(如 4 列)。
  • 行数:动态计算(根据数据项数和列数)。
  • 宽度:继承父容器,通过 margin 调整边距。
  • 高度:动态计算(行高 + 行间距 + 边距)。
  • 间距与样式:通过 rowsGappadding 等属性控制。

这种设计确保了网格布局的灵活性和响应性。

二、GridNestSwiperIndex页面交互

GridNestSwiperIndex.ets 文件中,页面通过 SwiperWaterFlow 组件的协同工作,实现了一个动态的混合布局效果。以下是页面的完整解析:


1. 页面结构与功能

(1) 整体布局

  • 顶层容器Column 纵向排列,包含 Swiper(顶部滑动菜单)和 WaterFlow(下方瀑布流内容)。
  • 交互逻辑
    • Swiper 切换分类时,动态调整自身高度,间接影响 WaterFlow 的展示空间。
    • WaterFlow 根据数据源和分区配置,动态渲染内容项。

(2) 核心组件

组件 作用 数据依赖 动态控制点
Swiper 横向滑动菜单 GRID_COL_LIST swiperDistance(高度)
WaterFlow 瀑布流内容展示 WATER_FLOW_DATA sections(分区配置)

2. 数据驱动设计

(1) Swiper 分页与高度

  • 分页数据
    GRID_COL_LIST 定义分页内容和数量,例如:
    GRID_COL_LIST = [
      ["推荐", "热门"], // 第一页
      ["最新", "分类"]  // 第二页
    ]
    
  • 高度控制
    通过滑动事件 (onGestureSwipe) 和动画回调 (onAnimationStart) 动态设置 swiperDistance
    .onGestureSwipe((index: number) => {
      this.swiperDistance = (index === 0) ? SWIPER_HEIGHT_SIZE2 : SWIPER_HEIGHT_SIZE1;
    })
    

(2) WaterFlow 分区与内容

  • 数据源
    WATER_FLOW_DATA 提供内容项数据(如图片资源),通过 WaterFlowDataSource 封装。
  • 分区配置
    sections 定义布局规则,例如:
    sections = [
      { itemsCount: 1, crossCount: 1 }, // 单栏Banner
      { itemsCount: 6, crossCount: 2 }  // 双栏商品列表
    ]
    
  • 动态渲染
    使用 LazyForEach 按需生成 FlowItem
    LazyForEach(this.waterFlowDataSource, (item: Resource) => {
      FlowItem() {
        Image(item) // 绑定数据
      }
    })
    

3. 关键交互流程

  1. 初始化

    • 加载 GRID_COL_LISTWATER_FLOW_DATA
    • 配置 sections 分区。
  2. 用户滑动 Swiper

    • 触发 onGestureSwipe → 更新 swiperDistance
    • 动画过渡高度变化。
  3. WaterFlow 响应

    • 高度变化后,layoutWeight(1) 自动调整内容区域空间。
    • 根据当前分区重新布局项。

4. 设计优势与扩展性

(1) 优势

  • 性能优化LazyForEach 懒加载 + 动画平滑过渡。
  • 灵活配置:通过修改 sections 或数据源即可调整布局。

(2) 扩展建议

  • 动态数据:通过接口异步更新 GRID_COL_LISTWATER_FLOW_DATA
  • 高度自适应:根据 WaterFlow 内容高度动态计算 swiperDistance

如果需要进一步调整或扩展功能,请具体说明需求!

解锁动态键:TypeScript 索引签名完全指南

一、什么是索引签名?

索引签名就是为对象定义一种“规则”,规定了“什么样的键”对应“什么样的值”。

语法如下:

interface MyObject {
    [key: KeyType]: ValueType;
}

// 或者使用 type
type MyType = {
    [key: KeyType]: ValueType;
};
  • key: 只是一个占位符,你可以取任何名字,比如 prop, index 等。
  • KeyType: 键的类型。它必须是 string, number, symbol 或由它们组成的联合类型。因为在JavaScript中,对象的键最终都会被转换成字符串。
  • ValueType: 值的类型,可以是任何 TypeScript 类型。
  • 所有成员都必须符合字符串的索引签名

示例

interface ScoreRecord {
    [name: string]: number;
}

const scores: ScoreRecord = {
    'alice': 100,
    'bob': 95,
    'charlie': 98
};

// 访问是类型安全的
const aliceScore: number = scores['alice']; // OK

// 添加新的键值对也必须遵守规则
scores['dave'] = 99; // OK

scores['eve'] = 'A+'; // Error:不能将类型“string”分配给类型“number”

二、注意事项

2.1 可以与其他属性共存,但必须兼容

一个类型可以同时拥有索引签名和明确的属性。但有一个重要前提:所有明确定义的属性,其类型都必须是索引签名值类型的子类型

interface UserProfile {
    // 明确的属性
    id: number;
    name: string;
  
    // 索引签名
    [prop: string]: string | number; // 值的类型是 string 或 number
}

const user: UserProfile = {
    id: 123,       // OK, number 是 string | number 的子类型
    name: "Alice", // OK, string 是 string | number 的子类型
    city: "New York", // OK, 'city' 是 string, "New York" 是 string
    age: 30        // OK, 'age' 是 string, 30 是 number
};


interface InvalidProfile {
    id: number; // Error: 类型“number”的属性“id”不能赋给“string”索引类型“string”
    //索引签名要求所有值都是 string,但 id 是 number,不兼容!
    [prop: string]: string; // 
}

2.2 number 类型的键是 string 类型键的“特例”

因为 JavaScript 会将数字键转换为字符串键(例如 obj[5] 等同于 obj['5']),TypeScript 也遵循这个规则。所以,如果你同时定义了 stringnumber 的索引签名,number 索引签名的值类型必须是 string 索引签名值类型的子类型。

interface DataCache {
    [key: string]: any;
    [index: number]: string; // OK, string 是 any 的子类型
}

interface InvalidDataCache {
    [key: string]: string;
    [index: number]: number; // Error! “number”索引类型“number”不能分配给“string”索引类型“string”
}

2.3 访问不存在的属性

interface ScoreRecord {
    [name: string]: number;
}

const scores: ScoreRecord = { 'alice': 100 };
const bobScore = scores['bob']; // `bobScore` 的类型是 number,而不是 number | undefined
console.log(bobScore); // 输出:undefined
console.log(bobScore.toFixed(2)); // 运行时错误!TypeError: Cannot read properties of undefined (reading 'toFixed')

strictNullChecks 模式下,这会成为一个安全隐患。如何解决?

  1. 明确声明 undefined:这是最推荐的方式。

    interface ScoreRecord {
        [name: string]: number | undefined;
    }
    const scores: ScoreRecord = { 'alice': 100 };
    const bobScore = scores['bob']; // `bobScore` 的类型现在是 number | undefined
    if (bobScore) {
        console.log(bobScore.toFixed(2)); // OK,类型被收窄
    }
    
  2. 使用 noUncheckedIndexedAccess:在 tsconfig.json 中开启此选项,TypeScript 会自动在索引签名的结果中加入 | undefined

interface ScoreRecord {
    [name: string]: number;
}
const scores: ScoreRecord = { 'alice': 100 };
const bobScore = scores['bob']; // `bobScore` 的类型现在是 number | undefined
console.log(bobScore?.toFixed(2));// 在vscode中调用`toFixed()`时自动会补全`?`判断undefined

三、现代替代方案 Record<K, T>

Record<string, number> 和我们之前的 ScoreRecord 接口几乎是等价的:

// 使用 Record 工具类型
type ScoreRecord = Record<string, number>;

const scores: ScoreRecord = {
    'alice': 100,
    'bob': 95,
};

为什么推荐 Record

  • 可读性更好Record<string, number> 清晰地表达了“一个键为字符串、值为数字的记录”。
  • 更灵活Keys 参数不限于 stringnumber,它可以是具体的字面量联合类型,从而创建更精确的对象类型。
type UserRole = 'admin' | 'user' | 'guest';

// 创建一个确保每种角色都存在的配置对象
const roleConfig: Record<UserRole, { permissions: string[] }> = {
    admin: { permissions: ['create', 'read', 'update', 'delete'] },
    user: { permissions: ['read', 'update'] },
    //guest: { permissions: ['read'] },  //如果你漏掉了一个角色,TypeScript 会报错!
};

这是索引签名无法做到的。当你的键集合是已知且有限的时,Record 或**映射类型(Mapped Types)**是比索引签名更好的选择。

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多JavaScript/TypeScript开发干货

前端常见焦点事件(Focus)解析

DOM焦点事件是当元素获得或失去焦点时触发的一系列事件,主要包括以下四种类型:

  1. focus:元素获得焦点时触发(例如用户点击输入框)。
  2. blur:元素失去焦点时触发(例如用户点击页面其他区域)。
  3. focusin:元素即将获得焦点时触发,可冒泡(适用于父元素监听子元素事件)。
  4. focusout:元素即将失去焦点时触发,可冒泡(与focusin成对使用)。

焦点事件的“武器库”:属性与方法

1. 事件属性

  • target:触发事件的元素(例如被点击的输入框)。
  • relatedTarget:与焦点转移相关的元素(例如从输入框跳转到按钮时,relatedTarget指向按钮)。
  • eventPhase:事件当前所处的阶段(捕获、目标、冒泡)。

2. 常用方法

  • addEventListener:动态绑定事件,灵活且可避免覆盖已有监听器。
    input.addEventListener('focus', () => console.log('获得焦点'));  
    
  • onfocus/onblur:直接绑定属性,适合简单场景。
    <input onfocus="handleFocus()" onblur="handleBlur()">  
    
  • focus():强制让元素获得焦点(常用于表单自动聚焦)。
    document.getElementById('username').focus();  
    

焦点事件的“实战秘籍”:使用技巧

1. 表单验证的“黄金搭档”

结合focusblur事件,实现输入框的实时校验:

input.addEventListener('focus', () => {  
  input.style.borderColor = 'red'; // 获得焦点时高亮边框  
});  
input.addEventListener('blur', () => {  
  if (input.value === '') input.style.borderColor = 'gray'; // 离开时恢复  
});  

2. 动态交互的“魔法棒”

通过focusinfocusout控制父元素样式:

form.addEventListener('focusin', (e) => {  
  e.target.classList.add('highlight'); // 子元素获得焦点时,父元素变色  
});  
form.addEventListener('focusout', (e) => {  
  e.target.classList.remove('highlight'); // 恢复默认样式  
});  

3. 优化用户体验的“小妙招”

  • 自动聚焦:页面加载时自动聚焦到第一个输入框,减少用户操作。
    window.onload = () => document.getElementById('username').focus();  
    
  • 输入提示:结合focus事件显示帮助信息,提升用户引导效率。

焦点事件的“应用场景”:从表单到游戏

  1. 表单交互:登录注册页面的实时验证、密码强度检测。
  2. 动态列表:点击列表项时高亮当前项(通过focusin)。
  3. 游戏开发:键盘控制角色移动时,需确保游戏区域始终处于焦点状态。
  4. 无障碍设计:通过键盘导航(Tab键)时,为焦点元素添加视觉反馈(如轮廓线)。

五、避坑指南:焦点事件的“雷区”

  1. 别滥用focus():强制聚焦可能导致用户困惑(例如弹窗自动聚焦输入框,打断用户操作)。
  2. 注意事件冒泡
    • focusblur不冒泡,需直接绑定目标元素。
    • focusinfocusout可冒泡,适合父元素监听子元素事件。
  3. 兼容性陷阱
    • focusinfocusout在旧版IE中支持较好,但现代浏览器(如Chrome)需通过addEventListener绑定。
    • relatedTarget在部分浏览器中可能为null

代码示例:从0到1的完整案例

需求:创建一个输入框,当用户点击时显示“欢迎回来!”,离开时隐藏提示。

<input type="text" id="username" placeholder="请输入用户名">  
<p id="welcomeMsg" style="display: none;">欢迎回来!</p>  

<script>  
  const input = document.getElementById('username');  
  const msg = document.getElementById('welcomeMsg');  

  input.addEventListener('focus', () => {  
    msg.style.display = 'block'; // 获得焦点时显示提示  
  });  

  input.addEventListener('blur', () => {  
    msg.style.display = 'none'; // 失去焦点时隐藏提示  
  });  
</script>  

总结:焦点事件的“超能力”

焦点事件是网页交互的核心工具之一。通过合理使用focusblurfocusinfocusout,开发者可以:

  • 提升表单验证的实时性,减少用户错误。
  • 实现动态的视觉反馈,增强用户体验。
  • 构建无障碍友好的界面,适配键盘导航用户。

记住一句话

“焦点事件就像舞台上的聚光灯,精准控制它,你的网页将永远抓住用户的目光!”


彩蛋:如果你对焦点事件的底层原理感兴趣,可以尝试研究eventPhase和事件委托的结合,解锁更多高级玩法!

手把手教程:使用 Postman 测试与调试淘宝商品详情 API

在电商开发中,调用第三方平台 API(如淘宝商品详情 API)是常见需求。Postman 作为一款强大的 API 测试工具,能帮助开发者快速调试 API 接口。本文将手把手教你如何使用 Postman 测试淘宝商品详情 API,并提供相关代码示例。

一、准备工作

1. 注册淘宝账号

  • 访问
  • 完成开发者账号注册与认证
  • 获取 Api Key 和 Api Secret

2. 安装 Postman

  • 下载并安装适合你操作系统的版本
  • 完成基本设置并登录

3. 了解淘宝商品详情 API

淘宝商品详情 API(item_get)用于获取淘宝商品的详细信息,基本参数包括:

  • api_key:应用标识
  • method:接口名称(如 "taobao.item_get")
  • timestamp:时间戳
  • format:返回格式(通常为 json)
  • v:API 版本号
  • sign:签名
  • num_iid:商品 ID

二、使用 Postman 测试淘宝商品详情 API

1. 创建新请求

  • 打开 Postman,点击左上角 "New" 按钮
  • 选择 "Request",输入请求名称(如 "淘宝商品详情 API 测试")
  • 创建一个新的集合(Collection)来管理相关请求

2. 配置请求参数

  • 选择请求方法为 "GET"
  • 输入请求 URL(淘宝 API 网关:http://gw.api.taobao.com/router/rest
  • 切换到 "Params" 标签,添加以下参数:
参数名 说明
app_key 你的 App Key 从淘宝开放平台获取
method taobao.item_get 商品详情 API 方法名
timestamp 2023-10-01 12:00:00 当前时间,格式为 yyyy-MM-dd HH:mm:ss
format json 返回数据格式
v 2.0 API 版本
num_iid 520813250866 示例商品 ID,可替换为实际商品 ID
sign 待生成 签名,下文将介绍如何生成

3. 生成 API 签名

淘宝 API 需要签名验证,签名生成规则如下:

  1. 将除 sign 外的所有参数按字母顺序排序
  2. 拼接成 "参数名 = 参数值" 的形式,并用 & 连接
  3. 在字符串首尾加上 Api Secret
  4. 进行 MD5 加密并转为大写

以下是生成签名的 JavaScript 代码:

function generateSign(params, appSecret) {
    // 1. 按参数名排序
    const sortedParams = Object.keys(params).sort().reduce((obj, key) => {
        obj[key] = params[key];
        return obj;
    }, {});
    
    // 2. 拼接参数
    let signStr = '';
    for (const key in sortedParams) {
        signStr += `${key}=${sortedParams[key]}&`;
    }
    // 去除最后一个&
    signStr = signStr.slice(0, -1);
    
    // 3. 首尾加上AppSecret
    signStr = appSecret + signStr + appSecret;
    
    // 4. MD5加密并转为大写
    const md5 = require('md5'); // 需要安装md5包: npm install md5
    return md5(signStr).toUpperCase();
}

// 使用示例
const params = {
    app_key: '你的AppKey',
    method: 'taobao.item_get',
    timestamp: '2023-10-01 12:00:00',
    format: 'json',
    v: '2.0',
    num_iid: '520813250866'
};

const appSecret = '你的AppSecret';
const sign = generateSign(params, appSecret);
console.log('生成的签名:', sign);

4. 运行并查看结果

  • 将生成的签名填入 Postman 的 sign 参数中
  • 点击 "Send" 按钮发送请求
  • 在下方的响应区域查看 API 返回结果

成功响应示例:

{
  "item_get_response": {
    "item": {
      "num_iid": "520813250866",
      "title": "夏季女装连衣裙新品...",
      "price": "139.00",
      "sales": 1250,
      // 更多商品信息...
    }
  }
}

三、调试常见问题

1. 签名错误

  • 检查参数是否完整且正确
  • 确认参数排序是否按字母顺序
  • 验证 Api Secret 是否正确
  • 检查时间戳是否有效(通常需与淘宝服务器时间相差在 10 分钟内)

2. 参数错误

  • 检查是否遗漏必填参数
  • 确认参数格式是否正确(如时间戳格式)
  • 验证商品 ID 是否有效

3. 权限问题

  • 检查应用是否已获得 API 调用权限
  • 确认应用是否已通过审核
  • 查看应用调用次数是否超限

4. 使用 Postman 调试技巧

  • 使用 "Console" 查看完整请求信息
  • 利用 "Environment" 功能管理不同环境(开发 / 测试 / 生产)的参数
  • 使用 "Tests" 标签添加测试脚本自动验证响应结果

四、自动化测试代码示例

除了手动测试,我们还可以编写代码实现自动化测试:

const axios = require('axios');
const md5 = require('md5');

// 配置信息
const config = {
    appKey: '你的AppKey',
    appSecret: '你的AppSecret',
    apiUrl: 'http://gw.api.taobao.com/router/rest'
};

// 生成签名
function generateSign(params) {
    const sortedParams = Object.keys(params).sort().reduce((obj, key) => {
        obj[key] = params[key];
        return obj;
    }, {});
    
    let signStr = '';
    for (const key in sortedParams) {
        signStr += `${key}=${sortedParams[key]}&`;
    }
    signStr = signStr.slice(0, -1);
    signStr = config.appSecret + signStr + config.appSecret;
    
    return md5(signStr).toUpperCase();
}

// 调用淘宝商品详情API
async function getItemDetail(numIid) {
    const params = {
        app_key: config.appKey,
        method: 'taobao.item_get',
        timestamp: new Date().toISOString().slice(0, 19).replace('T', ' '),
        format: 'json',
        v: '2.0',
        num_iid: numIid
    };
    
    // 生成签名
    params.sign = generateSign(params);
    
    try {
        const response = await axios.get(config.apiUrl, { params });
        return response.data;
    } catch (error) {
        console.error('API调用失败:', error.response ? error.response.data : error.message);
        throw error;
    }
}

// 测试API
async function testApi() {
    try {
        console.log('开始测试淘宝商品详情API...');
        const result = await getItemDetail('520813250866');
        
        // 验证响应结果
        if (result.item_get_response && result.item_get_response.item) {
            console.log('测试成功!');
            console.log('商品标题:', result.item_get_response.item.title);
            console.log('商品价格:', result.item_get_response.item.price);
        } else {
            console.log('测试失败: 未获取到商品信息');
            console.log('响应结果:', result);
        }
    } catch (error) {
        console.log('测试失败:', error.message);
    }
}

// 执行测试
testApi();

五、总结

通过本文的教程,你已经掌握了使用 Postman 测试淘宝商品详情 API 的基本方法,包括:

  1. 准备账号和 API 凭证
  2. 配置 Postman 请求参数
  3. 生成和验证 API 签名
  4. 分析 API 响应结果
  5. 调试常见问题
  6. 编写自动化测试脚本

Postman 提供了丰富的功能,如集合测试、环境变量管理、文档生成等,可以进一步提高 API 测试效率。在实际开发中,建议结合这些功能构建完整的 API 测试流程,确保接口调用的稳定性和正确性。

最后,记得遵守淘宝的使用规范,合理调用 API,避免超出调用限制。

🔥 Vue状态管理越写越乱,Pinia拯救了我

🎯 学习目标:掌握Pinia状态管理的5个核心技巧,让Vue3项目的状态管理更加清晰、高效

📊 难度等级:中级
🏷️ 技术标签#Vue3 #Pinia #状态管理 #TypeScript
⏱️ 阅读时间:约8分钟


🌟 引言

在日常的Vue3开发中,你是否遇到过这样的困扰:

  • 状态散乱:组件间状态传递复杂,props和emit满天飞
  • 数据同步困难:多个组件需要共享状态,但同步更新总是出问题
  • 代码维护性差:状态逻辑分散在各个组件中,难以统一管理
  • TypeScript支持不友好:状态类型定义复杂,开发体验不佳

今天分享5个Pinia状态管理的实战技巧,让你的Vue3项目状态管理更加优雅高效!


💡 核心技巧详解

1. 模块化Store设计:告别单一巨型Store

🔍 应用场景

当项目规模增大,需要管理用户信息、商品数据、购物车等多个业务模块的状态时

❌ 常见问题

将所有状态都放在一个Store中,导致代码臃肿难维护

// ❌ 传统写法:单一巨型Store
export const useMainStore = defineStore('main', {
  state: () => ({
    user: null,
    products: [],
    cart: [],
    orders: [],
    notifications: [],
    // ... 更多状态
  }),
  actions: {
    // 所有业务逻辑都混在一起
  }
})

✅ 推荐方案

按业务模块拆分Store,每个Store职责单一

/**
 * 用户状态管理Store
 * @description 管理用户登录、个人信息等状态
 */
export const useUserStore = defineStore('user', () => {
  // 状态定义
  const userInfo = ref<UserInfo | null>(null)
  const isLoggedIn = ref(false)
  const permissions = ref<string[]>([])

  /**
   * 用户登录
   * @param credentials 登录凭证
   * @returns Promise<boolean> 登录是否成功
   */
  const login = async (credentials: LoginCredentials): Promise<boolean> => {
    try {
      const response = await authApi.login(credentials)
      userInfo.value = response.data.user
      isLoggedIn.value = true
      permissions.value = response.data.permissions
      return true
    } catch (error) {
      console.error('登录失败:', error)
      return false
    }
  }

  /**
   * 用户登出
   */
  const logout = () => {
    userInfo.value = null
    isLoggedIn.value = false
    permissions.value = []
  }

  return {
    userInfo: readonly(userInfo),
    isLoggedIn: readonly(isLoggedIn),
    permissions: readonly(permissions),
    login,
    logout
  }
})

💡 核心要点

  • 单一职责:每个Store只管理一个业务领域的状态
  • 类型安全:使用TypeScript定义清晰的状态类型
  • 只读暴露:使用readonly包装状态,防止外部直接修改

🎯 实际应用

在组件中使用多个Store协同工作

<template>
  <div class="dashboard">
    <UserProfile v-if="userStore.isLoggedIn" />
    <ProductList :products="productStore.products" />
    <CartSummary :items="cartStore.items" />
  </div>
</template>

<script setup lang="ts">
import { useUserStore } from '@/stores/user'
import { useProductStore } from '@/stores/product'
import { useCartStore } from '@/stores/cart'

// 使用多个Store
const userStore = useUserStore()
const productStore = useProductStore()
const cartStore = useCartStore()

// 组件挂载时初始化数据
onMounted(() => {
  if (userStore.isLoggedIn) {
    productStore.fetchProducts()
    cartStore.loadCart()
  }
})
</script>

2. 响应式计算属性:让数据自动更新

🔍 应用场景

需要基于现有状态计算派生数据,如购物车总价、用户权限判断等

❌ 常见问题

在组件中重复计算,或者手动维护派生状态

// ❌ 在组件中重复计算
const totalPrice = computed(() => {
  return cartItems.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
})

✅ 推荐方案

在Store中定义计算属性,实现数据的自动更新

/**
 * 购物车状态管理Store
 * @description 管理购物车商品、计算总价等
 */
export const useCartStore = defineStore('cart', () => {
  // 基础状态
  const items = ref<CartItem[]>([])
  const discountCode = ref<string>('')
  const shippingFee = ref<number>(0)

  /**
   * 计算购物车总数量
   */
  const totalQuantity = computed(() => {
    return items.value.reduce((sum, item) => sum + item.quantity, 0)
  })

  /**
   * 计算商品小计
   */
  const subtotal = computed(() => {
    return items.value.reduce((sum, item) => {
      return sum + (item.price * item.quantity)
    }, 0)
  })

  /**
   * 计算折扣金额
   */
  const discountAmount = computed(() => {
    if (!discountCode.value) return 0
    // 根据折扣码计算折扣
    return subtotal.value * 0.1 // 示例:10%折扣
  })

  /**
   * 计算最终总价
   */
  const totalPrice = computed(() => {
    return subtotal.value - discountAmount.value + shippingFee.value
  })

  /**
   * 添加商品到购物车
   * @param product 商品信息
   * @param quantity 数量
   */
  const addItem = (product: Product, quantity: number = 1) => {
    const existingItem = items.value.find(item => item.id === product.id)
    
    if (existingItem) {
      existingItem.quantity += quantity
    } else {
      items.value.push({
        id: product.id,
        name: product.name,
        price: product.price,
        quantity,
        image: product.image
      })
    }
  }

  return {
    items: readonly(items),
    totalQuantity,
    subtotal,
    discountAmount,
    totalPrice,
    addItem
  }
})

💡 核心要点

  • 自动更新:计算属性会在依赖的状态变化时自动重新计算
  • 缓存机制:只有依赖变化时才重新计算,提高性能
  • 链式依赖:计算属性可以依赖其他计算属性

🎯 实际应用

在组件中直接使用计算属性,无需关心计算逻辑

<template>
  <div class="cart-summary">
    <div class="item-count">商品数量:{{ cartStore.totalQuantity }}</div>
    <div class="subtotal">小计:¥{{ cartStore.subtotal.toFixed(2) }}</div>
    <div class="discount" v-if="cartStore.discountAmount > 0">
      折扣:-¥{{ cartStore.discountAmount.toFixed(2) }}
    </div>
    <div class="total">总计:¥{{ cartStore.totalPrice.toFixed(2) }}</div>
  </div>
</template>

<script setup lang="ts">
const cartStore = useCartStore()
</script>

3. 异步Action最佳实践:优雅处理API调用

🔍 应用场景

需要调用API获取数据、提交表单、处理异步操作时

❌ 常见问题

在组件中直接调用API,缺乏统一的错误处理和加载状态管理

// ❌ 在组件中直接调用API
const fetchProducts = async () => {
  loading.value = true
  try {
    const response = await api.getProducts()
    products.value = response.data
  } catch (error) {
    // 错误处理逻辑分散
  } finally {
    loading.value = false
  }
}

✅ 推荐方案

在Store中统一管理异步操作,包含加载状态和错误处理

/**
 * 商品状态管理Store
 * @description 管理商品列表、详情、搜索等功能
 */
export const useProductStore = defineStore('product', () => {
  // 状态定义
  const products = ref<Product[]>([])
  const currentProduct = ref<Product | null>(null)
  const loading = ref(false)
  const error = ref<string | null>(null)
  const searchKeyword = ref('')

  /**
   * 获取商品列表
   * @param params 查询参数
   * @returns Promise<void>
   */
  const fetchProducts = async (params?: ProductQueryParams): Promise<void> => {
    loading.value = true
    error.value = null
    
    try {
      const response = await productApi.getProducts(params)
      products.value = response.data.items
    } catch (err) {
      error.value = err instanceof Error ? err.message : '获取商品列表失败'
      console.error('获取商品列表失败:', err)
    } finally {
      loading.value = false
    }
  }

  /**
   * 搜索商品
   * @param keyword 搜索关键词
   */
  const searchProducts = async (keyword: string): Promise<void> => {
    searchKeyword.value = keyword
    await fetchProducts({ keyword })
  }

  /**
   * 获取商品详情
   * @param id 商品ID
   * @returns Promise<Product | null>
   */
  const fetchProductDetail = async (id: string): Promise<Product | null> => {
    loading.value = true
    error.value = null
    
    try {
      const response = await productApi.getProductDetail(id)
      currentProduct.value = response.data
      return response.data
    } catch (err) {
      error.value = err instanceof Error ? err.message : '获取商品详情失败'
      console.error('获取商品详情失败:', err)
      return null
    } finally {
      loading.value = false
    }
  }

  /**
   * 清除错误状态
   */
  const clearError = () => {
    error.value = null
  }

  // 过滤后的商品列表
  const filteredProducts = computed(() => {
    if (!searchKeyword.value) return products.value
    return products.value.filter(product => 
      product.name.toLowerCase().includes(searchKeyword.value.toLowerCase())
    )
  })

  return {
    products: readonly(products),
    currentProduct: readonly(currentProduct),
    loading: readonly(loading),
    error: readonly(error),
    filteredProducts,
    fetchProducts,
    searchProducts,
    fetchProductDetail,
    clearError
  }
})

💡 核心要点

  • 统一错误处理:在Store中集中处理API错误
  • 加载状态管理:提供loading状态给组件使用
  • 错误恢复机制:提供清除错误的方法

🎯 实际应用

在组件中使用Store的异步方法

<template>
  <div class="product-list">
    <div v-if="productStore.loading" class="loading">加载中...</div>
    <div v-else-if="productStore.error" class="error">
      {{ productStore.error }}
      <button @click="productStore.clearError">重试</button>
    </div>
    <div v-else class="products">
      <ProductCard 
        v-for="product in productStore.filteredProducts" 
        :key="product.id"
        :product="product"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
const productStore = useProductStore()

// 组件挂载时获取数据
onMounted(() => {
  productStore.fetchProducts()
})
</script>

4. Store组合与依赖:实现Store间的协作

🔍 应用场景

不同Store之间需要相互依赖,如用户登录后需要加载个人数据

❌ 常见问题

在组件中手动协调多个Store的交互,导致逻辑复杂

// ❌ 在组件中协调Store交互
const login = async () => {
  const success = await userStore.login(credentials)
  if (success) {
    await cartStore.loadUserCart()
    await orderStore.loadUserOrders()
    await notificationStore.loadNotifications()
  }
}

✅ 推荐方案

在Store内部处理依赖关系,实现自动化的协作

/**
 * 用户Store - 处理与其他Store的协作
 */
export const useUserStore = defineStore('user', () => {
  const userInfo = ref<UserInfo | null>(null)
  const isLoggedIn = ref(false)

  /**
   * 用户登录 - 自动触发相关数据加载
   * @param credentials 登录凭证
   */
  const login = async (credentials: LoginCredentials): Promise<boolean> => {
    try {
      const response = await authApi.login(credentials)
      userInfo.value = response.data.user
      isLoggedIn.value = true

      // 登录成功后自动加载用户相关数据
      await loadUserRelatedData()
      return true
    } catch (error) {
      console.error('登录失败:', error)
      return false
    }
  }

  /**
   * 加载用户相关数据
   * @description 登录后自动加载购物车、订单等数据
   */
  const loadUserRelatedData = async (): Promise<void> => {
    if (!isLoggedIn.value) return

    // 获取其他Store实例
    const cartStore = useCartStore()
    const orderStore = useOrderStore()
    const notificationStore = useNotificationStore()

    // 并行加载用户数据
    await Promise.allSettled([
      cartStore.loadUserCart(),
      orderStore.loadUserOrders(),
      notificationStore.loadNotifications()
    ])
  }

  /**
   * 用户登出 - 清理所有相关数据
   */
  const logout = (): void => {
    userInfo.value = null
    isLoggedIn.value = false

    // 清理其他Store的用户数据
    const cartStore = useCartStore()
    const orderStore = useOrderStore()
    const notificationStore = useNotificationStore()

    cartStore.clearCart()
    orderStore.clearOrders()
    notificationStore.clearNotifications()
  }

  return {
    userInfo: readonly(userInfo),
    isLoggedIn: readonly(isLoggedIn),
    login,
    logout,
    loadUserRelatedData
  }
})

/**
 * 购物车Store - 响应用户状态变化
 */
export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])
  const userStore = useUserStore()

  /**
   * 加载用户购物车
   */
  const loadUserCart = async (): Promise<void> => {
    if (!userStore.isLoggedIn) return

    try {
      const response = await cartApi.getUserCart()
      items.value = response.data.items
    } catch (error) {
      console.error('加载购物车失败:', error)
    }
  }

  /**
   * 清空购物车
   */
  const clearCart = (): void => {
    items.value = []
  }

  /**
   * 监听用户登录状态变化
   */
  watch(
    () => userStore.isLoggedIn,
    (isLoggedIn) => {
      if (isLoggedIn) {
        loadUserCart()
      } else {
        clearCart()
      }
    }
  )

  return {
    items: readonly(items),
    loadUserCart,
    clearCart
  }
})

💡 核心要点

  • 自动化协作:在Store内部处理依赖关系,减少组件复杂度
  • 响应式监听:使用watch监听其他Store的状态变化
  • 错误隔离:使用Promise.allSettled避免单个请求失败影响整体

🎯 实际应用

组件中只需要调用主要操作,相关数据会自动加载

<template>
  <div class="login-form">
    <form @submit.prevent="handleLogin">
      <input v-model="credentials.username" placeholder="用户名" />
      <input v-model="credentials.password" type="password" placeholder="密码" />
      <button type="submit" :disabled="loading">登录</button>
    </form>
  </div>
</template>

<script setup lang="ts">
const userStore = useUserStore()
const loading = ref(false)
const credentials = reactive({
  username: '',
  password: ''
})

/**
 * 处理登录
 */
const handleLogin = async (): Promise<void> => {
  loading.value = true
  try {
    const success = await userStore.login(credentials)
    if (success) {
      // 登录成功,相关数据会自动加载
      await router.push('/dashboard')
    }
  } finally {
    loading.value = false
  }
}
</script>

5. 持久化存储:让状态在刷新后保持

🔍 应用场景

需要在页面刷新或重新打开时保持某些状态,如用户登录信息、购物车数据等

❌ 常见问题

手动处理localStorage的读写,容易出现数据不同步的问题

// ❌ 手动处理localStorage
const saveToStorage = () => {
  localStorage.setItem('userInfo', JSON.stringify(userInfo.value))
}

const loadFromStorage = () => {
  const stored = localStorage.getItem('userInfo')
  if (stored) {
    userInfo.value = JSON.parse(stored)
  }
}

✅ 推荐方案

使用Pinia插件实现自动持久化,或自定义持久化逻辑

/**
 * 持久化配置接口
 */
interface PersistConfig {
  key: string
  storage: Storage
  paths?: string[]
}

/**
 * 创建持久化Store
 * @param id Store ID
 * @param storeSetup Store设置函数
 * @param persistConfig 持久化配置
 */
const createPersistedStore = <T>(
  id: string,
  storeSetup: () => T,
  persistConfig: PersistConfig
) => {
  return defineStore(id, () => {
    const store = storeSetup()

    /**
     * 从存储中恢复状态
     */
    const restoreFromStorage = (): void => {
      try {
        const stored = persistConfig.storage.getItem(persistConfig.key)
        if (stored) {
          const data = JSON.parse(stored)
          
          // 如果指定了特定路径,只恢复这些路径的数据
          if (persistConfig.paths) {
            persistConfig.paths.forEach(path => {
              if (data[path] !== undefined && store[path]) {
                store[path].value = data[path]
              }
            })
          } else {
            // 恢复所有状态
            Object.keys(data).forEach(key => {
              if (store[key] && store[key].value !== undefined) {
                store[key].value = data[key]
              }
            })
          }
        }
      } catch (error) {
        console.error('恢复状态失败:', error)
      }
    }

    /**
     * 保存状态到存储
     */
    const saveToStorage = (): void => {
      try {
        const dataToSave: Record<string, any> = {}
        
        if (persistConfig.paths) {
          // 只保存指定路径的数据
          persistConfig.paths.forEach(path => {
            if (store[path] && store[path].value !== undefined) {
              dataToSave[path] = store[path].value
            }
          })
        } else {
          // 保存所有响应式状态
          Object.keys(store).forEach(key => {
            if (store[key] && store[key].value !== undefined) {
              dataToSave[key] = store[key].value
            }
          })
        }
        
        persistConfig.storage.setItem(
          persistConfig.key,
          JSON.stringify(dataToSave)
        )
      } catch (error) {
        console.error('保存状态失败:', error)
      }
    }

    // 初始化时恢复状态
    restoreFromStorage()

    // 监听状态变化并自动保存
    if (persistConfig.paths) {
      persistConfig.paths.forEach(path => {
        if (store[path]) {
          watch(
            store[path],
            () => saveToStorage(),
            { deep: true }
          )
        }
      })
    } else {
      // 监听所有状态变化
      Object.keys(store).forEach(key => {
        if (store[key] && store[key].value !== undefined) {
          watch(
            store[key],
            () => saveToStorage(),
            { deep: true }
          )
        }
      })
    }

    return {
      ...store,
      $persist: {
        save: saveToStorage,
        restore: restoreFromStorage
      }
    }
  })
}

/**
 * 用户Store - 带持久化
 */
export const useUserStore = createPersistedStore(
  'user',
  () => {
    const userInfo = ref<UserInfo | null>(null)
    const isLoggedIn = ref(false)
    const preferences = ref({
      theme: 'light',
      language: 'zh-CN'
    })

    /**
     * 登录
     */
    const login = async (credentials: LoginCredentials): Promise<boolean> => {
      try {
        const response = await authApi.login(credentials)
        userInfo.value = response.data.user
        isLoggedIn.value = true
        return true
      } catch (error) {
        console.error('登录失败:', error)
        return false
      }
    }

    /**
     * 登出
     */
    const logout = (): void => {
      userInfo.value = null
      isLoggedIn.value = false
    }

    /**
     * 更新用户偏好设置
     */
    const updatePreferences = (newPreferences: Partial<UserPreferences>): void => {
      preferences.value = { ...preferences.value, ...newPreferences }
    }

    return {
      userInfo,
      isLoggedIn,
      preferences,
      login,
      logout,
      updatePreferences
    }
  },
  {
    key: 'user-store',
    storage: localStorage,
    paths: ['userInfo', 'isLoggedIn', 'preferences'] // 只持久化这些状态
  }
)

💡 核心要点

  • 自动同步:状态变化时自动保存到存储
  • 选择性持久化:可以指定只持久化某些状态
  • 错误处理:处理存储读写可能出现的异常

🎯 实际应用

使用持久化Store,状态会在页面刷新后自动恢复

<template>
  <div class="app">
    <div v-if="userStore.isLoggedIn">
      欢迎回来,{{ userStore.userInfo?.name }}!
    </div>
    <div class="theme-switcher">
      <button @click="toggleTheme">
        切换到{{ userStore.preferences.theme === 'light' ? '深色' : '浅色' }}主题
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
const userStore = useUserStore()

/**
 * 切换主题
 */
const toggleTheme = (): void => {
  const newTheme = userStore.preferences.theme === 'light' ? 'dark' : 'light'
  userStore.updatePreferences({ theme: newTheme })
}

// 应用主题
watchEffect(() => {
  document.documentElement.setAttribute('data-theme', userStore.preferences.theme)
})
</script>

📊 技巧对比总结

技巧 使用场景 优势 注意事项
模块化Store设计 大型项目状态管理 职责清晰、易维护 避免过度拆分
响应式计算属性 派生数据计算 自动更新、性能优化 避免副作用
异步Action最佳实践 API调用处理 统一错误处理、状态管理 合理的错误恢复机制
Store组合与依赖 Store间协作 自动化协作、减少组件复杂度 避免循环依赖
持久化存储 状态保持 用户体验好、数据不丢失 注意存储容量限制

🎯 实战应用建议

最佳实践

  1. 模块化设计:按业务领域拆分Store,保持单一职责原则
  2. 类型安全:使用TypeScript定义清晰的状态和方法类型
  3. 错误处理:在Store中统一处理API错误和异常情况
  4. 性能优化:合理使用计算属性和响应式特性
  5. 持久化策略:根据业务需求选择合适的持久化方案

性能考虑

  • 避免在Store中存储大量数据,考虑分页和虚拟滚动
  • 使用computed而不是watch来处理派生数据
  • 合理使用readonly包装状态,防止意外修改
  • 在组件卸载时清理不必要的监听器

💡 总结

这5个Pinia状态管理技巧在日常Vue3开发中能显著提升代码质量和开发效率,掌握它们能让你的状态管理:

  1. 模块化设计:让代码结构更清晰,维护更容易
  2. 响应式计算:实现数据的自动更新和性能优化
  3. 异步处理:统一管理API调用和错误处理
  4. Store协作:实现自动化的数据协调和同步
  5. 持久化存储:提供更好的用户体验和数据保持

希望这些技巧能帮助你在Vue3项目中写出更优雅、更高效的状态管理代码!


🔗 相关资源


💡 今日收获:掌握了5个Pinia状态管理实战技巧,这些知识点在Vue3项目开发中非常实用。

如果这篇文章对你有帮助,欢迎点赞、收藏和分享!有任何问题也欢迎在评论区讨论。 🚀

分享链接格式不统一,rel="share-url" 提案试图解决这个问题

01 引言:当前分享机制的混乱情况

在如今的互联网世界中,内容分享已成为一种近乎本能的行为。我们看到一篇有趣的文章、一段精彩的视频,或是一则自己认为比较重要的新闻,往往第一时间就想将其分享给朋友或社交平台。然而,在这看似简单的点击动作背后,却有着混乱且割裂的问题。

目前,内容网站通常通过在页面底部放置一系列社交图标来提供分享功能。每一个图标背后,都对应着一个完全不同结构的分享链接。有些平台只允许通过 URL 分享,有些则支持附加标题或描述文字,还有一些则仅提供一个文本输入框,由用户自行组织内容。这些不一致的方案不仅增加了开发者的维护成本,也为用户的分享行为增加了操作难度。

更令人困扰的是,这些分享机制缺乏统一的标准。每一个社交网络都独立设计其分享接口,导致参数名称、编码方式、甚至 HTTP 方法都各不相同。这使得第三方工具或浏览器难以提供一个一致的、流畅的分享体验。

02 rel="share-url 提案的背景与动机

面对当前分享机制各自为政的混乱局面,寻求一种标准化解决方案的呼声日渐高涨。正是在这样的背景下,一项由开发者本·沃德马勒(Ben Werdmuller)提出的“Share Openly”倡议进入了公众视野,其核心思想是引入一个名为 rel="share-url" 的 HTML 链接关系类型,为暴露分享意图提供一个简单、机器可读的统一格式。

该提案的动机并非要推翻或取代各大平台现有的分享接口,而是以一种“适配器”的思维,为这些分散的接口提供一个共同的、可被自动发现的描述层。其核心逻辑非常直观:如果每个网页都能在其<head>区域通过一个标准的<link>标签,明确地指出其首选的或平台专用的分享端点 URL,那么浏览器、扩展程序乃至第三方分享服务就能自动识别并利用这些信息,从而动态地构建出分享菜单,无需再依赖网站开发者手动维护一套又一遍重复的社交图标按钮。

从技术实现上看,该提案巧妙地借鉴了模板变量的概念。比如,可以通过嵌入这样一行代码:<link rel="share-url" href="https://www.facebook.com/sharer.php?u={url}" />,来声明其 Facebook 分享链接的格式。这里的{url}是一个占位符,在实际分享时会被替换成当前页面的实际 URL。同样,对于支持标题和链接的平台,可以使用{text}{url}两个变量来灵活适配。这种设计既尊重了不同平台 API 的多样性,又通过极简的约定实现了初步的统一。

03 该提案的具体实现方式

rel="share-url" 提案的精妙之处在于其实现的灵活性与包容性,它并未试图用一刀切的方式强行统一所有平台,而是通过一套简洁的模板语法来适应现实中千差万别的分享接口。这种设计使得从传统的中心化社交网络到时下流行的去中心化社交平台,都能以一种低成本的方式融入未来的标准化分享生态。

该提案通过两个核心的模板变量{url}{text}来应对不同平台的参数需求。对于那些仅支持通过链接分享的平台,例如 LinkedIn,网站只需在 <head> 中声明一个包含 {url} 变量的链接模板即可。当用户触发分享时,用户代理(如浏览器或扩展程序)会自动将当前页面的网址编码并填入模板中,无缝跳转到目标平台的分享界面。

<link rel="share-url" href="https://www.linkedin.com/sharing/share-offsite/?url={url}">

而对于像 Facebook 或 Reddit 这类同时支持链接与预填文本的平台,实现方式则更为全面。开发者可以构造一个同时包含 {url}{text} 的完整链接模板。在实际操作中,{text} 通常会被替换为当前页面的标题(document.title)或其他元数据,从而生成一个预填充了标题和链接的分享草案,极大地提升了用户的分享效率。

<link rel="share-url" href="https://www.facebook.com/sharer.php?u={url}&t={text}">
<link rel="share-url" href="https://lemmy.world/create_post?url={url}&title={text}">

最具挑战性的,或许是处理像 Mastodon 或 Bluesky 这样仅提供一个自由格式文本框的平台。它们的分享接口通常只接受一个单一的 text 参数。对此,当前的提案建议采取一种务实的策略:将 {text} 变量替换为一个由页面标题和实际网址拼接而成的字符串。虽然是一种妥协,但也确保了分享功能在各大平台都基本可用,为未来的优化留下了空间。

<link rel="share-url" href="https://mastodon.social/share?text={text}%0A{url}">
<link rel="share-url" href="https://bsky.app/intent/compose?text={text}">

参考链接

[1]shkspr.mobi/blog/2025/0…

[2]news.ycombinator.com/item?id=449…

教你如何用 JS 实现一个 Agent 系统(1)—— 认识 Agentic System

一、前言

大家好,我是唐某人~ 我想是通过分享 AI 的相关经验,帮助更多的前端工程师或者更多的开发岗位能够通俗易懂的学会 AI 应用开发的相关技能。

在上一篇《前端仔如何在公司搭建 AI Review 系统》中,我分享了如何实现简易版 AI Code Review 的内容。该篇得到官方“一周精选”的认可和众多公众号的转发。

看这篇文章的同学,我默认是对 AI 应用相关的基础概念有一个基本的认知了,如果你还缺乏这块的知识。可以先看这篇《AI 应用开发入门:前端也可以学习 AI》。想学更多的 AI 应用开发、进阶相关的内容,可以关注我的专栏,这里会持续的更新。

1.1 内容结构

在接下的 2 ~ 3 篇的文章里,会给大家分享 Agent 相关的内容。由于内容较多,暂时决定分为 2~ 3 篇来发布。核心可能会讲三块内容:

  • Agent 系统的定义、组成结构、运行机制(偏理论 + 轻实战)
  • Agent 系统的设计模式、设计原则、与工具、MCP 的关系(偏偏理论 + 轻实战)
  • 设计一个深度研究 Agent 系统,可以自主研究学术、股票、新闻等(篇实战,较为复杂的 Agent 应用)

1.2 本篇核心

本篇将会给大家分享第一部分的内容。核心是让你的脑海中对 Agentic System 有一个基本概念,能够看懂市面上一些 Agent 产品的门道,同时也能够自己开发一些小玩意。

本篇你将会学习到:

  • Agent 产品是怎么设计的
  • 什么是 Agentic System
  • 什么是 workflow 工作流
  • 什么是 Agent 智能体

二、Agent 产品

这里我们先看一下,有哪些是 Agent 产品。

  1. Cusror 和 Trae:它们主要解决编程领域的问题。

  1. 豆包、元宝、通义等,它们的能力范围就会更广和更通用。
  2. B 端的一些金融、医疗、企业知识库、智能客服等

  1. AI PPT 、文档创作类产品

上面列举的产品其实都属于 Agentic System。那到底什么是 Agentic System?

三、Agentic System

3.1 定义

在 Anthropic 的官方文档中,一篇如何构建 Agent 的文章《Building effective agents》有提到 Agentic System 到的定义:

"Agent" can be defined in several ways. Some customers define agents as fully autonomous systems that operate independently over extended periods, using various tools to accomplish complex tasks. Others use the term to describe more prescriptive implementations that follow predefined workflows. At Anthropic, we categorize all these variations as agentic systems, but draw an important architectural distinction between workflows and agents:

“智能体”可以有多种定义方式。一些用户将智能体定义为完全自主的系统,这些系统能够长时间独立运行,并使用各种工具来完成复杂任务。另一些人则用这个术语来描述更具规定性的实现,它们遵循预定义的工作流程。在Anthropic,我们将所有这些变体归类为智能体系统,但在架构上对工作流程和智能体进行了重要区分:

  • Workflows are systems where LLMs and tools are orchestrated through predefined code paths.
  • 工作流是通过预定义代码路径编排大语言模型和工具的系统。
  • Agents, on the other hand, are systems where LLMs dynamically direct their own processes and tool usage, maintaining control over how they accomplish tasks.
  • 另一方面,智能体是这样一种系统,其中大语言模型(LLMs)动态引导自身流程和工具使用,对完成任务的方式保持控制。

简单来说就是,Agentic System(agents 系统)就是由一个或多个 agent(智能体) + 一种或者多种 workflow(工作流)组成的一个能够理解用户需求,规划解决方案,自主决策,解决用户问题的系统。

画板

通俗点的理解,你可以把 agent 理解成一个个特定的岗位(需求,开发,设计),workflow 理解成工作办公流程(需求确定 --> 团队分工 --> 设计UI --> 前后端开发 -->测试上线),由这些岗位按照工作流程来工作的团队就是 Agentic System。

3.2 案例分析

Agent?Workflow?组成的系统? 这三个概念到底是啥?这里分析一个 Cursor 解决问题的案例,咱们先宏观的感受一下 Agentic System 是怎么解决问题的。

我让 Cursor 迁移项目中的一个页面模块,通过对话信息可以看到:

  1. 它通过分析,发现我提的可能是一个复杂问题
  2. 于是开展工作前,它生成一份清晰的计划方案
  3. 接着它开始按照计划,分步或者并行的执行任务(代码查找、文档搜索)
  4. 搜集完明确的信息后,开始逐个的修改和创建文件
  5. 最后它会对每个修改过的文件进行测试和检查

上面看似是一次对话,然后 Cuscor 自己一个人在那里跑,其实它背后有很多的门道。不知道有没有同学好奇过,为什么有时候在使用 Cursor 或者 Trae 的时候,明明跟它只是一次对话,却消耗了多次的“高级请求”。

其实是因为它们作为一个复杂的 Agentic System,在处理问题的过程中,通常是多个 Agent 配合完成的。作为用户视角,你只是一次对话,但是在系统内部的就可能执行了多个复杂的流程。

这里我画一个简单的流程图(这个流程图并非真实的 Cursor 推理流程,只是根据当前场景的一个推断)

  1. 用户输入
  2. 问题判断 Agent:识别问题类型,引导到不同的流程上
  3. 需求澄清 Agent:可能会跟用户多轮沟通,直到确定用户的需求
  4. 计划 Agent:根据“需求澄清 Agent”和用户聊天的上下文,分析并生成一份执行计划
  5. 执行 Agent:根据计划执行各种任务,这里面可能会有各种 Agent(代码检索、代码编写等)
  6. 代码审查 Agent:在将代码真正写入用户文件时,会不断检测代码是否存在问题,当满足要求时才会写入

画板

上面就是 Agent + Workflow 的一个大致工作流程。说了这么多,那 Agent 和 Worflow 到底是什么东西?

四、Agent

4.1 定义

对于 Agent 的认知,我认为可以分为两个层面来看

  • 抽象定义:Agent 是一个能够明确目标,自主规划,感应环境,基于目标解决问题的实例
  • 代码定义:就是一个 LLM + 提示词 + 工具 + Memony 组成的程序

下面我们通过实现一个简易版的旅游规划大师,来感受下 Agent 解决问题的过程。

4.2 增强版 LLM

我们先在 LLM API 的基础上扩展一下,让它支持设定系统提示词工具调用以及对话记忆,这些基础能力对于 Agent 非常重要。至于为什么,在实现完这个 Agent 以后再讲。

完成的代码: 代码地址

属性配置

实现一个 Block 类,支持配置提示词指令、工具、模型参数等

/**
 * 增强的 LLM 实例
 * 具备检索、工具调用、记忆的核心能力
 */
export class Block {
  private baseUrl: string;
  private modelName: string;
  private apiKey: string;
  private instruction: string;
  private tools: Tool[] = [];

  constructor(props: BlockProps) {
    this.baseUrl = props.baseUrl;
    this.modelName = props.modelName;
    this.apiKey = props.apiKey;
    this.instruction = props.instruction;
    this.tools = props.tools || [];
  }
}

对话记忆

接着实现对话的记忆能力。这里要实现一个 messages 管理对话的信息以及 invoke 方法,在调用 invoke时,会把用户的输入和 LLM 的回复都记录下来。

export class Block {
  // 记录对话信息(上下文)
  private messages: Message[] = [];
  
  /** 省略 …… */
  async invoke(query?: string) {
    // 记录用户输入
    if (query) {
      this.messages.push({
        role: 'user',
        content: query
      });
    }

    // 获取 LLM 的回复
    const assistantMessage = await fetch(/** 调用 LLM 的 API */)

    // 记录 LLM 的回复
    if (assistantMessage) {
      this.messages.push({
        role: 'assistant',
        content: assistantMessage
      });
    }
  }
}

工具调用

然后就是工具调用的能力。它的核心逻辑就是在收到 LLM 的工具调用指令后,能够找到工具执行,并将执行的结果添加到 messages 对话上下文中,然后再触发一次新的对话。

export class Block {
  /** 省略 …… */
  async invoke(query?: string) {
    // 记录用户输入
    if (query) {
      this.messages.push({
        role: 'user',
        content: query
      });
    }

    // 获取 LLM 的回复
    const res = await fetch(/** 调用 LLM 的 API */)

    const assistantMessage = '' // 处理 res 得到 LLM 的文本回复
    const functionName = '' // 处理 res 得到 LLM 想调用的工具名
    const functionArguments = '' // 处理 res 得到 LLM 的调用参数

    // 记录 LLM 的回复
    if (assistantMessage) {
      this.messages.push({
        role: 'assistant',
        content: assistantMessage
      });
    }


    // 如果 LLM 回复的是工具调用指令,则找到工具执行,并自动再出发一次 LLM 的对话
    if (functionName && functionArguments) {
      const tool = this.tools.find(tool => tool.function.name === functionName);
      if (tool) {
        const args = JSON.parse(functionArguments);
        const result = await tool.func(args);

        console.log(`${functionName} 工具调用参数 --> ${functionArguments}`);
        console.log(`${functionName} 工具调用结果 --> ${result}`);

        this.messages.push({
          role: 'tool',
          content: result,
          tool_call_id
        });

        // 自执行一次
        await this.invoke();
      }
    }
  }
}

4.3 Agent 实践

下面我们创建旅行规划大师。先定义提示词,作为指导 Agent 完成任务的重要信息

const prompt = `
你是一个旅行规划大师,你职责是根据根用户输入的旅游目的地和具体的日期,结合那天的天气推介出最适合游玩的景点。

你可以调用以下工具,获取详细的信息:
1. get_current_weather:查询指定城市在指定日期的天气
2. get_recommended_attractions:根据城市的名称、天气获取推荐的旅游景点

注意,这两个工具不允许同时调用,一次只能调用一个工具。你必须在拥有详细的且能够自信回答的信息后,才能告诉用户推荐的景点信息。
`;

然后是给它配置工具

const tools: Tool[] = [
  {
    type: 'function',
    function: {
      name: 'get_current_weather',
      description: '查询指定城市在指定日期的天气',
      parameters: {
        type: 'object',
        properties: {
          location: {
            type: 'string',

            description: '城市名称'
          },
          date: {
            type: 'string',
            description: '日期'
          }
        },
        required: ['location', 'date']
      }
    },
    func: async (args: { [key: string]: any }) => {
      console.log(args);
      // 这里实现获取天气的具体逻辑
      return `获取到${args.location}的天气数据为: 天晴 30度`;
    }
  },
  {
    type: 'function',
    function: {
      name: 'get_recommended_attractions',
      description: '根据城市的名称、天气获取推荐的旅游景点',
      parameters: {
        type: 'object',
        properties: {
          location: {
            type: 'string',
            description: '城市名称'
          },
          weather: {
            type: 'string',
            description: '当地天气'
          }
        },
        required: ['location', 'weather']
      }
    },
    func: async (args: { [key: string]: any }) => {
      console.log(args);
      // 这里实现获取日期的具体逻辑
      return `获取到${args.location}的推荐景点为: 广州塔、珠江新城、黄埔军校`;
    }
  }
];

最后是创建实例

export const travelAgent = new Block({
  baseUrl: process.env.BASE_URL!,
  modelName: process.env.MODEL_NAME!,
  apiKey: process.env.API_KEY!,
  instruction: prompt,
  tools: tools,
  processWrite: true
});

先来问一个广州有啥好玩的(案例中我是用的是deepseek的推理模型,所以白色文字是它的思考过程,绿色文字才是真实的回答)

LLM 发现缺乏日期信息,所以它开始向我咨询

于是我补充了具体的日期。在补充信息后,它调用了获取天气的工具,得到了指定日期的天气情况。

有了天气的信息,它开始自己继续下一步规划,调用了获取推荐景点的工具。在得推荐景点后,它给出了我完整的答复。

上面就是一个简单的 Agent 自主思考、规划、决策的解决任务的过程。它成功的告诉了在广州9月1号有啥好玩的地方。

4.4 运行机制

基于上面的案例,我们现在来讨论,为什么开发一个 Agent 必须要具备提示词设定、工具调用、对话记忆的核心基础能力。

前面有说到,从抽象定义来看,Agent 必须具备**明确目标,自主规划,感应环境 **的基本特性。这些特性就是由这三个核心能力支持的。

明确目标

基于 提示词设定 职责的设定,结合用户问题的输入,Agent 就能知道要完成的目标是什么以及推理出达成目标所需的必要条件。

比如第一轮对话 Agent 知道了用户的目标是想得到广州的推荐景点信息,所以它要知道是哪天去

画板

自主规划

在用户给出了具体日期后,基于记忆能力它能够知道过去聊了什么,对话中积累了哪些关键信息,再配合上现有的工具能力,就可以规划出后续每一步需要做什么。

例如案例中,结合用户回复的日期,LLM 决策了要调用哪些工具,我们要做的就是在程序中就会依次的找到工具并执行。

画板

这里提醒一下,因为我怕有些同学忘记了。在《AI 应用开发入门:前端也可以学习 AI》中我有提到过,LLM 本身是无状态的,也是它不会记录你之前的聊天信息。但是,它之所能记得之前聊了什么,是因为我们在代码里面维护了所有的聊天信息、工具调用结果,并且在每一次新的对话中,会全部的重新传给他。

下面这个图来自 deepseek 官网,它描述了对话中,上下文拼接的过程。每次对话都会把过去的所有对话信息,重新传给 LLM。

感应环境

那什么是感应环境呢?

首先来说一下“环境”。你可以理解为你代码的执行环境。例如 LLM 回复了调用工具的指令,所以我们需要在代码的执行环境里调用工具,帮它查询所需要的信息。

“感应”其实就把工具查询的结果,再丢进对话的上下文里再传给 LLM,让 LLM 知道工具实际调用的情况。此时 LLM 结合用户输入的信息、工具调用的信息,就可以判断出是否满足解答问题的条件。

画板

4.5 与传统软件的区别

现在我们来探讨一个问题:Agent 与传统软件有什么区别?

首先思考一下,传统软件解决问题的逻辑:一般情况下,你想得到想要的结果,就必须遵循固定的输入,然后按照程序固定的程序流程执行。

然而相比 Agent ,它的解决方式更像是一个能够自己寻找最短路径的“聪明人”,只要给定它目标以及能够解决该领域问题所需要用到的工具,它就会自己找到最优的“解”。

《12-factor-agents》有提到关于 Agent 和传统软件区别的概念,它认为传统软件解决问题的过程就是一个有向无环图,而 Agent 可以自己在里面做出决策找到解决路径

I'm not the first person to say this, but my biggest takeaway when I started learning about agents, was that you get to throw the DAG away. Instead of software engineers coding each step and edge case, you can give the agent a goal and a set of transitions

我并不是第一个这么说的人,但当我开始学习智能体时,最大的收获是你可以抛开有向无环图了。不再需要软件工程师编写每一个步骤和边缘情况,你只需给智能体一个目标和一系列转换规则即可

4.6 小结

这一章节重点讲述了关于 Agent 的三块内容

  1. Agent 的定义,可以从抽象层和代码层开看待
  2. Agent 的运行离不开系统提示词、工具的调用、对话记忆的基础核心能力
  3. Agent 与传统软件解决问题的方式区别在于,它不需要完全固定的编码,更像一个 “自主决策的聪明人”,只需给定目标和工具,即可自主寻找最优解决路径,无需硬编码每一步和边缘情况。

五、Workflow

5.1 定义

工作流本质就是按照固定的、可控的方式,组织各种 Agent 的一种方式。它存在的意义就是,让 AI 应用的逻辑流程是程序性可控的。其实就好比你在代码里面写 if/else/switch/for组织各种函数逻辑的调用是一样的。

我们传统程序的逻辑流程是:功能函数 + 条件判断 + 循环,而 AI 工作流其实也是如此,组成一个工作流的核心,通常有两个核心要素:

  • LLM 节点
  • 控制逻辑

下面我用代码演示两个要素。

LLM 节点

什么是叫 LLM 节点呢?在我的理解,它可以是一个执行简单任务的 LLM,也可以是一个具备自主决策能力的 Agent。下面我通过代码简单演示一下。

这是 LLM 的提示词,它任务是翻译技术文档

你是一个专业的技术文档翻译。你的任务是将英文的技术文档翻译成中文文档。
注意,一些专业的技术名词、代码不需要翻译,保持原来的英文和格式。

配合上代码

class LLMNode {
  private instruction: string;

  constructor(props) {
    this.instruction = props.instruction;
  }

  // 接受输入,调用 LLM 输出
  async invoke(query: string) {
    const res = await fetch(/* 调用 LLM API */)
    return res;
  }
}

const translationNode = new LLMNode({ instruction: '你是一个专业的技术文档翻译……' })

现在已经创建了一个用于翻译技术文档的 LLM 节点,在调用 invoke 时,它只会去做“翻译”这个事情。通常在需要 LLM 解决某一个问题的时候,我们会把解决步骤拆分到一个个单一职责的 LLM 节点上。例如,下面这个翻译英文文档,再总结提炼的工作流。

画板

为什么要将工作拆分给不同的、职责单一的 LLM 来完成任务呢?在 《12-Factor Agents - Principles for building reliable LLM applications》有提到过

As context grows, LLMs are more likely to get lost or lose focus

随着上下文增加,大型语言模型更容易迷失方向或分散注意力

Benefits of small, focused agents

小型、专注的智能体的优势:

  1. Manageable Context: Smaller context windows mean better LLM performance 可管理的上下文:更小的上下文窗口意味着更优的大模型性能
  2. Clear Responsibilities: Each agent has a well-defined scope and purpose 职责明确:每个智能体都有清晰界定的范围和目标
  3. Better Reliability: Less chance of getting lost in complex workflows 更高的可靠性:在复杂工作流程中迷失的可能性更低
  4. Easier Testing: Simpler to test and validate specific functionality 测试更简单:特定功能的测试和验证更为简便
  5. Improved Debugging: Easier to identify and fix issues when they occur 改进的调试功能:出现问题时更容易识别和修复

所以尽可能让 LLM 处理单一的任务,效率和效果会更加好的。开发者维护起来也会更加友好。

编排逻辑

那这些 LLM 节点是怎么被组织成工作流的呢?其实就是通过代码里面的 if/else/switch/for控制的。例如我们基于链式的工作流,实现一个翻译英文文档再总结提炼的工具

代码实现如下

const translationNode = new LLMNode(/* 翻译任务的 LLM */)
const summaryNode = new LLMNode(/* 分析总结任务的 LLM */) 

const chain = async (nodes) => {
  returun (input) => {
    let cur = input
    for(let node of nodes) {
      cur = await node.invoke(curInput)
    }
    return cur
  }
}

const tanslateChain = chain([translationNode, summaryNode])

chain('帮我总结一下这个文档……')

其实就是一个正常的循环顺序执行,说高级点就是函数式编程里面的 Compose 设计,将一组函数组合起来。

5.2 设计模式

在实际的 Agentic System 的开发中,也会像写传统软件一样,会用到多种设计模式来组织应用流程。在这篇《Building effective agents》文章中,Anthropic 总结了 5 种关于 Workflow 的设计模式。

Chain

链模式是将一个任务分解为一系列步骤,每次调用 LLM 都会接受上一步的输出。你可以在任何中间步骤添加编程检查(见下图中的 “卡控”),以确保该过程仍按计划进行。它一般比较适合串行处理场景,如文案创作、分析总结等

这里演示的是一个 “生成技术博客”的工具: 完整代码

实现逻辑

Anthropic 对这种工作流的点评是:

When to use this workflow: This workflow is ideal for situations where the task can be easily and cleanly decomposed into fixed subtasks. The main goal is to trade off latency for higher accuracy, by making each LLM call an easier task.

何时使用此工作流程: 此工作流程非常适合那些可以轻松、清晰地分解为固定子任务的情况。其主要目标是通过将每次大语言模型调用变为更简单的任务,以牺牲延迟来换取更高的准确性。

Routing

路由对输入进行分类,并将其导向专门的后续任务。这种工作流程有助于实现关注点分离,并构建更具针对性的提示。如果没有这种工作流程,针对某一类输入进行优化可能会损害模型在其他输入上的性能。

比较典型的场景,就是分类引导。例如客服助手的问题引导,还有像 Cursor 、Trae 它们判断输入需求的复杂度和类型,来决定是简单任务还是复杂任务,复杂任务就会走到所谓的“高级请求”(由更多 Agent、更复杂的 Workflow 组成的流程)里面。

这里演示的是一个客户问题引导的案例:完整代码

Anthropic 对这种工作流的点评是:

When to use this workflow: Routing works well for complex tasks where there are distinct categories that are better handled separately, and where classification can be handled accurately, either by an LLM or a more traditional classification model/algorithm.

何时使用此工作流程: 路由适用于复杂任务,这些任务存在不同的类别,且分开处理效果更佳,同时无论是通过大语言模型(LLM)还是更传统的分类模型/算法,都能准确进行分类。

Parallelization

大语言模型有时可以同时处理相同或者不同的任务,并通过编程方式汇总它们的输出。这种工作流程,有两个方式:

  • 切分:将一个任务分解为可并行运行的独立子任务。
  • 投票:多次运行同一任务以获得不同的输出,最终选择最合适的那个结果。

这里继续拿 Cursor、Trae 举例,有时候它们需要并行去查看多文件,从而得到一些关键信息来推进后续的步骤。

这里演示的是一个多角度分析师的案例,当你想发起一个技术专项的时候,可以通过多角度并行的去分析一下可行性,最后生成一个汇总分析报告:完整代码

Anthropic 对这种工作流的点评是:

When to use this workflow: Parallelization is effective when the divided subtasks can be parallelized for speed, or when multiple perspectives or attempts are needed for higher confidence results. For complex tasks with multiple considerations, LLMs generally perform better when each consideration is handled by a separate LLM call, allowing focused attention on each specific aspect.

何时使用此工作流程: 当划分后的子任务可以并行处理以提高速度,或者为了获得更可靠的结果需要从多个角度进行尝试时,并行化是有效的。对于需要多方面考量的复杂任务,通常当每个考量因素由单独的大语言模型调用处理时,大语言模型的表现会更好,这样可以专注于每个特定方面。

Orchestrator-workers

在编排器-执行器工作流程中,一个中央大语言模型会动态分解任务,将它们委托给执行器大语言模型,并综合其结果。

比较典型的例子就是 Cursor、Trae 在处理复杂任务的时候,它们会将任务拆分成不同的步骤,然后每个步骤交给一个子 Agent 或者工具去独立完成。

下面我演示一个代码查询的案例,用协调器 Agent 分派多个查询任务给子 Agent。

先实现一个协调器 Agent,它的任务是基于用户输入的计划拆分为多个子任务,并通过工具并行触发多个子 Agent 的流程

image.png 然后创建子 Agent

image.png 最后在执行的时候,它就分派三个查询任务给子 Agent image.png

image.png Anthropic 对这种工作流的点评是:

When to use this workflow: This workflow is well-suited for complex tasks where you can’t predict the subtasks needed (in coding, for example, the number of files that need to be changed and the nature of the change in each file likely depend on the task). Whereas it’s topographically similar, the key difference from parallelization is its flexibility—subtasks aren't pre-defined, but determined by the orchestrator based on the specific input.

何时使用此工作流程: 此工作流程非常适合那些无法预测所需子任务的复杂任务(例如,在编码中,需要更改的文件数量以及每个文件中更改的性质可能取决于任务本身)。尽管它在形式上与并行处理相似,但与并行处理的关键区别在于其灵活性——子任务不是预先定义的,而是由协调器根据特定输入来确定。

Evaluator-optimizer

在评估器-优化器工作流程中,一个大语言模型调用生成回复,而另一个则在循环中提供评估和反馈。

When to use this workflow: This workflow is particularly effective when we have clear evaluation criteria, and when iterative refinement provides measurable value. The two signs of good fit are, first, that LLM responses can be demonstrably improved when a human articulates their feedback; and second, that the LLM can provide such feedback. This is analogous to the iterative writing process a human writer might go through when producing a polished document.

何时使用此工作流程: 当我们有明确的评估标准,且迭代优化能带来可衡量的价值时,此工作流程尤为有效。适用的两个标志是:其一,当人类阐明反馈时,大语言模型(LLM)的回复能得到明显改进;其二,大语言模型能够提供此类反馈。这类似于人类作者在撰写一篇精良文档时可能经历的迭代写作过程。

4.3 小结

这里我再来小结一下关于 Workflow 的主要内容:

  1. Workflow 的定义:Workflow 是按照固定、可控的方式组织LLM节点和运行逻辑的设计模式,其核心要素包括LLM节点和代码控制的运行逻辑。
  2. Workflow的核心要素:Workflow的核心要素包括LLM节点和代码控制的运行逻辑。LLM节点是构成Workflow的基本单元,负责执行具体的任务;代码控制的运行逻辑则负责定义节点之间的执行顺序和条件。
  3. Workflow的设计模式
    • 链模式(Chain):将多个LLM节点按顺序连接起来,依次执行。
    • 路由模式(Routing):根据条件判断,将任务路由到不同的LLM节点执行。
    • 并行化(Parallelization):将多个LLM节点并行执行,提高效率。
    • 编排器-执行器(Orchestrator-workers):将任务分配给多个执行器并行处理,由编排器统一调度和管理。
    • 评估模式(Evaluator-optimizer):引入另一个 LLM 节点不断评估改善的模式

六、最后

这一章重点是给大家讲述如何 Agentic System 的理论知识,先让你对 Agent 应用的有一个基本的概念。下一章节,我会给大家分享关于 Agent 目前业界流行的一些设计方案和模式,以及它如何跟 MCP 协作的。

HTML5语义化标签详解

总裁您好,欢迎来到html5语义化航空,本文将为您提供从入门到登机的一站式服务

出发 --- 为什么选择语义化航空

语义化航空的优势:

  • 提升代码的可读性和可维护性

    • 当您看到<header></ header>这样的标签时,您会很流畅的明白“这一部分组成网页的头部”
  • 有利于搜索引擎优化(SEO)

    • 搜索引擎爬虫看到语义化标签后,它会立即明白页面结构和内容优先级
  • 减少对额外class和id的依赖

    • 无需额外使用class='nav'这样的形式来标识区块的作用
  • 增强网页的可访问性

    • 能帮助多种工具和辅助技术理解页面结构和内容关系

已到达 --- 机场大门

在您的左手边,是一份语义化航空站点大纲,我们一起来阅读一下

  • 🧑<header></ header>

    表示页面或section的头部,通常包含,标题、logo、导航栏等

  • 👣<nav></ nav>

    用于定义导航区域

  • 📇<main></ main>

    用于定义页面的主要内容

  • 📑<artical></ artical>

    用于定义可独立于页面且能够完整存在的内容,例如博客,评论等

  • 👥<section></ section>

    用于对页面内容进行分组,通常包含一个主题性的标题,适用于章节、板块等。

  • 💡<aside></ aside>

    主要用于和主要内容相关的辅助区域,例如章节大纲栏

  • 🦶🏻<footer></ footer>

    表示页面的尾部

  • 💿<figure></ figure>&<figcaption></ figcaption>

    前者用于包裹媒体内容(图片,图表,代码等) 后者用于给媒体内容提供标题

  • <time></ time>

    用于表示时间,datetime属性提供机器可读的时间'2022-10-09'

  • <mark></ mark>

    用于表示高亮文本

已到达 --- 安检区域

安检需检查您对于语义化网页可访问性高的理解,在经过安检之前,先为您提供一些指导,以便您能迅速通过安检

首先我们需要清楚为什么语义化标签能提升可访问性

  • 减少认知负担:对于残障人士常用的屏幕阅读器来说,语义化标签可以帮助它直接理解网页内容
  • 符合WCAG标准:W3C 可访问性指南(WCAG)明确要求 "信息和关系能被用户代理识别",语义标签是满足这一要求的基础。

提供一些语义化标签示例供您参考学习

  • header + nav
    • 明确标识页头和导航栏,可以帮助屏幕阅读器直接识别到导航区块
<header>
  <h1>网站标题</h1>
  <nav aria-label="主导航"> <!-- aria-label补充说明导航用途 -->
    <ul>
      <li><a href="/home">首页</a></li>
      <li><a href="/news">新闻</a></li>
    </ul>
  </nav>
</header>
  • section + h2~h6(h1最好在header中)
    • 每个section最好都要有一个h_i标签,并符合层级关系
<section>
  <h2>产品介绍</h2>
  <section>
    <h3>基础版</h3> <!-- 层级清晰,辅助工具可识别章节关系 -->
    <p>...</p>
  </section>
</section>
  • article
    • 表示独立完整的内容(如文章、评论、卡片),屏幕阅读器会将其识别为一个 "可独立访问的单元",用户可单独聚焦查看。
<article aria-labelledby="post-title"> <!-- 关联标题ID,增强语义 -->
  <h2 id="post-title">如何提升可访问性</h2>
  <p>...</p>
</article>
  • aside
    • 示与主内容相关的辅助信息(如侧边栏、注释、广告),辅助工具会将其与主内容区分开,用户可选择是否忽略。
<main>
  <article>...</article>
  <aside aria-label="相关推荐"> <!-- 说明辅助内容的用途 -->
    <h3>你可能还喜欢</h3>
    <ul>...</ul>
  </aside>
</main>

眼尖的总裁也许发现了,article和aside示例中,都有一个aria开头的属性,这个属性的作用是增强语义,或区分多个相同语义化标签

已到达 --- 候机平台

宽敞明亮的候机平台,随处可见各种指引路标,路标提供的便利不禁使我们联想起➡️语义化标签对于搜索引擎解析页面结构和定位内容提供的便利(SEO)

搜索引擎爬虫依赖标签的语义来解析页面结构,清晰的层级能帮助爬虫快速识别核心内容和次要内容。

1、<header> + 标题标签(h1-h6)

`<header>` 中的 `<h1>` 通常被视为页面的核心主题,爬虫会赋予其较高权重。后续内容通过 `<h2>`(章节)、`<h3>`(子章节)建立层级,形成 "主题 - 分支" 结构,让爬虫理解内容逻辑。



```html
<header>
  <h1>2024年最佳旅行背包推荐</h1> <!-- 页面核心主题 -->
</header>
<main>
  <section>
    <h2>如何选择旅行背包?</h2> <!-- 主要章节 -->
    <p>...</p>
    <section>
      <h3>容量选择指南</h3> <!-- 子章节 -->
      <p>...</p>
    </section>
  </section>
</main>
```
**注意**:一个页面建议只保留一个 `<h1>`(通常在 `<header>` 中),避免稀释核心主题权重。

2、使用<main>突出核心内容

<main> 能让爬虫直接识别 "这是页面最重要的内容",减少对冗余代码的解析成本。以及强化核心关键词与页面主题的联系

<main>
  <article>
    <h2>东京自由行全攻略</h2>
    <p>东京作为日本首都,拥有丰富的旅游资源...(包含"东京旅游"、"自由行攻略"等核心关键词)</p>
    <!-- 核心内容:景点推荐、交通指南、住宿建议等 -->
  </article>
</main>

3、使用 <article> 标记独立内容,提升内容价值识别

<article> 表示独立完整的内容(如博客文章、产品介绍、新闻报道),搜索引擎会将其视为 "具有独立价值的信息单元",更易被判定为高质量内容。

  1. 对于博客、资讯类网站,每篇文章用 <article> 包裹,有助于爬虫将其识别为单独的索引单元。
  2. 对于电商产品页,<article> 可包裹单个产品的详细信息,提升 "产品名称"、"特性" 等关键词的权重。
<article>
  <h2>Apple iPhone 15 Pro 评测:性能与摄影的突破</h2>
  <p>iPhone 15 Pro 搭载 A17 Pro 芯片,相机系统升级...(包含产品名、核心参数等关键词)</p>
</article>

4、使用<nav>标签,标记网站重要页面,提升搜索结果排名

<nav aria-label="主导航">
  <ul>
    <li><a href="/travel">旅行攻略</a></li>
    <li><a href="/equipment">户外装备</a></li>
    <li><a href="/community">旅游社区</a></li>
  </ul>
</nav>

5、<figure> + <figcaption> 优化媒体内容的 SEO

图片、视频等媒体内容通过 <figure> 包裹,并配合 <figcaption> 提供描述,能帮助搜索引擎理解媒体内容,提升图片搜索(如 Google Images)的曝光率。

<figure>
  <img src="tokyo-tower.jpg" alt="东京铁塔夜景">
  <figcaption>东京铁塔:建于1958年,高333米,是东京的标志性建筑之一</figcaption>
</figure>

6.  <time> 标签提升时间敏感内容的相关性

对于新闻、活动、教程等时间敏感的内容,<time> 标签的 datetime 属性提供标准化时间格式,帮助搜索引擎识别内容的时效性,提升在 "最新资讯" 类搜索中的排名。

<p>发布于 <time datetime="2024-08-20">2024年8月20日</time></p>
<p>活动时间:<time datetime="2024-09-15T10:00">2024年9月15日10:00</time></p>

正在经过检票口

总结一下,语义化标签,使得内容含义直接与标签绑定,提高了开发效率,提升网站在搜索中的排名,并且对于屏幕阅读器和其他辅助工具更加友好,是现代web中结构与语义分离的具体实践和体现

成功登机,准备起飞

欢迎总裁乘坐html5语义化航空00001号客机,请关闭手机,系好安全带,我们将在两小时后到达西安,开启新一轮的旅途

常见UI事件解析:Load/Unload、Error/Abort、Resize/Scroll、Select/DOMFocusIn等

在数字世界的角斗场里,用户界面事件就是掌控战场的将军。它们如同交响乐团的指挥棒,让浏览器这个精密仪器在用户的每一次点击、滑动、缩放间奏响完美的乐章。今天,我们就来揭开这些"幕后英雄"的神秘面纱,看看它们如何构建现代网页的交互魔法。

一、页面加载的生死时速(Load/Unload)

当用户输入网址的瞬间,load事件就像一位严谨的门卫,确保所有资源——从图片到字体——都整齐列队完毕才允许用户进入战场。而unload则是最后的清场警报,它在页面即将关闭时发出最后的挽歌。这两个事件常被用来做资源预加载和数据清理,但要注意:在unload中执行复杂操作可能导致浏览器崩溃,就像在暴风雨中修补船帆一样危险。

window.addEventListener('load', () => {
  console.log('所有资源加载完成,可以开始表演了!');
});

window.addEventListener('beforeunload', (e) => {
  e.preventDefault();
  e.returnValue = ''; // 提示用户确认离开
});

二、异常处理的守门员(Error/Abort)

在网页加载过程中,error事件是永不缺席的危机处理专家。无论是图片加载失败还是脚本解析错误,它都能及时拉响警报。而abort事件则像一个敏锐的观察者,当用户突然取消请求时(比如点击后退按钮),它会立即做出反应。这两个事件对提升用户体验至关重要,但要特别注意:过度使用error监听可能导致内存泄漏,就像给每个士兵都配发了永不断电的通讯器。

const img = new Image();
img.addEventListener('error', () => {
  console.log('图片加载失败,启动备用方案!');
  document.getElementById('fallback').style.display = 'block';
});

三、动态交互的魔术师(Resize/Scroll)

当用户调整浏览器窗口大小时,resize事件会像一个灵活的变形金刚,实时调整页面布局。而scroll事件则是永不停歇的观察者,它能精确捕捉用户浏览的每一个细微动作。这对组合在实现响应式设计和无限滚动时堪称完美搭档,但要记住:在resize回调中直接操作DOM可能导致性能灾难,就像让舞者穿着铁鞋跳舞。

window.addEventListener('resize', () => {
  requestAnimationFrame(() => {
    console.log('窗口尺寸变化为:', window.innerWidth);
    // 使用requestAnimationFrame优化性能
  });
});

document.addEventListener('scroll', () => {
  if (window.scrollY > 500) {
    showBackToTopButton(); // 显示返回顶部按钮
  }
});

四、用户行为的解码器(Select/DOMFocusIn)

select事件是文字领域的哨兵,当用户在输入框中选中文本时立即触发。而DOMFocusIn(现为focus)和DOMFocusOut(现为blur)这对双胞胎,则是跟踪用户注意力流动的绝佳工具。这些事件在表单验证和富文本编辑器开发中尤为重要,但要小心:过度监听可能导致用户输入卡顿,就像给每个字都加上繁琐的安检程序。

input.addEventListener('select', () => {
  const selectedText = input.value.substring(
    input.selectionStart, 
    input.selectionEnd
  );
  showTooltip(selectedText); // 显示选中文字的提示信息
});

五、被遗忘的古董(DOMActive)

曾经的明星事件DOMActive如今已成明日黄花,它在用户交互时短暂激活,但随着focuspointer事件的成熟,这个老古董早已退出历史舞台。现代开发中遇到它就像在博物馆看到蒸汽机车——值得了解,但不必使用。

六、移动端的隐藏武器(PageHide/PageShow)

在移动浏览器战场上,pagehidepageshow是开发者必须掌握的秘密武器。它们比传统的unload/load更精确地追踪页面可见状态,特别适合PWA应用开发。但要注意:这些事件在桌面浏览器支持度有限,使用时要准备好备选方案。

实战技巧

  1. 事件节流:对高频事件(如resize/scroll)使用debouncethrottle技术,就像给高速运转的引擎安装限速器。
  2. 性能监控:使用PerformanceObserver跟踪事件处理耗时,及时发现性能瓶颈。
  3. 事件委托:将多个元素的事件监听委托给父元素,减少内存占用。
  4. 兼容性处理:注意不同浏览器对事件的支持差异,特别是移动端特殊处理。

致命陷阱

  • 内存泄漏:忘记移除事件监听器会导致内存持续增长,就像漏水的水管。
  • 阻塞渲染:在事件处理中执行重计算操作会引发页面卡顿。
  • 过度监听:给每个元素都绑定事件会形成性能黑洞。

在这个充满挑战的前端战场,掌握UI事件就是掌握了与用户对话的密码本。从页面加载的瞬间到每一次滚动的细节,这些看似简单的事件背后,藏着无数提升用户体验的奥秘。当你下次编写事件监听器时,不妨想象自己正在指挥一场精密的数字交响乐——每个音符都恰到好处,每次互动都浑然天成。

开源!让二维变成三维立体雷达,提升项目的颜值

大家好,我是日拱一卒的攻城师不浪,致力于前沿科技探索,摸索小而美工作室,这是2025年输出的第42/100篇原创文章。

效果预览

www.bilibili.com/video/BV1L7…

前言

之前我们分享的都是平面雷达,总是感觉缺少一点立体感,或者说是科技感。

那今天我们就来聊聊如何在Cesium中实现一个立体的雷达扫描效果。

应用场景

立体雷达特效常见于以下应用场景:

  1. 军事指挥系统:显示雷达站的探测覆盖范围,这个在军工项目中基本上是必不可少的功能了

  1. 通信网络规划:可视化信号基站的覆盖区域
  2. 智慧城市:展示特定设施的服务覆盖区域
  3. 气象预报:显示气象雷达的探测范围

原理分析

这个雷达扫描效果的实现主要涉及以下几个技术要点:

  1. 几何体构建:使用Cesium实体系统创建半圆球体立体墙体

  2. 动态更新:通过Clock.onTick监听事件驱动扫描动画

  3. 坐标转换:在地理坐标和笛卡尔坐标间进行转换

  4. 数学计算:使用三角函数计算扫描扇区的点位

核心思路是通过不断更新墙体的位置来实现扫描动画,同时保持球体不变以表示覆盖范围。

代码详解

1. 球体和立体墙创建

addEntities() {
    let entity = this.viewer.entities.add({
      id: this.id,
      position: this.position,
      // 立体墙
      wall: {
        positions: new Cesium.CallbackProperty(() => {
          return Cesium.Cartesian3.fromDegreesArrayHeights(this.positionArr);
        }, false),
        material: new Cesium.Color.fromCssColorString("#00dcff82"),
        distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
          0.0,
          10.5e6
        ),
      },
      // 球体
      ellipsoid: {
        radii: new Cesium.Cartesian3(
          this.shortwaveRange,
          this.shortwaveRange,
          this.shortwaveRange
        ),
        maximumCone: Cesium.Math.toRadians(90),
        material: new Cesium.Color.fromCssColorString("#00dcff82"),
        outline: true,
        outlineColor: new Cesium.Color.fromCssColorString("#00dcff82"),
        outlineWidth: 1,
        distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
          0.0,
          10.5e6
        ),
      },
    });
  }

这个方法创建了两个关键几何体:

  • wall:一个墙体,用于表示扫描的扇区,它的位置通过CallbackProperty动态更新

  • ellipsoid:一个球体,表示雷达的覆盖范围

几个关键参数:

  • shortwaveRange:雷达的覆盖半径

  • position:雷达所在的位置(经度、纬度)

两者都使用了半透明的青色材质#00dcff82,增强了科技感。

2. 动画实现

addPostRender() {
  this.tickListener = this.viewer.clock.onTick.addEventListener(() => {
    this.heading += 1.0; //可调节转动速度
    this.positionArr = this.calcPoints(
      this.longitude,
      this.latitude,
      this.shortwaveRange,
      this.heading
    );
  });
}

动画的核心在于监听Cesium的时钟事件,每一帧更新heading角度,并重新计算扇区的点位数组。

转动速度可以通过调整每帧增加的角度值来控制。

3. 扇区点位计算

calcPoints(x1, y1, radius, heading) {
  var m = Cesium.Transforms.eastNorthUpToFixedFrame(
    Cesium.Cartesian3.fromDegrees(x1, y1)
  );
  var rx = radius * Math.cos((heading * Math.PI) / 180.0);
  var ry = radius * Math.sin((heading * Math.PI) / 180.0);
  var translation = Cesium.Cartesian3.fromElements(rx, ry, 0);
  var d = Cesium.Matrix4.multiplyByPoint(
    m,
    translation,
    new Cesium.Cartesian3()
  );
  var c = Cesium.Cartographic.fromCartesian(d);
  var x2 = Cesium.Math.toDegrees(c.longitude);
  var y2 = Cesium.Math.toDegrees(c.latitude);
  return this.computeCirclularFlight(x1, y1, x2, y2, 0, 90);
}

这个方法是整个效果的核心,它通过以下步骤计算扇区的边缘点:

  1. 创建一个从地理坐标到笛卡尔坐标的转换矩阵

  2. 根据当前heading角度计算出半径方向上的偏移

  3. 将这个偏移应用到矩阵上,得到扇区边缘的一个点

  4. 将这个点转换回地理坐标

  5. 调用computeCirclularFlight方法计算扇区内的所有点

4. 扇区插值计算

computeCirclularFlight(x1, y1, x2, y2, fx, angle) {
  let positionArr = [];
  positionArr.push(x1);
  positionArr.push(y1);
  positionArr.push(0);
  var radius = Cesium.Cartesian3.distance(
    Cesium.Cartesian3.fromDegrees(x1, y1),
    Cesium.Cartesian3.fromDegrees(x2, y2)
  );
  for (let i = fx; i <= fx + angle; i++) {
    let h = radius * Math.sin((i * Math.PI) / 180.0);
    let r = Math.cos((i * Math.PI) / 180.0);
    let x = (x2 - x1) * r + x1;
    let y = (y2 - y1) * r + y1;
    positionArr.push(x);
    positionArr.push(y);
    positionArr.push(h);
  }
  return positionArr;
}

这个方法根据起点和终点计算出一个弧形路径上的所有点。

它首先将起点坐标添加到数组中,然后计算从起点到终点的距离作为半径,接着在指定角度范围内(这里是0到90度)生成一系列点,并设置每个点的高度,形成一个立体的扇区。

5. 资源管理与控制方法

clear() {
  // 停止时钟监听
  if (this.tickListener) {
    this.tickListener();
    this.tickListener = null;
  }

  // 移除实体
  if (this.viewer && this.id) {
    const entity = this.viewer.entities.getById(this.id);
    if (entity) {
      this.viewer.entities.remove(entity);
    }
  }

  // 清理属性
  this.positionArr = [];
  this.heading = 0;
}

类中还提供了多个实用的控制方法:

  • clear():清理资源但保留对象

  • destroy():完全销毁对象

  • stop():暂停动画

  • start():开始动画

  • hide():隐藏雷达效果

  • show():显示雷达效果

这些方法使得雷达效果更易于在不同场景中控制,提高了组件的可复用性。

使用示例

import RadarSolidScan from './RadarSolidScan.js';

// 创建雷达效果
const radar = new RadarSolidScan({
  viewer: viewer, 
  id: 'radar1',  // 唯一标识
  position: [120, 36],
  shortwaveRange: 50000 // 50公里的覆盖范围
});

最后

【完整源码地址】:github.com/tingyuxuan2…

想系统学习Cesium的小伙伴儿,可以了解下不浪的教程《Cesium从入门到实战》,将Cesium的知识点进行串联,让不了解Cesium的小伙伴拥有一个完整的学习路线,并最终完成一个智慧城市的完整项目,课程最近也更新了很多进阶内容,想了解课程大纲,+作者:brown_7778(备注来意)。

有需要进可视化&Webgis交流群可以加我:brown_7778(备注来意)。

玩转React Hooks

我至今记得第一次用useState时那种惊艳感——代码量直接砍半!但很快就被莫名其妙的闭包陷阱和无限循环教做人。今天就用真实踩坑经历,聊聊Hooks那些看似简单却暗藏玄

Vue3的渲染秘密:从同步批处理到异步微任务

面试里,当面试官把两段看似「都是改 5 次数据」的代码摆在你面前,却问「渲染了几次?」,如果你只回答「改了 5 次所以 5 次」,那大概率就踩坑了。本文用 100 行代码把同步批处理与异步微任务的底层机制拆开讲透,让你以后遇到同类问题直接秒答。

一、手写一个带合并渲染的 Component

需求

  1. 修改数据时触发 render
  2. 同步多次修改只触发一次 render

实现思路

利用 微任务队列 把同一次事件循环里的多次 set 合并到下一帧执行。

class Component {
  data = { name: '' }
  _pending = false          // 标记位:是否有未 flush 的修改

  constructor() {
    // 通过 Proxy 拦截所有属性写入
    this.data = new Proxy(this.data, {
      set: (target, key, value) => {
        target[key] = value
        this.scheduleRender()
        return true
      }
    })
  }

  scheduleRender() {
    if (this._pending) return   // 已在队列中,跳过
    this._pending = true
    queueMicrotask(() => {
      this.render()
      this._pending = false
    })
  }

  render() {
    console.log(`render - name: ${this.data.name}`)
  }
}

// 测试
const com = new Component()
com.data.name = '张三'
com.data.name = '李四'
com.data.name = '王五'
setTimeout(() => com.data.name = '巷子', 0)

输出顺序:

render - name: 王五      // 第一帧批处理
render - name: 巷子      // setTimeout 宏任务

核心原理:queueMicrotask 把多次写操作合并到同一微任务阶段,只执行一次 render

二、Vue 中同步 vs 异步赋值到底渲染几次?

代码一:同步 for 循环

<script setup>
import { ref } from 'vue'
const rCount = ref(0)
for (let i = 1; i <= 5; ++i) {
  rCount.value = i
}
</script>

渲染次数:2 次

  1. 初始挂载:渲染 0
  2. 批处理队列:5 次赋值被合并,最终渲染 5

Vue 内部使用 异步队列(queueJob) 收集同步变更,下一事件循环统一 flush。同一代码块内无论改多少次,都只走一次 DOM diff。

代码二:setTimeout 异步循环

<script setup>
import { ref } from 'vue'
const rCount = ref(0)
for (let i = 1; i <= 5; ++i) {
  setTimeout(() => rCount.value = i, 0)
}
</script>

渲染次数:6 次

  1. 初始挂载:渲染 0
  2. 每个 setTimeout 回调都是一个独立宏任务,Vue 的批处理无法跨任务合并,于是 5 次回调触发 5 次独立渲染。

总结

同步代码 → 全部进入同一批处理队列 → 1 次渲染

异步代码 → 每次回调独立任务 → n 次渲染

如何在vue项目中封装自己的全局message组件?一步教会你!

 我都使用过element plus 或者ant design Vue这些UI组件库,像element的全局message组件是如何通过一个ElMessage.success(),.warning(),.error()方法调用UI弹框显示,我们明明没有在template区域注入组件,这是为什么呢?答案就在下面!

这里因个人习惯,我使用的是typescript。

一、先定义message类型和实例类型。

import { createVNode } from 'vue';
import type { VNode } from 'vue'
import type { App } from 'vue';

// 定义通知类型
export type NotificationType = 'info' | 'success' | 'warning' | 'error';

// 定义通知选项
export interface NotificationOptions {
  message: string;
  type?: NotificationType;
  duration?: number;
  onClose?: () => void;
}

// 定义通知实例
interface NotificationInstance {
  id: number;
  vnode: VNode;
  container: HTMLDivElement;
}

二、创建容器实例和通知列表、id计数器,每一个通知实例都有一个唯一的id。

// 通知容器
let notificationContainer: HTMLElement | null = null;
// 通知实例列表
const notifications: NotificationInstance[] = [];
// ID计数器
let seed = 0;

三、创建容器,自定义样式模板

// 创建通知容器
const createNotificationContainer = () => {
  if (notificationContainer) return;

  notificationContainer = document.createElement('div');
  notificationContainer.className = 'global-notification-container';
  document.body.appendChild(notificationContainer);

  // 添加样式
  const style = document.createElement('style');
  style.textContent = `
    .global-notification-container {
      position: fixed;
      top: 20px;
      left: 50%;
      transform: translateX(-50%);
      z-index: 9999;
      width: fit-content;
      max-width: 1000px;
    }
    
    .global-notification {
      margin-bottom: 16px;
      padding: 12px 20px;
      border-radius: 4px;
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
      display: flex;
      align-items: center;
      transform: translateY(-100%);
      opacity: 0;
      transition: all 0.3s ease;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
      min-width: 300px;
      justify-content: center;
    }
    
    .global-notification.show {
      transform: translateY(0);
      opacity: 1;
    }
    
    .global-notification.info {
      background-color: #e6f7ff;
      border: 1px solid #91d5ff;
      color: #333;
    }
    
    .global-notification.success {
      background-color: #f6ffed;
      border: 1px solid #b7eb8f;
      color: #333;
    }
    
    .global-notification.warning {
      background-color: #fffbe6;
      border: 1px solid #ffe58f;
      color: #333;
    }
    
    .global-notification.error {
      background-color: #fff2f0;
      border: 1px solid #ffccc7;
      color: #333;
    }
    
    .global-notification-icon {
      margin-right: 12px;
      font-size: 16px;
      line-height: 20px;
    }
    
    .global-notification-content {
      flex: 1;
      font-size: 14px;
      line-height: 20px;
      text-align: center;
    }
    
    .global-notification-close {
      margin-left: 12px;
      cursor: pointer;
      font-size: 14px;
      line-height: 20px;
      color: #999;
    }
    
    .global-notification-close:hover {
      color: #333;
    }
    
    .global-notification.info .global-notification-icon {
      color: #1890ff;
    }
    
    .global-notification.success .global-notification-icon {
      color: #52c41a;
    }
    
    .global-notification.warning .global-notification-icon {
      color: #faad14;
    }
    
    .global-notification.error .global-notification-icon {
      color: #ff4d4f;
    }
  `;
  document.head.appendChild(style);
};

四、创建单个通知,自定义HTML模板

// 创建单个通知
const createNotification = (options: NotificationOptions) => {
  const id = seed++;
  const type = options.type || 'info';
  const duration = options.duration === undefined ? 4500 : options.duration;

  // 创建容器元素
  const container = document.createElement('div');
  container.className = 'global-notification';

  // 创建通知内容
  const icons = {
    info: 'ℹ️',
    success: '✅',
    warning: '⚠️',
    error: '❌'
  };

  container.innerHTML = `
    <div class="global-notification-icon">${icons[type]}</div>
    <div class="global-notification-content">${options.message}</div>
    <div class="global-notification-close">×</div>
  `;

  container.classList.add(type);

  // 添加到容器
  notificationContainer?.appendChild(container);

  // 触发进入动画
  setTimeout(() => {
    container.classList.add('show');
  }, 10);

  // 绑定关闭事件
  const closeBtn = container.querySelector('.global-notification-close');
  const close = () => {
    container.classList.remove('show');
    setTimeout(() => {
      container.remove();
      options.onClose?.();
    }, 300);
  };

  closeBtn?.addEventListener('click', close);

  // 自动关闭
  if (duration > 0) {
    setTimeout(close, duration);
  }

  // 保存实例
  const instance: NotificationInstance = {
    id,
    vnode: createVNode('div'),
    container
  };

  notifications.push(instance);

  return instance;
};

五、导出方法

// 提供不同类型的通知方法
export const notification = {
  open: (options: NotificationOptions) => {
    createNotificationContainer();
    return createNotification(options);
  },

  info: (message: string, options?: Omit<NotificationOptions, 'message' | 'type'>) => {
    return notification.open({
      message,
      type: 'info',
      ...options
    });
  },

  success: (message: string, options?: Omit<NotificationOptions, 'message' | 'type'>) => {
    return notification.open({
      message,
      type: 'success',
      ...options
    });
  },

  warning: (message: string, options?: Omit<NotificationOptions, 'message' | 'type'>) => {
    return notification.open({
      message,
      type: 'warning',
      ...options
    });
  },

  error: (message: string, options?: Omit<NotificationOptions, 'message' | 'type'>) => {
    return notification.open({
      message,
      type: 'error',
      ...options
    });
  },

  // 关闭所有通知
  closeAll: () => {
    notifications.forEach(instance => {
      instance.container.remove();
    });
    notifications.length = 0;
  }
};

六、导出自定义插件

// Vue插件安装方法
export default {
  install(app: App) {
    app.config.globalProperties.$notification = notification;
    app.provide('notification', notification);
  }
};

七、注册插件

import globalMessage from './utils/global-message'
app.use(globalMessage)

八、所有代码

// src/utils/global-message.ts
import { createVNode } from 'vue';
import type { VNode } from 'vue'
import type { App } from 'vue';

// 定义通知类型
export type NotificationType = 'info' | 'success' | 'warning' | 'error';

// 定义通知选项
export interface NotificationOptions {
  message: string;
  type?: NotificationType;
  duration?: number;
  onClose?: () => void;
}

// 定义通知实例
interface NotificationInstance {
  id: number;
  vnode: VNode;
  container: HTMLDivElement;
}

// 通知容器
let notificationContainer: HTMLElement | null = null;
// 通知实例列表
const notifications: NotificationInstance[] = [];
// ID计数器
let seed = 0;

// 创建通知容器
const createNotificationContainer = () => {
  if (notificationContainer) return;

  notificationContainer = document.createElement('div');
  notificationContainer.className = 'global-notification-container';
  document.body.appendChild(notificationContainer);

  // 添加样式
  const style = document.createElement('style');
  style.textContent = `
    .global-notification-container {
      position: fixed;
      top: 20px;
      left: 50%;
      transform: translateX(-50%);
      z-index: 9999;
      width: fit-content;
      max-width: 1000px;
    }
    
    .global-notification {
      margin-bottom: 16px;
      padding: 12px 20px;
      border-radius: 4px;
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
      display: flex;
      align-items: center;
      transform: translateY(-100%);
      opacity: 0;
      transition: all 0.3s ease;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
      min-width: 300px;
      justify-content: center;
    }
    
    .global-notification.show {
      transform: translateY(0);
      opacity: 1;
    }
    
    .global-notification.info {
      background-color: #e6f7ff;
      border: 1px solid #91d5ff;
      color: #333;
    }
    
    .global-notification.success {
      background-color: #f6ffed;
      border: 1px solid #b7eb8f;
      color: #333;
    }
    
    .global-notification.warning {
      background-color: #fffbe6;
      border: 1px solid #ffe58f;
      color: #333;
    }
    
    .global-notification.error {
      background-color: #fff2f0;
      border: 1px solid #ffccc7;
      color: #333;
    }
    
    .global-notification-icon {
      margin-right: 12px;
      font-size: 16px;
      line-height: 20px;
    }
    
    .global-notification-content {
      flex: 1;
      font-size: 14px;
      line-height: 20px;
      text-align: center;
    }
    
    .global-notification-close {
      margin-left: 12px;
      cursor: pointer;
      font-size: 14px;
      line-height: 20px;
      color: #999;
    }
    
    .global-notification-close:hover {
      color: #333;
    }
    
    .global-notification.info .global-notification-icon {
      color: #1890ff;
    }
    
    .global-notification.success .global-notification-icon {
      color: #52c41a;
    }
    
    .global-notification.warning .global-notification-icon {
      color: #faad14;
    }
    
    .global-notification.error .global-notification-icon {
      color: #ff4d4f;
    }
  `;
  document.head.appendChild(style);
};

// 创建单个通知
const createNotification = (options: NotificationOptions) => {
  const id = seed++;
  const type = options.type || 'info';
  const duration = options.duration === undefined ? 4500 : options.duration;

  // 创建容器元素
  const container = document.createElement('div');
  container.className = 'global-notification';

  // 创建通知内容
  const icons = {
    info: 'ℹ️',
    success: '✅',
    warning: '⚠️',
    error: '❌'
  };

  container.innerHTML = `
    <div class="global-notification-icon">${icons[type]}</div>
    <div class="global-notification-content">${options.message}</div>
    <div class="global-notification-close">×</div>
  `;

  container.classList.add(type);

  // 添加到容器
  notificationContainer?.appendChild(container);

  // 触发进入动画
  setTimeout(() => {
    container.classList.add('show');
  }, 10);

  // 绑定关闭事件
  const closeBtn = container.querySelector('.global-notification-close');
  const close = () => {
    container.classList.remove('show');
    setTimeout(() => {
      container.remove();
      options.onClose?.();
    }, 300);
  };

  closeBtn?.addEventListener('click', close);

  // 自动关闭
  if (duration > 0) {
    setTimeout(close, duration);
  }

  // 保存实例
  const instance: NotificationInstance = {
    id,
    vnode: createVNode('div'),
    container
  };

  notifications.push(instance);

  return instance;
};

// 提供不同类型的通知方法
export const notification = {
  open: (options: NotificationOptions) => {
    createNotificationContainer();
    return createNotification(options);
  },

  info: (message: string, options?: Omit<NotificationOptions, 'message' | 'type'>) => {
    return notification.open({
      message,
      type: 'info',
      ...options
    });
  },

  success: (message: string, options?: Omit<NotificationOptions, 'message' | 'type'>) => {
    return notification.open({
      message,
      type: 'success',
      ...options
    });
  },

  warning: (message: string, options?: Omit<NotificationOptions, 'message' | 'type'>) => {
    return notification.open({
      message,
      type: 'warning',
      ...options
    });
  },

  error: (message: string, options?: Omit<NotificationOptions, 'message' | 'type'>) => {
    return notification.open({
      message,
      type: 'error',
      ...options
    });
  },

  // 关闭所有通知
  closeAll: () => {
    notifications.forEach(instance => {
      instance.container.remove();
    });
    notifications.length = 0;
  }
};

// Vue插件安装方法
export default {
  install(app: App) {
    app.config.globalProperties.$notification = notification;
    app.provide('notification', notification);
  }
};

效果展示

编辑

编辑

CSS 布局小技巧:用 padding 撑开 div 不香吗?

在 CSS 中,利用padding撑开div而不直接设置width的写法有以下几个显著优势:

image.png

当不设置固定`width`时,元素宽度会默认随内容自然扩展,
配合`padding`可以在内容周围创建空间,
同时保持元素宽度与内容动态匹配。
这在处理不确定长度的内容(如文本、动态加载的内容)时非常实用,避免内容溢出或留白过多。
  1. 简化响应式设计
    不固定宽度的元素会自动适应父容器的可用空间,配合padding可以在不同屏幕尺寸下保持一致的内边距比例,无需为不同断点重复设置宽度,减少代码冗余。

  2. 避免盒模型计算问题
    在默认的box-sizing: content-box下,width不包含paddingborder。如果设置固定width再添加padding,实际宽度会超出预期(需要手动计算调整)。而利用padding撑开元素时,宽度会自动包含内边距(配合box-sizing: border-box效果更佳),避免计算错误。

  3. 灵活的比例控制
    结合padding-top/bottom的百分比值(相对于父元素宽度),可以创建固定比例的容器(如 16:9 的视频框),这种技巧在响应式布局中常用于保持元素的宽高比。

示例代码:

/* 利用padding撑开元素,不设置固定width */
.box {
  padding: 20px;
  box-sizing: border-box; /* 确保padding不增加总宽度 */
  background: #f0f0f0;
}

先说优点:这波操作有点东西

最让开发者心动的莫过于自适应内容宽度这个特性。想象一下你要做一个标签组件,标签里的文字可能是 "热门",也可能是 "新品上市",如果傻乎乎地写死 width,要么文字溢出要么留白太多。但用 padding 就不一样了,设置padding: 5px 10px后,标签会像有弹性的气球,内容多长它就多大,完美贴合内容尺寸,从此和 "内容溢出" 警告说拜拜。

在响应式设计中,这种写法更是救星。现在的设备屏幕尺寸五花八门,从手机到平板再到电脑,固定 width 的元素在小屏幕上可能直接撑破容器。而不设 width 只加 padding 的元素,会乖乖地根据父容器宽度调整自己的大小,内边距比例还能保持一致。这意味着你不用在媒体查询里反复修改 width 值,少写 N 行代码的快乐谁懂啊!

还有个容易被忽略的好处是避免盒模型计算灾难。默认情况下 CSS 的盒模型是content-box,width 不包含 padding 和 border。假设你设置width: 200px; padding: 20px,实际宽度会变成 240px,这种 "算不准" 的情况经常导致布局错位。但用 padding 撑开 div 时,配合box-sizing: border-box,元素的总宽度会自动包含 padding,再也不用拿着计算器算来算去,妈妈再也不用担心我数学不好了。

特别值得一提的是比例控制小技巧。当你设置padding-top: 56.25%时(16:9 的比例),元素会形成一个固定比例的容器,这在视频播放器、图片占位符等场景中简直是神器。无论父容器怎么缩放,这个容器都能保持完美比例,比固定 width+height 的方式灵活多了。

再谈缺点:不是万能钥匙

当然这种写法也不是没有短板。最明显的问题是宽度无法精确控制。如果你的设计稿里某个元素必须是 300px 宽,那用 padding 撑开就很难保证精度,因为内容长度会直接影响最终宽度。这时候强行用 padding 反而会让你陷入 "调整 padding 值试错" 的循环,反而不如直接写 width 来得痛快。

在某些特殊布局中还可能遇到父容器宽度依赖问题。当元素设置padding-top百分比值时,这个百分比是相对于父元素宽度计算的。如果父元素没有明确宽度,就可能出现 "子元素比父元素还宽" 的尴尬情况,这种时候调试起来能让你怀疑人生。

还有个容易踩的坑是嵌套布局复杂化。当你在一个用 padding 撑开的元素里再嵌套同样布局的子元素时,子元素的宽度会继承父元素的 "自适应特性",有时候会出现意想不到的宽度叠加效果。比如父元素 padding 导致宽度变大,子元素的 padding 又在此基础上叠加,最后可能超出预期尺寸。

另外在极端内容场景下表现不佳。如果内容少到只有一个字,元素可能会窄得像条线;如果内容超长且不换行,又会无限拉伸容器宽度。这时候需要配合min-width、max-width或word-break等属性才能解决,反而增加了代码复杂度。

总结:合适的才是最好的

其实没有绝对好或坏的布局方式,只有适合不适合的场景。当你需要做按钮、标签、徽章等小组件,或者要实现响应式比例容器时,padding 撑开 div 的写法绝对值得一试;但如果遇到精确尺寸要求的元素,或者复杂的多层嵌套布局,固定 width 可能更靠谱。

记住 CSS 布局的真谛:没有银弹,只有权衡。下次写布局时不妨多试试不同的方法,或许会发现更多有趣的小技巧。最后留个小问题:你在项目中用过 padding 撑开 div 的写法吗?遇到过哪些有趣的问题?欢迎在评论区分享你的经历~

❌