普通视图

发现新文章,点击刷新页面。
昨天 — 2025年11月26日技术

大道至简-Shadcn/ui设计系统初体验(下):Theme与色彩系统实战

作者 ArkPppp
2025年11月26日 18:51

大道至简-Shadcn/ui设计系统初体验(下):Theme与色彩系统实战

前言

在上篇文章中,我们探讨了shadcn/ui的安装、组件引入和基础定制。本文将继续深入,关注一个更核心的话题——主题系统设计。作为前端工程师,我们都明白一个好的设计系统不仅要有美观的组件,更需要一套完整、可维护、可扩展的色彩体系。本文将通过实际项目实践,详细分析shadcn/ui如何通过CSS变量和TailwindCSS构建这套体系。

一、自定义主题配置:从CSS变量到TailwindCSS

1.1 shadcn/ui的主题系统原理

shadcn/ui采用了基于CSS自定义属性(CSS Variables)的设计模式。每个主题实际上就是一套CSS变量的集合。不同于传统组件库通过JavaScript动态计算颜色值,shadcn/ui选择在CSS层面定义好所有颜色状态,然后通过类名切换来实现主题变换。

这种设计的优势显而易见:

  • 无需JavaScript计算,避免频繁的重排重绘
  • 颜色值在构建阶段就已经确定,性能更好
  • CSS变量天然支持继承和级联,便于管理复杂的色彩体系

让我们查看项目的核心配置文件:

/* src/index.css */
@import "tailwindcss";

@custom-variant dark (&:is(.dark *));

@theme {
  --color-border: hsl(var(--border));
  --color-input: hsl(var(--input));
  --color-ring: hsl(var(--ring));
  --color-background: hsl(var(--background));
  --color-foreground: hsl(var(--foreground));
  /* ... 更多颜色变量 */
}

注意这里使用TailwindCSS 4的新语法 @theme 替代了传统的 tailwind.config.js 配置。这种方式将主题配置直接内联到CSS文件中,更加直观。

1.2 主题色彩定义

shadcn/ui使用HSL色彩空间来定义颜色。HSL由色相(Hue)、饱和度(Saturation)、亮度(Lightness)三个分量组成,相比RGB更容易理解和调整。

我们项目的实际配色:

:root {
  /* 背景与前景色 */
  --background: 210 20% 96%;
  --foreground: 222 15% 15%;

  /* 主色调 - 浅蓝色系 */
  --primary: 205 85% 60%;
  --primary-foreground: 210 40% 98%;

  /* 次要色 - 浅绿色系 */
  --secondary: 145 65% 60%;
  --secondary-foreground: 222 15% 15%;

  /* 强调色 - 青绿色系 */
  --accent: 175 70% 55%;
  --accent-foreground: 210 40% 98%;
}

.dark {
  /* 深色主题配色 */
  --background: 210 15% 10%;
  --foreground: 210 15% 92%;
  --primary: 205 85% 65%;
  --secondary: 145 60% 65%;
  --accent: 175 65% 60%;
  /* ... */
}

配色方案的设计遵循以下原则:

  1. 语义化命名:每个颜色都有明确的语义(background、primary、secondary等)
  2. 状态配套:每个主要颜色都有对应的foreground色,保证可读性
  3. 明暗适配:深色模式下适当调整亮度和饱和度

1.3 扩展色系:成功与警告

除了标准的设计语言色彩,shadcn/ui还允许定义扩展色系,用于表达特定状态:

:root {
  --success: 145 60% 50%;
  --success-light: 145 65% 60%;
  --success-dark: 145 55% 45%;

  --warning: 45 85% 60%;
  --warning-light: 45 90% 65%;
  --warning-dark: 45 80% 55%;
}

这种命名方式(基础色-light-dark)为每个语义色提供了三个亮度级别,在实际开发中可以根据不同场景选择合适的深浅。

二、颜色系统设计:CSS变量与OKLCH色彩空间

2.1 CSS变量的高级特性

CSS自定义属性(CSS Variables)不仅仅是简单的键值对,它具备许多强大的特性:

1. 继承性

.card {
  background: hsl(var(--primary));
}

.card-header {
  /* 自动继承父元素的 --primary */
  color: hsl(var(--primary));
}

2. 动态计算

:root {
  --primary-light: 205 85% calc(60% + 10%);
}

3. 作用域控制

/* 全局作用域 */
:root {
  --global-primary: blue;
}

/* 局部作用域 */
.theme-dark {
  --local-primary: red;
}

这些特性使得CSS变量非常适合构建复杂的颜色系统。

2.2 OKLCH色彩空间:下一代色彩标准

传统的HSL色彩空间有一个明显缺陷:感知不均匀性。也就是说,在HSL中同样数值的变化,人眼感知的差异并不一致。例如,HSL中饱和度从50%到60%的变化,看起来比60%到70%的变化更明显。

OKLCH(Lightness-Chroma-Hue)色彩空间解决了这个问题。OKLCH是基于CIELAB色彩空间的现代色彩模型,具有以下优势:

  • 感知均匀:数值的微小变化对应人眼感知的微小变化
  • 色域更广:支持更多可见色彩
  • 对比度可控:更容易满足WCAG可访问性标准

虽然浏览器对OKLCH的支持还在逐步完善中,但TailwindCSS已经开始采用OKLCH。未来shadcn/ui很可能会迁移到OKLCH色彩空间。

2.3 构建语义化颜色系统

一个好的颜色系统需要避免直接使用底层色彩值,而是通过语义化变量来使用:

/* ❌ 不好的做法 - 直接使用底层颜色 */
.button {
  background: rgb(59, 130, 246);
}

/* ✅ 好的做法 - 使用语义化变量 */
.button {
  background: hsl(var(--primary));
}

这种设计的好处:

  1. 可维护性强:修改主题时只需更改CSS变量定义
  2. 一致性保证:全站使用统一的语义化色彩
  3. 灵活性高:可以针对不同区域覆盖特定变量

三、项目实践:TodoList的主题更新实现

3.1 ThemeProvider设计

shadcn/ui提供了一个独立的ThemeProvider实现,位于 src/components/theme-provider.tsx。这个实现替代了传统的next-themes,更轻量且完全基于原生Web API。

核心实现分析:

export function ThemeProvider({
  children,
  defaultTheme = 'system',
  storageKey = 'vite-ui-theme',
}: ThemeProviderProps) {
  const [theme, setTheme] = useState<Theme>(
    () => (localStorage.getItem(storageKey) as Theme) || defaultTheme
  )

  useEffect(() => {
    const root = window.document.documentElement

    root.classList.remove('light', 'dark')

    if (theme === 'system') {
      const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
        .matches
        ? 'dark'
        : 'light'

      root.classList.add(systemTheme)
      return
    }

    root.classList.add(theme)
  }, [theme])

  const value = {
    theme,
    setTheme: (theme: Theme) => {
      localStorage.setItem(storageKey, theme)
      setTheme(theme)
    },
  }

  return (
    <ThemeProviderContext.Provider {...props} value={value}>
      {children}
    </ThemeProviderContext.Provider>
  )
}

关键点分析:

  1. 三种主题模式

    • light: 强制使用浅色主题
    • dark: 强制使用深色主题
    • system: 跟随系统设置
  2. 本地存储持久化 使用localStorage保存用户偏好,应用重启后自动恢复。

  3. 类名切换机制 通过操作documentElement的classList来切换主题,避免频繁的style重写。

3.2 TodoList中的主题切换按钮

在TodoList组件中,主题切换按钮的实现:

import { useTheme } from './theme-provider'

function TodoList() {
  const { theme, setTheme } = useTheme()

  const toggleTheme = () => {
    setTheme(theme === 'light' ? 'dark' : 'light')
  }

  return (
    <Button
      variant="ghost"
      size="icon"
      onClick={toggleTheme}
      aria-label="切换主题"
    >
      {theme === 'light' ? (
        <Moon className="h-4 w-4" />
      ) : (
        <Sun className="h-4 w-4" />
      )}
    </Button>
  )
}

注意这里的实现细节:

  • 使用aria-label提升可访问性
  • 根据当前主题显示对应图标(月亮/太阳)
  • variant设为ghost保持视觉简洁

3.3 主题变量的实际应用

在TodoList组件中,我们看到各种shadcn/ui组件都使用了语义化的颜色变量:

<div className="min-h-screen bg-background text-foreground transition-colors">
  <Card className="border-border">
    <CardHeader>
      <CardTitle className="bg-gradient-to-r from-primary via-secondary to-accent bg-clip-text text-transparent">
        待办事项列表
      </CardTitle>
    </CardHeader>
  </Card>
</div>

关键点:

  • bg-backgroundtext-foreground:使用语义变量确保文本可读性
  • border-border:边框颜色随主题变化
  • 渐变色使用CSS变量,保持主题一致性

四、shadcn/ui的设计哲学总结

通过上下两篇文章的分析,我们可以总结shadcn/ui的设计哲学:

4.1 零抽象成本

shadcn/ui不将组件封装为黑盒,而是提供完整源代码。这种"代码所有权"模式让开发者可以:

  • 任意修改组件实现
  • 深入理解组件逻辑
  • 无框架依赖,便于迁移

4.2 原子化设计

每个组件都是独立的、无样式基础的(headless),样式完全通过TailwindCSS类控制。这带来:

  • 样式完全可控
  • 避免CSS优先级冲突
  • 更好的Tree-shaking效果

4.3 设计令牌驱动

通过CSS变量系统,shadcn/ui建立了完整的设计令牌(Design Tokens)体系:

  • 颜色、字体、间距等都有对应的令牌
  • 令牌支持层级继承
  • 便于实现设计系统的一致性

4.4 可访问性优先

基于Radix UI构建,所有组件都具备:

  • 完整的键盘导航支持
  • 正确的ARIA属性
  • 语义化的HTML结构

4.5 现代化工具链

shadcn/ui深度集成了现代前端工具:

  • TailwindCSS 4(最新语法)
  • TypeScript(完整类型定义)
  • Vite(快速构建)
  • ESLint(代码规范)

5.成果展示

让我们看看最终的成果吧。

LightMode.png

NightMode.png

结语

shadcn/ui不仅仅是一个组件库,更是一套完整的设计系统实现方案。它通过CSS变量、TailwindCSS和现代React模式的结合,为我们提供了一种全新的组件库构建思路。

这种"大道至简"的设计理念——将复杂的UI抽象还原为简单的CSS变量和可组合的组件——或许正是前端开发的一种新范式。在AI编程工具日益成熟,Vibe Coding愈发普遍的今天,一个开放、可定制、无黑盒的组件库将更具生命力。


参考:

栈(Stack)详解:从原理到实现,再到括号匹配应用

作者 www_stdio
2025年11月26日 18:45

栈(Stack)详解:从原理到实现,再到括号匹配应用

栈是一种基础而重要的线性数据结构,在计算机科学中被广泛应用于函数调用、表达式求值、回溯算法等场景。本文将围绕栈的定义、抽象数据类型(ADT)、ES6 实现方式(数组与链表)、性能对比,以及一个经典应用场景——括号匹配问题,进行系统讲解。


一、什么是栈?

栈(Stack)是一种遵循 先进后出(First In Last Out, FILO) 原则的线性数据结构。你可以把它想象成一摞盘子:你只能从顶部放入(入栈)或取出(出栈)盘子,不能从中间或底部操作。

栈的核心操作包括:

  • push(x) :将元素 x 压入栈顶。
  • pop() :弹出并返回栈顶元素。
  • peek() / top() :查看栈顶元素但不移除。
  • isEmpty() :判断栈是否为空。
  • size() :获取栈中元素个数。

二、栈的抽象数据类型(ADT)

抽象数据类型(Abstract Data Type, ADT)是对数据结构行为的规范描述,不涉及具体实现。栈的 ADT 应包含以下属性和方法:

属性/方法 描述
size 只读属性,返回当前栈的大小
isEmpty() 判断栈是否为空
push(val) 入栈操作
pop() 出栈操作,若栈空则抛出异常
peek() 返回栈顶元素,若栈空则抛出异常
toArray() (可选)将栈内容转换为数组,便于调试或输出

三、ES6 Class 实现栈

ES6 引入了 class 语法,使面向对象编程更加清晰。结合私有字段(#)、get 访问器等特性,可以优雅地封装栈的实现细节。

1. 基于链表实现栈(LinkedListStack)

链表天然适合动态增长,每个节点包含值和指向下一个节点的指针。

class ListNode {
    constructor(val) {
        this.val = val;
        this.next = null;
    }
}

class LinkedListStack {
    #stackPeek = null; // 私有栈顶指针
    #size = 0;

    push(num) {
        const node = new ListNode(num);
        node.next = this.#stackPeek;
        this.#stackPeek = node;
        this.#size++;
    }

    pop() {
        if (!this.#stackPeek) throw new Error('栈为空');
        const val = this.#stackPeek.val;
        this.#stackPeek = this.#stackPeek.next;
        this.#size--;
        return val;
    }

    peek() {
        if (!this.#stackPeek) throw new Error('栈为空');
        return this.#stackPeek.val;
    }

    get size() { return this.#size; }
    isEmpty() { return this.#size === 0; }

    toArray() {
        let node = this.#stackPeek;
        const res = new Array(this.#size);
        for (let i = res.length - 1; i >= 0; i--) {
            res[i] = node.val;
            node = node.next;
        }
        return res;
    }
}

优点:动态扩容,无空间浪费;插入/删除均为 O(1)
缺点:每个节点需额外存储指针,内存开销略大


2. 基于数组实现栈(ArrayStack)

利用 JavaScript 数组的 pushpop 方法,可快速实现栈。

class ArrayStack {
    #stack = [];

    get size() { return this.#stack.length; }
    isEmpty() { return this.size === 0; }

    push(num) {
        this.#stack.push(num);
    }

    pop() {
        if (this.isEmpty()) throw new Error("栈为空");
        return this.#stack.pop();
    }

    peek() {
        if (this.isEmpty()) throw new Error("栈为空");
        return this.#stack[this.size - 1];
    }

    toArray() {
        return [...this.#stack]; // 返回副本更安全
    }
}

优点:缓存友好,访问快;代码简洁
缺点:扩容时需复制整个数组(O(n)),但均摊时间复杂度仍为 O(1)


四、数组 vs 链表实现栈:性能对比

维度 数组实现 链表实现
时间效率 平均 O(1),扩容时 O(n) 稳定 O(1)
空间效率 可能有预分配空间浪费 每个节点多一个指针开销
内存布局 连续内存,缓存命中率高 离散内存,缓存局部性差
适用场景 数据量可预估、追求速度 动态性强、内存敏感

💡 在大多数实际应用中,数组实现的栈更常用,因为其简单高效,且现代 JavaScript 引擎对数组优化极佳。


五、实战应用:括号匹配问题

栈的经典应用场景之一是验证括号字符串是否合法。例如:"([{}])" 合法,而 "([)]" 不合法。

解题思路:

  1. 遇到左括号 ([{,将其对应的右括号压入栈;
  2. 遇到右括号,检查是否与栈顶元素匹配;
  3. 若不匹配或栈为空,则非法;
  4. 遍历结束后,栈必须为空才合法。

代码实现:

const leftToRight = {
    "(": ")",
    "[": "]",
    "{": "}"
};

function isValid(s) {
    if (!s) return true;
    const stack = [];
    for (const ch of s) {
        if (ch in leftToRight) {
            stack.push(leftToRight[ch]); // 压入期望的右括号
        } else {
            if (!stack.length || stack.pop() !== ch) {
                return false; // 不匹配或栈空
            }
        }
    }
    return stack.length === 0; // 必须完全匹配
}

✅ 时间复杂度:O(n)
✅ 空间复杂度:O(n)(最坏情况全为左括号)


六、总结

  • 栈是一种 FILO 的线性结构,核心操作为 pushpoppeek
  • ES6 的 class、私有字段 #get 访问器,让栈的实现更安全、更清晰。
  • 数组实现简单高效,适合大多数场景;链表实现动态灵活,适合不确定规模的场景。
  • 栈在算法中用途广泛,如括号匹配、表达式求值、深度优先搜索(DFS)等。

掌握栈,不仅是理解数据结构的第一步,更是打开算法世界大门的钥匙。


📌 提示:在实际开发中,除非有特殊需求(如限制使用内置数组方法),否则直接使用 Arraypush/pop 即可高效模拟栈行为。

前端日常工作开发技巧汇总

2025年11月26日 18:41

一、JS篇

1. structuredClone 深拷贝

JavaScript 内置了一个 structuredClone() 的方法, 此方法提供了一种简单有效的方法来深度克隆对象,支持复杂数据类型,包括 DateRegExpMapSetArrayBufferBlobFile 等。浏览器底层实现,通常比手动递归或 JSON 方法更高效。

兼容性 image.png

2. 函数式编程

ES14 更新了许多数组方法或者为原有的数组方法增加不会带来突变(without mutation) 的互补方法。意味着它们会基于原数组创建新的数组,而不是直接修改原数组。

新增的互补方法有

  • Array.sort() -> Array.toSorted()
  • Array.splice() -> Array.toSpliced()
  • Array.reverse() -> Array.toReversed()

新增的新数组方法有:Array.with()Array.findLast()Array.findLastIndex()

  • Array.with()
    返回一个新数组,将原数组中指定索引 index 的元素替换为 value不修改原数组

语法
index:要替换的元素的索引(可以是负数,表示从末尾开始计数)。
value:替换后的新值

const newArray = array.with(index, value)

const arr = [1, 2, 3, 4];
const newArr = arr.with(1, "hello"); // 替换索引 1 的元素 

console.log(arr);    // [1, 2, 3, 4](原数组不变)
console.log(newArr); // [1, "hello", 3, 4](新数组)

// 支持负数索引(从末尾开始)
const newArr2 = arr.with(-2, "world"); // 替换倒数第 2 个元素
console.log(newArr2); // [1, 2, "world", 4]
  • Array.findLast()
    从数组末尾向前查找第一个满足 callback 条件的元素,并返回该元素。如果未找到,返回 undefined

  • Array.findLastIndex()
    从数组末尾向前查找第一个满足 callback 条件的元素,并返回其索引。如果未找到,返回 -1

3. 惰性函数

JavaScript 中的 惰性函数(Lazy Function) 是一种优化技术,其核心思想是:函数在第一次调用时执行一些初始化或判断逻辑,并在执行后将自身重定义为一个更高效或更简单的版本,后续调用就直接使用这个新版本,避免重复开销

普通写法

function copyToClipboard(text) {
    // 优先使用Clipboard API
    if (navigator.clipboard) {
      return navigator.clipboard
        .writeText(text)
        .then(() => {
          Message.success('复制成功')
          return true
        })
        .catch((err) => {
          console.error('使用Clipboard API复制失败: ', err)
          // 如果Clipboard API失败,尝试使用降级方案
          return copyUsingExecCommand(text)
        })
    } else {
      // 如果不支持Clipboard API,直接使用降级方案
      return copyUsingExecCommand(text)
    }
}

惰性写法

function copyToClipboard(text) {
  // 第一次调用时进行能力检测,并重定义自身
  if (navigator.clipboard) {
    // 支持 Clipboard API
    copyToClipboard = function (text) {
      return navigator.clipboard
        .writeText(text)
        .then(() => {
          console.log('文本已成功复制到剪贴板');
          return true;
        })
        .catch((err) => {
          console.error('Clipboard API 复制失败:', err);
          return false;
        });
    };
  } else {
    // 不支持 Clipboard API,使用 execCommand 降级方案
    copyToClipboard = function (text) {
      return copyUsingExecCommand(text);
    };
  }

  // 执行第一次调用
  return copyToClipboard(text);
}

二、CSS篇

1. 滚动吸附

<template>
  <div>
    <div class="container">
      <div class="item">1</div>
      <div class="item">2</div>
      <div class="item">3</div>
    </div>
  </div>
</template>

<script setup name="Snap"></script>

<style lang="scss" scoped>
.container {
  width: 100%;
  height: 300px;
  display: flex;
  overflow-x: scroll;
  // 吸附效果 mandatory: 必须吸附  proximity: 靠近时吸附
  scroll-snap-type: x mandatory;
  .item {
    flex-shrink: 0;
    width: 100%;
    height: 100%;
    display: flex;
    justify-content: center;
    align-items: center;
    font-size: 30px;
    color: #fff;
    background-color: #ccc;
    // 吸附位置
    scroll-snap-align: start;
    scroll-snap-stop: always;
    &:nth-child(1) {
      background-color: #f56c6c;
    }
    &:nth-child(2) {
      background-color: #67c23a;
    }
    &:nth-child(3) {
      background-color: #409eff;
    }
  }
}
</style>
兼容性较高

image.png

2. 字体自适应容器大小

<template>
  <div>
    <div class="container">
      <p>字体自适应容器大小</p>
    </div>
  </div>
</template>

<script setup name=""></script>

<style lang="scss" scoped>
.container {
  width: 500px;
  height: 300px;
  padding: 15px;
  resize: both;
  overflow: hidden;
  background-color: aquamarine;
  container-type: inline-size; // 启用容器查询 size:基于宽高 / inline-size 基于宽度 / normal 不启用
  p {
    font-size: 5cqh;
  }
}
</style>
兼容性还行

image.png

3. 选择器

  • 选择器特定性(通常叫做选择器权重)

当希望某个css属性优先级高于其他属性值时,尽量不要使用!important!important会打破这些固有的级联规则,使得样式的应用变得不那么可预测。这可能会导致样式表难以维护和理解,尤其是在大型项目中。增加调试难度,也限制了样式的灵活性。

替代方案: 通过编写更具体(或更精确)的选择器来覆盖样式,或者叠加选择器,比如:222

.el-button.el-button {
    color: red;
}
  • 新型选择器

:has()选择器: 根据一个元素是否包含某些特定的后代元素,或者其后的同级元素是否满足某些条件,来选中该元素本身。这实现了“向下”观察的能力。

示例1: 选择包含 <img><div>

<div>这个 div 没有图片,不会被选中</div>
<div>
    <img src="example.jpg" alt="示例图片">
    这个 div 包含图片,会被红色边框包围
</div>
/* 选择包含 <img> 的 div */ 
div:has(img) {
    border: 3px solid red; padding: 10px;
}

示例2: 选择紧跟着 <p><h2>

<h2>这个 h2 后面没有紧跟着 p,不会被选中</h2>
<div>分隔内容</div>
<h2>这个 h2 后面紧跟着 p</h2>
<p>这个 p 是 h2 的紧邻兄弟元素,因此 h2 会变成蓝色斜体</p>
/* 选择后面紧跟着 <p> 的 h2 */
h2:has(+ p) {
    color: blue; font-style: italic;
}

兼容性 image.png

:is()选择器: 它接受一个逗号分隔的选择器列表作为参数,并匹配其中任意一个选择器。这有助于减少冗余代码,提高可读性。:is() 的权重等于它括号里所有选择器中权重最高的那个。

示例:

<header>
    <h1>这是 header 的 h1(紫色)</h1>
</header>
<main>
    <h1>这是 main 的 h1(紫色)</h1>
</main>
<footer>
    <h1>这是 footer 的 h1(紫色)</h1>
</footer>
<section>
    <h1>这个 h1 不在 :is() 范围内,保持默认颜色</h1>
</section>
/* 统一设置 header、main、footer 下的 h1 样式 */
:is(header, main, footer) h1 {
    color: purple; font-family: Arial, sans-serif;
}

兼容性 image.png

:where()选择器: 与 :is() 类似,但权重永远为 0,适合默认样式。

兼容性 image.png

三、VUE篇

1. v-memo

Vue 3 提供的性能优化指令,其作用是通过缓存模板子树的渲染结果,仅在依赖项变化时重新渲染,从而减少不必要的虚拟 DOM 计算和更新操作。

v-memo 接收一个依赖数组,只有当数组中的值发生变化时才会重新渲染。

示例:优化大型列表渲染,避免全量更新。 当 item.id 或 item.status 变化时,仅更新对应项;其他项复用缓存结果

<div v-for="item in list" :key="item.id" v-memo="[item.id, item.status]">
  {{ item.content }}
  <StatusBadge :status="item.status" />
</div>

2. watch —— 副作用和深度监听

3. customRef ——— 自定义响应式依赖追踪

4. 组件的二次封装

四、Chrome浏览器调试技巧

1. $0

2. 模拟聚焦网页

3. 重放XHR

五、VSCode编辑器插件分享

1. i18n Ally

  • 代码内直接预览翻译文本
  • 快速生成初始翻译
  • 一键跳转至对应翻译条目
  • 集中管理
  "i18n-ally.localesPaths": ["./src/i18n/lang/locales"], // 翻译文件夹路径
  "i18n-ally.pathMatcher": "{locale}/**/{namespace}.json", // 翻译目标文件路径匹配
  "i18n-ally.keystyle": "nested", // 翻译路径格式,
  "i18n-ally.sourceLanguage": "zh-CN", // 翻译源语言
  "i18n-ally.displayLanguage": "zh-CN", //显示语言, 这里也可以设置显示英文为en
  "i18n-ally.sortKeys": true, // 是否自动排序
  "i18n-ally.namespace": false, // 是否启用命名空间,一般在积攒多个待翻译文案时启用,可以自动编辑至对应文件中
  "i18n-ally.enabledParsers": ["ts", "js", "json"], // 翻译文件可允许的格式,默认json

2. koroFileHeader @4.9.2

用于生成文件头部注释和函数注释的插件

快捷键‌:

  • 头部注释:Ctrl+Win+I(Windows/Linux)或 Ctrl+Cmd+I(Mac)
  • 函数注释:Ctrl+Win+T(Windows/Linux)或 Ctrl+Cmd+T(Mac)
// 头部注释
"fileheader.customMade": {
"Author": "git config user.name && git config user.email", // 同时获取用户名与邮箱
"Date": "Do not edit", // 文件创建时间
"LastEditors": "git config user.name && git config user.email", // 文件最后编辑者 与Author字段一致
"LastEditTime": "Do not edit", // 文件最后编辑时间
"FilePath": "Do not edit", // 文件在项目中的相对路径 自动更新
"Description": "" // 文件描述
},

通义千问Qwen3-Coder模型帮我总结的typescript笔记

作者 炒米2333
2025年11月26日 18:31

简单学了学typescript,但是又觉得做笔记麻烦,于是找AI帮帮忙,提示词为“帮我总结typescript的常用知识点,并生成一篇markdown。” 生成了下面的内容。


TypeScript 常用知识点速查

TypeScript (TS) 是 JavaScript 的超集,添加了静态类型系统,有助于提高代码质量和开发效率。以下是其核心和常用的特性总结。

目录

  1. 基础类型
  2. 变量声明与类型注解
  3. 接口 (Interfaces)
  4. 类型别名 (Type Aliases)
  5. 联合类型 (Union Types) & 交叉类型 (Intersection Types)
  6. 数组与元组 (Tuples)
  7. 函数类型
  8. 类 (Classes)
  9. 泛型 (Generics)
  10. 模块 (Modules) 和 导入/导出
  11. 装饰器 (Decorators)
  12. 实用工具类型 (Utility Types)

基础类型

TypeScript 提供了一系列基础数据类型:

  • number: 数字类型 (整数或浮点数)。
  • string: 字符串类型。
  • boolean: 布尔值 (truefalse)。
  • nullundefined: 它们有自己的类型 nullundefined。但在 strictNullChecks 模式下(推荐),它们只能赋值给 any 和各自的类型;否则(非严格模式),它们是所有类型的子类型。
  • symbol: ES6 新增的原始数据类型,表示独一无二的值。
  • bigint: 大整数类型。
  • void: 通常用于表示没有返回值的函数的返回类型。
  • any: 允许任何类型的值,会跳过类型检查(不推荐滥用)。
  • unknown: 代表任何值。与 any 不同的是,在对 unknown 类型的值执行操作前,必须进行类型检查或类型断言。
  • never: 表示永不存在的值的类型。例如,总是抛出异常或根本不可能有返回值的函数表达式的返回类型。
  • object: 非原始类型(即除 number, string, boolean, symbol, null, undefined, bigint 之外的类型)。注意:它不同于 {}

变量声明与类型注解

使用 let, const, var 声明变量,并通过冒号 : 添加类型注解。

let isDone: boolean = false;
let decimal: number = 6;
let color: string = "blue";
let list: number[] = [1, 2, 3];
let u: undefined = undefined;
let n: null = null;

// 函数参数和返回值的类型注解
function add(x: number, y: number): number {
    return x + y;
}

接口 (Interfaces)

接口用于定义对象的结构(Shape),是一种契约。

interface Person {
    name: string;
    age: number;
    address?: string; // 可选属性
    readonly id: number; // 只读属性
}

const user: Person = {
    name: "Alice",
    age: 30,
    id: 1
};
// user.id = 2; // Error: Cannot assign to 'id' because it is a read-only property.

// 函数类型接口
interface SearchFunc {
    (source: string, subString: string): boolean;
}
let mySearch: SearchFunc;
mySearch = function(src, sub) { // 参数名不必与接口中定义的名字相匹配
    let result = src.search(sub);
    return result > -1;
}

类型别名 (Type Aliases)

类型别名为类型创建一个新的名称。它可以用于原始值、联合类型、元组以及其它任何你需要手写的类型。

type Name = string;
type NameResolver = () => string;
type NameOrResolver = Name | NameResolver;

function getName(n: NameOrResolver): Name {
    if (typeof n === 'string') {
        return n;
    } else {
        return n();
    }
}

// 也可以像接口一样描述对象形状
type Point = {
    x: number;
    y: number;
};

// 类型别名可以使用交集和联合
type PartialPoint = { x: number; } | { y: number; };

接口 vs 类型别名:

  • 接口可以“打开”并扩展(Declaration Merging)。
  • 类型别名不能被重新打开以添加新的属性。
  • 接口通常用于定义对象的结构,而类型别名更通用。

联合类型 (Union Types) & 交叉类型 (Intersection Types)

  • 联合类型: 表示一个值可以是几种类型之一。使用 | 分隔每个类型。

    let value: string | number;
    value = "hello"; // OK
    value = 42;      // OK
    // value = true; // Error
    
  • 交叉类型: 将多个类型合并为一个类型。使用 & 连接。

    interface Colorful {
        color: string;
    }
    interface Circle {
        radius: number;
    }
    
    type ColorfulCircle = Colorful & Circle;
    
    const cc: ColorfulCircle = {
        color: "red",
        radius: 10
    }; // 必须同时满足 Colorful 和 Circle 的要求
    

数组与元组 (Tuples)

  • 数组: 存储相同类型的元素。

    let list1: number[] = [1, 2, 3];
    let list2: Array<number> = [1, 2, 3]; // 泛型语法
    
  • 元组: 允许表示一个已知元素数量和类型的数组,各元素的类型不必相同。

    let tuple: [string, number] = ['hello', 10];
    // tuple[0] = 10; // Error: Type 'number' is not assignable to type 'string'.
    

函数类型

  • 函数声明/表达式中的类型注解:

    function greet(name: string): string {
        return "Hello, " + name;
    }
    
    const greeter = function(name: string): string {
         return "Hello, " + name;
    };
    
    const arrowGreeter = (name: string): string => {
        return "Hello, " + name;
    };
    
  • 可选参数和默认参数:

    function buildName(firstName: string, lastName?: string) { ... } // lastName 是可选的
    function buildNameWithDefault(firstName: string, lastName = "Smith") { ... } // 默认参数
    
  • 剩余参数:

    function buildNameRest(firstName: string, ...restOfName: string[]) {
        return firstName + " " + restOfName.join(" ");
    }
    

类 (Classes)

TypeScript 支持面向对象编程的类。

class Animal {
    name: string;
    private age: number; // 私有属性
    protected species: string; // 受保护的属性
    readonly legs: number = 4; // 只读属性

    constructor(theName: string, theAge: number, theSpecies: string) {
        this.name = theName;
        this.age = theAge;
        this.species = theSpecies;
    }

    move(distanceInMeters: number = 0) {
        console.log(`${this.name} moved ${distanceInMeters}m.`);
    }
}

class Dog extends Animal {
    breed: string;

    constructor(name: string, age: number, species: string, breed: string) {
        super(name, age, species); // 调用父类构造函数
        this.breed = breed;
    }

    bark() {
        console.log('Woof! Woof!');
    }

    move(distanceInMeters = 5) { // 重写父类方法
        console.log("Running...");
        super.move(distanceInMeters);
    }
}

泛型 (Generics)

泛型允许你编写可复用的组件,这些组件可以工作在多种类型之上。

// 泛型函数
function identity<T>(arg: T): T {
    return arg;
}
let output1 = identity<string>("myString"); // type argument specified
let output2 = identity("myString"); // type argument inferred

// 泛型接口
interface GenericIdentityFn<T> {
    (arg: T): T;
}
let myIdentity: GenericIdentityFn<number> = identity;

// 泛型类
class GenericNumber<T> {
    zeroValue: T;
    add: (x: T, y: T) => T;
}

let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function(x, y) { return x + y; };

// 泛型约束
interface Lengthwise {
    length: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
    console.log(arg.length); // Now we know it has a .length property, so no more error
    return arg;
}
// loggingIdentity(3); // Error, number doesn't have a .length property
loggingIdentity({length: 10, value: 3}); // OK

模块 (Modules) 和 导入/导出

将代码组织到不同的文件中,并控制它们之间的可见性。

// math.ts
export const pi = 3.14;
export function add(x: number, y: number) {
    return x + y;
}
// 或者统一导出
// const pi = 3.14;
// function add(x: number, y: number) { return x + y; }
// export { pi, add };

// main.ts
import { pi, add } from './math';
console.log(pi); // 3.14
console.log(add(1, 2)); // 3

// 导入重命名
import { add as sum } from './math';

// 导入整个模块
import * as math from './math';
console.log(math.pi);

// 默认导出
// export default class Calculator { ... }
// import Calculator from './Calculator'; // 注意没有花括号 {}

装饰器 (Decorators)

一种特殊类型的声明,可以被附加到类声明,方法,访问符,属性或参数上。装饰器使用 @expression 这种形式,expression 求值后必须为一个函数,该函数会在运行时被调用。

需要启用实验性支持 ("experimentalDecorators": true in tsconfig.json)。

// 类装饰器示例
function sealed(constructor: Function) {
    Object.seal(constructor);
    Object.seal(constructor.prototype);
}

@sealed
class Greeter {
    greeting: string;
    constructor(message: string) {
        this.greeting = message;
    }
    greet() {
        return "Hello, " + this.greeting;
    }
}

实用工具类型 (Utility Types)

TypeScript 内置了一些非常有用的工具类型来简化常见的类型转换。

  • Partial<T>: 构造类型 T,并将它所有的属性设置为可选的。
  • Readonly<T>: 构造类型 T,并将它所有的属性设置为只读的。
  • Record<K, T>: 构造一个类型,其属性名的类型是 K,属性值的类型是 T
  • Pick<T, K>: 从类型 T 中选择出属性 K,构造成一个新的类型。
  • Omit<T, K>: 从类型 T 中排除掉属性 K,构造成一个新的类型。
  • Exclude<T, U>: 从 T 中排除掉那些可以赋值给 U 的类型。
  • Extract<T, U>: 从 T 中提取那些可以赋值给 U 的类型。
  • NonNullable<T>: 从 T 中排除 nullundefined
  • Parameters<T>: 获取函数类型的参数类型组成的元组类型。
  • ReturnType<T>: 获取函数类型的返回值类型。
  • InstanceType<T>: 获取构造函数类型的实例类型。
interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

type TodoPreview = Pick<Todo, 'title' | 'completed'>; // { title: string; completed: boolean; }

type TodoInfo = Omit<Todo, 'description'>; // { title: string; completed: boolean; }

type Status = 'pending' | 'fulfilled' | 'rejected';
type SyncStatus = Exclude<Status, 'pending'>; // 'fulfilled' | 'rejected'

function f1(s: string) { return { a: 1, b: s }; }
type F1Params = Parameters<typeof f1>; // [s: string]
type F1ReturnType = ReturnType<typeof f1>; // { a: number; b: string; }

这 5 个冷门的 HTML 标签,能让你少写 100 行 JS

作者 ErpanOmer
2025年11月25日 15:38

image.png

大家好!😁。

Code Review 的时候,我最怕看到什么?

不是复杂的算法,也不是什么正则。而是明明一个 HTML 标签就能搞定的事,有人非要写几百行 JS + CSS 去重新发明轮子

前几天,我看到一个新同学为了写一个折叠面板(Accordion),引入了一个重型的第三方库,还写了一堆 useStateonClick 和动画逻辑。

我默默地把他的代码全删了,换成了 3 行 <details>。他看我的眼神,仿佛在看一个外星人🤣。

在 2025 年的今天,浏览器原生 HTML 的能力早已今非昔比。很多我们习惯用 JS 去模拟的交互,现在不仅有原生支持,而且性能更好、兼容性更强、无障碍(a11y)更完善

今天,我就来盘点 5 个被严重低估的HTML标签👇。


<details> & <summary>:折叠组件

你是不是还在写这样的 React 代码?

// JS 模拟版
const [isOpen, setIsOpen] = useState(false);
return (
  <div className="accordion">
    <div className="header" onClick={() => setIsOpen(!isOpen)}>
      点击展开 {isOpen ? '⬆️' : '⬇️'}
    </div>
    {isOpen && <div className="content">...</div>}
  </div>
);

为了这个功能,你还得写 CSS 动画,还得处理键盘事件(Tab 键能不能选到?回车能不能展开?等等)。

HTML 原生写法:

<details>
  <summary>点击展开</summary>
  <div class="content">
    这里是展开后的内容,原生支持 Ctrl+F 页内搜索!
  </div>
</details>
  1. 没有任何JS:自带点击展开/收起交互。
  2. 无障碍(a11y)满分:屏幕阅读器能完美识别,Tab 键、回车键原生支持。
  3. 页内搜索:这是 JS 模拟版最大的痛点。如果内容被 JS 隐藏了(display: none),浏览器的 Ctrl+F 往往搜不到。但 <details> 里的内容,即使折叠,浏览器也能搜到并自动展开!

Untitled ‑ Made with FlexClip.gif

配合 CSS 👇

details {
  border: 1px solid #ccc;
  border-radius: 6px;
  padding: 8px;
}

summary {
  cursor: pointer;
  font-weight: bold;
}

/* 包住内容,让它能动画高度 */
details > .content {
  overflow: hidden;
  max-height: 0;
  opacity: 0;
  transition: max-height .45s ease, opacity .3s ease;
}

/* details 处于 open 状态时 */
details[open] > .content {
  max-height: 200px; /* 你内容高度大概多少设多少,足够大即可 */
  opacity: 1;
}

依然可以做动画。


<dialog>:弹窗组件

写模态框(Modal)是前端最大的坑之一。你需要考虑:

  • z-index 层级会不会被遮挡?
  • 点击遮罩层关闭?
  • Focus Trap(焦点锁定) :打开弹窗后,Tab 键不能跑到底层页面去。
  • 按下 Esc 键关闭?

为了解决这些,我们通常会引入 Antd Modal 或者 React Portal。但在轻量级场景下,原生 <dialog> 才是神🫅。

HTML 原生:

<dialog id="myModal">
  <form method="dialog">
    <p>这是一个原生模态框</p>
    <button>关闭(自动)</button>
  </form>
</dialog>

<button onclick="myModal.showModal()">打开弹窗⏏</button>

Untitled ‑ Made with FlexClip.gif

  1. Top Layer(顶层特性) :浏览器会把它渲染在所有 DOM 的最上层,彻底无视父元素的 z-indexoverflow: hidden
  2. ::backdrop 伪元素:直接用 CSS 定制遮罩层样式。
/* 背景遮罩 */
dialog::backdrop {
    background: rgba(0, 0, 0, 0.45);
    backdrop-filter: blur(3px);
    transition: opacity .3s ease;
}
  1. 原生交互:自带 Esc 关闭,自带焦点管理,表单提交自动关闭。

<datalist>:搜索自动补全

当产品经理要求做一个带搜索建议的输入框时,你的第一反应是不是:“快!引入 Select2 或者 Antd AutoComplete!😖

且慢。如果只是简单的建议列表,几 KB 的 JS 库都显得太重了。

HTML 原生版:

<label>选择你喜欢的框架:</label>
<input list="frameworks" />

<datalist id="frameworks">
  <option value="React">
  <option value="Vue">
  <option value="Svelte">
  <option value="Angular">
  <option value="Solid">
</datalist>
  1. 模糊搜索:浏览器原生支持模糊匹配(打 u 会出来 Vue)。
  2. 响应式:在手机上,它会调用系统原生的下拉选择 UI,体验比网页模拟的更顺滑。
  3. 解耦:它只是一个建议列表,用户依然可以输入列表里没有的值(这点和 Select 不同)。

<fieldset> & disabled:一键禁用整个表单

场景:用户点击提交按钮后,为了防止重复提交,我们需要禁用表单里的所有输入框

JS 笨办法:

// 还要一个个去拿 DOM,或者维护一个 loading 状态传给所有组件
inputs.forEach(input => input.disabled = true);
buttons.forEach(btn => btn.disabled = true);

HTML 原生写法:

<form>
  <fieldset disabled id="login-group">
    <legend>登录</legend>
    <input type="text" placeholder="用户名">
    <input type="password" placeholder="密码">
    <button>提交</button>
  </fieldset>
</form>

<script>
  // 一行代码搞定状态切换
  document.getElementById('login-group').disabled = true; 
</script>

clideo_editor_c3a7f45a392f482ea0added4098a5be3.gif

这是一个极好的分组思维。通过给 <fieldset> 设置 disabled,浏览器会自动禁用内部所有的 <input>, <select>, <button>。不用写循环,不用维护复杂的 State。


<input type="file" capture>:H5 调用手机相机

场景:业务需要用户上传一张照片,可以是相册选的,也可以是当场拍的

很多新手的反应是:是不是要接微信 JSSDK?是不是要写个 Bridge 调原生 App 能力?

答案是不需要!

HTML 原生:

<input type="file" capture="environment" accept="image/*">

只要加上 capture 属性,在移动端(iOS/Android)点击上传时,系统会直接拉起相机,而不是让你去选文件。拍完照后,你拿到的就是一个标准的 File 对象。

不需要什么 JS SDK,实现原生级体验👍。


我总是强调 最好的代码,是没有代码!

HTML 标准一直在进化,很多曾经需要重型 JS 才能实现的功能,现在已经成了浏览器的出厂设置了。

使用这些原生标签,不仅能减少打包体积,更能让你的应用在可访问性性能上天然领先。

下次再想 npm install 一个 UI 库之前,先查查 MDN。说不定,HTML 早就帮你做好了🤔。

UI小姐姐要求有“Duang~Duang”的效果怎么办?

作者 前端九哥
2025年11月25日 13:51

test.gif

设计小姐姐: “搞一下这样的回弹效果,你行不行?”
:“行!直接梭哈 50 行 keyframes + transform + 各种百分比,搞定 ”
设计小姐姐:“太硬(撇嘴),不够 Q 弹(鄙视)”
:(裂开)
隔壁老王:这么简单你都不行,我来一行贝塞尔 cubic-bezier(0.3, 1.15, 0.33, 1.57) 秒了😎
设计小姐姐:哇哦!(兴奋)好帅!(星星眼🌟)好Q弹!(一脸崇拜😍)
“???”


🧠 一、为什么一行贝塞尔就能“Duang”起来?

1️⃣ cubic-bezier 是什么?

在 CSS 动画里,我们经常写:

transition: all 0.5s ease;

但其实 easelinearease-in-out 这些都只是封装好的贝塞尔曲线。
底层原理是:

cubic-bezier(x1, y1, x2, y2)

这四个参数定义了时间函数曲线,控制动画速度的变化。

  • x:时间轴(必须在 0~1 之间)
  • y:数值轴(可以超出 0~1!)

👉 当 y 超过 1 或小于 0 时,动画值就会冲过终点再回弹
这就是“回弹感”的核心。


2️⃣ 回弹的本质:过冲 + 衰减

想象一个球掉下来:

  • 过冲:球落地时会压扁(超出终点)
  • 回弹:然后反弹回来,再逐渐稳定

在动画中,这个“过冲”就是 y>1 的部分,
而“回弹”就是曲线回到 y=1 的过程。


🧪 二、一行贝塞尔的魔法

✅ 火箭发射

export_1764044056566.gif

<div class="bounce">🚀发射!</div>

<style>
.bounce {
 transition: transform 0.8s cubic-bezier(0.68, -0.55, 0.27, 1.55);
}
.bounce:hover {
 transform: translateY(-500px);
}
</style>

💡 参数解析:

  • y1 = -0.55 → 先轻微反向缩小
  • y2 = 1.55 → 再冲过头 55%,最后回弹到原位

🧩 四、常用贝塞尔参数

效果描述 贝塞尔参数 备注
微回弹(按钮) cubic-bezier(0.34, 1.31, 0.7, 1) 轻柔弹性
强回弹(卡片) cubic-bezier(0.68, -0.55, 0.27, 1.55) 爆发力强
柔和出入 cubic-bezier(0.4, 0, 0.2, 1.4) iOS 风
弹性放大 cubic-bezier(0.175, 0.885, 0.32, 1.275) 弹簧感
火箭猛冲 cubic-bezier(0.68, -0.55, 0.27, 1.55) 推背感

🧰 五、调试神器推荐

  • 🎨 cubic-bezier.com
    拖动手柄实时预览动画,复制参数一键搞定。

  • ⚙️ easings.net
    收录各种 easing 函数(含物理弹簧、阻尼等)。

本地系统、虚拟机、远程服务器三者之间的核心区别

作者 tomato_404
2025年11月26日 18:25

🌍 一句话总结

✅ 你之所以用 Tabby 连接公司服务器 不需要虚拟机,
是因为那台服务器 已经是一台运行着 Ubuntu 的独立电脑(远程机器)
而你要在自己的电脑上“拥有一个 Ubuntu 环境”,
就得自己“造出”这样一台虚拟的电脑——那就是虚拟机。


🧠 类比理解:虚拟机 vs 远程服务器

概念 是什么 在哪儿运行 你如何进入
💻 你自己的电脑(Windows) 物理机 桌子上的电脑 直接操作
🧱 虚拟机(VM) 一台“在你电脑里模拟出来的另一台电脑” 运行在你的物理机里 通过虚拟机软件打开
🖥️ 公司服务器(Ubuntu) 一台真实存在的远程电脑(在机房或云上) 运行在公司网络 用 SSH(如 Tabby)远程连接

所以区别就在于:

  • 虚拟机 = 在你电脑里“模拟出另一台电脑”
  • 服务器 = 已经存在的“另一台真实电脑”
  • SSH(Tabby) = 你用网线连过去远程操作那台电脑的“键盘和屏幕”。

🔍 更深入一点:Tabby 并不是运行 Ubuntu,而是在“远程登录”Ubuntu

当你运行:

ssh user@192.168.31.185

或用 Tabby 连接公司服务器时:

  • 你的电脑只是一个终端界面(相当于“显示器 + 键盘”);
  • 运行命令、文件系统、网络配置都发生在远程服务器上;
  • 所以你会看到那边的:
    • Ubuntu 系统
    • 已经安装好的 nginx、docker 等等;
    • 你只是在操作那台远程机器。

而如果你想在自己的电脑上也有一样的 Ubuntu 环境,
因为你手上这台机器运行的可能是 WindowsmacOS
那就得“虚拟”出一台 Ubuntu 系统的电脑,靠虚拟机实现。


🧩 对比一下你的两种情况:

场景 运行的系统 物理机器是谁 为什么能用 Ubuntu
① Tabby 连公司服务器 Ubuntu 服务器(192.168.31.185) 服务器已经装好了 Ubuntu
② 自己电脑(Windows) Windows 你自己的笔电 / PC 没有 Ubuntu,需要虚拟机或 WSL

⚙️ 那如果我想像公司服务器那样,本机也能跑 Ubuntu,有哪些选择?

方案 原理 是否需要虚拟机
VirtualBox / VMware 安装 Ubuntu 直接在 Windows 上开一台“虚拟电脑”装 Ubuntu ✅ 需要虚拟机
WSL2 (Windows Subsystem for Linux) 微软提供的轻量 Linux 子系统 ❌ 不需要传统虚拟机,推荐
Docker Desktop 它在底层用轻量虚拟化跑 Linux 内核 ⚙️ 自动管理虚拟层,无需你配置
买/租云服务器(远程主机) 直接连远程 Ubuntu ❌ 不需要虚拟机,本身就是服务器

🧭 你可以这样理解一条主线:

物理机器(你的电脑 或 公司服务器)
→ 上面运行系统(Windows 或 Ubuntu)
→ 系统里装环境(nginx、docker 等)

当你连接服务器时,
你是“借用公司那台 Ubuntu 机器的系统”;
当你在本机装虚拟机时,
你是在“自己电脑里再创建一台 Ubuntu 机器”。


🚀 推荐你目前最方便的做法(如果你是 Windows 用户)

👉 使用 WSL2 + Ubuntu 子系统
不需要虚拟机软件、不占太多资源、命令与公司服务器完全一致。

安装命令(管理员 PowerShell):

wsl --install

安装完成后:

wsl -l -v   # 查看 Ubuntu 是否安装成功
wsl         # 进入 Ubuntu 终端

现在你就在一个真正的 Ubuntu 环境里了,
可以执行:

sudo apt update
sudo apt install nginx docker.io -y

这时你本地环境的使用体验,就和你用 Tabby 连公司服务器非常接近。

uni-app D5 实战(小兔鲜)

2025年11月26日 17:45

1. 热门推荐

1.1 热门推荐-准备工作

内容不同,但结构相同,所以要复用热门推荐组件,再动态传值渲染 image.png

1.2 静态结构

// /src/pages/hot/hot.vue
<script setup lang="ts">
// 热门推荐页 标题和url
const hotMap = [
  { type: '1', title: '特惠推荐', url: '/hot/preference' },
  { type: '2', title: '爆款推荐', url: '/hot/inVogue' },
  { type: '3', title: '一站买全', url: '/hot/oneStop' },
  { type: '4', title: '新鲜好物', url: '/hot/new' },
]
</script>

<template>
  <view class="viewport">
    <!-- 推荐封面图 -->
    <view class="cover">
      <image
        src="http://yjy-xiaotuxian-dev.oss-cn-beijing.aliyuncs.com/picture/2021-05-20/84abb5b1-8344-49ae-afc1-9cb932f3d593.jpg"
      ></image>
    </view>
    <!-- 推荐选项 -->
    <view class="tabs">
      <text class="text active">抢先尝鲜</text>
      <text class="text">新品预告</text>
    </view>
    <!-- 推荐列表 -->
    <scroll-view scroll-y class="scroll-view">
      <view class="goods">
        <navigator
          hover-class="none"
          class="navigator"
          v-for="goods in 10"
          :key="goods"
          :url="`/pages/goods/goods?id=`"
        >
          <image
            class="thumb"
            src="https://yanxuan-item.nosdn.127.net/5e7864647286c7447eeee7f0025f8c11.png"
          ></image>
          <view class="name ellipsis">不含酒精,使用安心爽肤清洁湿巾</view>
          <view class="price">
            <text class="symbol">¥</text>
            <text class="number">29.90</text>
          </view>
        </navigator>
      </view>
      <view class="loading-text">正在加载...</view>
    </scroll-view>
  </view>
</template>

<style lang="scss">
page {
  height: 100%;
  background-color: #f4f4f4;
}
.viewport {
  display: flex;
  flex-direction: column;
  height: 100%;
  padding: 180rpx 0 0;
  position: relative;
}
.cover {
  width: 750rpx;
  height: 225rpx;
  border-radius: 0 0 40rpx 40rpx;
  overflow: hidden;
  position: absolute;
  left: 0;
  top: 0;
}
.scroll-view {
  flex: 1;
}
.tabs {
  display: flex;
  justify-content: space-evenly;
  height: 100rpx;
  line-height: 90rpx;
  margin: 0 20rpx;
  font-size: 28rpx;
  border-radius: 10rpx;
  box-shadow: 0 4rpx 5rpx rgba(200, 200, 200, 0.3);
  color: #333;
  background-color: #fff;
  position: relative;
  z-index: 9;
  .text {
    margin: 0 20rpx;
    position: relative;
  }
  .active {
    &::after {
      content: '';
      width: 40rpx;
      height: 4rpx;
      transform: translate(-50%);
      background-color: #27ba9b;
      position: absolute;
      left: 50%;
      bottom: 24rpx;
    }
  }
}
.goods {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  padding: 0 20rpx 20rpx;
  .navigator {
    width: 345rpx;
    padding: 20rpx;
    margin-top: 20rpx;
    border-radius: 10rpx;
    background-color: #fff;
  }
  .thumb {
    width: 305rpx;
    height: 305rpx;
  }
  .name {
    height: 88rpx;
    font-size: 26rpx;
  }
  .price {
    line-height: 1;
    color: #cf4444;
    font-size: 30rpx;
  }
  .symbol {
    font-size: 70%;
  }
  .decimal {
    font-size: 70%;
  }
}

.loading-text {
  text-align: center;
  font-size: 28rpx;
  color: #666;
  padding: 20rpx 0 50rpx;
}
</style>

1.3 给热门组件绑定跳转到hot页面

image.png

1.3.1 传参

image.png

image.png

image.png

1.3.2 顶部传参结果

image.png

image.png

1.4 获取数据-获取热门推荐的数据

image.png 四个接口除路径,其他都相同,所以做个统一封装,提高代码复用 image.png

1.4.1 封装四个接口的通用结构

使用交叉类型‘&’拓展字段 image.png

1.4.2 在热门页面中获取数据并调用

image.png

1.4.3 控制台输出的结果:

image.png

1.5 热门推荐-定义类型

返回的数据结构和类型与先前的‘猜你喜欢’差不多,所以可以复用‘猜你喜欢’

image.png 返回的结构为:

image.png

image.png

1.5.1 通用类型的定义

item:

image.pngimage.png

1.5.2 因为热门推荐和‘猜你喜欢’的数据类型完全一致,所以直接复用

image.png

1.5.3 封装热门推荐的数据类型(有复用之前的数据类型)

HotResult:

image.png subTypes:

image.png 之前封装的通用分页类型:

image.png

最终的代码展示:

image.png

1.5.4 使用定义好的HotResult方法

image.png

1.6 热门推荐-渲染页面和Tab交互

image.png给渲染作为参考的后端数据:

image.png

1.6.1 使用ref,定义bannerpicture接收res。reslut。bannerpicture,再动态传值给对应的页面标签

image.png

1.6.2 bannerpicture成功渲染

image.png

image.png

1.6.3 使用同样的方法更改hot页面的两个title

image.png

1.6.4 数据渲染成功

image.png

image.png

1.7 解决字段都有active导致的所有字段高亮问题(使用动态绑定样式)

v-for自动循环activeIndex,先默认选中第一个activeindex(下标为0)。index是tap事件获得的,如果activeIndex===index,就给对应的activeindex添加高亮, image.png

1.7.1结果:

image.png

1.8 列表内容的渲染

(点击tap实现内容的切换)(这里使用v-show;不用v-if,因为v-if就是在不断的销毁和创建,很消耗性能)
image.png

image.png

1.8.1 结果:

image.png

1.9 列表的分页加载

当页面滚动触底的时候加载页面数据

image.png

1.9.1 给滚动容器添加一个滚动触底的事件

image.png 滚动tab触底,输出的结果:

image.png

1.9.2 基于对应的id,触底才传goodsitem(用于区别tab)

- getHotRecommendAPI 被调用时传入的就是 currHot?.url,也就是那一个匹配项的 url。所以只会返回哪一个页面的goodsitem image.png发现只有滚动的对应的tabs才会有goodsitem image.png

1.9.3 将获取到的新的后端数据添加到列表

image.png

1.9.4 实现了页码的累加和数组的追加

image.png

1.10 分页条件

image.png

1.10.1 使用条件语句判断是否能继续加载,否则轻提示

image.png
页面全都加载完,触发轻提示 image.png

小技巧:如果在开发环境,则设置为30,便于测试。否则为1 image.png

2.商品分类

2.1 静态结构-商品分类页属于tabBar页

<script setup lang="ts">
//
</script>

<template>
  <view class="viewport">
    <!-- 搜索框 -->
    <view class="search">
      <view class="input">
        <text class="icon-search">女靴</text>
      </view>
    </view>
    <!-- 分类 -->
    <view class="categories">
      <!-- 左侧:一级分类 -->
      <scroll-view class="primary" scroll-y>
        <view v-for="(item, index) in 10" :key="item" class="item" :class="{ active: index === 0 }">
          <text class="name"> 居家 </text>
        </view>
      </scroll-view>
      <!-- 右侧:二级分类 -->
      <scroll-view class="secondary" scroll-y>
        <!-- 焦点图 -->
        <XtxSwiper class="banner" :list="[]" />
        <!-- 内容区域 -->
        <view class="panel" v-for="item in 3" :key="item">
          <view class="title">
            <text class="name">宠物用品</text>
            <navigator class="more" hover-class="none">全部</navigator>
          </view>
          <view class="section">
            <navigator
              v-for="goods in 4"
              :key="goods"
              class="goods"
              hover-class="none"
              :url="`/pages/goods/goods?id=`"
            >
              <image
                class="image"
                src="https://yanxuan-item.nosdn.127.net/674ec7a88de58a026304983dd049ea69.jpg"
              ></image>
              <view class="name ellipsis">木天蓼逗猫棍</view>
              <view class="price">
                <text class="symbol">¥</text>
                <text class="number">16.00</text>
              </view>
            </navigator>
          </view>
        </view>
      </scroll-view>
    </view>
  </view>
</template>

<style lang="scss">
page {
  height: 100%;
  overflow: hidden;
}
.viewport {
  height: 100%;
  display: flex;
  flex-direction: column;
}
.search {
  padding: 0 30rpx 20rpx;
  background-color: #fff;
  .input {
    display: flex;
    align-items: center;
    justify-content: space-between;
    height: 64rpx;
    padding-left: 26rpx;
    color: #8b8b8b;
    font-size: 28rpx;
    border-radius: 32rpx;
    background-color: #f3f4f4;
  }
}
.icon-search {
  &::before {
    margin-right: 10rpx;
  }
}
/* 分类 */
.categories {
  flex: 1;
  min-height: 400rpx;
  display: flex;
}
/* 一级分类 */
.primary {
  overflow: hidden;
  width: 180rpx;
  flex: none;
  background-color: #f6f6f6;
  .item {
    display: flex;
    justify-content: center;
    align-items: center;
    height: 96rpx;
    font-size: 26rpx;
    color: #595c63;
    position: relative;
    &::after {
      content: '';
      position: absolute;
      left: 42rpx;
      bottom: 0;
      width: 96rpx;
      border-top: 1rpx solid #e3e4e7;
    }
  }
  .active {
    background-color: #fff;
    &::before {
      content: '';
      position: absolute;
      left: 0;
      top: 0;
      width: 8rpx;
      height: 100%;
      background-color: #27ba9b;
    }
  }
}
.primary .item:last-child::after,
.primary .active::after {
  display: none;
}
/* 二级分类 */
.secondary {
  background-color: #fff;
  .carousel {
    height: 200rpx;
    margin: 0 30rpx 20rpx;
    border-radius: 4rpx;
    overflow: hidden;
  }
  .panel {
    margin: 0 30rpx 0rpx;
  }
  .title {
    height: 60rpx;
    line-height: 60rpx;
    color: #333;
    font-size: 28rpx;
    border-bottom: 1rpx solid #f7f7f8;
    .more {
      float: right;
      padding-left: 20rpx;
      font-size: 24rpx;
      color: #999;
    }
  }
  .more {
    &::after {
      font-family: 'erabbit' !important;
      content: '\e6c2';
    }
  }
  .section {
    width: 100%;
    display: flex;
    flex-wrap: wrap;
    padding: 20rpx 0;
    .goods {
      width: 150rpx;
      margin: 0rpx 30rpx 20rpx 0;
      &:nth-child(3n) {
        margin-right: 0;
      }
      image {
        width: 150rpx;
        height: 150rpx;
      }
      .name {
        padding: 5rpx;
        font-size: 22rpx;
        color: #333;
      }
      .price {
        padding: 5rpx;
        font-size: 18rpx;
        color: #cf4444;
      }
      .number {
        font-size: 24rpx;
        margin-left: 2rpx;
      }
    }
  }
}
</style>

2.2 获取轮播图数据

首页轮播图和这里的轮播可以复用

image.png 结果:

image.png

一键去水印|5 款免费小红书解析工具推荐

2025年11月26日 17:14

一、前言

刷短视频时,看到喜欢的内容想存到手机里,结果点"保存"却提示"不允许"?别慌,不是手机坏了,而是作者或平台把下载开关关掉了。下面教你几招,照样能把视频"救"回来——适用于抖音、小红书、快手等主流 App,全程大白话,一看就会。

二、直接保存到相册

对于未限制下载权限的视频,操作步骤如下:

  1. 点击分享按钮。
  2. 在弹出的选项中,选择保存到相册
  3. 视频会自动保存到手机相册中。

三、无法直接保存的视频

有些视频因创作者设置了"禁止下载"功能,保存到相册按钮会显示为灰色,无法直接点击。这种情况下,可以尝试以下方法:

1. 屏幕录制

  • 利用手机自带的屏幕录制功能,手动录制视频内容。
  • 缺点:可能需要后续裁剪多余的内容。

2. 借助第三方工具

下面几个在线工具亲测可用,支持抖音 + 小红书。请按需选择,用完即走:

工具名称 & 入口 支持平台 速度/稳定性 特色小功能
去水印下载鸭 nologo.code24.top 抖音、小红书、快手等 快(CDN 国内双线) 浏览器插件+小程序;自动识别剪贴板
小红书专用下载 www.xhs-download.online 仅小红书 极快(节点在香港,晚高峰也稳) 专注小红书高清去水印下载
下载狗 www.xiazaitool.com/xhs 抖音、小红书、快手等 中等(教育网可用) 轻量级小红书去水印神器,图片、视频一键在线去水印;无广告
小红刷 www.xiaohongshua.com 仅小红书 界面极简,三步搞定
RedNote 视频下载器 www.rednote-downloader.com/zh 仅小红书 界面极简

使用流程(大同小异):

  1. 在 App 里点"分享 → 复制链接"
  2. 把链接粘贴到对应网页的输入框
  3. 等待 3-5 秒出现下载按钮 → 右键/长按保存即可

前端三大权限场景全解析:设计、实现、存储与企业级实践

2025年11月26日 17:13
权限控制是前端应用(尤其是中大型系统)的核心安全与体验保障,完整的权限体系需覆盖「路由权限、页面元素权限、接口权限」三大场景。本文结合真实项目落地经验,系统梳理各场景的应用逻辑、实现方案、设计模式与存

鸿蒙 web组件开发

作者 lichong951
2025年11月26日 16:35

20251126-163113.gifHarmonyOS NEXT(API 12+) 的 ArkTS 工程。示例均已在 DevEco Studio 4.1.3 真机运行通过。


一、最小可运行骨架(ArkTS)

// entry/src/main/ets/pages/WebPage.ets
import { webview } from '@kit.ArkWeb';

@Entry
@Component
struct WebPage {
  // 1. 控制器
  ctrl = new webview.WebviewController();

  build() {
    Column() {
      Web({
        src: 'https://developer.harmonyos.com',
        controller: this.ctrl
      })
        .width('100%')
        .height('100%')
        .javaScriptAccess(true)          // 允许 JS
        .domStorageAccess(true)          // 允许 localStorage
        .mixedMode(MixedMode.All)        // 允许 http/https 混合
        .onPageEnd(() => {
          console.info('=== 页面加载完成');
        });
    }
  }
}

module.json5 里记得加网络权限:

"requestPermissions": [
  { "name": "ohos.permission.INTERNET" }
]

二、常见能力“开箱即用”

能力点 关键代码/注意事项 备注
加载本地包内页面 src: $rawfile('src/main/resources/rawfile/index.html') 把静态资源放到 rawfile 目录即可
本地 HTML 字符串 this.ctrl.loadData('Hello', 'text/html', 'UTF-8') 适合做富文本邮件、协议弹窗
进度条/标题栏联动 .onProgressChange(e => this.curProgress = e.newProgress) 0-100,可绑 Progress 组件
返回键拦截 onBackPress():boolean{ if(this.ctrl.accessBackward()){this.ctrl.backward();return true} return false } 避免直接退出页面
调试+远程 inspect webview.WebviewController.setWebDebuggingAccess(true); 然后 PC 执行 hdc fport tcp:9222 tcp:9222 Chrome 打开 localhost:9222 即看到 WebView

三、原生 ↔ JS 双向通信(类型安全)

  1. ArkTS 调 JS
this.ctrl.runJavaScript('window.calc(3,4)', (err, result) => {
  if (!err) console.log('JS 返回结果:' + result); // 7
});
  1. JS 调 ArkTS
// 1. 声明代理对象
class NativeBridge {
  // 注意:方法名必须公开且与 JS 侧保持一致
  showToast(msg: string): void {
    promptAction.showToast({ message: msg, duration: 2000 });
  }
}
// 2. 注册到 window.appBridge
this.ctrl.registerJavaScriptProxy(new NativeBridge(), 'appBridge', ['showToast']);

页面里即可:

<script>
  appBridge.showToast('Hello from H5!');
</script>

完整示例见 。


四、文件下载完全托管(HarmonyOS 5.0+)

  1. 自定义下载委托
import { webview } from '@kit.ArkWeb';

class MyDownloadDelegate implements webview.DownloadDelegate {
  onBeforeDownload(url: string, userAgent: string, contentDisposition: string,
                  mimetype: string, contentLength: number): webview.DownloadConfig {
    // 返回自定义路径 / 文件名
    return {
      downloadPath: getContext().cacheDir + '/web_download/' + Date.now() + '.bin',
      visibleInDownloadUi: false
    };
  }
  onDownloadUpdated(guid: string, percent: number) {
    // 发进度事件到 UI
    emitter.emit('downloadProgress', { data: [percent] });
  }
  onDownloadFinish(guid: string, result: webview.DownloadResult) {
    promptAction.showToast({ message: '下载完成' });
  }
}
  1. 绑定到 WebView
aboutToAppear(): void {
  this.ctrl.setDownloadDelegate(new MyDownloadDelegate());
}

如此即可拦截所有 <a download>、Blob、DataURL 等资源,走系统级下载,无需自己写线程 。


五、本地 H5 “ES-Module” 跨域踩坑 & 根治

现象 index.html 里写

<script type="module">import {a} from './util.js'</script>

真机报 CORS blockedfile:// 协议被同源策略拦截 。

官方方案(API 12+)

aboutToAppear(): void {
  // 1. 允许 file 协议加载任意资源
  webview.WebviewController.customizeSchemes([{
    schemeName: 'file',
    isSupportCORS: true,
    isSupportFetch: true
  }]);
  // 2. 允许 file 访问 file
  let cfg = new webview.WebViewConfig();
  cfg.setAllowFileAccessFromFileURLs(true);
  cfg.setAllowUniversalAccessFromFileURLs(true);
  // 3. 创建 WebviewController 时把 config 带进去
  this.ctrl = new webview.WebviewController(cfg);
}

再配合 onInterceptRequest 做“本地资源兜底”,可 100% 解决本地 ES-Module、Fetch、XHR 跨域问题 。


六、性能/体验小贴士

  1. 前进/后退缓存(打开即切页面不闪白)
const cacheOpt = new webview.BackForwardCacheOptions();
cacheOpt.size = 5;               // 缓存 5 个历史
cacheOpt.timeToLive = 5 * 60;    // 5 分钟
this.ctrl.setBackForwardCache(cacheOpt);
  1. 内核级启动加速
this.ctrl.setNativeOption({
  kernelParams: ['--disable-gpu-vsync', '--enable-fast-unload']
});
  1. 预览器 不支持 WebView,一切效果以 真机 为准 。

七、快速复现 & 学习路径

  1. 将上面“最小骨架”复制到 Empty Ability 工程 → 真机运行 → 能出网页即环境 OK。
  2. 再把“双向通信”“下载托管”“本地 ES-Module”三段代码分别贴进去验证。
  3. 官方示例仓库(Gitee) gitee.com/openharmony… 已同步上述所有用法,可直接 git clone 跑通。

至此,鸿蒙 WebView(ArkWeb)开发所需 加载、通信、下载、跨域、性能 主线能力已全部覆盖,可直接搬入生产项目。祝开发顺利!

Electron - IPC 解决主进程和渲染进程之间的通信

作者 一千柯橘
2025年11月26日 16:30

Electron 的主进程是一个 Node.js 环境,因此 主进程可以使用 Node.js 的内置模块以及相关 Node.js 环境的 npm 安装包,主进程拥有对操作系统的完全访问权限。 但是渲染器进程默认运行在网页端而不是运行 Node.js, 为了将 Electron 的不同进程类型桥接在一起,我们需要使用一个称为 preload 的特殊脚本

注意的是:从 Electron 20 开始,preload 脚本默认被沙箱化,并且不再能够访问完整的 Node.js 环境。这意味着您只能访问一组有限的 API, 代码参考: github.com/kejuqu/elec…

BrowserWindow's preload 脚本运行在既有能访问 HTML DOM 又有部分 Node.js 和 Electron 子集功能, 详细的区别:

可用的 API 详细
Electron modules 渲染器进程模块
Node.js modules eventstimersurl
Polyfilled globals BufferprocessclearImmediatesetImmediate

Preload 脚本在渲染器页面加载前被注入,如果要为渲染器添加需要特权访问的功能可以通过contextBridge API 来定义 全局对象 

暴露 node.js 模块的信息到页面(renderer 进程)

contextBridge.exposeInMainWorld 在主进程和渲染进程之间建立一个安全的 “通道”,将主进程指定的 API/变量/函数暴露到渲染进程的 全局对象(window) 对象上,供前端直接调用。 将 process.versions 的 node, chrome, electron 暴露到 renderer 进程

// 新建 src/preloads/versions.js
import { contextBridge } from "electron";

// contextBridge.exposeInMainWorld(key,value)

// 通过 contextBridge.exposeInMainWorld 向 window 对象暴露一个名为 versions 的对象
contextBridge.exposeInMainWorld("versions", {
  node: () => process.versions.node,
  chrome: () => process.versions.chrome,
  electron: () => process.versions.electron,
  // we can also expose variables, not just functions,
  value: "any",
  ping: () => "pong",
});

为了将 preload 脚本和 renderer 进程,需要在要暴露的 page 创建时,及在创建 BrowserWindow的构造函数指定 webPreferences.preload 的路径

// main.js
const createWindow = () => {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    // 添加在这里
    webPreferences: {
      preload: path.resolve(__dirname, "src", "preloads", "versions.js"),
    },
  });

  win.loadFile("index.html");
};

// 然后在 index.html 中新增
<body>
    <h1>Hello from Electron renderer!</h1>
    <p>👋</p>
    <!-- display windows.versions info -->
    <p id="versions"></p>
    <!-- Import the renderer code here -->
    <script src="./src/renderers/versions.js"></script>
</body>


// src/renderers/versions.js
const versionNode = document.getElementById("versions-content");

// 在 renderer 里可以使用 dom 和其他 web 相关技术
console.log("window.versions: ", window.versions);

versionNode.innerText = `Node.js version: ${window.versions.node()}, Electron version: ${window.versions.electron()}, Chrome version: ${window.versions.chrome()}, ping: ${
  window.versions.ping
}`;

进程间的通信

为了解决主进程不能访问 DOM, web page(渲染进程)不能访问 Node.js API 的问题,Electron 的 ipcMainipcRenderer 模块实现了, 利用 ipcRenderer.invoke(channel: string, ...args: any[]): Promise<any> 可以从 web page 向 主进程发送消息,然后在主进程通过 ipcMain.handle(channel: string, listener: (event: IpcMainInvokeEvent, ...args: any[]) => (Promise<any>) | (any)): void;(handle 或者 ipcMain 下边的对应方法)来处理 ipcRenderer 发送过来的channel

// src/preloads/versions.js
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('versions', {
  node: () => process.versions.node,
  chrome: () => process.versions.chrome,
  electron: () => process.versions.electron,
  ping: () => ipcRenderer.invoke('ping')
  // we can also expose variables, not just functions
})

// main.js
app.whenReady().then(() => {
  createWindow();

  app.on("activate", () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow();
    }
  });
  
  // 主要添加这个 处理
  ipcMain.handle("ping", async () => {
    return "pong";
  });
});

注意

不要通过 contextBridge.exposeInMainWorld 将 ipcRenderer 这个模块都给暴露到 global 上,因为这样可能会造成 web page 可以发送任意的 IPC 消息给主进程,造成恶意攻击的危害

在html中使用js动态交换两个元素的位置

作者 1024小神
2025年11月26日 16:24

大家好,我的开源项目PakePlus可以将网页/Vue/React项目打包为桌面/手机应用并且小于5M只需几分钟,官网地址:pakeplus.com

1. DOM操作交换

使用 insertBefore 或 replaceChild 方法直接操作DOM元素

function swapElementsDOM() {
  const container = document.getElementById('elementsContainer');
  const element1 = document.getElementById('element1');
  const element2 = document.getElementById('element2');

  if (element1.nextElementSibling === element2) {
    container.insertBefore(element2, element1);
  } else {
    container.insertBefore(element1, element2);
  }
}

2. CSS类切换交换

通过切换CSS类来改变元素的显示顺序(使用flex-direction或order属性)

function swapElementsCSS() {
  const container = document.getElementById('elementsContainer');
  container.classList.toggle('reversed');
}

/* CSS部分 */
.reversed {
  flex-direction: column-reverse;
}

3. 动画交换

使用CSS过渡或动画实现平滑的位置交换效果

function swapElementsAnimated() {
  const element1 = document.getElementById('element1');
  const element2 = document.getElementById('element2');

  // 添加动画类
  element1.classList.add('swapping');
  element2.classList.add('swapping');

  setTimeout(() => {
    // 实际交换位置
    const container = document.getElementById('elementsContainer');
    if (element1.nextElementSibling === element2) {
      container.insertBefore(element2, element1);
    } else {
      container.insertBefore(element1, element2);
    }

    // 移除动画类
    element1.classList.remove('swapping');
    element2.classList.remove('swapping');
  }, 300);
}

大家好,我是1024小神,技术群 / 私活群 / 股票群 或 交朋友 都可以私信我。 如果你觉得本文有用,一键三连 (点赞、评论、关注),就是对我最大的支持~

逐步手写,实现符合 Promise A+ 规范的 Promise

2025年11月26日 16:23

前言

之前找工作的时候凭感觉做了一个实现 Promise A+ 规范的 Promise的练习,最近在准备新的工作机会,又看到了这个面试题。

我感觉之前的实现有很大优化空间。之前用前次调用结果作为标记来实现 Promise 多次 resolve 和 reject 触发的正确逻辑,感觉有点太麻烦了,通过和 AI 的深入交流,这完全可以用简单的布尔值标记做到。

这篇博客权当是复习吧...

简介 Promise A+ 规范

变量和术语

Promise 表示异步操作的最终结果。

  1. Promise 具有 3 种状态:pending(等待中)、fulfilled(成功执行)、rejected(失败拒绝),初始状态为 pending,切换为 fulfilled 或者 rejected 后就不能再转换。处于非 pending 状态时称为 settled。
const testPromise = new Promise((resolve, reject) => {
  // DO SOMETHING
})

像这样子,传入的函数我们称为executorresolvereject会触发 Promise 的状态改变以及数据更新。

value表示成功执行(fulfilled 状态)的 Promise 的结果,reason表示失败拒绝(rejected 状态)的 Promise 的原因,它们可以取 JS 中任何合法的值。

Promise A+ 规范的 Promise 上的方法只有简单的thencatchfinally之类的方法并不包含。

graph
    A[创建Promise] --> B["执行executor(resolve, reject)"]
    
    
    F{"executor执行结果?"}
    C -->|settled|G[忽略重复调用]
    C -->|not settled|E
    B --> F
    
    F -->|"调用resolve(value)"| I{"Promise settled?"}
    I -->|settled|G
    I -->|not settled| D["状态: pending → fulfilled<br>存储value"]
    
    F -->|抛出异常| C
    F -->|"调用reject(reason)"| C{"Promise settled?"}
    E["状态: pending → rejected<br>存储reason"]
    

    F -->|"当前未执行resolve和reject,没有抛出异常"| H[pending]
    
    style A fill:#e1f5ff
    style B fill:#e1f5ff

then 方法

  1. then方法具有onFulfilledonRejected两个入参,返回一个 Promise(链式调用)。

举个栗子:

const temp = testPromise.then(function onFulfilled (value) {
  // DO SOMETHING
}, function onRejected (reason) {
  // DO SOMETHING
})
console.log(temp instanceof Promise) // true
console.log(temp === testPromise) // false
  • Promise 从 pending 状态切换到 fulfilled 或者 rejected 时,执行此前then传入的onFulfilledonRejected。fulfilled 状态的 Promise 会执行then传入的onFulfilled,rejected 状态的 Promise 会执行then传入的onRejected

  • 执行onFulfilledonRejected的结果会被传入新的 Promise tempresolve方法中,如果发生了错误则传入reject中,改变的状态和数据。

  • onFulfilled或者onRejected不是函数时,返回的 Promise 与原 Promise 具有相同的状态和数据(传值穿透)。

用一个流程图总结一下:

graph 
    
    F["调用promise.then(onFulfilled, onRejected)"] --> G{"当前状态?"}
    
    G -->|pending| H["注册回调到队列<br>等待状态改变"]
    G -->|fulfilled| I["异步执行onFulfilled(value)"]
    G -->|rejected| J["异步执行onRejected(reason)"]
    
    I --> K{"onFulfilled返回值?"}
    J --> L{"onRejected返回值?"}
    
    K -->|正常返回| M["Promise Resolution Procedure"]
    L -->|正常返回| M
    K -->|抛出异常| O["调用新Promise的reject<br>状态: rejected"]
    L -->|抛出异常| O
    
    H -->|状态变为fulfilled| I
    H -->|状态变为rejected| J
    
    O --> Z[返回新Promise]

    style F fill:#fff2e1
    style G fill:#f0e1ff
    style M fill:#e8f5e9
    style H fill:#fff9c4

Promise Resolution Procedure

  1. resolve被触发时发生什么事了?此时 Promise 的状态仍未真正变化,会进入一段处理程序,规范称之为 Promise Resolution Procedure,主要逻辑是如果传入的是非 thenable 对象或者基本类型则直接修改 Promise 的状态和数据,是 thenable 就执行下面 thenable 相关逻辑。
  • 此外,不支持我返回我自己,onFulfilled或者onRejected返回该then返回的 Promise 时,抛出TypeError错误,例如:
const temp = testPromise.then(function onFulfilled (value) {
  return temp
})

处理 thenable 对象

  1. thenable 的对象是具有then方法的对象或者函数。then方法接受两个回调函数onResolvePromiseonRejectPromise,类似于这里的 Promise 的then。thenable 实际上包括实现了 Promise A+ 规范的 Promise,例如 ES6 原生的 Promise。举个 thenable 对象的栗子:
const thenable = {
  then: function (onResolvePromise, onRejectPromise) {
    onResolvePromise('miao~~')
  }
}
  • 如果触发了onFulfilled,返回了一个 thenable。如果是该 Promise 的实例,不是当前 Promise,则传入当前 Promise 的resolvereject,调用then方法。

  • 兼容其他 thenable:调用then方法,传入当前 Promise 的resolvereject,像该 Promise 实例一样解析。

  • 允许其他 thenable 对象乱写,这里需要处理 thenable 对象重复触发onResolvePromise或/和onRejectPromise的情况,这两个回调函数最多只能改变 1 次 Promise 的状态。

  1. 其他详细见 Promise A+ 规范。

这里再用个流程图总结一下

graph 
    M["Promise Resolution Procedure"]
    
    M --> P{返回值是thenable?}
    P -->|是| Q{是否返回自身?}
    Q -->|是| R[抛出TypeError]
    Q -->|否| S["调用thenable.then(resolvePromise, rejectPromise)"]
    
    P -->|否| N
    
    S --> T{thenable行为?}
    T -->|"调用resolvePromise(x)"| U{Promise settled?}
    T -->|"调用rejectPromise(reason)"| V{Promise settled?}
    T -->|抛出异常| O[调用新Promise的reject<br>状态: rejected]
    
    U -->|not settled| W["状态: fulfilled<br>value = x"]
    V -->|not settled| X["状态: rejected<br>reason = reason"]
    U -->|settled| Y[忽略重复调用]
    V -->|settled| Y
    
    N[调用新Promise的resolve<br>状态: fulfilled] --> Z[返回新Promise]
    O --> Z
    W --> Z
    X --> Z
    R --> AA["返回rejected Promise<br>reason = TypeError"]

    style M fill:#e8f5e9

前期准备

先定义好类型和一个发起微任务的辅助函数。

enum PromiseState {
  fulfilled = 'fulfilled',
  pending = 'pending',
  rejected = 'rejected'
}

type Executor<T> = (
  resolve: (value: T | PromiseLike<T>) => void,
  reject: (reason?: any) => void
) => void

const scheduleMicrotask = (callback: () => void) => {
  if (typeof queueMicrotask === 'function') {
    queueMicrotask(callback)
  } else if (typeof process !== 'undefined' && process.nextTick) {
    process.nextTick(callback)
  } else {
    Promise.resolve().then(callback)
  }
}

简单地写一个 Promise

class ShikaPromise<T = any> {
  private state: PromiseState = PromiseState.pending
  private value: T | undefined
  private reason: any
  constructor(executor: Executor<T>) {
    try {
      executor(
        (value) => this.resolve(value),
        (reason) => this.reject(reason)
      )
    } catch (error) {
      this.reject(error)
    }
  }
  private resolve(value: T): void {
    // 不支持 resolve 自己
    if (value === this) {
      this.reject(new TypeError('Cannot resolve promise with itself'))
      return
    }
    
    scheduleMicrotask(() => {
      this.state = PromiseState.fulfilled
      this.value = value
    })
  }
  private reject(reason: any): void {
    scheduleMicrotask(() => {
      this.state = PromiseState.rejected
      this.reason = reason
    })
  }
  then<TResult1 = T, TResult2 = never>(
    onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null | undefined,
    onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null | undefined
  ) {
    // TODO
  }
}

下面就来写then方法实现异步的链式调用。

then 方法

then返回一个 Promise,虽然 Promise A+ 规范没有说明需要返回的 Promise 不能和原有的是同一个,但是考虑到后续链式调用也会涉及到 Promise 状态的改变,所以这里就返回一个新的 Promise。

fulfilled 和 rejected 状态

假设const promise2 = promise1.then(onFulfilled, onRejected),调用promise1.then时创建一个新的 Promise promise2返回出去。用过 ES6 的Promise很好理解,如果原有promise1是 fulfilled 的,则在新的promise2executor中的resolve传入onFulfilled的结果,如果promise1处于失败状态,rejected 了,则在promise2resolve中传入onRejected的结果。

举个栗子:

const promiseTmp1 = Promise.resolve('ok').then(value => value, reason => reason)
// 此时 promiseTmp1.value 是 'ok'
const promiseTmp2 = Promise.resolve('error').then(value => value, reason => reason)
// 此时 promiseTmp2.value 是 'error'

下面编写 fulfilled 和 rejected 状态的处理逻辑。

// ...

class ShikaPromise {
  // ...
  then<TResult1 = T, TResult2 = never>(
    onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null | undefined,
    onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null | undefined
  ): ShikaPromise<TResult1 | TResult2> {
    return new ShikaPromise<TResult1 | TResult2>((resolve, reject) => {
      const handleCallback = (isFulfilled: boolean) => {
        scheduleMicrotask(() => {
          const callback = isFulfilled ? onFulfilled : onRejected
          const data = isFulfilled ? this.value : this.reason
          // 传值穿透
          if (typeof callback !== 'function') {
            if (isFulfilled) {
              resolve(data as TResult1)
            } else {
              reject(data)
            }
            return
          }

          try {
            const result = callback(data)
            resolve(result)
          } catch (error) {
            reject(error)
          }
        })
      }

      switch (this.state) {
        case PromiseState.fulfilled:
          handleCallback(true)
          break
        case PromiseState.rejected:
          handleCallback(false)
          break
        default:
          // TODO
      }
    })
  }
}

pending 状态

promise1在等待的时候,可以在promise1上新建两个属性fulfilledHandlersrejectedHandlers缓存给promise2触发resolvereject的回调函数。promise2处于 pending 状态,promise1切换状态后触发这些回调函数,用来改变promise2的状态。

// ...
class ShikaPromise {
  // ...
  // 记录等待 fulfilled 或者 rejected 后执行的回调函数
  private fulfilledHandlers: Array<() => void> = []
  private rejectedHandlers: Array<() => void> = []
  // ...
  private resolve(value: T): void {
    scheduleMicrotask(() => {
      this.state = PromiseState.fulfilled
      this.value = value

      const handlers = this.fulfilledHandlers.splice(0)
      handlers.forEach((h) => h())
    })
  }
  private reject(reason: any): void {
    scheduleMicrotask(() => {
      this.state = PromiseState.rejected
      this.reason = reason

      const handlers = this.rejectedHandlers.splice(0)
      handlers.forEach((h) => h())
    })
  }
  // ...
  then (onFulfilled?: ThenCallback, onRejected?: ThenCallback) {
    return new ShikaPromise<TResult1 | TResult2>((resolve, reject) => {
      // ...
      switch (this.state) {
        // ...
        default:
          this.fulfilledHandlers.push(() => handleCallback(true))
          this.rejectedHandlers.push(() => handleCallback(false))
      }
    })
  }
}

防止多次触发

我们通过添加标记isResolved记录是否已经触发resolve。当重复触发resolvereject时,遇到isResolvedtrue就返回。

// ...
class ShikaPromise<T = any> {
  // ...
  private isResolved = false
  // ...

  private resolve(value: T | PromiseLike<T>): void {
    if (this.isResolved) return
    
    if (value === this) {
      this.reject(new TypeError('Cannot resolve promise with itself'))
      return
    }
    
    // TODO: thenable 处理
    this.fulfill(value as T)
  }

  private fulfill(value: T): void {
    if (this.isResolved) return
    this.isResolved = true
    
    scheduleMicrotask(() => {
      this.state = PromiseState.fulfilled
      this.value = value

      const handlers = this.fulfilledHandlers.splice(0)
      handlers.forEach((h) => h())
    })
  }

  private reject(reason: any): void {
    if (this.isResolved) return
    this.isResolved = true
    // ...
  }
  // ...
}

解析 thenable 对象

如果遇到 thenable 对象,等待其进入 fulfilled 或者 rejected 状态,同样的,thenable 对象也需要防止重复进入 fulfilled 和 rejected 状态。

class ShikaPromise<T = any> {
  // ...

  private resolve(value: T | PromiseLike<T>): void {
    // ...
    const thenable = this.getThenable(value)
    if (thenable) {
      this.resolveThenable(thenable)
    } else {
      this.fulfill(value as T)
    }
  }
  private getThenable(value: any): { then: Function; target: any } | null {
    if (value !== null && (typeof value === 'object' || typeof value === 'function')) {
      try {
        // 在规范中有 Let then be x.then 的描述,测试用例中 value.then 只能被取一次
        const then = value.then
        if (typeof then === 'function') {
          return { then, target: value }
        }
      } catch (error) {
        this.reject(error)
      }
    }
    return null
  }

  private resolveThenable(thenable: { then: Function; target: any }): void {
    let called = false

    try {
      thenable.then.call(
        thenable.target,
        (value: any) => {
          if (called) return
          called = true
          this.resolvevaluey)
        },
        (reason: any) => {
          if (called) return
          called = true
          this.reject(reason)
        }
      )
    } catch (error) {
      if (!called) this.reject(error)
    }
  }
}

其他方法

JS 的 Promise

下面就来实现一下 JS 的 Promse 的catchfinallycatch就是then方法只提供第二个参数。finally方法回调函数不接收任何参数,返回一个状态和数据与原来相同的 Promise。

class ShikaPromise {
  catch<TResult = never>(
    onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null | undefined
  ): ShikaPromise<T | TResult> {
    return this.then(null, onRejected)
  }

  finally(onFinally?: (() => void) | null | undefined): ShikaPromise<T> {
    return this.then(
      (value) => {
        onFinally?.()
        return value
      },
      (reason) => {
        onFinally?.()
        throw reason
      }
    )
  }
}

还有Promise.resolvePromise.reject两个静态方法:

class ShikaPromise {
  static resolve<T>(value: T | PromiseLike<T>): ShikaPromise<T> {
    return value instanceof ShikaPromise ? value : new ShikaPromise((resolve) => resolve(value))
  }
  static reject<T = never>(reason?: any): ShikaPromise<T> {
    return new ShikaPromise((_, reject) => reject(reason))
  }
}

如果 Promise 可以停止

如果想要 Promise 后面的thencatchfinally)都不会触发,这里只需要返回一个 pending 状态的 Promise。这里实现一个时链式调用停止的cancel方法和返回 pending 的 Promise 的wait方法:

class ShikaPromise {
  static wait(): ShikaPromise<never> {
    return new ShikaPromise(() => {})
  }
  cancel(): ShikaPromise<never> {
    return new ShikaPromise(() => {})
  }
}

Promise A+ 测试

下载 promises-aplus-tests 包:

npm i promises-aplus-tests

要求 Promise 所在文件采用 commonjs 方式导出。还需要在 Promise 上实现静态方法:

class ShikaPromise {
  static deferred<T>() {
    let resolve!: (value: T | PromiseLike<T>) => void
    let reject!: (reason?: any) => void

    const promise = new ShikaPromise<T>((res, rej) => {
      resolve = res
      reject = rej
    })

    return { promise, resolve, reject }
  }
}

promises-aplus-tests Promise 的所在文件即可运行,如果你在用 TS,文件为编译后的文件,例如:

promises-aplus-tests dist/文件名.js

Promise A+ 的测试用例覆盖面非常全,调试时烦死了x,通过了所有 817 条用例,就说明你的 Promise 实现了 Promise A+ 标准了。

我把 TS 编译和运行测试用例在 package.json 组装成一条命令:

{
  // ...
  "scripts": {
    // ...
    "test": "tsc && promises-aplus-tests dist/文件名.js",
  },
  // ...
}

这里 tsc 会默认编译 tsconfig.json 设置的根目录(这里是 ./src),然后放到输出目录中(这里是 ./dist)。

最终实现

enum PromiseState {
  fulfilled = 'fulfilled',
  pending = 'pending',
  rejected = 'rejected'
}

type Executor<T> = (
  resolve: (value: T | PromiseLike<T>) => void,
  reject: (reason?: any) => void
) => void

const scheduleMicrotask = (callback: () => void) => {
  if (typeof queueMicrotask === 'function') {
    queueMicrotask(callback)
  } else if (typeof process !== 'undefined' && process.nextTick) {
    process.nextTick(callback)
  } else {
    Promise.resolve().then(callback)
  }
}

class ShikaPromise<T = any> {
  private state: PromiseState = PromiseState.pending
  private value: T | undefined
  private reason: any
  private fulfilledHandlers: Array<() => void> = []
  private rejectedHandlers: Array<() => void> = []
  private isResolved = false

  constructor(executor: Executor<T>) {
    try {
      executor(
        (value) => this.resolve(value),
        (reason) => this.reject(reason)
      )
    } catch (error) {
      this.reject(error)
    }
  }

  private resolve(value: T | PromiseLike<T>): void {
    if (this.isResolved) return

    if (value === this) {
      this.reject(new TypeError('Cannot resolve promise with itself'))
      return
    }

    const thenable = this.getThenable(value)
    if (thenable) {
      this.resolveThenable(thenable)
    } else {
      this.fulfill(value as T)
    }
  }

  private fulfill(value: T): void {
    if (this.isResolved) return
    this.isResolved = true

    scheduleMicrotask(() => {
      this.state = PromiseState.fulfilled
      this.value = value

      const handlers = this.fulfilledHandlers.splice(0)
      handlers.forEach((h) => h())
    })
  }

  private reject(reason: any): void {
    if (this.isResolved) return
    this.isResolved = true

    scheduleMicrotask(() => {
      this.state = PromiseState.rejected
      this.reason = reason

      const handlers = this.rejectedHandlers.splice(0)
      handlers.forEach((h) => h())
    })
  }

  private getThenable(value: any): { then: Function; target: any } | null {
    if (value !== null && (typeof value === 'object' || typeof value === 'function')) {
      try {
        const then = value.then
        if (typeof then === 'function') {
          return { then, target: value }
        }
      } catch (error) {
        this.reject(error)
      }
    }
    return null
  }

  private resolveThenable(thenable: { then: Function; target: any }): void {
    let called = false

    try {
      thenable.then.call(
        thenable.target,
        (value: any) => {
          if (called) return
          called = true
          this.resolve(value)
        },
        (reason: any) => {
          if (called) return
          called = true
          this.reject(reason)
        }
      )
    } catch (error) {
      if (!called) this.reject(error)
    }
  }

  then<TResult1 = T, TResult2 = never>(
    onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null | undefined,
    onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null | undefined
  ): ShikaPromise<TResult1 | TResult2> {
    return new ShikaPromise<TResult1 | TResult2>((resolve, reject) => {
      const handleCallback = (isFulfilled: boolean) => {
        scheduleMicrotask(() => {
          const callback = isFulfilled ? onFulfilled : onRejected
          const data = isFulfilled ? this.value : this.reason

          if (typeof callback !== 'function') {
            if (isFulfilled) {
              resolve(data as TResult1)
            } else {
              reject(data)
            }
            return
          }

          try {
            const result = callback(data)
            resolve(result)
          } catch (error) {
            reject(error)
          }
        })
      }

      switch (this.state) {
        case PromiseState.fulfilled:
          handleCallback(true)
          break
        case PromiseState.rejected:
          handleCallback(false)
          break
        default:
          this.fulfilledHandlers.push(() => handleCallback(true))
          this.rejectedHandlers.push(() => handleCallback(false))
      }
    })
  }

  catch<TResult = never>(
    onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null | undefined
  ): ShikaPromise<T | TResult> {
    return this.then(null, onRejected)
  }

  finally(onFinally?: (() => void) | null | undefined): ShikaPromise<T> {
    return this.then(
      (value) => {
        onFinally?.()
        return value
      },
      (reason) => {
        onFinally?.()
        throw reason
      }
    )
  }

  static resolve<T>(value: T | PromiseLike<T>): ShikaPromise<T> {
    return value instanceof ShikaPromise ? value : new ShikaPromise((resolve) => resolve(value))
  }

  static reject<T = never>(reason?: any): ShikaPromise<T> {
    return new ShikaPromise((_, reject) => reject(reason))
  }

  static wait(): ShikaPromise<never> {
    return new ShikaPromise(() => {})
  }

  cancel(): ShikaPromise<never> {
    return new ShikaPromise(() => {})
  }

  static deferred<T>() {
    let resolve!: (value: T | PromiseLike<T>) => void
    let reject!: (reason?: any) => void

    const promise = new ShikaPromise<T>((res, rej) => {
      resolve = res
      reject = rej
    })

    return { promise, resolve, reject }
  }
}

module.exports = ShikaPromise

结尾

这里实现了一个 Promise A+ 规范的 Promise,重新理解 Promise A+ 规范也修复了我以前对此的认识不足之处。

大家的阅读是我发帖的动力,本文首发于我的博客:deerblog.gu-nami.com/,欢迎大家来玩, 转载请注明出处。

🌟让你的uniapp应用拥有更现代的交互体验,一个支持滚动渐变透明的导航栏组件🌟

作者 Cerrda
2025年11月26日 16:14

uni-app 自适应透明导航栏组件实现

一个支持滚动渐变透明的 uni-app 导航栏组件,让你的小程序拥有更现代的交互体验

📖 前言

在开发小程序时,我们经常会看到这样的效果:页面顶部有张大图,导航栏初始是透明的,随着页面向下滚动,导航栏逐渐变得不透明。这种设计既美观又实用,今天就来分享如何实现这个效果。

✨ 核心特性

  • 🎨 自动透明渐变:滚动时导航栏背景从透明到不透明平滑过渡
  • 🎯 精准控制:基于 IntersectionObserver 实现,性能优异
  • 🔧 灵活配置:支持自定义背景色、标题、返回按钮等
  • 📱 完美适配:自动适配不同机型的状态栏高度

🎬 效果演示

当用户向下滚动页面时,导航栏会从完全透明逐渐变为设定的背景色,整个过渡非常丝滑自然。

PixPin_2025-11-26_15-18-08.webp

🔍 实现原理

核心思路

  1. 占位元素:在页面顶部放置一个与导航栏等高的透明占位元素
  2. 交叉观察:使用 IntersectionObserver 监听占位元素与视口的交叉情况
  3. 透明度计算:根据交叉比例动态计算导航栏背景的透明度
  4. 实时更新:通过响应式数据驱动样式更新

关键技术点

  • IntersectionObserver:性能优于传统的 scroll 事件监听
  • RGBA 动态计算:保持颜色不变,只改变透明度通道
  • 临界值优化:处理真机环境下交叉比例不精确的问题

💻 代码实现

1. 组件主体 (kl-navbar/index.vue)

<script lang="ts" setup>
const { 
  title = '', 
  placeholder = false,
  leftArrow = false,
  backgroundColor = '#fff',
  autoTransparent = false 
} = defineProps<{
  title?: string
  placeholder?: boolean
  leftArrow?: boolean
  backgroundColor?: string
  /** 滚动时标题栏透明渐变 ( tip : placeholder = true时无效 ) */
  autoTransparent?: boolean
}>()

// 只有在不使用 placeholder 模式时才启用自动透明
const canIUseAutoTransparent = computed(() => autoTransparent && !placeholder)

const { statusBarHeight, headerHeight, navbarHeight } = useGlobalStore()

// 按需启用透明度计算
const { r, g, b, a } = (!canIUseAutoTransparent.value)
  ? {}
  : useAutoTransparent(backgroundColor)
</script>

<script lang="ts">
export default {
  options: {
    addGlobalClass: true,
    virtualHost: true,
    styleIsolation: 'shared',
  },
}
</script>

<template>
  <view>
    <!-- 导航栏主体 -->
    <view
      class="fixed left-0 top-0 z-996 grid grid-cols-3 w-100vw items-center"
      :class="[canIUseAutoTransparent && 'transition-background-color duration-100 ease-out']"
      :style="{
        height: `${navbarHeight}px`,
        paddingTop: `${statusBarHeight}px`,
        lineHeight: `${navbarHeight}px`,
        backgroundColor: canIUseAutoTransparent 
          ? `rgba(${r},${g},${b},${a})` 
          : `${backgroundColor}`,
      }"
    >
      <!-- 返回按钮 -->
      <view 
        v-if="leftArrow" 
        class="i-line-md:chevron-small-left p-x-12Px text-24Px" 
        @tap="navigateBack" 
      />
      <!-- 标题 -->
      <text class="col-start-2 text-center">
        {{ title }}
      </text>
    </view>
    
    <!-- 占位模式:推开后续内容 -->
    <view v-if="placeholder" :style="{ height: `${headerHeight}px` }" />
    
    <!-- 自动透明模式:用于观察的目标元素 -->
    <view
      v-else-if="autoTransparent"
      class="_auto-transparent__observer-target pointer-events-none absolute w-full"
      :style="{ height: `${headerHeight}px` }"
    />
  </view>
</template>

设计要点:

  • 导航栏使用 fixed 定位,始终固定在顶部
  • 动态计算状态栏高度,适配不同机型
  • 根据模式渲染不同的占位/观察元素

2. 透明度逻辑 (use-auto-transparent.ts)

import { convertToRGBA } from '@/utils'

export function useAutoTransparent(backgroundColor: string) {
  // 将背景色转换为 RGB 值
  const { r, g, b } = convertToRGBA(backgroundColor)
  const a = ref(0)  // 透明度通道,0 表示完全透明

  let observer: UniNamespace.IntersectionObserver
  
  onMounted(() => {
    const instance = getCurrentInstance()
    
    // 创建交叉观察器,设置 51 个观察阈值(0%, 2%, 4%...100%)
    observer = uni.createIntersectionObserver(
      instance?.proxy, 
      { thresholds: Array.from({ length: 51 }, (_, i) => (i / 50)) }
    )
    
    // 相对于视口顶部进行观察
    observer
      .relativeToViewport({ top: 0 })
      .observe('._auto-transparent__observer-target', ({ intersectionRatio }) => {
        // 处理临界值:真机环境下可能不会精确等于 0 或 1
        // >= 0.95 视为完全可见(透明)
        // <= 0.05 视为完全不可见(不透明)
        a.value = intersectionRatio >= 0.95 
          ? 0 
          : intersectionRatio <= 0.05 
          ? 1 
          : 1 - intersectionRatio
      })
  })
  
  onUnmounted(() => observer.disconnect())

  return { r, g, b, a }
}

核心逻辑:

  1. 观察阈值:设置 51 个阈值点,确保过渡足够平滑
  2. 交叉比例intersectionRatio 表示目标元素有多少比例与视口交叉
    • 1 表示完全在视口内 → 导航栏透明
    • 0 表示完全不在视口内 → 导航栏不透明
  3. 临界值处理:处理精度问题,避免无法完全透明/不透明

3. 颜色转换工具 (utils/index.ts)

/** 返回合法颜色值的 r, g, b 值 */
export function convertToRGBA(color: string) {
  // 处理 HEX 格式:#fff 或 #ffffff
  if (color.startsWith('#')) {
    const hex = color.slice(1).replace(/^([0-9A-F]{3})$/i, '$1$1')
    const r = Number.parseInt(hex.substring(0, 2), 16)
    const g = Number.parseInt(hex.substring(2, 4), 16)
    const b = Number.parseInt(hex.substring(4, 6), 16)
    return { r, g, b }
  }
  // 处理 RGB 格式:rgb(255, 255, 255)
  else if (color.startsWith('rgb')) {
    const parts = color.match(/(\d+),\s*(\d+),\s*(\d+)/)
    if (parts) {
      const [_, r, g, b] = parts
      return { r, g, b }
    }
  }
  throw new Error('Invalid color format')
}

支持格式:

  • HEX:#fff#ffffff
  • RGB:rgb(255, 255, 255)

4. 全局状态管理 (store/global.ts)

export const useGlobalStore = defineStore('global', () => {
  const systemInfo = uni.getSystemInfoSync()

  // 高度相关常量(单位:px)
  const navbarHeight = 44  // 导航栏高度
  const statusBarHeight = systemInfo.statusBarHeight || 0  // 状态栏高度
  const headerHeight = statusBarHeight + navbarHeight  // 总头部高度

  const tabbarHeight = 50
  const whiteBarHeight = systemInfo.safeAreaInsets?.bottom || 0
  const footerHeight = tabbarHeight + whiteBarHeight

  return {
    systemInfo,
    statusBarHeight,
    navbarHeight,
    headerHeight,
    tabbarHeight,
    whiteBarHeight,
    footerHeight,
  }
})

全局常量:

  • 统一管理各种高度值
  • 自动适配不同设备的状态栏高度

📝 使用示例

<template>
  <view>
    <!-- 基础用法:固定背景色 -->
    <kl-navbar 
      title="页面标题" 
      :left-arrow="true" 
      background-color="#ffffff"
    />

    <!-- 占位模式:推开页面内容 -->
    <kl-navbar 
      title="页面标题" 
      :placeholder="true"
      background-color="#ffffff"
    />

    <!-- 自动透明模式:滚动渐变 -->
    <kl-navbar 
      title="页面标题" 
      :left-arrow="true"
      :auto-transparent="true"
      background-color="#ffffff"
    />
    
    <!-- 页面内容 -->
    <view class="content">
      <!-- 这里通常会放一张大图或其他内容 -->
    </view>
  </view>
</template>

Props 说明

参数 类型 默认值 说明
title string '' 导航栏标题
placeholder boolean false 是否占位模式(推开内容)
leftArrow boolean false 是否显示返回箭头
backgroundColor string '#fff' 背景颜色
autoTransparent boolean false 是否启用自动透明渐变

⚠️ 注意: autoTransparentplaceholder 不能同时使用,当 placeholder=true 时,autoTransparent 会被忽略。


🎯 技术亮点

1. 性能优化

使用 IntersectionObserver 而非 scroll 事件监听,优势:

  • 浏览器原生 API,性能更好
  • 自动节流,避免频繁计算
  • 更精确的元素可见性判断

2. 边界处理

a.value = intersectionRatio >= 0.95 
  ? 0 
  : intersectionRatio <= 0.05 
  ? 1 
  : 1 - intersectionRatio

在真机测试中发现,intersectionRatio 在接近 0 或 1 时可能出现微小误差(如 0.9999 或 0.0001),导致导航栏永远无法完全透明或不透明。通过设置 5% 的容差范围,确保视觉效果完美。

3. 灵活的颜色支持

通过 convertToRGBA 工具函数,支持多种颜色格式输入,最终转换为 RGBA 格式,只改变透明度通道,保持颜色不变。

4. 响应式设计

利用 Vue 3 的响应式系统,透明度 a 的变化会自动触发样式更新,无需手动操作 DOM。


🤔 常见问题

Q1: 为什么要设置 51 个观察阈值?

A: 阈值越多,过渡越平滑。51 个阈值意味着每 2% 的变化就会触发一次回调,在性能和流畅度之间取得平衡。

Q2: 占位模式和透明模式有什么区别?

A:

  • 占位模式:导航栏下方有一个等高的空白占位,页面内容被推到导航栏下方
  • 透明模式:导航栏使用 fixed 定位悬浮在页面上方,页面内容从屏幕顶部开始

Q3: 能否自定义渐变速度?

A: 当前实现中渐变速度与滚动速度成正比。如需自定义,可以在计算透明度时添加缓动函数。


🚀 扩展思路

  1. 支持渐变色背景:当前只支持纯色,可以扩展支持渐变背景
  2. 标题颜色联动:背景变化时,标题颜色也跟随变化(黑 ↔ 白)
  3. 自定义阈值:将阈值数量作为 prop 暴露,让用户自定义平滑度
  4. 支持其他样式:除了透明度,还可以支持模糊效果(backdrop-filter)

📚 总结

这个导航栏组件的实现虽然代码量不大,但涉及了多个技术点:

  • ✅ IntersectionObserver API 的使用
  • ✅ Vue 3 Composition API 的实践
  • ✅ 跨平台适配(状态栏高度)
  • ✅ 性能优化(避免频繁计算)
  • ✅ 边界情况处理

希望这篇文章能帮助你理解并实现类似的效果。如果你有更好的实现思路,欢迎交流讨论!

❌
❌