普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月24日掘金 前端

QML 入门对象、属性、绑定与信号(六)

作者 HelloReader
2026年3月24日 11:16

适合人群: 跑通过第一个 Qt Quick 应用,想系统理解 QML 语法的新手

前言

前面几篇我们已经能写出简单的 QML 界面,但对语法的理解还停留在"照着抄"的阶段。本文的目标是让你真正理解 QML 的核心机制——对象树、属性绑定、信号与处理器——这些是整个 Qt Quick 开发的地基,后续所有课程都建立在这里。

一、QML 是什么:声明式 vs 命令式

传统的命令式编程描述"怎么做":

// C++ 命令式:一步步告诉程序怎么做
QLabel *label = new QLabel(this);
label->setText("Hello");
label->setGeometry(100, 100, 200, 40);
label->setAlignment(Qt::AlignCenter);

QML 的声明式描述"是什么":

// QML 声明式:描述这个元素的状态
Text {
    x: 100; y: 100
    width: 200; height: 40
    text: "Hello"
    horizontalAlignment: Text.AlignHCenter
}

两段代码效果相同,但 QML 版本更直接地表达了"这是一个文本,位置在这里,内容是这个",而不是一系列操作步骤。这就是声明式的本质。


二、QML 文档结构

每个 .qml 文件的基本结构如下:

// 1. 导入语句
import QtQuick
import QtQuick.Controls

// 2. 根对象(每个文件只有一个根对象)
Rectangle {

    // 3. 属性赋值
    width: 400
    height: 300
    color: "white"

    // 4. 子对象
    Text {
        anchors.centerIn: parent
        text: "Hello, QML!"
    }
}

三条基本规则:

  1. 每个 .qml 文件有且只有一个根对象
  2. 对象可以嵌套,形成父子关系的对象树
  3. import 语句必须写在文件最顶部

三、对象与对象树

QML 界面是一棵对象树,父对象包含子对象,子对象的坐标相对于父对象计算。

Rectangle {              // 根对象(父)
    width: 400
    height: 300
    color: "#f0f0f0"

    Rectangle {          // 子对象 1
        x: 20; y: 20
        width: 160; height: 120
        color: "#4A90E2"

        Text {           // 孙对象
            anchors.centerIn: parent
            text: "左上角"
            color: "white"
        }
    }

    Rectangle {          // 子对象 2
        x: 220; y: 20
        width: 160; height: 120
        color: "#E24A4A"

        Text {
            anchors.centerIn: parent
            text: "右上角"
            color: "white"
        }
    }
}

子对象的 x: 20 是相对于父对象左上角的偏移,而不是相对于屏幕。这让布局计算变得直观:移动父对象,所有子对象跟着一起移动。


四、属性

4.1 基本属性赋值

Rectangle {
    width: 200          // 整数
    height: 100
    color: "steelblue"  // 颜色字符串
    opacity: 0.8        // 浮点数
    visible: true       // 布尔值
    radius: 8           // 圆角半径
}

4.2 属性的类型

QML 中常见的属性类型:

类型 示例
int width: 200
real opacity: 0.5
bool visible: true
string text: "Hello"
color color: "#FF5733"color: "red"
url source: "images/logo.png"
var 任意类型

4.3 自定义属性

property 关键字在对象上定义自己的属性:

Rectangle {
    width: 300
    height: 200

    // 自定义属性
    property int clickCount: 0
    property string userName: "访客"
    property color themeColor: "#4A90E2"

    color: themeColor   // 使用自定义属性

    Text {
        anchors.centerIn: parent
        text: userName + " 点击了 " + clickCount + " 次"
    }
}

自定义属性的好处:把重要的数据集中管理,而不是散落在各个子元素里。

4.4 强类型属性(推荐写法)

Qt 6 推荐使用强类型属性声明,能在编译时发现类型错误:

// 推荐:强类型声明
property int score: 0
property string playerName: ""
property bool isGameOver: false

// 不推荐:var 类型失去类型检查
property var score: 0

五、属性绑定:QML 最核心的概念

属性绑定是 QML 中最重要、也是最容易被忽视的机制。

5.1 什么是属性绑定

Rectangle {
    width: 400
    height: width / 2    // height 绑定到 width
}

这里 height: width / 2 不是一次性赋值,而是建立了一个持续有效的依赖关系:每当 width 变化时,height 自动重新计算。

Rectangle {
    id: container
    width: 400
    height: width / 2    // 绑定

    // 拖动窗口改变 width 时,height 自动跟随
}

5.2 绑定 vs 赋值

这是新手最容易犯的错误:

Rectangle {
    id: box
    width: 200

    Text {
        text: "宽度:" + box.width    // 绑定:自动跟随 box.width 变化
    }

    MouseArea {
        anchors.fill: parent
        onClicked: {
            box.width = 300           // 普通赋值:只改变一次
            // 注意:如果之前有绑定,赋值会破坏绑定!
        }
    }
}

关键规则: 在 JavaScript 代码块(如 onClicked)中用 = 赋值,会打断原有的属性绑定。如果需要在事件处理中保持绑定,使用 Qt.binding()

onClicked: {
    box.width = Qt.binding(function() { return parent.width / 2 })
}

5.3 绑定的实际应用

ApplicationWindow {
    id: window
    width: 640
    height: 480
    visible: true

    Rectangle {
        // 始终填满窗口的一半宽度
        width: window.width / 2
        height: window.height
        color: "#E6F1FB"

        Text {
            anchors.centerIn: parent
            // 实时显示父容器尺寸
            text: parent.width + " × " + parent.height
            font.pixelSize: 16
        }
    }
}

拖动窗口调整大小,矩形自动跟随,文字自动更新。这一切不需要写任何事件监听代码。


六、id:给对象命名

id 是对象在当前 QML 文件中的唯一标识符,用于在其他地方引用这个对象:

Rectangle {
    width: 400
    height: 300

    Rectangle {
        id: redBox          // 定义 id
        width: 100
        height: 100
        color: "red"
    }

    Rectangle {
        // 通过 id 引用另一个对象
        x: redBox.x + redBox.width + 20    // 紧跟在 redBox 右边
        width: redBox.width                 // 与 redBox 同宽
        height: redBox.height
        color: "blue"
    }
}

id 的命名规范:

  • 以小写字母开头:myButtonnameInput
  • 使用驼峰命名:userNameLabel
  • 不能和 QML 关键字冲突:不要用 itemparentroot

七、信号与信号处理器

7.1 什么是信号

信号(Signal)是 Qt 对象系统的核心通信机制。当某件事情发生时,对象发出信号;其他对象可以响应这个信号。

在 QML 中,每个信号对应一个信号处理器,命名规则是:on + 信号名首字母大写。

Button {
    text: "点我"
    onClicked: console.log("按钮被点击")    // clicked 信号的处理器
    onPressed: console.log("按下")          // pressed 信号的处理器
    onReleased: console.log("松开")         // released 信号的处理器
}

7.2 常见的内置信号

// 组件加载完成
Rectangle {
    Component.onCompleted: {
        console.log("组件已加载,宽度:" + width)
    }
}

// 属性变化信号:on + 属性名 + Changed
Rectangle {
    width: 200
    onWidthChanged: console.log("宽度变为:" + width)
}

// 鼠标区域信号
MouseArea {
    anchors.fill: parent
    onClicked: console.log("点击位置:" + mouse.x + ", " + mouse.y)
    onDoubleClicked: console.log("双击")
    onEntered: console.log("鼠标进入")
    onExited: console.log("鼠标离开")
}

7.3 自定义信号

Rectangle {
    id: card
    width: 200
    height: 120
    color: "#f5f5f5"
    radius: 8

    // 声明自定义信号
    signal cardSelected(string cardName)

    property string name: "卡片 A"

    MouseArea {
        anchors.fill: parent
        onClicked: card.cardSelected(card.name)    // 发出信号
    }
}

在父对象中响应这个信号:

Rectangle {
    Card {
        id: myCard
        onCardSelected: function(name) {          // 响应自定义信号
            console.log("选中了:" + name)
        }
    }
}

八、组件:创建可复用的元素

当某段 QML 代码需要在多处使用时,把它封装成组件

方式一:独立的 .qml 文件

新建文件 RoundButton.qml

// RoundButton.qml
import QtQuick
import QtQuick.Controls

Button {
    id: root

    // 暴露可配置的属性
    property color buttonColor: "#4A90E2"

    contentItem: Text {
        text: root.text
        color: "white"
        font.pixelSize: 14
        horizontalAlignment: Text.AlignHCenter
        verticalAlignment: Text.AlignVCenter
    }

    background: Rectangle {
        color: root.buttonColor
        radius: height / 2    // 完全圆角
        opacity: root.pressed ? 0.8 : 1.0
    }
}

在其他文件中使用(文件名即类型名):

import QtQuick

Rectangle {
    width: 400
    height: 200

    RoundButton {
        anchors.centerIn: parent
        text: "确认"
        buttonColor: "#1D9E75"
        width: 120
        height: 44
        onClicked: console.log("确认按钮点击")
    }
}

方式二:内联组件

在同一个文件内定义局部组件:

import QtQuick

Rectangle {
    width: 400
    height: 300

    // 定义内联组件
    component TagLabel: Rectangle {
        property string labelText: ""
        width: tagText.width + 16
        height: 24
        radius: 12
        color: "#E6F1FB"

        Text {
            id: tagText
            anchors.centerIn: parent
            text: parent.labelText
            color: "#185FA5"
            font.pixelSize: 12
        }
    }

    // 使用内联组件
    Row {
        anchors.centerIn: parent
        spacing: 8

        TagLabel { labelText: "Qt Quick" }
        TagLabel { labelText: "QML" }
        TagLabel { labelText: "跨平台" }
    }
}

九、parent 关键字

在 QML 中,parent 指当前对象的父对象:

Rectangle {
    width: 400
    height: 300

    Rectangle {
        // parent 指外层 Rectangle
        width: parent.width * 0.5      // 父宽度的 50%
        height: parent.height * 0.5    // 父高度的 50%
        anchors.centerIn: parent       // 居中于父对象
        color: "#4A90E2"
    }
}

注意: 在信号处理器的 JavaScript 代码块中,parent 的含义可能改变。建议在需要引用特定对象时使用 id,而不是依赖 parent

Rectangle {
    id: outerRect    // 用 id 明确标识

    Rectangle {
        MouseArea {
            onClicked: {
                // 用 id 比用 parent.parent 更清晰可靠
                outerRect.color = "red"
            }
        }
    }
}

十、一个完整的综合示例

把本文的知识点整合成一个计数器应用:

import QtQuick
import QtQuick.Controls

ApplicationWindow {
    width: 360
    height: 480
    visible: true
    title: "计数器"

    // 自定义属性:集中管理状态
    property int count: 0
    property int step: 1
    property color activeColor: count >= 0 ? "#1D9E75" : "#E24A4A"

    Column {
        anchors.centerIn: parent
        spacing: 24
        width: 240

        // 计数显示
        Rectangle {
            width: parent.width
            height: 100
            radius: 12
            color: activeColor          // 绑定到 activeColor

            Text {
                anchors.centerIn: parent
                text: count             // 绑定到 count
                font.pixelSize: 48
                font.bold: true
                color: "white"
            }
        }

        // 步长选择
        Row {
            anchors.horizontalCenter: parent.horizontalCenter
            spacing: 8

            Text {
                anchors.verticalCenter: parent.verticalCenter
                text: "步长:"
                font.pixelSize: 14
                color: "#666"
            }

            Repeater {
                model: [1, 5, 10]
                delegate: Button {
                    required property int modelData
                    text: modelData
                    highlighted: step === modelData    // 绑定高亮状态
                    onClicked: step = modelData
                }
            }
        }

        // 操作按钮
        Row {
            anchors.horizontalCenter: parent.horizontalCenter
            spacing: 12

            Button {
                text: "−"
                font.pixelSize: 20
                width: 72; height: 48
                onClicked: count -= step
            }

            Button {
                text: "重置"
                width: 72; height: 48
                onClicked: count = 0
            }

            Button {
                text: "+"
                font.pixelSize: 20
                width: 72; height: 48
                onClicked: count += step
            }
        }

        // 状态文字:纯绑定,无需任何事件代码
        Text {
            anchors.horizontalCenter: parent.horizontalCenter
            text: count === 0 ? "归零"
                : count > 0  ? "正数:" + count
                :               "负数:" + count
            font.pixelSize: 14
            color: "#888"
        }
    }

    // 监听 count 变化
    onCountChanged: {
        if (Math.abs(count) > 100)
            console.log("警告:计数超过 100!")
    }
}

这个示例展示了:自定义属性、属性绑定(颜色跟随正负值变化)、信号处理器、Repeater 动态生成元素,以及属性变化信号 onCountChanged


十一、常见错误与注意事项

错误一:id 重复定义

// 错误:同一文件中 id 必须唯一
Rectangle { id: box; color: "red" }
Rectangle { id: box; color: "blue" }   // 报错!

错误二:在 JS 代码块中误用绑定语法

// 错误:冒号绑定语法不能用在 JS 代码块中
onClicked: {
    myText.color: "red"    // 语法错误!
    myText.color = "red"   // 正确:JS 代码块中用 =
}

错误三:循环绑定

// 错误:a 绑定 b,b 又绑定 a,产生无限循环
Rectangle {
    id: a
    width: b.width    // a.width 依赖 b.width
}
Rectangle {
    id: b
    width: a.width    // b.width 又依赖 a.width → 循环!
}

总结

概念 要点
声明式 描述"是什么",而不是"怎么做"
对象树 父子嵌套,子对象坐标相对于父对象
属性 内置属性 + 自定义 property,推荐强类型声明
属性绑定 a: b + c 建立持续依赖,= 赋值会打断绑定
id 唯一标识符,用于跨对象引用
信号处理器 on + 信号名,响应事件和状态变化
自定义信号 signal 关键字声明,实现组件间通信
组件 独立 .qml 文件或内联 component,封装可复用逻辑

JavaScript 模块化详解:CommonJS、ES Module 到底有什么区别?

2026年3月24日 11:10

前言

当项目代码越来越多时,如果所有变量、函数、对象都写在一个文件中,就会带来很多问题:

  • 容易命名冲突
  • 不方便协作
  • 代码难维护
  • 功能难复用

所以 JavaScript 需要 模块化

模块化并不只是“把代码拆分成多个文件”,更重要的是:

让每个文件都有自己的作用域,并且可以通过导入和导出组织代码。

本文就来系统讲清楚 JavaScript 模块化的发展和常见方案。


一、为什么需要模块化?

早期 JavaScript 没有模块系统,大家通常把变量和函数直接写到全局作用域中:

var name = 'Tom';

function getUser() {
  return name;
}

如果多个文件都写同名变量,就容易冲突。

模块化的出现,就是为了解决这些问题:

  • 避免全局污染
  • 提高代码复用
  • 方便拆分文件
  • 便于团队协作
  • 提高可维护性

二、早期模块化:IIFE

在 ES Module 出现之前,经常用立即执行函数来模拟模块作用域。

const userModule = (function () {
  let name = 'Tom';

  function getName() {
    return name;
  }

  function setName(newName) {
    name = newName;
  }

  return {
    getName,
    setName
  };
})();

console.log(userModule.getName()); // Tom

这种方式本质上利用了 闭包

特点:

  • 可以创建私有作用域
  • 能避免部分全局污染
  • 但不够标准,维护成本高

三、CommonJS

CommonJS 是 Node.js 中常见的模块化规范。

导出

// math.js
function add(a, b) {
  return a + b;
}

module.exports = {
  add
};

导入

// app.js
const math = require('./math');

console.log(math.add(1, 2)); // 3

特点:

  • 主要用于 Node.js

  • 使用

    require

    导入

  • 使用

    module.exports

    导出

  • 同步加载


四、ES Module(ESM)

这是现在最主流、最推荐的模块化方案。

命名导出

// math.js
export function add(a, b) {
  return a + b;
}

export function sub(a, b) {
  return a - b;
}

导入:

import { add, sub } from './math.js';

console.log(add(1, 2));
console.log(sub(5, 3));

默认导出

// user.js
export default function getUser() {
  return { name: 'Tom' };
}

导入:

import getUser from './user.js';

console.log(getUser());

五、命名导出和默认导出的区别

命名导出

export const name = 'Tom';

导入时必须使用对应名字:

import { name } from './file.js';

默认导出

export default function () {}

导入时名字可以自定义:

import myFn from './file.js';

六、CommonJS 和 ES Module 的区别

CommonJS

const math = require('./math');
module.exports = { add };

ES Module

import { add } from './math.js';
export { add };

常见区别:

  • CommonJS 多用于 Node.js 传统环境
  • ES Module 是 JavaScript 官方标准模块系统
  • CommonJS 是同步加载
  • ES Module 更适合现代前端工程化

七、模块化的实际意义

模块化最大的价值是让代码更清晰。

比如一个项目可以拆成:

src
├── api
├── utils
├── components
├── views
└── store

每个模块负责不同功能,代码更容易维护。


八、总结

JavaScript 模块化的核心目标是:

把代码拆分成独立、可维护、可复用的模块。

学习模块化时,重点记住:

  • 早期有 IIFE

  • Node.js 常见 CommonJS

  • 现代前端主流是 ES Module

  • export / import是最常见写法

原型与原型链

2026年3月24日 11:06

前言

在 JavaScript 中,原型是一个非常重要的概念。
如果不理解原型,很多内容都会变得很模糊,比如:

  • 构造函数
  • 实例方法共享
  • 原型链
  • 类的本质

本文重点讲清楚:什么是原型、为什么需要原型、prototype 和 proto 有什么区别、原型链


一、什么是原型?

在 JavaScript 中,每个函数都有一个特殊属性:

prototype

这个属性指向一个对象,这个对象就叫做 原型对象

看一个例子:

function Person(name) {
  this.name = name;
}

Person.prototype.sayHello = function () {
  console.log(`你好,我是 ${this.name}`);
};

const p1 = new Person('Tom');
const p2 = new Person('Alice');

p1.sayHello(); // 你好,我是 Tom
p2.sayHello(); // 你好,我是 Alice

这里:

Person.prototype

就是构造函数

Person

的原型对象。


二、为什么需要原型?

如果我们把方法写在构造函数内部:

function Person(name) {
  this.name = name;
  this.sayHello = function () {
    console.log(`你好,我是 ${this.name}`);
  };
}

那么每创建一个实例,就会重新创建一次

sayHello

方法。

这样会导致:

  • 方法重复创建
  • 浪费内存

如果写到原型上:

Person.prototype.sayHello = function () {
  console.log(`你好,我是 ${this.name}`);
};

那么所有实例就可以共享同一个方法。

所以原型最核心的作用就是:

让实例共享属性和方法。


三、prototype 和 proto 的区别

这是非常高频、也非常容易混淆的知识点。

1)prototype

  • 只有函数才有
  • 指向构造函数的原型对象

2)__proto__

  • 对象才有
  • 指向该对象的原型

看例子:

function Person() {}

const p = new Person();

console.log(Person.prototype);
console.log(p.__proto__);
console.log(p.__proto__ === Person.prototype); // true

这个关系非常重要:

实例对象.__proto__ === 构造函数.prototype


四、原型上的属性和实例上的属性

function Person(name) {
  this.name = name;
}

Person.prototype.age = 18;

const p = new Person('Tom');

console.log(p.name); // Tom
console.log(p.age);  // 18

这里:

  • name

    是实例自己的属性

  • age

    是原型上的属性

当访问

p.age

时,JS 发现实例本身没有这个属性,就会去原型上找。


五、原型的实际意义

原型的最大意义就是“共享”。

例如数组为什么都有

push

pop

map

这些方法?

因为这些方法都定义在:

Array.prototype

上。

所以数组实例本身不需要重复拥有这些方法。


六、总结

原型可以用一句话总结:

原型是 JavaScript 中实现属性和方法共享的机制。

重点记住:

  • 函数有

    prototype

  • 对象有

    __proto__

  • 实例.__proto__ === 构造函数.prototype


学完原型之后,接下来最重要的就是 原型链
原型链本质上解决的是一个问题:

当我们访问对象属性时,JavaScript 到底是怎么查找的?

理解了原型链,你就能更清楚地看懂:

  • 为什么对象可以调用某些方法

  • 为什么数组能用

    push

  • 为什么实例可以访问原型方法


原型链

一、什么是原型链?

当访问一个对象的属性或方法时,JavaScript 会先在对象本身查找。
如果找不到,就会去对象的原型上查找。
如果原型上还找不到,就继续去原型的原型上查找。
这一层一层向上查找的结构,就叫做 原型链


二、属性查找过程

function Person(name) {
  this.name = name;
}

Person.prototype.sayHello = function () {
  console.log(`你好,我是 ${this.name}`);
};

const p = new Person('Tom');

console.log(p.name); // Tom
p.sayHello();        // 你好,我是 Tom

查找p.name

  1. 先看

    p

    自身有没有

    name

  2. 有,直接返回

查找p.sayHello

  1. 先看

    p

    自身有没有

    sayHello

  2. 没有

  3. p.__proto__

    ,也就是

    Person.prototype

    上找

  4. 找到了,执行它


三、原型链的尽头是什么?

function Person() {}

const p = new Person();

console.log(p.__proto__ === Person.prototype); // true
console.log(Person.prototype.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__); // null

所以原型链大致是:

p
→ Person.prototypeObject.prototypenull

null

就是原型链的终点。


四、数组的原型链

数组也是对象,所以也有原型链。

const arr = [1, 2, 3];

console.log(arr.__proto__ === Array.prototype); // true
console.log(Array.prototype.__proto__ === Object.prototype); // true

原型链结构大致是:

arr
→ Array.prototypeObject.prototypenull

所以数组既能用数组方法,也能用对象原型上的某些方法。


五、为什么原型链重要?

因为 JavaScript 中很多方法并不是对象自身直接拥有的,而是通过原型链继承来的。

例如:

const arr = [1, 2, 3];

arr.push(4);
arr.toString();

这里:

  • push

    来自

    Array.prototype

  • toString

    可能继续来自更上层原型


六、总结

原型链可以简单理解为:

对象查找属性时,沿着原型一层层向上查找的链式结构。

查找规则:

  1. 先找对象自身

  2. 再找原型

  3. 再找原型的原型

  4. 直到

    null

Qt Quick vs Qt Widgets如何选择适合你的 UI 技术路线(五)

作者 HelloReader
2026年3月24日 10:33

适合人群: 了解 Qt 基础,想搞清楚两种 UI 框架区别的开发者

前言

Qt 提供了两套完全不同的 UI 技术路线:Qt Quick(QML)Qt Widgets(C++)。很多新手在项目开始时就卡在这个选择上,不知道该学哪个、用哪个。

本文从渲染原理、开发体验、适用场景三个维度做完整对比,帮你做出清晰判断——而不是给你一个模糊的"看情况"。

一、两种技术的本质区别

Qt Widgets

Qt Widgets 诞生于 1990 年代,是 Qt 最早的 UI 系统。它基于操作系统的原生控件体系:

  • 按钮、输入框、菜单等控件由操作系统负责渲染
  • 外观与系统原生应用一致(Windows 上看起来像 Windows 应用,macOS 上看起来像 Mac 应用)
  • C++ 编写,代码风格偏命令式
// Qt Widgets 示例:创建一个按钮
QPushButton *button = new QPushButton("点我", this);
button->setGeometry(100, 100, 120, 40);
connect(button, &QPushButton::clicked, this, &MainWindow::onButtonClicked);

Qt Quick

Qt Quick 诞生于 2010 年,是 Qt 为现代 UI 设计的全新系统:

  • 所有元素由 Qt 自己的渲染引擎(基于 OpenGL / Vulkan / Metal)绘制,不依赖操作系统控件
  • 外观完全由开发者控制,默认不带任何平台风格
  • QML 编写,声明式语法,更接近现代前端开发
// Qt Quick 示例:创建一个按钮
Button {
    x: 100; y: 100
    width: 120; height: 40
    text: "点我"
    onClicked: console.log("被点击了")
}

二、核心对比

2.1 渲染方式

Qt Widgets Qt Quick
渲染引擎 操作系统原生控件 Qt 自有 GPU 渲染引擎
外观 跟随系统主题 完全自定义
动画性能 有限,复杂动画吃力 流畅,GPU 加速
高 DPI 支持 需要手动适配 原生支持

2.2 开发语言与风格

Qt Widgets Qt Quick
主要语言 C++ QML(+ 少量 JavaScript)
编程范式 命令式 声明式
UI 与逻辑 混合在 C++ 中 UI(QML)与逻辑(C++)分离
学习曲线 需要扎实 C++ 基础 QML 上手较快

2.3 功能特性

Qt Widgets Qt Quick
动画系统 基础 丰富,内置多种动画类型
触控支持 有限 原生支持多点触控
自定义样式 繁琐 灵活,QSS / 属性直接控制
复杂表格/树形控件 成熟完善 相对较弱(但持续改进)
无障碍访问 完善 仍在完善中
Qt Designer 支持 完整拖拽设计 Qt Design Studio

三、适用场景

应该选 Qt Widgets 的情况

1. 需要与操作系统原生外观高度一致

企业内部工具、行政软件、开发者工具等,用户期望看到"原生"的 Windows 或 macOS 风格界面。Widgets 无需任何配置就能做到这一点。

2. 大量复杂的数据展示

需要大型表格、树形结构、可编辑数据网格的应用(如 IDE、数据库管理工具、ERP 系统),Widgets 的 Model/View 框架非常成熟。

3. 团队有深厚的 C++ 背景

已有大量 C++ 代码库,需要与现有代码深度集成的项目。

4. 典型应用举例

  • 桌面 IDE / 代码编辑器
  • 企业 ERP / CRM 系统
  • 科学计算工具
  • 数据库管理软件

应该选 Qt Quick 的情况

1. 需要自定义视觉风格

品牌化的产品 UI、游戏界面、仪表盘等,不希望受系统主题限制,需要完全掌控每一个像素的外观。

2. 流畅动画和过渡效果

Qt Quick 的动画系统基于 GPU 渲染,天然流畅。做炫酷的 UI 过渡、数据可视化动效,Qt Quick 远胜 Widgets。

3. 触控设备 / 移动端

Android、iOS 应用,或带触摸屏的嵌入式设备,Qt Quick 对多点触控的支持比 Widgets 完善得多。

4. UI 与逻辑需要分工协作

设计师用 Qt Design Studio 做 QML 界面,开发者用 C++ 写后端逻辑,两者通过 QML/C++ 集成机制连接。这种分工在 Qt Quick 下非常自然。

5. 典型应用举例

  • 汽车中控 / 仪表盘
  • 智能家居控制面板
  • 嵌入式设备 HMI
  • 移动端应用
  • 多媒体播放器
  • 数据可视化大屏

四、动手对比:同一个 UI,两种写法

用一个包含标签、输入框和按钮的简单表单,直观感受两者的差异。

Qt Widgets 写法(C++)

// mainwindow.h
#include <QMainWindow>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QVBoxLayout>

class MainWindow : public QMainWindow {
    Q_OBJECT
public:
    MainWindow(QWidget *parent = nullptr);

private slots:
    void onSubmit();

private:
    QLabel    *m_label;
    QLineEdit *m_input;
    QPushButton *m_button;
};
// mainwindow.cpp
#include "mainwindow.h"

MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent)
{
    QWidget *central = new QWidget(this);
    QVBoxLayout *layout = new QVBoxLayout(central);

    m_label  = new QLabel("请输入名字:", this);
    m_input  = new QLineEdit(this);
    m_button = new QPushButton("提交", this);

    layout->addWidget(m_label);
    layout->addWidget(m_input);
    layout->addWidget(m_button);

    setCentralWidget(central);
    resize(320, 160);

    connect(m_button, &QPushButton::clicked, this, &MainWindow::onSubmit);
}

void MainWindow::onSubmit()
{
    m_label->setText("你好," + m_input->text() + "!");
}

Qt Quick 写法(QML)

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

ApplicationWindow {
    width: 320
    height: 160
    visible: true

    ColumnLayout {
        anchors.fill: parent
        anchors.margins: 16
        spacing: 8

        Label {
            id: greetLabel
            text: "请输入名字:"
        }

        TextField {
            id: nameInput
            Layout.fillWidth: true
            placeholderText: "名字"
        }

        Button {
            text: "提交"
            onClicked: greetLabel.text = "你好," + nameInput.text + "!"
        }
    }
}

直观感受:

  • Widgets 版本:约 40 行 C++ 代码,需要手动 new 控件、设置布局、连接信号
  • Qt Quick 版本:约 25 行 QML,声明式描述结构,逻辑内联在 onClicked

功能完全相同,但 QML 版本更简洁、更直观地表达了"界面是什么样的"。


五、可以混用吗?

可以,Qt 提供了两种方式将 Qt Quick 内容嵌入 Widgets 应用:

方式一:QQuickWidget

在 Widgets 窗口中嵌入一个 QML 场景:

#include <QQuickWidget>

QQuickWidget *qmlView = new QQuickWidget(this);
qmlView->setSource(QUrl("qrc:/MyComponent.qml"));
qmlView->setResizeMode(QQuickWidget::SizeRootObjectToView);
layout->addWidget(qmlView);

方式二:QQuickView

将整个 QML 场景显示为独立窗口,适合渐进式迁移:

#include <QQuickView>

QQuickView *view = new QQuickView();
view->setSource(QUrl("qrc:/Main.qml"));
view->show();

混用的典型场景: 现有 Widgets 应用想引入一个炫酷的数据可视化面板或动画组件,不需要重写整个应用,只在需要的地方嵌入 Qt Quick。


六、决策流程图

用以下问题快速判断你的项目应该选哪个:

需要触控 / 移动端支持?
├── 是 → Qt Quick
└── 否 ↓

需要完全自定义视觉风格 / 复杂动画?
├── 是 → Qt Quick
└── 否 ↓

主要是数据密集型桌面工具(大表格、树形结构)?
├── 是 → Qt Widgets
└── 否 ↓

团队只有 C++ 经验,没有时间学 QML?
├── 是 → Qt Widgets
└── 否 → Qt Quick(现代项目的默认选择)

七、对于本系列的学习者

你的学习目标包含桌面、嵌入式、移动端和 UI 动画,Qt Quick 是主线

  • 移动端(Android / iOS):Qt Quick
  • 嵌入式 HMI / MCU:Qt Quick / Qt for MCUs
  • UI 动画:Qt Quick 的动画系统
  • 桌面应用:Qt Quick 也完全胜任,复杂数据工具再考虑 Widgets

Qt Widgets 作为补充知识了解即可,在本系列的后期阶段(第四阶段)会专项涉及。


总结

维度 Qt Widgets Qt Quick
渲染 操作系统原生 Qt GPU 渲染引擎
语言 C++ QML + JavaScript
动画 有限 流畅,GPU 加速
触控 有限 原生支持
自定义外观 繁琐 灵活
数据表格 成熟完善 持续改进
适合场景 桌面工具、企业软件 移动端、嵌入式、现代 UI
学习曲线 需要 C++ 基础 QML 上手较快

一句话总结: 做现代 UI、触控设备、嵌入式 HMI 选 Qt Quick;做传统桌面工具、数据密集型企业软件选 Qt Widgets;两者也可以混用。

开源一年,我的 AI 全栈项目 AI 协同编辑器终于有 1.1 k star了 😍😍😍

作者 Moment
2026年3月24日 10:20

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

📖 简介

DocFlow 是一款面向团队协作的块级文档编辑器。它融合了 Notion 的灵活性与飞书的协作能力,通过块级内容架构、实时协同编辑和 AI 辅助功能,帮助团队高效完成文档创作与知识管理。

我们希望通过技术手段减少协作摩擦,让文档编辑更接近团队的真实工作流。无论是产品规划文档、技术方案设计,还是会议记录整理,DocFlow 都能提供流畅的创作体验。

✨ 核心特性

DocFlow 参考了 Notion 与飞书的设计理念,将内容以块为单位进行组织。每个块都是独立的编辑单元,可以灵活组合与调整,同时支持实时协作与 AI 辅助。

  • 🧱 块级编辑器:支持文本、标题、列表、代码块、表格、图片、视频等 20+ 种内容类型,通过拖拽即可调整块级元素的顺序与层级关系。

  • ⚡ 实时协作:基于 Yjs CRDT 算法实现多人同步编辑,自动处理编辑冲突。支持实时光标跟踪、成员在线状态与历史版本回溯。

  • 🤖 AI 功能:内置 AI 助手,支持头脑风暴、内容润色、文档续写与智能问答。可根据上下文生成结构化内容建议。

技术选型

DocFlow 采用全栈 TypeScript 架构,前端基于 Next.js 构建,后端使用 NestJS 框架。通过统一的类型系统和现代化的工程实践,保证了代码质量与开发效率。

🎨 前端架构 (Client-side)

Next.js

项目基于 Next.js App Router 架构,利用 React Server Components 优化首屏渲染性能。通过 Server Actions 实现前后端通信,确保类型安全的同时简化了数据流转。

Tiptap

编辑器核心采用 Tiptap 框架,基于 ProseMirror 构建。通过扩展机制实现了丰富的块级编辑能力,支持自定义节点与快捷命令,为用户提供接近 Notion 的编辑体验。

Yjs

协作功能基于 Yjs CRDT 算法实现,能够自动处理多人编辑时的冲突,保证数据最终一致性。配合 Awareness 模块,实现了实时光标追踪与在线状态同步。

⚙️ 后端架构 (Server-side)

NestJS & Prisma

后端使用 NestJS 模块化框架,通过依赖注入实现业务逻辑解耦。Prisma ORM 提供类型安全的数据访问层,支持高效的数据库查询与迁移管理。

Hocuspocus

Hocuspocus 作为 Yjs 的 WebSocket 服务端,负责协调文档协作会话,处理客户端连接与数据同步。通过拦截器机制实现权限控制与数据持久化。

Prometheus & Grafana

集成 Prometheus 进行指标采集,通过 Grafana 可视化展示系统运行状态。监控包括 API 响应时间、数据库查询性能、WebSocket 连接数等核心指标。

20260203091658

Grafana 监控面板实时展示系统各项性能指标,包括请求量、响应时间、错误率等关键数据,帮助快速定位性能瓶颈。

ELK Stack (Elasticsearch & Kibana)

使用 Elasticsearch 存储和检索日志数据,Kibana 提供日志分析与可视化能力。支持全文搜索、日志聚合与异常检测,便于问题排查与系统审计。

日志分析系统

Kibana 日志分析界面,支持按时间、日志级别、服务模块等维度查询和过滤日志,提供结构化的问题排查路径。

MinIO & RabbitMQ

MinIO 提供对象存储服务,用于存储用户上传的图片、视频等文件。RabbitMQ 作为消息队列,处理异步任务如图片压缩、邮件发送等,避免阻塞主业务流程。

功能介绍

DocFlow 将 AI 能力集成到编辑器中,通过理解文档上下文来辅助内容创作。AI 不是简单的文本生成工具,而是能够理解语义、提供决策建议的智能助手。

AI 头脑风暴

当你有一个初步想法但不知如何展开时,AI 头脑风暴可以帮助拓展思路。输入核心概念后,AI 会从不同角度生成 3-6 个结构化方案,每个方案都包含具体的实施思路。

AI 头脑风暴输入界面

在编辑器中输入头脑风暴主题,AI 会基于输入内容理解你的需求场景。

AI 头脑风暴结果展示

AI 生成的多个方案以卡片形式展示,每个方案都有清晰的标题和详细说明。你可以选择任意方案插入到文档中,或者继续优化调整。

这不只是简单的内容生成,AI 会根据上下文理解你的意图。无论是产品功能设计、内容分类规划,还是业务流程优化,AI 都能提供可行的思路参考,帮助快速决策。

AI 文本润色

AI 文本润色功能

选中需要优化的文本段落,AI 会分析文本结构与表达方式,提供更清晰、更专业的改写建议。支持调整语气风格,如正式、简洁、友好等。

AI 续写

AI 续写功能会根据前文内容自然延续写作。当前文内容较长时,系统通过 RAG (检索增强生成) 技术,从文档中检索相关段落,确保续写内容与上下文保持逻辑一致,避免偏离主题。

AI 续写功能演示

AI 续写时会参考前文的写作风格、用词习惯和逻辑结构,生成连贯自然的后续内容。你可以继续编辑生成的文本,或者重新生成。

AI 聊天

目前 AI 聊天功能作为独立页面存在,后续会集成到编辑器侧边栏,与文档内容深度关联。未来计划实现 Agent 模式,类似 Cursor 那样能够自动编辑文档内容。

7a8ba58a4ab3b592bb7fae1b45634648

协同编辑

多人协同编辑

多人同时编辑时,每个用户都有独立的光标颜色标识。文档修改实时同步,冲突自动合并。右侧显示当前在线成员列表与他们的编辑位置。

未来计划

DocFlow 将持续优化协作体验与 AI 能力,同时加强工程化建设,提升系统可扩展性。

🏗️ 工程化体系深度重构

  • 迈向 Monorepo 架构:计划基于 pnpm workspaces 和 Turborepo 将项目重构为 Monorepo。前后端代码分离,共享类型定义与工具函数,提升代码复用率与构建效率。

  • 组件库与插件生态开放:将 Tiptap 自定义扩展(如代码沙箱、交互式图表等)提取为独立 npm 包,开放给社区使用。同时建立插件开发规范,支持第三方开发者扩展编辑器能力。

🎙️ 多维协同体验升级

  • 集成 LiveKit 实时音视频:在文档协作场景中引入实时音视频通话。团队成员可以边看文档边讨论,提升复杂决策场景下的沟通效率。

LiveKit 集成方案

  • 实时群聊系统:在文档侧边栏集成实时聊天功能,支持针对文档内容发起讨论。消息可以关联到具体的文档块,形成完整的协作反馈闭环。

🤖 智能内核的跨越式进化

  • 基于 RAG 的私有知识库:引入 RAG (Retrieval-Augmented Generation) 技术,让 AI 能够检索用户的历史文档。AI 回答问题时会参考团队沉淀的知识资产,提供更精准的决策支持。

  • 从 Copilot 迈向 Agent:探索 AI Agent 在文档场景的应用。未来 AI 将能够自主执行任务,例如从会议纪要中提取待办事项,自动同步到第三方工具,实现从辅助创作到自动化办公的升级。

🚀 快速开始

环境要求

  • Node.js >= 24
  • pnpm >= 10.28.2

本地开发

  1. 克隆仓库
git clone https://github.com/xun082/DocFlow.git
cd DocFlow
  1. 安装依赖
pnpm install
  1. 启动开发服务器
pnpm dev
  1. 打开浏览器访问
http://localhost:3000

🐳 Docker 部署

方式一:使用 Docker Compose(推荐)

# 使用预构建镜像
docker-compose up -d

# 访问应用
http://localhost:3000

方式二:手动构建

  1. 构建镜像
docker build -t docflow:latest .
  1. 运行容器
docker run -d \
  --name docflow \
  -p 3000:3000 \
  -e NODE_ENV=production \
  docflow:latest
  1. 访问应用
http://localhost:3000

健康检查

容器内置健康检查端点:

curl http://localhost:3000/api/health

🤝 贡献指南

欢迎提交 Issue 和 Pull Request!

在提交代码前,请确保:

  • 运行 pnpm type-check 通过类型检查

  • 运行 pnpm lint 通过代码检查

  • 运行 pnpm format 格式化代码

  • 遵循项目的代码规范和提交规范

详见 CONTRIBUTING.md

📬 联系方式

吃透 ES6 Generator:yield/next/yield* 核心用法详解

作者 cmd
2026年3月24日 10:13

Generator 是 ES6 为异步编程和迭代器设计的核心特性,本文聚焦 Generator 函数的核心语法:yield 暂停执行、next () 传参机制、yield* 调用迭代器 / 生成器的逻辑,通过极简示例讲清原理;

ES6中也提出了新的循环方式for...of;它是只能用于可生成迭代对象的循环的;比如数组,Set, Map, String,自定义的迭代器等等;提这个知识点是它跟本文的知识点有关联;

一、Generator 定义

Generator主要用于异步编程,最大的特点就是交出函数的执行权(暂停);本质上,整个Generator函数就是一个封装的异步任务,或者说是异步任务的容器;

返回方式为yieldyield命令是异步不同阶段的分界线

yieldreturn的区别:

  • yield:暂停函数,再次调用会执行到遇到下一个yield为止;
  • return:结束函数,再次调用会重新执行,直到return结束;

Generator 函数体内使用yield语句,可以定义不同的内部状态;

函数的调用方法:next()方法;会返回一个对象,{ value: 值,done:  布尔值 }

function* print(){
    yield 'a';
    yield 'b';
    yield 'c';
    return 'd ...end';
}
let p = print();
console.log(p.next())
// { value: 'a', done: false}
console.log(p.next())
// { value: 'b', done: false}
console.log(p.next())
// { value: 'c', done: false}
console.log(p.next())
// { value: 'd ...end', done: true}
console.log(p.next())
// { value: undefined, done: true}

当输出完成后done会变成true;接着调用的还是能返回值,但是返回的都是undefined

二、next()

Generator函数中的.next()方法可以接收参数;

  • 传入的参数,其实是把上一个yield语句的返回的值给覆盖;
  • 第一个.next()方法其实就是启动器,在它之前没有yield语句,所以给第一个.next()方法传参是没有意义的
function* generatorFunction() {
  const a = yield;
  while(true) {
    yield a;
  }
}

const generator = generatorFunction();

console.log(generator.next());  // { value: undefined, done: false}
console.log(generator.next(1)); // { value: 1, done: false }
console.log(generator.next()); // { value: 1, done: false }
console.log(generator.next()); // { value: 1, done: false }

第一次调用的时候yield没有返回语句,所以是undefined;第二次调用的时候传1,接收到的是1,往后只会一直输出1

三、yield 和 yield*

比如创建一个生成器用于返回一个斐波那契数列,斐波那契数列定义如下:

  • 第一个数是 0
  • 第二个数是 1
  • 之后的每一个数是前两个数之和

换言之,即:F(0) = 0; F(1) = 1; ... F(n) = F(n-1) + F(n-2);

实际上我们希望在一个生成器里输出一些来自其它生成器的值。这时 yield* 就派上用场了:

function* fibonacciGeneratorFunction(a = 0, b = 1) {
  yield a;   
  yield* fibonacciGeneratorFunction(b, b + a);
}

const fibonacciGenerator = fibonacciGeneratorFunction();

fibonacciGenerator.next(); // { value: 0, done: false }
fibonacciGenerator.next(); // { value: 1, done: false }
fibonacciGenerator.next(); // { value: 1, done: false }
fibonacciGenerator.next(); // { value: 2, done: false }
fibonacciGenerator.next(); // { value: 3, done: false }
fibonacciGenerator.next(); // { value: 5, done: false }

yield* 是可用于调用其他的生成器的;

function* g1() {
  yield 2;
  yield 3;
  yield 4;
}

function* g2() {
  yield 1;
  yield* g1();
  yield 5;
}

const iterator = g2();

console.log(iterator.next()); // {value: 1, done: false}
console.log(iterator.next()); // {value: 2, done: false}
console.log(iterator.next()); // {value: 3, done: false}
console.log(iterator.next()); // {value: 4, done: false}
console.log(iterator.next()); // {value: 5, done: false}
console.log(iterator.next()); // {value: undefined, done: true}

yield* 的用处

yield* 紧跟的表达式可以是任何可生成迭代对象的迭代器或另一个生成器;可生成迭代对象的ObjectArray, String, Set, Map等;可以通过原型链上的Symbol.Iterator判断是否可生成迭代对象;

Symbol.IteratorSymbol的那一篇(第一篇)有提

四、作用

数组的合并分级遍历

let a = [1,2,3,4]
let b = [5,6,7,8]
// let c = [...a,...b]
// for(let key of c) {
//     console.log(key);
// }
function* obPrint(...args) {
    console.log(...args);
    for(const key of args) {
        yield* key
    }
}
for(const num of obPrint(a,b)) {
    console.log(num);
}

感谢您抽出宝贵的时间观看本文;本文是JavaScript系列的第4篇,后续会持续更新,欢迎关注~

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

作者 SmalBox
2026年3月24日 10:12

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

Sample Virtual Texture 节点是 Unity URP Shader Graph 中用于处理虚拟纹理采样的核心节点。虚拟纹理技术是一种先进的纹理流送系统,它允许开发者使用超出传统 GPU 内存限制的超高分辨率纹理,同时保持高效的内存使用和渲染性能。该节点通过智能的纹理流送机制,仅加载当前视锥体内可见的纹理部分,为现代游戏和实时渲染应用提供了处理大规模纹理数据的能力。

在 Shader Graph 中使用 Sample Virtual Texture 节点,开发者可以轻松集成虚拟纹理系统,而无需深入了解底层复杂的实现细节。该节点支持最多四个纹理层的采样,每个层可以包含不同类型的纹理数据,如基础颜色、法线、高度或金属度等材质属性。这种多层架构使得单个虚拟纹理资产能够替代多个传统纹理,简化材质设置并提高渲染效率。

虚拟纹理系统的核心优势在于其动态流送能力。当摄像机移动或物体在场景中变换时,系统会自动检测需要的高分辨率纹理块,并在后台异步加载它们。这种机制特别适合开放世界游戏、建筑可视化和其他需要大量高分辨率纹理的应用场景。Sample Virtual Texture 节点作为这一系统在 Shader Graph 中的接口,提供了直观的方式来访问这些高级功能。

描述

Sample Virtual Texture 节点专门设计用于对虚拟纹理属性进行采样操作。它接收 UV 坐标作为输入,并返回最多四个 Vector 4 类型的颜色值,这些值对应虚拟纹理中不同层的采样结果。每个输出通道可以提供独立的材质属性,使得单个采样操作能够获取完整的表面材质信息。

虚拟纹理与传统纹理的根本区别在于其存储和访问方式。传统纹理需要完整加载到 GPU 内存中,而虚拟纹理则按需加载纹理的特定区域和 mip 级别。这种按需加载机制使得虚拟纹理能够处理极端高分辨率的纹理数据,比如 16K、32K 甚至更高分辨率的纹理,而不会耗尽可用的 GPU 内存资源。

在 Shader Graph 中使用 Sample Virtual Texture 节点时,需要注意其默认只能在片元着色器阶段使用。这是因为虚拟纹理采样依赖于屏幕空间导数来计算适当的 mip 级别,而这些导数在顶点着色器中不可用。不过,通过特定的配置,也可以在顶点着色器中使用该节点,但这通常需要手动指定 LOD 级别,而不是依赖自动计算。

当项目中禁用了虚拟纹理功能时,Sample Virtual Texture 节点会自动回退到标准的 2D 纹理采样行为。这种向后兼容性确保了着色器在不同项目配置下的可移植性,无需为支持或不支持虚拟纹理的系统创建两套独立的着色器。

Sample Virtual Texture 节点必须连接到 Shader Graph 资源中的 Virtual Texture 属性才能正常工作。如果节点未连接到任何虚拟纹理属性,Shader Graph 编译器会报错,提示需要建立连接。这种设计确保了资源的正确引用和流送系统的正常初始化。

对于法线贴图的处理,Sample Virtual Texture 节点提供了专门的配置选项。要为特定层启用法线贴图采样,需要在 Graph Inspector 中选择该层,并将 Layer Type 从 Default 改为 Normal。这一设置会确保法线向量被正确解码和转换,从切线空间到世界空间或视图空间,具体取决于着色器的需求。

虚拟纹理的工作原理

虚拟纹理系统通过将超大纹理分割成固定大小的图块(通常为 128x128 或 256x256 像素)来工作。这些图块根据摄像机的距离和视角动态流送。系统维护一个物理纹理缓存,其中包含当前可见区域所需的图块。当需要访问尚未加载的图块时,系统会触发异步加载请求,并从磁盘或网络存储中获取所需数据。

Sample Virtual Texture 节点在这一过程中扮演关键角色,它处理以下任务:

  • 将输入的 UV 坐标映射到虚拟纹理空间
  • 确定当前采样所需的 mip 级别
  • 检查所需图块是否已加载到物理缓存中
  • 如果图块未加载,触发回退机制(如使用较低 mip 级别)
  • 从缓存中获取最终采样数据并返回

性能考量

使用 Sample Virtual Texture 节点时,性能是需要重点考虑的因素。虽然虚拟纹理可以减少总体内存使用,但采样操作本身可能比传统纹理采样更昂贵,因为它涉及额外的间接查找。优化建议包括:

  • 尽量减少单个着色器中的虚拟纹理采样次数
  • 合理设置虚拟纹理的图块大小和缓存尺寸
  • 使用适当的 mip 映射偏置来平衡质量和性能
  • 在移动平台上谨慎使用,考虑其有限的带宽和计算资源

端口

Sample Virtual Texture 节点提供了一系列输入和输出端口,用于控制采样过程和获取结果。理解每个端口的功能和适用场景对于有效使用该节点至关重要。

输入端口

UV 输入端口接收 Vector 2 类型的坐标值,用于指定虚拟纹理上的采样位置。这个端口通常连接到 UV 节点或经过变换的坐标值。UV 端口的绑定类型为 UV,意味着它可以自动适应不同的 UV 通道,但也可以手动提供自定义的坐标值。

VT 输入端口是 Sample Virtual Texture 节点的核心输入,它必须连接到 Shader Graph 中的 Virtual Texture 属性。这个连接确保了虚拟纹理系统能够正确初始化和流送所需的纹理数据。VT 端口不绑定到特定的纹理类型,而是引用整个虚拟纹理资产,其中可能包含多个纹理层。

输出端口

Out 输出端口提供虚拟纹理中第 1 层的 RGBA 采样结果。这个端口通常用于基础颜色或漫反射纹理,是大多数材质着色器的主要输入。输出值的范围取决于纹理数据的编码方式,通常是 0 到 1 的线性空间值,但也可能是其他颜色空间,如 sRGB。

Out2 输出端口提供第 2 层的采样结果,常用于存储如粗糙度、金属度或环境光遮蔽等材质属性。在多图层虚拟纹理中,这一层可能包含单通道或双通道数据,但输出始终是四分量向量,未使用的通道通常填充为 0 或 1。

Out3 输出端口在虚拟纹理至少包含三层时可用,提供第 3 层的采样数据。这一层可能用于高度图、发射图或其他特殊用途的材质属性。使用前应确保虚拟纹理资产确实包含三层或以上,否则该端口可能输出默认值或导致编译错误。

Out4 输出端口在虚拟纹理包含四层时可用,提供最顶层的数据采样。在完整的四层虚拟纹理设置中,这四层通常构成标准的 PBR 材质属性:基础颜色、法线、金属度/粗糙度和高度/环境光遮蔽。

端口使用策略

有效使用 Sample Virtual Texture 节点的端口需要遵循一定的策略:

  • 仅连接实际需要的输出端口,未使用的端口不会产生额外开销,但清晰的连接有助于提高可读性
  • 确保 UV 输入提供正确的坐标空间和缩放,避免纹理拉伸或压缩
  • 对于多层虚拟纹理,了解每层存储的数据类型和编码方式至关重要
  • 在简单用例中,可能只需要使用 Out 端口,而更复杂的材质可能需要所有四个输出

设置

Sample Virtual Texture 节点提供了丰富的设置选项,用于精确控制采样行为和适应不同的渲染需求。这些设置可以通过 Graph Inspector 访问,当节点被选中时,相关选项会显示在 inspector 面板中。

Lod Mode

Lod Mode 设置决定了采样时如何选择 mipmap 级别。这一设置对渲染质量和性能有显著影响,特别是在处理动态摄像机移动或物体变换时。

Automatic 模式是默认设置,它根据屏幕空间导数自动计算适当的 mip 级别。这种模式在大多数情况下提供最佳的质量和性能平衡,因为它确保了纹理细节与屏幕像素密度匹配。自动 LOD 计算依赖于 ddx 和 ddy 指令,这些指令在片元着色器中可用,但在顶点着色器中不可用。

Lod Level 模式允许手动指定固定的 mip 级别。这种模式适用于需要精确控制纹理细节的情况,如特效着色器或特定风格化渲染。当使用固定 LOD 时,纹理采样不会随距离变化,这可能导致远处物体过度模糊或近处物体锯齿。

Lod Bias 模式在自动计算的 mip 级别上应用一个偏置值。正偏置会使纹理更模糊,负偏置会使纹理更锐利,但可能引入闪烁或噪点。这一模式适用于微调纹理外观,或在不同硬件上平衡性能和质量。

Derivatives 模式使用显式导数计算 mip 级别,适用于特殊情况,如自定义 UV 映射或非标准着色器流程。这种模式提供了最大的灵活性,但需要更深入的理解才能正确使用。

Quality

Quality 设置控制采样时的过滤质量,影响纹理的视觉平滑度和性能。

Low 质量使用双线性过滤,计算开销较小但可能在高对比度边缘产生明显的块状效应。这种设置适用于移动平台或性能敏感的场景,其中渲染速度优先于视觉保真度。

High 质量使用各向异性过滤,更好地保持倾斜视角下的纹理细节。这种设置会产生更高质量的图像,特别是对于远离摄像机的表面,但会增加内存带宽和计算需求。高性能硬件通常能够处理高质量设置而不会显著影响帧率。

Automatic Streaming

Automatic Streaming 开关控制虚拟纹理系统的流送行为。

Enabled 状态允许 Unity 自动管理纹理流送,根据摄像机位置和视角动态加载和卸载纹理块。这是推荐设置,因为它提供了最佳的可用性和性能平衡,无需手动干预。

Disabled 状态关闭自动流送,需要手动管理纹理加载。这种高级模式适用于特殊用例,如自定义流送逻辑或特定的内存管理需求,但对大多数用户不推荐,因为它增加了复杂性且容易出错。

Enable Global Mip Bias

Enable Global Mip Bias 开关控制是否应用 Unity 引擎的全局 mip 偏置。

当启用时,Unity 可以在运行时自动调整 mip 级别,以配合动态分辨率缩放或其他后处理效果。这种偏置改善了细节重建和 temporal anti-aliasing 的效果,但可能轻微改变纹理外观。

禁用此选项会确保 mip 级别完全由 Lod Mode 设置控制,提供更可预测的采样行为,但可能牺牲一些图像质量优化。

Layer Type

Layer Type 设置允许为虚拟纹理的每个层指定数据类型,这影响了采样结果的解释和处理方式。

Default 类型适用于标准颜色数据,如漫反射贴图、粗糙度贴图或金属度贴图。采样结果直接作为颜色或标量值使用,无需特殊转换。

Normal 类型指定该层包含法线贴图数据。当设置为 Normal 时,采样器会执行适当的解码和转换,将存储在纹理中的切线空间法线转换为可用的向量数据。对于法线贴图层,确保原始纹理数据正确编码至关重要——通常是使用两种常见的编码方式:DX 风格(绿色通道不变)或 OpenGL 风格(红色通道不变)。

生成的代码示例

Sample Virtual Texture 节点在编译时会生成相应的 HLSL 代码,理解这些代码有助于深入掌握节点的功能和在需要时进行自定义修改。

函数结构分析

生成的代码核心是一个名为 SampleVirtualTexture 的函数,它封装了虚拟纹理采样的完整流程:

HLSL

float4 SampleVirtualTexture(float2 uv, VTPropertyWithTextureType vtProperty, out float4 Layer0)
{
    // 初始化虚拟纹理参数结构
    VtInputParameters vtParams;
    vtParams.uv = uv;
    vtParams.lodOrOffset = 0.0f;
    vtParams.dx = 0.0f;
    vtParams.dy = 0.0f;
    vtParams.addressMode = VtAddressMode_Wrap;
    vtParams.filterMode = VtFilter_Anisotropic;
    vtParams.levelMode = VtLevel_Automatic;
    vtParams.uvMode = VtUvSpace_Regular;
    vtParams.sampleQuality = VtSampleQuality_High;

    // 光线追踪特殊处理
    #if defined(SHADER_STAGE_RAY_TRACING)
    if (vtParams.levelMode == VtLevel_Automatic || vtParams.levelMode == VtLevel_Bias)
    {
        vtParams.levelMode = VtLevel_Lod;
        vtParams.lodOrOffset = 0.0f;
    }
    #endif

    // 准备虚拟纹理采样
    StackInfo info = PrepareVT(vtProperty.vtProperty, vtParams);

    // 采样特定层
    Layer0 = SampleVTLayerWithTextureType(vtProperty, vtParams, info, 0);

    // 返回解析输出
    return GetResolveOutput(info);
}

参数详解

VtInputParameters 结构包含了控制采样行为的所有参数:

  • uv:输入的纹理坐标,通常来自顶点着色器插值或计算所得
  • lodOrOffset:LOD 级别或偏置值,具体含义取决于 levelMode 设置
  • dx 和 dy:屏幕空间导数,用于自动 LOD 计算
  • addressMode:寻址模式,控制 UV 坐标超出[0,1]范围时的行为
  • filterMode:过滤模式,决定纹理放大缩小时的插值方法
  • levelMode:LOD 计算模式,对应 Graph Inspector 中的 Lod Mode 设置
  • uvMode:UV 空间模式,通常为常规 UV 空间
  • sampleQuality:采样质量,对应 Quality 设置

特殊情形处理

代码中的条件编译部分处理了光线追踪等特殊情形:

HLSL

#if defined(SHADER_STAGE_RAY_TRACING)
if (vtParams.levelMode == VtLevel_Automatic || vtParams.levelMode == VtLevel_Bias)
{
    vtParams.levelMode = VtLevel_Lod;
    vtParams.lodOrOffset = 0.0f;
}
#endif

在光线追踪着色器中,自动 LOD 和 LOD 偏置模式被强制转换为固定 LOD 模式,因为光线追踪环境下无法计算屏幕空间导数。这一转换确保了代码的兼容性和稳定性。

采样流程

函数的执行流程遵循标准的虚拟纹理采样步骤:

  1. 参数初始化:设置所有必要的采样参数
  2. 系统准备:调用 PrepareVT 函数初始化虚拟纹理系统
  3. 层采样:对指定层进行实际采样操作
  4. 结果返回:通过输出参数和返回值提供采样结果

理解这一流程有助于调试虚拟纹理相关问题和优化着色器性能。

实际应用示例

基础虚拟纹理采样

最简单的 Sample Virtual Texture 用法是采样单层虚拟纹理作为漫反射颜色:

  1. 在 Shader Graph 中创建 Virtual Texture 属性
  2. 添加 Sample Virtual Texture 节点,将 VT 端口连接到 Virtual Texture 属性
  3. 将 UV 端口连接到适当的 UV 坐标(如 UV 节点)
  4. 将 Out 端口连接到主着色器的 Base Color 输入

这种设置适用于替换传统的 2D 纹理采样,同时获得虚拟纹理的流送优势。

多层 PBR 材质设置

对于完整的 PBR 材质,可以使用四层虚拟纹理:

  1. 第 1 层(Out):基础颜色,连接到 Base Color
  2. 第 2 层(Out2):法线贴图,设置为 Normal 类型,连接到 Normal 输入
  3. 第 3 层(Out3):金属度和粗糙度,通常红色通道为金属度,绿色通道为粗糙度
  4. 第 4 层(Out4):环境光遮蔽,连接到 Occlusion 输入

这种配置用一个虚拟纹理资产替代了多个独立纹理,简化了材质管理并提高了内存效率。

高级混合技术

Sample Virtual Texture 节点可以与其他节点结合,实现复杂的材质效果:

  • 使用 Height 输出与 Parallax Occlusion Mapping 节点结合,实现视差效果
  • 将多个虚拟纹理采样与 Blend 节点结合,实现材质混合
  • 使用自定义 UV 变换与 Triplanar 节点结合,实现无接缝的三平面映射

这些高级技术扩展了虚拟纹理的应用范围,使其能够适应各种复杂的渲染需求。

故障排除与最佳实践

常见问题

虚拟纹理采样中可能遇到的常见问题包括:

  • 纹理不显示或显示为粉色:检查 Virtual Texture 属性是否正确设置和连接
  • 性能下降:确保虚拟纹理缓存大小适当,减少不必要的采样
  • 纹理闪烁或 popping:调整流送设置,增加缓存尺寸或调整 mip 偏置
  • 法线看起来不正确:确认法线层的 Layer Type 设置为 Normal,并检查原始纹理的编码格式

优化建议

为了获得最佳性能和视觉效果:

  • 根据目标平台平衡虚拟纹理的分辨率和层数
  • 使用适当的 mip 偏置避免过采样或欠采样
  • 在移动平台上考虑使用较低质量的过滤设置
  • 定期分析虚拟纹理流送统计信息,优化纹理设置

兼容性考虑

Sample Virtual Texture 节点在不同平台和渲染管线中的行为可能有所差异:

  • 在 Built-in Render Pipeline 中需要额外的设置步骤
  • 移动平台可能对虚拟纹理的支持有限,需测试目标设备
  • 当虚拟纹理禁用时,确保备用纹理具有适当的分辨率和格式

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

JavaScript设计模式(三):代理模式实现与应用

2026年3月24日 10:05

1、代理模式定义

代理模式是为一个对象提供一个替身或占位符,以便控制对它的访问

典型的一个例子就是代购,你想买国外的限量版商品,但自己没有渠道或无法出国,所以找代购帮你去买。

2、核心思想

  1. 间接性 (Indirection)
    • 没有代理:直接访问,client → realObject
    • 有代理:client → proxy → realObjectproxy 层可以做很多事情。
  2. 分离关注点 (Separation of Concerns):将核心业务逻辑和辅助功能分离。核心业务逻辑还是在真实对象中,代理层 Proxy 可以添加一些辅助功能,如收集日志、添加缓存等。
  3. 开闭原则 (Open-Closed Principle):代理层 Proxy 不修改真实对象中原有代码,只是通过扩展来增强功能。

代理模式就是在访问者和目标对象之间建立一个中间层,这个中间层可以:

  • ✅ 控制访问时机(延迟加载)。
  • ✅ 控制访问权限(权限校验)。
  • ✅ 控制访问方式(缓存、日志)。
  • ✅ 隐藏复杂性(远程调用) 。
  • ✅ 增强功能而不修改目标对象 。

拿一个形象点的例子来说就是,想找明星的人必须先通过经纪人,而经纪人可以:

  • 过滤掉不合适的邀约(权限控制)。
  • 安排日程(延迟访问)。
  • 谈价格(添加功能)。
  • 处理合同(隐藏复杂性)。

3、代理模式的应用

3.1 虚拟代理 - 延迟加载

当操作代价比较昂贵时,我们可以将实际操作延迟到真正需要它的时候,这叫做虚拟代理,这样能降低执行开销。

// 推迟昂贵操作,直到真正需要
class VirtualProxy {
    constructor(factory) {
        this.factory = factory;
        this.instance = null;
    }
    
    getInstance() {
        if (!this.instance) {
            this.instance = this.factory.create(); // 延迟创建
        }
        return this.instance;
    }
}

3.2 保护代理 - 权限控制

代理在中间可以过滤掉一些操作,这个叫做保护代理。我们可以根据用户角色,进行操作限制。

// 根据访问者权限决定是否允许操作
class ProtectionProxy {
    constructor(realObject, allowedUsers) {
        this.realObject = realObject;
        this.allowedUsers = allowedUsers;
    }
    
    operation(user) {
        if (!this.allowedUsers.includes(user)) {
            throw new Error("Access denied");
        }
        return this.realObject.operation();
    }
}

3.3 缓存代理 - 结果复用

代理在中间也可以充当缓存作用,这就是缓存代理,可以缓存计算结果、接口请求结果等,大幅提高性能。

// 缓存重复计算的结果
class CacheProxy {
    constructor(realObject) {
        this.realObject = realObject;
        this.cache = new Map();
    }
    
    operation(key) {
        if (this.cache.has(key)) {
            return this.cache.get(key); // 直接返回缓存
        }
        const result = this.realObject.operation(key);
        this.cache.set(key, result);
        return result;
    }
}

3.4 日志代理 - 监控追踪

通过代理,我们可以在具体的操作前后收集相关操作信息,比如操作时间、操作耗时、环境信息、用户标识等,并打印日志或者上报到日志服务器,这就是日志代理

// 思想:在不修改业务代码的情况下添加监控
class LoggingProxy {
    constructor(realObject) {
        this.realObject = realObject;
    }
    
    operation(...args) {
        console.log(`[${new Date().toISOString()}] 调用 operation`, args);
        const start = performance.now();
        
        const result = this.realObject.operation(...args);
        
        const duration = performance.now() - start;
        console.log(`[${new Date().toISOString()}] 完成,耗时 ${duration}ms`);
        
        return result;
    }
}

3.5 远程代理 - 隐藏网络通信

针对一些远程服务,代理可以屏蔽和远程通信细节,让用户像本地对象一样使用远程服务,这叫做远程代理

// 让远程服务像本地对象一样使用
class RemoteProxy {
    constructor(remoteUrl) {
        this.remoteUrl = remoteUrl;
    }
    
    async operation(data) {
        // 隐藏网络通信细节
        const response = await fetch(this.remoteUrl, {
            method: 'POST',
            body: JSON.stringify(data)
        });
        return response.json();
    }
}

// 客户端使用起来像本地对象
const proxy = new RemoteProxy('https://api.example.com');
const result = await proxy.operation({ cmd: 'query' });

4、代理模式的优缺点

4.1 优点:

  • 职责清晰,符合单一职责原则:代理模式将核心业务逻辑与辅助功能分离,代理层实现辅助功能,核心业务逻辑由真实对象来完成,职责分明。
  • 高扩展性,符合开闭原则:在不修改原有代码的情况下,通过代理层增加新功能。
  • 智能控制访问:可以在访问真实对象前后添加各种控制逻辑,比如利用保护代理增加权限控制。
  • 延迟加载,优化性能:可以在真正需要时才创建昂贵的对象,也就是之前说的虚拟代理。

4.2 缺点:

  • 增加系统复杂度:因为多加了一层代理层,复杂度自然就上去了,包括实现、维护、调试的复杂度,比如利用缓存代理增加了缓存层,在数据库更新数据的时候,需要主动让缓存失效,这无疑增加了复杂度。
  • 增加调用链路:代理增加了方法的调用层级,可能会降低性能。
  • 客户端和真实对象需要相同接口。比如真实对象有多个方法,缓存层也必须全部实现,即使它只代理了一个方法。
// 真实对象有多个方法
class ComplexService {
    methodA() { }
    methodB() { }
    methodC() { }
    methodD() { }
}

// 代理必须实现所有方法,即使只需要代理部分方法
class ProxyService {
    constructor(service) {
        this.service = service;
    }
    
    methodA() {  // 即使不需要代理,也要实现
        return this.service.methodA();
    }
    
    methodB() {  // 即使不需要代理,也要实现
        return this.service.methodB();
    }
    
    methodC() {
        // 只关心 methodC 的缓存
        if (!this.cache) {
            this.cache = this.service.methodC();
        }
        return this.cache;
    }
    
    methodD() {  // 即使不需要代理,也要实现
        return this.service.methodD();
    }
}

小结

上面介绍了Javascript最经典的设计模式之一代理模式,代理模式就是为一个对象提供一个替身或占位符,以便控制对它的访问,它增加了一层代理层,我们可以很方便做权限控制、延迟加载、监控追踪等很多事情,同时代理模式也会增加系统复杂度、增加调用链路,实际项目中可根据需要使用。

往期回顾

大师助我,electron-hiprint 源码梳理

作者 Ankkaya
2026年3月24日 10:01

前言

上篇我们通过微信小程序云打印,添加了默认值属性,但只支持浏览器打印,如果要支持静默打印(本地打印)和远程打印,还要修改打印助手

源码解析

首先在我们项目内找到静默打印调用位置

  printSocket.emit('render-print', {
    template: hiprintTemplate.value.getJson(),
    data: printData.value,
  })

electron-hiprint搜索 render-print 标识的消息监听,有两个地方监听该消息,区别是一个监听客户端,一个监听中转服务器

他们都调用了RENDER_WINDOW.webContents.send("print", data);,可以问问 codex 具体执行,分析过程很具体,这是最后的总结

继续追问 hiprint.PrintTemplate 和 data.data 生成 html 的具体实现,红色部分很关键,打印模板赋值过程实际还是用的vue-plugin-hiprint逻辑,它是以插件的形式引入的,那么我们只需要把修改后的项目打包插件引入即可

引入插件

插件设置在打印助手,基础设置面板,对应项目文件set.html,软件内置了几个版本插件,同时支持在线切换,把这些逻辑告诉 codex,了解代码内实现

这是总结结果,配置中心默认设置plugin下已下载插件,列表拉取 npm 列表,选择后下载对应文件,并重启应用

那就简单了,把 npm 源换成我们 npm 包的地址,同时修改本地默认插件版本

小插曲

在本地启动electron-hiprint启动调试过程,我也遇到不少问题,虽然 codex 最后都能帮我解决,但如何从一开始的前提就是错误的,那么即使过程中得到正确的结果,最终结果还是有问题

说的有点绕口了,其实就是项目初始化我用的 pnpm 和 node24.0.0,这才导致了一系列问题,虽然最后打包成功,但启动报错。折腾一圈我感觉不太对劲,才切换为 npm 和 node16.0.0

还有啊

没有了,还没用的小伙伴赶紧甩开吧,效率杠杠的,24小时专属编程指导,不香吗

infer,TS 类型系统的手术刀

作者 ssshooter
2026年3月24日 09:48

在 TypeScript 的高级玩法里,infer 经常让初学者感到头大。它长得像关键字,用起来像正则表达式的“捕获组”,还必须寄生在 extends 条件语句里。

要把这东西彻底搞清楚,我们得先拆解它的核心逻辑,再看看它在实战中到底解决了什么问题。

一、 核心概念:infer 到底是什么?

简单来说,infer 就是 “类型系统里的临时变量”

在常规的泛型中,是你告诉 TypeScript 具体的类型;而在使用 infer 的场景下,是 TypeScript 自动推断出某个位置的类型,并把它存到一个变量里供你后续使用。

语法规则:

  1. 只能在 extends 条件类型的“真”分支中使用。
  2. 配合模式匹配使用。 你给出一个“模版”(比如函数结构、数组结构),让 TS 去匹配并提取其中的零件。

二、 语义纠偏:extends 的“变脸”

很多人的困惑源于 extends 这个词。在 Class 里它是“继承”,但在类型定义(尤其是配合 infer)时,它其实是 “模式匹配(Pattern Matching)”

  • Class 中的 extends:我是你的后代,我继承你的基因。
  • 类型中的 extends:我能不能塞进你这个形状的盒子里?

当你在写 T extends (infer R)[] ? R : never 时,你实际上是在对 TS 说:

“帮我看看 T 是不是一个数组。如果是,顺便把数组里装的那个东西的类型抠出来,起个临时名字叫 R。如果匹配成功,我就要这个 R。”


三、 实战场景:它能解决什么痛苦?

如果没有 infer,类型系统就是静态的、死板的。有了它,类型系统就具备了“解剖”和“重组”的能力。

1. 经典的“解包” (Unpacking)

这是最常见的用途。比如从 PromiseArrayMap 中提取内部类型。

// 提取 Promise 内部的类型
type Unbox<T> = T extends Promise<infer U> ? U : T;

type Str = Unbox<Promise<string>>; // 得到 string

2. 函数全家桶 (Function Extraction)

你可以轻松拿到一个函数的返回类型、参数类型,甚至是构造函数的参数。

// 提取函数第一个参数的类型
type FirstParam<T> = T extends (arg1: infer P, ...args: any[]) => any ? P : never;

function saveUser(id: number, name: string) {}
type IDType = FirstParam<typeof saveUser>; // number

3. 字符串模板的“手术刀”

这是 TS 4.1 之后的黑科技。你可以用它来拆分字符串,做一些像“驼峰转下划线”之类的类型转换。

type GetExtension<T> = T extends `${string}.${infer Ext}` ? Ext : never;

type FileExt = GetExtension<"config.json">; // "json"

四、 总结:什么时候该用它?

你不需要在每一处代码都写 infer,但在以下场景,它是无可替代的神器:

  • 处理第三方库:当你拿不到某个库内部定义的具体接口,但你能拿到它的函数或实例时,可以用 infer 反向推导出它的类型。
  • 减少重复定义:不想为了一个返回值再去手动写一遍复杂的 interface
  • 编写通用工具库:它是构建自动化、高适配性类型系统的基石。

虽然 infer 很好用,但它会显著增加类型的理解成本。对于团队协作项目,建议只在底层工具类型(Utils)中使用它,业务代码中还是尽量保持类型声明的直观和显式。

Docsify + Nginx 部署指南:解决 404 路由与 Markdown 加载失败问题

作者 Carsene
2026年3月24日 09:45

摘要:使用 Docsify 构建文档站点轻量又高效,但在部署到 Nginx 时,常遇到“首页能打开,点击导航后内容 404”或“无法加载 README.md”等问题。本文深入剖析原因,并提供完整、可靠的 Nginx 配置方案与调试技巧。


一、为什么 Docsify 需要特殊 Nginx 配置?

Docsify 是一个基于前端路由的单页应用(SPA) ,它不生成静态 HTML,而是通过 JavaScript 动态加载 Markdown 文件并渲染页面。

这意味着:

  • 用户访问 /guide/ 时,浏览器实际加载的是 index.html
  • Docsify 在前端解析路由,并发起 AJAX 请求获取 /guide/README.md
  • 如果用户直接刷新页面从外部链接进入 /guide/,服务器必须仍返回 index.html,否则会 404。

这与传统静态站点(如 Hugo、Jekyll)完全不同——后者每个页面都有真实 HTML 文件。


二、典型问题现象

  1. 首页正常,点击导航后空白或 404

    • 浏览器地址栏变为 /guide/,但页面无内容。
  2. 控制台报错:Failed to load resource: the server responded with a status of 404

    • 通常是 README.md_sidebar.md 等文件加载失败。
  3. 手动访问 .md 文件返回 404 或被拒绝

这些问题的根源往往在于 Nginx 配置未正确区分“前端路由”和“静态资源”


三、正确 Nginx 配置(关键!)

✅ 基础配置模板

server {
    listen 80;
    server_name your-domain.com;
    root /var/www/docs;  # 指向包含 index.html 的目录
    index index.html;

    # 允许 .md 文件作为纯文本返回(可选但推荐)
    location ~* .md$ {
        default_type text/markdown;
        add_header Content-Type text/markdown;
    }

    # 核心:SPA 路由回退机制
    location / {
        try_files $uri $uri/ /index.html;
    }

    # 可选:缓存静态资源
    location ~* .(js|css|png|jpg|svg)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

🔍 try_files 工作原理

try_files $uri $uri/ /index.html;

Nginx 按顺序尝试:

  1. $uri → 是否存在同名文件?(如 /style.css/guide/README.md
  2. $uri/ → 是否存在同名目录?(如 /guide/ 目录)
  3. 都不存在 → 返回 /index.html,交由 Docsify 前端处理路由

只要 .md 文件物理存在,就不会触发 fallback!


四、常见错误排查清单

问题 检查点 解决方案
.md 文件 404 文件是否真实存在于 root 目录下? 确认目录结构,如 /var/www/docs/guide/README.md
.md 被禁止访问 是否有 location ~ .(md|txt)$ { deny all; } 删除或注释掉该规则
请求路径错误 Docsify 的 basePath 是否配置错误? 若部署在根路径,设为 basePath: '/' 或省略
Nginx root 错误 root 是否指向了包含 index.html 的父目录? 确保 root /path/to/site; 下有 index.html

五、调试技巧

  1. 查看 Network 请求

    • 打开 DevTools → Network → 刷新页面
    • 查看 README.md_sidebar.md 的请求 URL 和状态码
  2. 手动测试 .md 文件

    • 在浏览器中直接访问:https://your-site.com/guide/README.md
    • 应能下载或显示 Markdown 内容
  3. 检查 Docsify 初始化代码

    <script>
      window.$docsify = {
        // basePath: '/docs/',  // ❌ 除非你部署在子路径
        loadSidebar: true,
        name: 'My Docs'
      }
    </script>
    

六、最佳实践建议

  • 不要用 alias 代替 root:容易导致路径混乱。
  • 避免在 Nginx 中重写 .md 请求:让 Docsify 自己管理 Markdown 加载。
  • 启用 Gzip 压缩:提升加载速度(Nginx 默认支持)。
  • 添加 nojekyll 文件:如果你也考虑 GitHub Pages,可保留兼容性。

七、结语

Docsify 的魅力在于“零构建、实时渲染”,但这也对服务器配置提出了 SPA 式的要求。只要理解 “前端路由 vs 静态资源” 的区别,并正确配置 try_files,就能轻松部署一个高性能、可维护的文档站点。

🌐 你的文档值得被优雅地呈现 —— 从正确的 Nginx 配置开始。


延伸阅读

🪝 别再重复造轮子了!教你偷懒:在 React 自定义 Hook

2026年3月24日 08:47

前言

React 组件时,你是不是总感觉有些逻辑似曾相识?

  • 比如,每次都要写一遍判断组件是否挂载的逻辑
  • 又比如,监听元素 hover 状态的代码复制了一次又一次
  • 再比如,组件挂载和卸载时的操作也总是那几行

React官方给出了许多hook供我们使用,比如我们常见的useEffectuseState等等,但光靠这些是不够的,今天分享一些自定义的hook,方便又高效!

🎯 场景一:我只是想知道组件 “活” 没活

你有没有遇到过这种情况:组件里的setTimeout还没跑完,组件就已经被卸载了,控制台立刻给你甩一个警告,仿佛在说 “你操作了一个不存在的组件”

别慌,咱们用useMountedState这个自定义 Hook 就能完美解决。

// useMountedState.js
import { useRef, useEffect } from 'react'

export default function useMountedState() {
    const mounted = useRef(false);
    const get = () => mounted.current;
    useEffect(() => {
        mounted.current = true;
        return () => {
            mounted.current = false;
        }
    }, [])
    return get;
}

在组件里用起来就像给组件装了个 “生命检测仪”

// App.jsx
import React, { useState, useEffect } from 'react'
import useMountedState from './hooks/useMountedState'

export default function App() {
    const isMounted = useMountedState();
    const [num, setNum] = useState(0);
    useEffect(() => {
        setTimeout(() => {
            // 先检查组件是否还活着,再更新状态
            if (isMounted()) {
                setNum(1);
            }
        }, 1000);
    }, []);

    return (
        <div>
            {isMounted() ? '组件挂载完成 🎉' : '组件还在编译 🛠️'}
        </div>
    )
}

刚打开浏览器(显示还在编译):

image.png

过几秒(挂载完成):

image.png

有了它,你再也不用担心在异步操作里更新一个已经 “去世” 的组件了。

🎬 场景二:组件的 “登场” 与 “谢幕” 要仪式感

组件挂载卸载时,我们经常需要做一些初始化和清理工作。

  • 比如页面埋点、订阅事件、定时器清理等
  • 直接用useEffect写虽然也行,但每次都要写return总觉得有点麻烦

这时候useLifecycles就派上用场了,它把组件的 “生命周期” 打包成了一个简单的接口

// useLifecycles.js
import { useEffect } from 'react'

export default function useLifecycles(onMount, onUnmount) {
    useEffect(() => {
        if (onMount) {
            onMount();
        }
       return () => {
            if (onUnmount) {
                onUnmount();
            }
        }
    }, []);
}

用起来就像给组件安排了 “入场” 和 “退场” 的节目单

// App2.jsx
import React, { useState } from 'react'
import useLifecycles from './hooks/useLifecycles';

const Child = () => {
    useLifecycles(
        () => {
            console.log('child组件挂载🎬');
        },
        () => {
            console.log('child组件卸载👋');   
        }
    )
    return <h1>child组件</h1>
}

export default function App2() {
    const [show, setShow] = useState(true);
    return (
        <div>
            <h1 onClick={() => setShow(!show)}>App2</h1>
            {
                show && <Child></Child>
            }
        </div>
    )
}

刚打开浏览器一定会打印child组件挂载🎬

image.png

当点击App2时,child组件消失 (卸载),打印child组件卸载 👋

image.png

✋ 场景三:元素 hover 状态的 “小雷达”

实现元素hover效果是前端的家常便饭,传统写法需要给元素绑定onMouseEnteronMouseLeave事件。

  • 逻辑不复杂,但写多了也烦
  • 咱们可以用useHover把这个逻辑封装成一个 Hook
// useHover.jsx
import { useState, cloneElement } from 'react'

export default function useHover(element) {
    const [state, setState] = useState(false);
    const onMouseEnter = (originalOnMouseEnter) => {
        return (event) => {
            originalOnMouseEnter?.(event);
            setState(true);
        }
    };
    const onMouseLeave = (originalOnMouseLeave) => {
        return (event) => {
            originalOnMouseLeave?.(event);
            setState(false);
        }
    };
    if (typeof element === 'function') {
        element = element(state);
    }
    const el = cloneElement(element, {
        onMouseEnter: onMouseEnter(element.props.onMouseEnter),
        onMouseLeave: onMouseLeave(element.props.onMouseLeave),
    })
    return [el, state];
}

在组件里使用时,就像给元素装了个 “小雷达”

// App3.jsx
import useHover from './hooks/useHover.jsx';

export default function App3() {
    const element = (hovered) => {
        return <div>
            Hover me! {hovered && 'Thanks!'}
        </div>
    }
    const [hoverable, hovered] = useHover(element);
    return (
        <div>
            {hoverable}
            {hovered ? 'yes ✅' : 'no ❌'}
        </div>
    )
}

鼠标不在Hover me!上面的时候(显示no ❌):

image.png

当鼠标🖱️移动到Hover me!上面的时候(显示yes ✅):

image.png

鼠标悬停时,元素会显示 “Thanks!”,下方也会同步显示状态,交互体验直接拉满!

🚀 用别人写好的库

大家应该发现了,上面的组件都是我自己手搓的,其实已经有很多人写好了,我们只需下载然后就可以使用了。我给大家推荐一个:

地址: www.npmjs.com/package/rea…

下载:npm i react-use

里面有许多已经封装好了的hook组件,包括上面介绍的,只需引入即可:

import { useMountedState } from 'react-use';
import { useHover } from 'react-use';
import { useLifecycles } from 'react-use';

结语

自定义 Hook 就像 React 世界里的 “乐高积木”,把零散的逻辑拼成一个个可复用的模块。

  • 它不是什么高大上的魔法,就是把你本来要重复写的代码打包了一下
  • 不仅能让你的代码更干净,还能让你开发时少掉几根头发

下次再遇到重复逻辑时,别再 cv 了,动手写个自定义 Hook 吧!毕竟,优秀的程序员都是会 “偷懒” 的艺术家

🎯 DOM 事件:onclick VS addEventListener('click')区别

2026年3月24日 02:10

🎯 DOM 事件:onclick vs addEventListener('click') 区别

特性 .on 事件(如 onclick addEventListener('click')
绑定数量 只能绑 1 个(后面覆盖前面) 可以绑 多个(按顺序执行)
移除方式 el.onclick = null 需要 removeEventListener,且必须传同一个函数引用
事件阶段 只能在 冒泡阶段 触发 可以选择 捕获 / 冒泡 阶段(第三个参数)
标准级别 DOM 0 级(老写法) DOM 2 级(现代标准推荐)

区别详解

绑定数量

onclick:只能绑 1 个,后面覆盖前面

const btn = document.getElementById('btn');

btn.onclick = function() {
  console.log('第一次点击'); // 不会执行!被覆盖了
};

btn.onclick = function() {
  console.log('第二次点击'); // 只有这个会执行
};

 addEventListener:多个都执行

const btn = document.getElementById('btn');

function fn1() {
  console.log('第一次点击'); // 会执行
}

function fn2() {
  console.log('第二次点击'); // 也会执行!按顺序来
}

btn.addEventListener('click', fn1);
btn.addEventListener('click', fn2);

移除事件

onclick 移除:直接设为 null

btn.onclick = function() { alert('点击了'); };
// 移除
btn.onclick = null; 

addEventListener 移除:必须传同一个函数

⚠️ 注意:如果用匿名函数,是无法移除的!

//✅ 正确写法(用命名函数)
function myClick() {
  console.log('点击了');
}

btn.addEventListener('click', myClick);
// 移除(必须传同一个函数名)
btn.removeEventListener('click', myClick);

//❌ 错误写法(无法移除)
btn.addEventListener('click', function() {
  console.log('匿名函数,删不掉我');
});

// 没用!因为这是两个不同的函数引用
btn.removeEventListener('click', function() {
  console.log('匿名函数,删不掉我');
});

事件阶段

// 第三个参数:
// true → 在捕获阶段触发
// false(默认)→ 在冒泡阶段触发

el.addEventListener('click', fn, true); // 捕获阶段
el.addEventListener('click', fn, false); // 冒泡阶段
简单理解事件流:

假设 HTML 是 body > div > button

  1. 捕获阶段:从外到内(body → div → button
  2. 目标阶段:到达 button
  3. 冒泡阶段:从内到外(button → div → body

onclick 只能在冒泡阶段触发,而 addEventListener 可以自由选择。

所以我用哪个呢?

  1. 90% 的场景:用 addEventListener

    • 更灵活,能绑多个事件
    • 现代标准,功能强大
    • 团队协作推荐
    • 不知道用啥就用它
  2. 简单快速测试 / 临时写个小功能:可以用 onclick

    • 代码少,写得快
    • 移除简单(直接 null

有趣味的登录页它踏着七彩祥云来了

作者 BugShare
2026年3月23日 23:09

最近,有一个比较火的很有趣且灵动的登录页火了。

  • 角色视觉跟随鼠标
  • 输入框打字时扯脖子瞅
  • 显示密码明文时避开视线

PixPin_2026-03-23_14-13-18.gif

已经有大神(katavii)复刻了动画效果,并在github上开源了:github.com/katavii/ani… ,基于React实现。

如果你的项目是用Vue开发的,可以考虑用AI将此项目转换成了Vue3的语法写法。

最简单的方式,直接用Claude Code一句话就能完成,根据模型能力,你可能需要多次调试。

claude
帮我把这个项目转成vue3 + ant-design-vue的前端项目

以下是我的转换代码,如果你的AI代码没有调试成功,可以参考下。

创建项目

现在开发前端项目,肯定首选Vite

pnpm create vite
# 选择Vue模板、TypeScript语法

PixPin_2026-03-23_14-19-09.png

封装组件

src/components/创建animated-characters文件夹

EyeBall

创建 src/components/animated-characters/EyeBall.vue,制作动画的大眼睛

<template>
  <div
    class="eyeball"
    :data-max-distance="maxDistance"
    :style="eyeballStyle"
  >
    <div
      class="eyeball-pupil"
      :style="pupilStyle"
    />
  </div>
</template>

<script setup lang="ts">
interface Props {
  size?: string
  pupilSize?: string
  maxDistance?: number
  eyeColor?: string
  pupilColor?: string
}

const {
  size,
  pupilSize,
  maxDistance,
  eyeColor,
  pupilColor
} = withDefaults(defineProps<Props>(), {
  size: '48px',
  pupilSize: '16px',
  maxDistance: 10,
  eyeColor: 'white',
  pupilColor: 'black'
})

const eyeballStyle = {
  width: size,
  height: size,
  borderRadius: '50%',
  backgroundColor: eyeColor,
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'center',
  overflow: 'hidden',
  willChange: 'height'
}

const pupilStyle = {
  width: pupilSize,
  height: pupilSize,
  borderRadius: '50%',
  backgroundColor: pupilColor,
  willChange: 'transform'
}
</script>

PixPin_2026-03-23_14-45-23.png

Pupil

创建 src/components/animated-characters/Pupil.vue,制作动画的小眼睛

<template>
  <div
    :data-max-distance="maxDistance"
    class="pupil"
    :style="pupilStyle"
  />
</template>

<script setup lang="ts">
interface Props {
  size?: string
  maxDistance?: number
  pupilColor?: string
}

const {
  size,
  maxDistance,
  pupilColor
} = withDefaults(defineProps<Props>(), {
  size: '12px',
  maxDistance: 5,
  pupilColor: 'black'
})

const pupilStyle = {
  width: size,
  height: size,
  borderRadius: '50%',
  backgroundColor: pupilColor,
  willChange: 'transform'
}
</script>

PixPin_2026-03-23_14-46-10.png

角色

安装依赖

pnpm install gsap --save

创建 src/components/animated-characters/Index.vue,制作动画的角色

props属性

- is-typing         是否正在输入
- show-password     显示密码明文
- password-length   密码输入框是否有值
<template>
  <div ref="containerRef" :style="containerStyle">
    <!-- 紫色角色 -->
    <div
      ref="purpleRef"
      :style="purpleBodyStyle"
    >
      <div ref="purpleFaceRef" :style="purpleFaceStyle">
        <EyeBall
          size="18px"
          pupil-size="7px"
          :max-distance="5"
          eye-color="white"
          pupil-color="#2D2D2D"
        />
        <EyeBall
          size="18px"
          pupil-size="7px"
          :max-distance="5"
          eye-color="white"
          pupil-color="#2D2D2D"
        />
      </div>
    </div>

    <!-- 黑色角色 -->
    <div
      ref="blackRef"
      :style="blackBodyStyle"
    >
      <div ref="blackFaceRef" :style="blackFaceStyle">
        <EyeBall
          size="16px"
          pupil-size="6px"
          :max-distance="4"
          eye-color="white"
          pupil-color="#2D2D2D"
        />
        <EyeBall
          size="16px"
          pupil-size="6px"
          :max-distance="4"
          eye-color="white"
          pupil-color="#2D2D2D"
        />
      </div>
    </div>

    <!-- 橘黄色角色 -->
    <div
      ref="orangeRef"
      :style="orangeBodyStyle"
    >
      <div ref="orangeFaceRef" :style="orangeFaceStyle">
        <Pupil size="12px" :max-distance="5" pupil-color="#2D2D2D" />
        <Pupil size="12px" :max-distance="5" pupil-color="#2D2D2D" />
      </div>
    </div>

    <!-- 黄色角色 -->
    <div
      ref="yellowRef"
      :style="yellowBodyStyle"
    >
      <div ref="yellowFaceRef" :style="yellowFaceStyle">
        <Pupil size="12px" :max-distance="5" pupil-color="#2D2D2D" />
        <Pupil size="12px" :max-distance="5" pupil-color="#2D2D2D" />
      </div>
      <div ref="yellowMouthRef" :style="yellowMouthStyle" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted, onBeforeUnmount, watch, toRef } from 'vue'
import gsap from 'gsap'
import Pupil from './Pupil.vue'
import EyeBall from './EyeBall.vue'

interface Props {
  isTyping?: boolean
  showPassword?: boolean
  passwordLength?: number
}

const props = withDefaults(defineProps<Props>(), {
  isTyping: false,
  showPassword: false,
  passwordLength: 0
})

const containerRef = ref<HTMLElement | null>(null)
const mouseRef = reactive({ x: 0, y: 0 })
const rafIdRef = ref<number>(0)

const purpleRef = ref<HTMLElement | null>(null)
const blackRef = ref<HTMLElement | null>(null)
const yellowRef = ref<HTMLElement | null>(null)
const orangeRef = ref<HTMLElement | null>(null)

const purpleFaceRef = ref<HTMLElement | null>(null)
const blackFaceRef = ref<HTMLElement | null>(null)
const yellowFaceRef = ref<HTMLElement | null>(null)
const orangeFaceRef = ref<HTMLElement | null>(null)

const yellowMouthRef = ref<HTMLElement | null>(null)

const purpleBlinkTimerRef = ref<ReturnType<typeof setTimeout>>()
const blackBlinkTimerRef = ref<ReturnType<typeof setTimeout>>()
const purplePeekTimerRef = ref<ReturnType<typeof setTimeout>>()

const isHidingPassword = toRef(() => props.passwordLength > 0 && !props.showPassword)
const isShowingPassword = toRef(() => props.passwordLength > 0 && props.showPassword)

const isLookingRef = ref(false)
const lookingTimerRef = ref<ReturnType<typeof setTimeout>>()

const stateRef = reactive({
  isTyping: false,
  isHidingPassword: false,
  isShowingPassword: false,
  isLooking: false
})

watch(
  () => [props.isTyping, isHidingPassword.value, isShowingPassword.value, isLookingRef.value] as const,
  ([isTyping, isHiding, isShowing, isLooking]) => {
    stateRef.isTyping = isTyping
    stateRef.isHidingPassword = isHiding
    stateRef.isShowingPassword = isShowing
    stateRef.isLooking = isLooking
  }
)

// GSAP quickTo instances
const quickToRef = ref<Record<string, any> | null>(null)

const containerStyle = {
  position: 'relative' as const,
  width: '550px',
  height: '400px'
}

const purpleBodyStyle = ref<any>({
  position: 'absolute',
  bottom: 0,
  left: '70px',
  width: '180px',
  height: '400px',
  backgroundColor: '#6C3FF5',
  borderRadius: '10px 10px 0 0',
  zIndex: 1,
  transformOrigin: 'bottom center',
  willChange: 'transform'
})

const blackBodyStyle = ref<any>({
  position: 'absolute',
  bottom: 0,
  left: '240px',
  width: '120px',
  height: '310px',
  backgroundColor: '#2D2D2D',
  borderRadius: '8px 8px 0 0',
  zIndex: 2,
  transformOrigin: 'bottom center',
  willChange: 'transform'
})

const orangeBodyStyle = ref<any>({
  position: 'absolute',
  bottom: 0,
  left: 0,
  width: '240px',
  height: '200px',
  backgroundColor: '#FF9B6B',
  borderRadius: '120px 120px 0 0',
  zIndex: 3,
  transformOrigin: 'bottom center',
  willChange: 'transform'
})

const yellowBodyStyle = ref<any>({
  position: 'absolute',
  bottom: 0,
  left: '310px',
  width: '140px',
  height: '230px',
  backgroundColor: '#E8D754',
  borderRadius: '70px 70px 0 0',
  zIndex: 4,
  transformOrigin: 'bottom center',
  willChange: 'transform'
})

const purpleFaceStyle = ref<any>({
  position: 'absolute',
  display: 'flex',
  gap: '32px',
  left: '45px',
  top: '40px'
})

const blackFaceStyle = ref<any>({
  position: 'absolute',
  display: 'flex',
  gap: '24px',
  left: '26px',
  top: '32px'
})

const orangeFaceStyle = ref<any>({
  position: 'absolute',
  display: 'flex',
  gap: '32px',
  left: '82px',
  top: '90px'
})

const yellowFaceStyle = ref<any>({
  position: 'absolute',
  display: 'flex',
  gap: '24px',
  left: '52px',
  top: '40px'
})

const yellowMouthStyle = ref<any>({
  position: 'absolute',
  width: '80px',
  height: '4px',
  backgroundColor: '#2D2D2D',
  borderRadius: '9999px',
  left: '40px',
  top: '88px'
})

// Initialize GSAP
onMounted(() => {
  gsap.set('.pupil', { x: 0, y: 0 })
  gsap.set('.eyeball-pupil', { x: 0, y: 0 })
})

onMounted(() => {
  if (
    !purpleRef.value ||
    !blackRef.value ||
    !orangeRef.value ||
    !yellowRef.value ||
    !purpleFaceRef.value ||
    !blackFaceRef.value ||
    !orangeFaceRef.value ||
    !yellowFaceRef.value ||
    !yellowMouthRef.value
  )
    return

  const qt = {
    purpleSkew: gsap.quickTo(purpleRef.value, 'skewX', { duration: 0.3, ease: 'power2.out' }),
    blackSkew: gsap.quickTo(blackRef.value, 'skewX', { duration: 0.3, ease: 'power2.out' }),
    orangeSkew: gsap.quickTo(orangeRef.value, 'skewX', { duration: 0.3, ease: 'power2.out' }),
    yellowSkew: gsap.quickTo(yellowRef.value, 'skewX', { duration: 0.3, ease: 'power2.out' }),
    purpleX: gsap.quickTo(purpleRef.value, 'x', { duration: 0.3, ease: 'power2.out' }),
    blackX: gsap.quickTo(blackRef.value, 'x', { duration: 0.3, ease: 'power2.out' }),
    purpleHeight: gsap.quickTo(purpleRef.value, 'height', { duration: 0.3, ease: 'power2.out' }),
    purpleFaceLeft: gsap.quickTo(purpleFaceRef.value, 'left', { duration: 0.3, ease: 'power2.out' }),
    purpleFaceTop: gsap.quickTo(purpleFaceRef.value, 'top', { duration: 0.3, ease: 'power2.out' }),
    blackFaceLeft: gsap.quickTo(blackFaceRef.value, 'left', { duration: 0.3, ease: 'power2.out' }),
    blackFaceTop: gsap.quickTo(blackFaceRef.value, 'top', { duration: 0.3, ease: 'power2.out' }),
    orangeFaceX: gsap.quickTo(orangeFaceRef.value, 'x', { duration: 0.2, ease: 'power2.out' }),
    orangeFaceY: gsap.quickTo(orangeFaceRef.value, 'y', { duration: 0.2, ease: 'power2.out' }),
    yellowFaceX: gsap.quickTo(yellowFaceRef.value, 'x', { duration: 0.2, ease: 'power2.out' }),
    yellowFaceY: gsap.quickTo(yellowFaceRef.value, 'y', { duration: 0.2, ease: 'power2.out' }),
    mouthX: gsap.quickTo(yellowMouthRef.value, 'x', { duration: 0.2, ease: 'power2.out' }),
    mouthY: gsap.quickTo(yellowMouthRef.value, 'y', { duration: 0.2, ease: 'power2.out' })
  }
  quickToRef.value = qt

  const calcPos = (el: HTMLElement) => {
    const rect = el.getBoundingClientRect()
    const cx = rect.left + rect.width / 2
    const cy = rect.top + rect.height / 3
    const dx = mouseRef.x - cx
    const dy = mouseRef.y - cy
    return {
      faceX: Math.max(-15, Math.min(15, dx / 20)),
      faceY: Math.max(-10, Math.min(10, dy / 30)),
      bodySkew: Math.max(-6, Math.min(6, -dx / 120))
    }
  }

  const calcEyePos = (el: HTMLElement, maxDist: number) => {
    const r = el.getBoundingClientRect()
    const cx = r.left + r.width / 2
    const cy = r.top + r.height / 2
    const dx = mouseRef.x - cx
    const dy = mouseRef.y - cy
    const dist = Math.min(Math.sqrt(dx ** 2 + dy ** 2), maxDist)
    const angle = Math.atan2(dy, dx)
    return { x: Math.cos(angle) * dist, y: Math.sin(angle) * dist }
  }

  const tick = () => {
    const container = containerRef.value
    if (!container) return

    const { isTyping: typing, isHidingPassword: hiding, isShowingPassword: showing, isLooking: looking } = stateRef

    if (purpleRef.value && !showing) {
      const pp = calcPos(purpleRef.value)
      if (typing || hiding) {
        qt.purpleSkew(pp.bodySkew - 12)
        qt.purpleX(40)
        qt.purpleHeight(440)
      } else {
        qt.purpleSkew(pp.bodySkew)
        qt.purpleX(0)
        qt.purpleHeight(400)
      }
    }

    if (blackRef.value && !showing) {
      const bp = calcPos(blackRef.value)
      if (looking) {
        qt.blackSkew(bp.bodySkew * 1.5 + 10)
        qt.blackX(20)
      } else if (typing || hiding) {
        qt.blackSkew(bp.bodySkew * 1.5)
        qt.blackX(0)
      } else {
        qt.blackSkew(bp.bodySkew)
        qt.blackX(0)
      }
    }

    if (orangeRef.value && !showing) {
      const op = calcPos(orangeRef.value)
      qt.orangeSkew(op.bodySkew)
    }

    if (yellowRef.value && !showing) {
      const yp = calcPos(yellowRef.value)
      qt.yellowSkew(yp.bodySkew)
    }

    if (purpleRef.value && !showing && !looking) {
      const pp = calcPos(purpleRef.value)
      const purpleFaceX = pp.faceX >= 0 ? Math.min(25, pp.faceX * 1.5) : pp.faceX
      qt.purpleFaceLeft(45 + purpleFaceX)
      qt.purpleFaceTop(40 + pp.faceY)
    }

    if (blackRef.value && !showing && !looking) {
      const bp = calcPos(blackRef.value)
      qt.blackFaceLeft(26 + bp.faceX)
      qt.blackFaceTop(32 + bp.faceY)
    }

    if (orangeRef.value && !showing) {
      const op = calcPos(orangeRef.value)
      qt.orangeFaceX(op.faceX)
      qt.orangeFaceY(op.faceY)
    }

    if (yellowRef.value && !showing) {
      const yp = calcPos(yellowRef.value)
      qt.yellowFaceX(yp.faceX)
      qt.yellowFaceY(yp.faceY)
      qt.mouthX(yp.faceX)
      qt.mouthY(yp.faceY)
    }

    if (!showing) {
      const allPupils = container.querySelectorAll('.pupil')
      allPupils.forEach((p) => {
        const el = p as HTMLElement
        const maxDist = Number(el.dataset.maxDistance) || 5
        const ePos = calcEyePos(el, maxDist)
        gsap.set(el, { x: ePos.x, y: ePos.y })
      })

      if (!looking) {
        const allEyeballs = container.querySelectorAll('.eyeball')
        allEyeballs.forEach((eb) => {
          const el = eb as HTMLElement
          const maxDist = Number(el.dataset.maxDistance) || 10
          const pupil = el.querySelector('.eyeball-pupil') as HTMLElement
          if (!pupil) return
          const ePos = calcEyePos(el, maxDist)
          gsap.set(pupil, { x: ePos.x, y: ePos.y })
        })
      }
    }

    rafIdRef.value = requestAnimationFrame(tick)
  }

  const onMove = (e: MouseEvent) => {
    mouseRef.x = e.clientX
    mouseRef.y = e.clientY
  }

  window.addEventListener('mousemove', onMove, { passive: true })
  rafIdRef.value = requestAnimationFrame(tick)

  onBeforeUnmount(() => {
    window.removeEventListener('mousemove', onMove)
    cancelAnimationFrame(rafIdRef.value)
  })
})

// Purple character blink
onMounted(() => {
  const purpleEyeballs = purpleRef.value?.querySelectorAll('.eyeball')
  if (!purpleEyeballs?.length) return

  const scheduleBlink = () => {
    purpleBlinkTimerRef.value = setTimeout(() => {
      purpleEyeballs.forEach((el) => {
        gsap.to(el, { height: 2, duration: 0.08, ease: 'power2.in' })
      })
      setTimeout(() => {
        purpleEyeballs.forEach((el) => {
          const size = Number((el as HTMLElement).style.width.replace('px', '')) || 18
          gsap.to(el, { height: size, duration: 0.08, ease: 'power2.out' })
        })
        scheduleBlink()
      }, 150)
    }, Math.random() * 4000 + 3000)
  }

  scheduleBlink()
  onBeforeUnmount(() => clearTimeout(purpleBlinkTimerRef.value))
})

// Black character blink
onMounted(() => {
  const blackEyeballs = blackRef.value?.querySelectorAll('.eyeball')
  if (!blackEyeballs?.length) return

  const scheduleBlink = () => {
    blackBlinkTimerRef.value = setTimeout(() => {
      blackEyeballs.forEach((el) => {
        gsap.to(el, { height: 2, duration: 0.08, ease: 'power2.in' })
      })
      setTimeout(() => {
        blackEyeballs.forEach((el) => {
          const size = Number((el as HTMLElement).style.width.replace('px', '')) || 16
          gsap.to(el, { height: size, duration: 0.08, ease: 'power2.out' })
        })
        scheduleBlink()
      }, 150)
    }, Math.random() * 4000 + 3000)
  }

  scheduleBlink()
  onBeforeUnmount(() => clearTimeout(blackBlinkTimerRef.value))
})

const applyLookAtEachOther = () => {
  const qt = quickToRef.value
  if (qt) {
    qt.purpleFaceLeft(55)
    qt.purpleFaceTop(65)
    qt.blackFaceLeft(32)
    qt.blackFaceTop(12)
  }
  purpleRef.value?.querySelectorAll('.eyeball-pupil').forEach((p) => {
    gsap.to(p, { x: 3, y: 4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
  })
  blackRef.value?.querySelectorAll('.eyeball-pupil').forEach((p) => {
    gsap.to(p, { x: 0, y: -4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
  })
}

const applyHidingPassword = () => {
  const qt = quickToRef.value
  if (qt) {
    qt.purpleFaceLeft(55)
    qt.purpleFaceTop(65)
  }
}

const applyShowPassword = () => {
  const qt = quickToRef.value
  if (qt) {
    qt.purpleSkew(0)
    qt.blackSkew(0)
    qt.orangeSkew(0)
    qt.yellowSkew(0)
    qt.purpleX(0)
    qt.blackX(0)
    qt.purpleHeight(400)

    qt.purpleFaceLeft(20)
    qt.purpleFaceTop(35)
    qt.blackFaceLeft(10)
    qt.blackFaceTop(28)
    qt.orangeFaceX(50 - 82)
    qt.orangeFaceY(85 - 90)
    qt.yellowFaceX(20 - 52)
    qt.yellowFaceY(35 - 40)
    qt.mouthX(10 - 40)
    qt.mouthY(0)
  }

  purpleRef.value?.querySelectorAll('.eyeball-pupil').forEach((p) => {
    gsap.to(p, { x: -4, y: -4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
  })
  blackRef.value?.querySelectorAll('.eyeball-pupil').forEach((p) => {
    gsap.to(p, { x: -4, y: -4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
  })
  orangeRef.value?.querySelectorAll('.pupil').forEach((p) => {
    gsap.to(p, { x: -5, y: -4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
  })
  yellowRef.value?.querySelectorAll('.pupil').forEach((p) => {
    gsap.to(p, { x: -5, y: -4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
  })
}

// Password peek effect
watch(
  () => [isShowingPassword.value, props.passwordLength],
  ([showing, len]) => {
    if (!showing || (len as number) <= 0) {
      clearTimeout(purplePeekTimerRef.value)
      return
    }

    const purpleEyePupils = purpleRef.value?.querySelectorAll('.eyeball-pupil')
    if (!purpleEyePupils?.length) return

    const schedulePeek = () => {
      purplePeekTimerRef.value = setTimeout(() => {
        purpleEyePupils.forEach((p) => {
          gsap.to(p, {
            x: 4,
            y: 5,
            duration: 0.3,
            ease: 'power2.out',
            overwrite: 'auto'
          })
        })
        const qt = quickToRef.value
        if (qt) {
          qt.purpleFaceLeft(20)
          qt.purpleFaceTop(35)
        }

        setTimeout(() => {
          purpleEyePupils.forEach((p) => {
            gsap.to(p, {
              x: -4,
              y: -4,
              duration: 0.3,
              ease: 'power2.out',
              overwrite: 'auto'
            })
          })
          schedulePeek()
        }, 800)
      }, Math.random() * 3000 + 2000)
    }

    schedulePeek()
    onBeforeUnmount(() => clearTimeout(purplePeekTimerRef.value))
  }
)

// Look at each other when typing
watch(
  () => [props.isTyping, isShowingPassword.value],
  ([typing, showing]) => {
    if (typing && !showing) {
      isLookingRef.value = true
      stateRef.isLooking = true
      applyLookAtEachOther()

      clearTimeout(lookingTimerRef.value)
      lookingTimerRef.value = setTimeout(() => {
        isLookingRef.value = false
        stateRef.isLooking = false
        purpleRef.value?.querySelectorAll('.eyeball-pupil').forEach((p) => {
          gsap.killTweensOf(p)
        })
      }, 800)
    } else {
      clearTimeout(lookingTimerRef.value)
      isLookingRef.value = false
      stateRef.isLooking = false
    }
  }
)

// Password state effects
watch(
  () => [isShowingPassword.value, isHidingPassword.value],
  ([showing, hiding]) => {
    if (showing) {
      applyShowPassword()
    } else if (hiding) {
      applyHidingPassword()
    }
  }
)
</script>

PixPin_2026-03-23_15-09-29.gif

登录页

安装依赖

pnpm install --save ant-design-vue @ant-design/icons-vue

src/main.js添加以下内容

import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/reset.css'

app.use(Antd)

创建 src/pages/login/Index.vue登录页

<script setup lang="ts">
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import {
  UserOutlined,
  LockOutlined,
  EyeOutlined,
  EyeInvisibleOutlined,
} from '@ant-design/icons-vue'
import AnimatedCharacters from '../../components/animated-characters/Index.vue'
import styles from './index.module.css'

/** 模拟登录 API(仅前端逻辑,无真实请求) */
async function mockLogin(_values: { username: string; password: string }) {
  await new Promise((resolve) => setTimeout(resolve, 800))
  return { data: { access_token: 'mock_token_' + Date.now() } }
}

const loading = ref(false)
const showPassword = ref(false)
const isTyping = ref(false)
const passwordValue = ref('')
const error = ref('')

const handleLogin = async (values: { username: string; password: string }) => {
  loading.value = true
  error.value = ''
  try {
    const { data } = await mockLogin(values)
    localStorage.setItem('access_token', data.access_token)
    message.success('登录成功')
    setTimeout(() => {
      window.location.href = '/'
    }, 500)
  } catch {
    error.value = '账号或密码有误,请重新输入'
  } finally {
    loading.value = false
  }
}
</script>

<template>
  <div :class="styles.container">
    <!-- 左侧:品牌视觉区 -->
    <div :class="styles.leftPanel">
      <div :class="styles.leftTop">
        <div :class="styles.brandMark">
          <svg width="28" height="28" viewBox="0 0 28 28" fill="none">
            <rect width="28" height="28" rx="7" fill="white" fill-opacity="0.15" />
            <path d="M7 14L12 9L17 14L12 19L7 14Z" fill="white" fill-opacity="0.9" />
            <path d="M13 14L18 9L21 12V16L18 19L13 14Z" fill="white" fill-opacity="0.5" />
          </svg>
        </div>
        <span :class="styles.brandName">Nexus</span>
      </div>

      <div :class="styles.charactersArea">
        <AnimatedCharacters
            :is-typing="isTyping"
            :show-password="showPassword"
            :password-length="passwordValue.length"
        />
      </div>

      <div :class="styles.leftFooter">
        <a href="#">帮助中心</a>
        <a href="#">隐私政策</a>
      </div>

      <div :class="styles.decorBlur1" />
      <div :class="styles.decorBlur2" />
      <div :class="styles.decorGrid" />
    </div>

    <!-- 右侧:登录表单 -->
    <div :class="styles.rightPanel">
      <div :class="styles.formWrapper">
        <div :class="styles.mobileLogo">
          <div :class="styles.mobileLogoIcon">
            <svg width="20" height="20" viewBox="0 0 28 28" fill="none">
              <path d="M7 14L12 9L17 14L12 19L7 14Z" fill="#1E40AF" fill-opacity="0.9" />
              <path d="M13 14L18 9L21 12V16L18 19L13 14Z" fill="#3B82F6" fill-opacity="0.7" />
            </svg>
          </div>
          <span>Nexus 平台</span>
        </div>

        <div :class="styles.formHeader">
          <h1 :class="styles.formTitle">登录到工作台</h1>
          <p :class="styles.formSubtitle">
            统一接入前端平台旗下所有系统
          </p>
        </div>

        <a-form
            name="login"
            @finish="handleLogin"
            autocomplete="off"
            size="large"
            :class="styles.form"
        >
          <div :class="styles.fieldLabel">账号</div>
          <a-form-item
              name="username"
              :rules="[
              { required: true, message: '请输入账号' },
              { min: 3, message: '账号长度不能少于 3 个字符' },
            ]"
          >
            <a-input
                placeholder="输入您的账号"
                @focus="isTyping = true"
                @blur="isTyping = false"
            >
              <template #prefix>
                <UserOutlined :class="styles.prefixIcon" />
              </template>
            </a-input>
          </a-form-item>

          <div :class="styles.fieldLabel">密码</div>
          <a-form-item
              name="password"
              :rules="[
              { required: true, message: '请输入密码' },
              { min: 6, message: '密码长度不能少于 6 个字符' },
            ]"
          >
            <a-input
                :type="showPassword ? 'text' : 'password'"
                placeholder="输入您的密码"
                v-model:value="passwordValue"
            >
              <template #prefix>
                <LockOutlined :class="styles.prefixIcon" />
              </template>
              <template #suffix>
                <span
                    :class="styles.eyeToggle"
                    @click="showPassword = !showPassword"
                >
                  <EyeOutlined v-if="showPassword" />
                  <EyeInvisibleOutlined v-else />
                </span>
              </template>
            </a-input>
          </a-form-item>

          <div v-if="error" :class="styles.errorBox">{{ error }}</div>

          <a-form-item :style="{ marginBottom: 0 }">
            <a-button
                type="primary"
                html-type="submit"
                :loading="loading"
                block
                :class="styles.submitBtn"
            >
              {{ loading ? '登录中...' : '登录' }}
            </a-button>
          </a-form-item>
        </a-form>

        <div :class="styles.divider">
          <span>或</span>
        </div>

        <a-button block :class="styles.googleBtn">
          飞书账号一键登录
        </a-button>

        <div :class="styles.signupRow">
          暂无账号?
          <a href="#" :class="styles.signupLink">
            联系管理员申请开通
          </a>
        </div>
      </div>
    </div>
  </div>
</template>

创建 src/pages/login/index.module.css登录页样式

.container {
    min-height: 100vh;
    display: grid;
    grid-template-columns: 1fr 1fr;
}

@media (max-width: 1024px) {
    .container {
        grid-template-columns: 1fr;
    }
}

/* ─── 左侧面板 ───────────────────────────────────────────────────────────────── */

.leftPanel {
    position: relative;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    padding: 48px;
    background: linear-gradient(145deg, #0f172a 0%, #1e3a8a 50%, #1e40af 100%);
    overflow: hidden;
}

@media (max-width: 1024px) {
    .leftPanel {
        display: none;
    }
}

.leftTop {
    position: relative;
    z-index: 20;
    display: flex;
    align-items: center;
    gap: 10px;
    font-size: 20px;
    font-weight: 700;
    color: #ffffff;
    letter-spacing: 0.5px;
}

.brandMark {
    width: 40px;
    height: 40px;
    border-radius: 10px;
    background: rgba(255, 255, 255, 0.12);
    border: 1px solid rgba(255, 255, 255, 0.2);
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    backdrop-filter: blur(8px);
}

.brandName {
    color: #ffffff;
    font-size: 20px;
    font-weight: 700;
    letter-spacing: 1px;
}

.charactersArea {
    position: relative;
    z-index: 20;
    display: flex;
    align-items: flex-end;
    justify-content: center;
    height: 500px;
}

.leftFooter {
    position: relative;
    z-index: 20;
    display: flex;
    align-items: center;
    gap: 24px;
}

.leftFooter a {
    font-size: 13px;
    color: rgba(255, 255, 255, 0.45);
    text-decoration: none;
    transition: color 0.2s;
    cursor: pointer;
}

.leftFooter a:hover {
    color: rgba(255, 255, 255, 0.85);
}

.decorBlur1 {
    position: absolute;
    top: 15%;
    right: 10%;
    width: 300px;
    height: 300px;
    background: rgba(59, 130, 246, 0.25);
    border-radius: 50%;
    filter: blur(80px);
    pointer-events: none;
    z-index: 0;
}

.decorBlur2 {
    position: absolute;
    bottom: 10%;
    left: 5%;
    width: 400px;
    height: 400px;
    background: rgba(30, 64, 175, 0.3);
    border-radius: 50%;
    filter: blur(100px);
    pointer-events: none;
    z-index: 0;
}

.decorGrid {
    position: absolute;
    inset: 0;
    background-image:
            linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
            linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
    background-size: 40px 40px;
    pointer-events: none;
    z-index: 1;
}

/* ─── 右侧面板 ───────────────────────────────────────────────────────────────── */

.rightPanel {
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 32px;
    background: #ffffff;
}

.formWrapper {
    width: 100%;
    max-width: 400px;
}

.mobileLogo {
    display: none;
    align-items: center;
    justify-content: center;
    gap: 8px;
    font-size: 18px;
    font-weight: 700;
    color: #0f172a;
    margin-bottom: 48px;
}

@media (max-width: 1024px) {
    .mobileLogo {
        display: flex;
    }
}

.mobileLogoIcon {
    width: 32px;
    height: 32px;
    border-radius: 8px;
    background: #eff6ff;
    display: flex;
    align-items: center;
    justify-content: center;
}

.formHeader {
    text-align: center;
    margin-bottom: 40px;
}

.formTitle {
    font-size: 26px;
    font-weight: 700;
    letter-spacing: -0.02em;
    color: #0f172a;
    margin: 0 0 10px 0;
    line-height: 1.3;
}

.formSubtitle {
    font-size: 14px;
    color: #6b7280;
    margin: 0;
    line-height: 1.6;
}

.form :global(.ant-form-item) {
    margin-bottom: 20px;
}

.form :global(.ant-input-affix-wrapper) {
    height: 48px !important;
    background: #fafafa !important;
    border: 1px solid #e5e7eb !important;
    border-radius: 10px !important;
    transition: border-color 0.2s, box-shadow 0.2s !important;
}

.form :global(.ant-input-affix-wrapper:hover) {
    border-color: #3b82f6 !important;
}

.form :global(.ant-input-affix-wrapper:focus),
.form :global(.ant-input-affix-wrapper-focused) {
    border-color: #1e40af !important;
    box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.08) !important;
    background: #ffffff !important;
}

.form :global(.ant-input-affix-wrapper .ant-input) {
    background: transparent !important;
    font-size: 14px !important;
    color: #111827 !important;
}

.form :global(.ant-input-affix-wrapper .ant-input::placeholder) {
    color: #c0c4cc !important;
}

.form :global(.ant-form-item-explain-error) {
    font-size: 13px !important;
    margin-top: 4px !important;
}

.fieldLabel {
    font-size: 13px;
    font-weight: 500;
    color: #374151;
    margin-bottom: 6px;
    letter-spacing: 0.2px;
}

.prefixIcon {
    color: #b0b7c3;
    font-size: 15px;
}

.eyeToggle {
    color: #6b7280;
    cursor: pointer;
    font-size: 16px;
    display: flex;
    align-items: center;
    transition: color 0.2s;
}

.eyeToggle:hover {
    color: #374151;
}

.errorBox {
    padding: 10px 14px;
    font-size: 13px;
    color: #dc2626;
    background: #fef2f2;
    border: 1px solid #fecaca;
    border-radius: 8px;
    margin-bottom: 16px;
}

.submitBtn {
    height: 48px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    border-radius: 10px !important;
    background: #1e40af !important;
    border-color: #1e40af !important;
    letter-spacing: 1px;
    transition: background 0.2s, opacity 0.2s !important;
    cursor: pointer;
}

.submitBtn:hover {
    background: #1d4ed8 !important;
    border-color: #1d4ed8 !important;
    opacity: 1 !important;
}

.submitBtn:active {
    opacity: 0.85 !important;
}

.divider {
    display: flex;
    align-items: center;
    gap: 12px;
    margin: 20px 0 0;
    color: #d1d5db;
    font-size: 13px;
}

.divider::before,
.divider::after {
    content: '';
    flex: 1;
    height: 1px;
    background: #e5e7eb;
}

.divider span {
    color: #9ca3af;
    white-space: nowrap;
}

.googleBtn {
    height: 48px !important;
    font-size: 14px !important;
    border-radius: 10px !important;
    margin-top: 12px !important;
    background: #ffffff !important;
    border: 1px solid #e5e7eb !important;
    color: #374151 !important;
    transition: background 0.2s, border-color 0.2s !important;
    cursor: pointer;
}

.googleBtn:hover {
    background: #eff6ff !important;
    border-color: rgba(30, 64, 175, 0.25) !important;
    color: #1e40af !important;
}

.signupRow {
    text-align: center;
    font-size: 13px;
    color: #6b7280;
    margin-top: 28px;
}

.signupLink {
    color: #1e40af;
    font-weight: 500;
    text-decoration: none;
    cursor: pointer;
}

.signupLink:hover {
    text-decoration: underline;
    color: #1d4ed8;
}

源代码

vue2分支是Vue2 + Element-ui实现。

Flutter+鸿蒙 | 调用原生模态窗

作者 Jalor
2026年3月23日 22:43

相信很多小伙伴都觉得鸿蒙原生的模态窗很好看,但困惑的是其需要绑定组件:

  Button('Open Sheet').width('90%').height('80vp')
  .onClick(() => {
      this.isShowSheet = !this.isShowSheet;
  })
  .bindSheet($$this.isShowSheet, this.SheetBuilder(), {
      detents: [SheetSize.MEDIUM, SheetSize.LARGE, 600],
      preferType: SheetType.BOTTOM,
      // 请将$r('app.string.tSheetBuilder_text2')替换为实际资源文件,在本示例中该资源文件的value值为"嵌套滚动场景"
      title: { title: $r('app.string.tSheetBuilder_text2') },
  })

那是否意味着Flutter开发的程序无法调用?

可以找到Flutter的鸿蒙工程中的Index.ets, 在原来的column背后加上.bindSheet即可。

Column() {
  FlutterPage({ viewId: this.viewId })
}.bindSheet($$this.isShowSheet, this.SheetBuilder(), {
  detents: [SheetSize.LARGE],
  preferType: SheetType.BOTTOM,
  onDisappear: () => {
    AppStorage.setOrCreate<boolean>('isShowSheet', false);
  }
})

也是在这个文件里,在Index这个struct中加上

@StorageLink('isShowSheet') isShowSheet: boolean = false;

private webController: webview.WebviewController = new webview.WebviewController();

@Builder
SheetBuilder() {
  Column() {
    Web({ src: 'https://baidu.com', controller: this.webController })
      .width('100%')
      .height('100%')
      .nestedScroll({
        scrollForward: NestedScrollMode.PARENT_FIRST,
        scrollBackward: NestedScrollMode.SELF_FIRST,
      })
  }
  .width('100%')
  .height('100%')
}

这个builder即可控制控制显示的内容,这里我们显示了一个网页。

到这里,我们的Flutter鸿蒙混合App是可以调用原生的模态窗口了,但需要有调用的地方。于是需要写一个插件:

// entry/src/main/ets/plugin/SheetPlugin.ets
import {
  FlutterPlugin,
  FlutterPluginBinding,
  MethodChannel,
  MethodCallHandler,
  MethodCall,
  MethodResult,
  AbilityPluginBinding,
} from '@ohos/flutter_ohos';
import UIAbility from '@ohos.app.ability.UIAbility';

export class SheetPlugin implements FlutterPlugin, MethodCallHandler {
  private channel: MethodChannel | null = null;
  private ability: UIAbility;

  constructor(ability: UIAbility) {
    this.ability = ability;
  }

  getUniqueClassName(): string {
    return 'SheetPlugin';
  }

  onAttachedToEngine(binding: FlutterPluginBinding): void {
    this.channel = new MethodChannel(
      binding.getBinaryMessenger(),
      'com.xxx/sheet'  // 与 Flutter 侧一致
    );
    this.channel.setMethodCallHandler(this);
  }

  onDetachedFromEngine(binding: FlutterPluginBinding): void {
    this.channel?.setMethodCallHandler(null);
    this.channel = null;
  }

  onMethodCall(call: MethodCall, result: MethodResult): void {
    if (call.method === 'openSheet') {
      this.triggerSheet();
      result.success(null);
    } else {
      result.notImplemented();
    }
  }

  private triggerSheet(): void {
    // 通过 AppStorage 驱动状态变更
    AppStorage.setOrCreate<boolean>('isShowSheet', true);
  }
}

然后在EntryAbility注册插件:

flutterEngine.getPlugins()?.add(new SheetPlugin(this));

最后在Flutter端写调用的代码:

static Future<void> openSheet() async {
  const String channelPath =
      "com.xxx/sheet"; //这儿要与MethodChannel(flutterEngine?.dartExecutor, CHANNEL)中CHANNEL名称一致
  const MethodChannel channel = const MethodChannel(channelPath);
  try {
    await channel.invokeMethod('openSheet');
  } on PlatformException catch (e) {
    print('调用 Sheet 失败: ${e.message}');
  }
}

然后在UI中调用:

GestureDetector(
    onTap: () {
      LaunchUtils.openSheet();
    },
    child: UniversalWidget(context)),

大工告成,假如你也是Flutter+ArkTS混合开发,也来试试吧!

第十五讲 本地存储

作者 节点玩家
2026年3月23日 22:35

前言:

这一章学完,通用的90%功能都能开发了,也就是说,已经可以开始vibecoding写代码了,在这个过程中,除了模型的代码外,你一定要学会排查问题,这是你学习这些知识点的目的。通过熟悉业务、熟悉需求,去控制生成代码,在深入细节处,生成代码或许就不足了,这个时候,你需要在细微处指导他怎么操作,就跟教导婴儿一样,你会发现,这十五讲的内容走下去,你已经具备了发现问题的内容。

至于深入能力,在你不断解决问题中就积累了。

一、总览

本讲聚焦 Flutter 应用中本地数据持久化的核心技术,解决“应用重启后数据不丢失”的问题。在移动开发中,本地存储是实现用户偏好设置、离线数据缓存、本地数据库等功能的基础。

  1. 存储方案选型

    1. 简单键值对(设置、状态)→ SharedPreferences;
    2. 中等规模结构化数据(收藏、缓存)→ Hive;
    3. 复杂关系型数据(订单、用户)→ Drift/SQLite;
    4. 大文件(图片、日志)→ 文件读写。
  2. 数据安全原则

    1. 敏感数据(Token、密码)必须加密存储;
    2. 密码使用不可逆哈希(SHA-256+盐),不存储明文;
    3. 密钥不要硬编码,建议动态获取。
  3. 性能与最佳实践

    1. Hive性能优于SharedPreferences,适合高频读写;
    2. Drift/SQLite适合复杂查询和关系型数据;
    3. 文件操作需处理异常,大文件分块读写。
  • 本讲解决的核心问题:如何在 Flutter 应用中安全、高效地将数据保存到设备本地,以及如何读取、更新、删除这些数据
  • 技术选型逻辑:根据数据类型(键值对/结构化数据/文件)、性能要求、复杂度选择对应的存储方案;
  • 应用场景:用户登录状态保存、APP 主题/语言设置、离线商品列表、本地日志文件、加密存储敏感信息(如token)等。

Flutter 本地存储的底层是对原生平台(Android/iOS)存储能力的封装,核心原理结构如下:

image.png

  1. 跨平台封装:Flutter 本地存储方案本质是对 Android/iOS 原生存储API的封装,保证跨平台一致性;

  2. 存储介质:所有本地存储最终都落地到设备的文件系统(沙盒目录),不同方案只是数据组织和读写方式不同;

  3. 分层设计:应用层通过抽象层调用存储API,加密层可对敏感数据进行加密后再存储;

  4. 数据形态

    1. 键值对存储(SharedPreferences/Hive):适合简单数据;
    2. 结构化存储(SQLite/Drift):适合复杂关系型数据;
    3. 文件存储:适合大文件(图片、日志、二进制数据)。

二、核心知识点

2.1 轻量存储:SharedPreferences

这个在上一讲的cache管理中有简单的使用到,这里详细的讲解一下。

核心概念

SharedPreferences 是 Flutter 中最基础的键值对存储方案,基于原生平台的轻量存储(Android SharedPreferences / iOS NSUserDefaults),适合存储简单数据(字符串、数字、布尔值等)。

前置依赖
flutter pub add shared_preferences
核心方法/属性
方法 作用 支持的数据类型
SharedPreferences.getInstance() 获取实例(异步) -
setString/setInt/setBool/setDouble/setStringList 保存数据 String/int/bool/double/List
getString/getInt/getBool/getDouble/getStringList 读取数据 返回对应类型或null
remove(key) 删除指定键的数据 -
clear() 清空所有数据 -
containsKey(key) 判断是否包含指定键 返回bool
案例代码
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'SharedPreferences 示例',
      home: const SharedPrefsDemo(),
    );
  }
}

class SharedPrefsDemo extends StatefulWidget {
  const SharedPrefsDemo({super.key});

  @override
  State<SharedPrefsDemo> createState() => _SharedPrefsDemoState();
}

class _SharedPrefsDemoState extends State<SharedPrefsDemo> {
  String _username = '';
  bool _isDarkMode = false;
  int _loginCount = 0;

  // 初始化:读取存储的数据
  @override
  void initState() {
    super.initState();
    _loadData();
  }

  // 读取数据
  Future<void> _loadData() async {
    final prefs = await SharedPreferences.getInstance();
    setState(() {
      _username = prefs.getString('username') ?? '';
      _isDarkMode = prefs.getBool('dark_mode') ?? false;
      _loginCount = prefs.getInt('login_count') ?? 0;
    });
  }

  // 保存数据
  Future<void> _saveData() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('username', 'FlutterDev');
    await prefs.setBool('dark_mode', _isDarkMode);
    await prefs.setInt('login_count', _loginCount + 1);
    // 重新加载数据
    _loadData();
    if (mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('数据保存成功!')),
      );
    }
  }

  // 清空数据
  Future<void> _clearData() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.clear();
    _loadData();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('SharedPreferences 演示')),
      body: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('用户名:$_username'),
            Text('暗黑模式:${_isDarkMode ? '开启' : '关闭'}'),
            Text('登录次数:$_loginCount'),
            const SizedBox(height: 20),
            SwitchListTile(
              title: const Text('开启暗黑模式'),
              value: _isDarkMode,
              onChanged: (value) => setState(() => _isDarkMode = value),
            ),
            const SizedBox(height: 20),
            Row(
              children: [
                ElevatedButton(
                  onPressed: _saveData,
                  child: const Text('保存数据'),
                ),
                const SizedBox(width: 20),
                ElevatedButton(
                  onPressed: _clearData,
                  style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
                  child: const Text('清空数据'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

注意事项
  1. 异步操作:所有读写操作依赖 SharedPreferences.getInstance() 的异步结果,需用 async/await
  2. 数据限制:仅支持基础数据类型,不支持自定义对象(需手动序列化/反序列化);
  3. 性能问题:不适合存储大量数据,频繁读写会影响性能;
  4. 持久化时机set 方法调用后数据会异步写入磁盘,并非立即持久化。

2.2 NoSQL 存储:Hive

核心概念

Hive 是 Flutter 专属的轻量级 NoSQL 键值数据库,无需原生依赖,支持自定义对象、索引、加密,性能优于 SharedPreferences,适合存储中等规模的结构化数据。

前置依赖
flutter pub add hive hive_flutter
# 可选:对象序列化生成器
flutter pub add dev:hive_generator dev:build_runner
核心概念/方法
概念/方法 作用 注意事项
Box Hive的数据库表,存储键值对 一个Box对应一个本地文件
Hive.initFlutter() 初始化Hive(Flutter专用) 需在runApp前执行
Hive.registerAdapter() 注册自定义对象适配器 自定义对象必须添加@HiveType和@HiveField
ValueListenableBuilder 监听Box变化自动刷新UI 无需手动调用setState
put/get/delete/clear 增删改查操作 同步操作,性能优于SharedPreferences
案例代码

user.dart

import 'package:hive/hive.dart';

part 'user.g.dart';

@HiveType(typeId: 0)
class User extends HiveObject {
  @HiveField(0)
  late String name;

  @HiveField(1)
  late int age;

  @HiveField(2)
  late String email;

  User({required this.name, required this.age, required this.email});
}

user.g.dart

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'user.dart';

// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************

class UserAdapter extends TypeAdapter<User> {
  @override
  final int typeId = 0;

  @override
  User read(BinaryReader reader) {
    final numOfFields = reader.readByte();
    final fields = <int, dynamic>{
      for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
    };
    return User(
      name: fields[0] as String,
      age: fields[1] as int,
      email: fields[2] as String,
    );
  }

  @override
  void write(BinaryWriter writer, User obj) {
    writer
      ..writeByte(3)
      ..writeByte(0)
      ..write(obj.name)
      ..writeByte(1)
      ..write(obj.age)
      ..writeByte(2)
      ..write(obj.email);
  }

  @override
  int get hashCode => typeId.hashCode;

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is UserAdapter &&
          runtimeType == other.runtimeType &&
          typeId == other.typeId;
}
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'user.dart';

void main() async {
  // 2. 初始化Hive
  await Hive.initFlutter();
  // 注册适配器(自定义对象必须)
  Hive.registerAdapter(UserAdapter());
  // 打开盒子(数据库表)
  await Hive.openBox<User>('users');
  
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Hive 示例',
      home: const HiveDemo(),
    );
  }
}

class HiveDemo extends StatefulWidget {
  const HiveDemo({super.key});

  @override
  State<HiveDemo> createState() => _HiveDemoState();
}

class _HiveDemoState extends State<HiveDemo> {
  final Box<User> _userBox = Hive.box<User>('users');
  final TextEditingController _nameController = TextEditingController();
  final TextEditingController _ageController = TextEditingController();
  final TextEditingController _emailController = TextEditingController();

  // 添加用户
  void _addUser() {
    final name = _nameController.text.trim();
    final ageText = _ageController.text.trim();
    final email = _emailController.text.trim();

    if (name.isEmpty || ageText.isEmpty || email.isEmpty) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('请填写所有字段')),
      );
      return;
    }

    final age = int.tryParse(ageText);
    if (age == null) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('年龄必须是数字')),
      );
      return;
    }

    final user = User(
      name: name,
      age: age,
      email: email,
    );
    _userBox.add(user);
    _clearInputs();
  }

  // 删除用户
  void _deleteUser(int index) {
    _userBox.deleteAt(index);
  }

  void _clearInputs() {
    _nameController.clear();
    _ageController.clear();
    _emailController.clear();
  }

  @override
  void dispose() {
    _nameController.dispose();
    _ageController.dispose();
    _emailController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Hive NoSQL 演示')),
      body: Column(
        children: [
          // 输入表单
          Padding(
            padding: const EdgeInsets.all(20),
            child: Column(
              children: [
                TextField(
                  controller: _nameController,
                  decoration: const InputDecoration(hintText: '姓名'),
                ),
                TextField(
                  controller: _ageController,
                  decoration: const InputDecoration(hintText: '年龄'),
                  keyboardType: TextInputType.number,
                ),
                TextField(
                  controller: _emailController,
                  decoration: const InputDecoration(hintText: '邮箱'),
                ),
                const SizedBox(height: 10),
                ElevatedButton(
                  onPressed: _addUser,
                  child: const Text('添加用户'),
                ),
              ],
            ),
          ),
          // 列表展示
          Expanded(
            child: ValueListenableBuilder(
              // 监听盒子变化,自动刷新UI
              valueListenable: _userBox.listenable(),
              builder: (context, Box<User> box, child) {
                return ListView.builder(
                  itemCount: box.length,
                  itemBuilder: (context, index) {
                    final user = box.getAt(index)!;
                    return ListTile(
                      title: Text(user.name),
                      subtitle: Text('${user.age}岁 | ${user.email}'),
                      trailing: IconButton(
                        icon: const Icon(Icons.delete, color: Colors.red),
                        onPressed: () => _deleteUser(index),
                      ),
                    );
                  },
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

生成序列化代码

在终端执行:

flutter packages pub run build_runner build
注意事项
  1. TypeId 唯一性:每个自定义对象的 typeId 和字段的 HiveField 序号必须唯一;
  2. 加密支持:打开Box时可设置密码(encryptionCipher: HiveAesCipher(key)),加密存储数据;
  3. 性能优势:Hive是纯Dart实现,无原生桥接开销,读写速度远快于SharedPreferences;
  4. 持久化:数据实时写入磁盘,无需手动提交。

2.3 数据库存储:SQLite & Drift

核心概念
  • SQLite:跨平台的轻量级关系型数据库,Flutter 通过 sqflite 插件封装;
  • Drift(原Moor) :基于SQLite的现代化ORM框架,用Dart语法替代原生SQL,简化数据库操作。
前置依赖(Drift)
flutter pub add drift sqlite3_flutter_libs path_provider path
flutter pub add dev:drift_dev dev:build_runner
核心概念/方法
概念/方法 作用 注意事项
Table 定义数据库表结构 支持多种字段类型(int/text/bool/dateTime等)
DriftDatabase 数据库核心类 需指定包含的表,生成CRUD代码
select/into/update/delete 数据库操作 支持链式调用(如where/orderBy)
watch() 监听数据变化,返回Stream 实时更新UI,响应式编程
NativeDatabase 原生SQLite数据库连接 支持加密(需额外依赖)
案例代码
// db.dart
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;

// 1. 定义表结构
class Todos extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get title => text().withLength(min: 1, max: 50)();
  TextColumn get content => text()();
  BoolColumn get isCompleted => boolean().withDefault(const Constant(false))();
  DateTimeColumn get createTime => dateTime().withDefault(currentDateAndTime)();
}

// 2. 定义数据库类
part 'db.g.dart';

@DriftDatabase(tables: [Todos])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(_openConnection());

  // 数据库版本
  @override
  int get schemaVersion => 1;

  // CRUD操作
  // 获取所有待办
  Future<List<Todo>> getAllTodos() => select(todos).get();
  // 监听待办变化(实时更新)
  Stream<List<Todo>> watchAllTodos() => select(todos).watch();
  // 添加待办
  Future<int> addTodo(TodosCompanion todo) => into(todos).insert(todo);
  // 更新待办
  Future<bool> updateTodo(Todo todo) => update(todos).replace(todo);
  // 删除待办
  Future<int> deleteTodo(Todo todo) => delete(todos).delete(todo);
  // 标记完成/未完成
  Future<void> toggleTodo(Todo todo) async {
    await update(todos).replace(todo.copyWith(isCompleted: !todo.isCompleted));
  }
}

// 打开数据库连接
LazyDatabase _openConnection() {
  return LazyDatabase(() async {
    final dbFolder = await getApplicationDocumentsDirectory();
    final file = File(p.join(dbFolder.path, 'todo_db.sqlite'));
    return NativeDatabase(file);
  });
}

// main.dart
import 'package:flutter/material.dart';
import 'package:drift/drift.dart';
import 'db.dart';

void main() async {
  // 初始化数据库
  final db = AppDatabase();
  runApp(MyApp(db: db));
}

class MyApp extends StatelessWidget {
  final AppDatabase db;
  const MyApp({super.key, required this.db});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Drift/SQLite 示例',
      home: TodoListPage(db: db),
    );
  }
}

class TodoListPage extends StatefulWidget {
  final AppDatabase db;
  const TodoListPage({super.key, required this.db});

  @override
  State<TodoListPage> createState() => _TodoListPageState();
}

class _TodoListPageState extends State<TodoListPage> {
  final TextEditingController _titleController = TextEditingController();
  final TextEditingController _contentController = TextEditingController();

  // 添加待办
  void _addTodo() {
    if (_titleController.text.isEmpty) return;
    final todo = TodosCompanion(
      title: Value(_titleController.text),
      content: Value(_contentController.text),
    );
    widget.db.addTodo(todo);
    _titleController.clear();
    _contentController.clear();
  }

  @override
  void dispose() {
    _titleController.dispose();
    _contentController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('待办事项 (Drift/SQLite)')),
      body: Column(
        children: [
          // 输入表单
          Padding(
            padding: const EdgeInsets.all(20),
            child: Column(
              children: [
                TextField(
                  controller: _titleController,
                  decoration: const InputDecoration(hintText: '待办标题'),
                ),
                TextField(
                  controller: _contentController,
                  decoration: const InputDecoration(hintText: '待办内容'),
                  maxLines: 2,
                ),
                const SizedBox(height: 10),
                ElevatedButton(
                  onPressed: _addTodo,
                  child: const Text('添加待办'),
                ),
              ],
            ),
          ),
          // 待办列表
          Expanded(
            child: StreamBuilder(
              stream: widget.db.watchAllTodos(),
              builder: (context, snapshot) {
                if (!snapshot.hasData) return const Center(child: CircularProgressIndicator());
                final todos = snapshot.data!;
                return ListView.builder(
                  itemCount: todos.length,
                  itemBuilder: (context, index) {
                    final todo = todos[index];
                    return ListTile(
                      title: Text(todo.title, style: TextStyle(
                        decoration: todo.isCompleted ? TextDecoration.lineThrough : null,
                      )),
                      subtitle: Text('创建时间:${todo.createTime.toString().substring(0, 19)}'),
                      trailing: Row(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          IconButton(
                            icon: Icon(
                              todo.isCompleted ? Icons.check_circle : Icons.circle_outlined,
                              color: todo.isCompleted ? Colors.green : null,
                            ),
                            onPressed: () => widget.db.toggleTodo(todo),
                          ),
                          IconButton(
                            icon: const Icon(Icons.delete, color: Colors.red),
                            onPressed: () => widget.db.deleteTodo(todo),
                          ),
                        ],
                      ),
                    );
                  },
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}
生成Drift代码
flutter packages pub run build_runner build
注意事项
  1. 数据库版本:表结构变更时需升级 schemaVersion 并编写迁移逻辑;
  2. 异步操作:所有数据库操作都是异步的,需用 Future/Stream 处理;
  3. 性能优化:大量数据查询时建议使用索引(indexed());
  4. 资源释放:应用退出时需关闭数据库连接(db.close())。

2.4 文件读写与应用目录

核心概念

Flutter 通过 dart:iopath_provider 插件实现文件读写,可访问应用专属的沙盒目录(避免权限问题),适合存储大文件(图片、日志、缓存等)。

前置依赖
flutter pub add path_provider
核心API/目录
API/目录 作用 注意事项
getApplicationDocumentsDirectory() 获取应用文档目录(持久化) 数据不会被系统自动清理
getTemporaryDirectory() 获取临时目录(缓存) 可能被系统清理,适合临时文件
File.writeAsString() 写入字符串到文件 支持编码格式(默认UTF-8)
File.readAsString() 从文件读取字符串 文件不存在时会抛出异常
jsonEncode/jsonDecode JSON序列化/反序列化 用于存储结构化数据
File.exists() 判断文件是否存在 读写前建议先判断
案例代码
import 'dart:io';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '文件读写示例',
      home: const FileOperationsDemo(),
    );
  }
}

class FileOperationsDemo extends StatefulWidget {
  const FileOperationsDemo({super.key});

  @override
  State<FileOperationsDemo> createState() => _FileOperationsDemoState();
}

class _FileOperationsDemoState extends State<FileOperationsDemo> {
  String _fileContent = '暂无数据';
  final TextEditingController _textController = TextEditingController();

  // 获取应用目录
  Future<Directory> _getAppDirectory() async {
    // 文档目录:持久化存储,用户可见(部分平台)
    // return await getApplicationDocumentsDirectory();
    // 缓存目录:临时存储,可能被系统清理
    return await getTemporaryDirectory();
  }

  // 写入文件
  Future<void> _writeToFile() async {
    if (_textController.text.isEmpty) return;
    final dir = await _getAppDirectory();
    final file = File('${dir.path}/demo.txt');
    // 写入字符串
    await file.writeAsString(_textController.text);
    // 写入JSON示例
    // final data = {'name': 'Flutter', 'version': '3.16.0'};
    // await file.writeAsString(jsonEncode(data));
    _textController.clear();
    _readFromFile(); // 重新读取
  }

  // 读取文件
  Future<void> _readFromFile() async {
    final dir = await _getAppDirectory();
    final file = File('${dir.path}/demo.txt');
    if (await file.exists()) {
      final content = await file.readAsString();
      // 读取JSON示例
      // final data = jsonDecode(content);
      setState(() => _fileContent = content);
    } else {
      setState(() => _fileContent = '文件不存在');
    }
  }

  // 删除文件
  Future<void> _deleteFile() async {
    final dir = await _getAppDirectory();
    final file = File('${dir.path}/demo.txt');
    if (await file.exists()) {
      await file.delete();
      setState(() => _fileContent = '文件已删除');
    }
  }

  @override
  void initState() {
    super.initState();
    _readFromFile(); // 初始化读取
  }

  @override
  void dispose() {
    _textController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('文件读写演示')),
      body: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(
          children: [
            TextField(
              controller: _textController,
              decoration: const InputDecoration(hintText: '请输入要保存的内容'),
              maxLines: 3,
            ),
            const SizedBox(height: 20),
            Row(
              children: [
                ElevatedButton(
                  onPressed: _writeToFile,
                  child: const Text('写入文件'),
                ),
                const SizedBox(width: 20),
                ElevatedButton(
                  onPressed: _readFromFile,
                  child: const Text('读取文件'),
                ),
                const SizedBox(width: 20),
                ElevatedButton(
                  onPressed: _deleteFile,
                  style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
                  child: const Text('删除文件'),
                ),
              ],
            ),
            const SizedBox(height: 30),
            const Text('文件内容:', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
            const SizedBox(height: 10),
            Expanded(
              child: SingleChildScrollView(
                child: Text(_fileContent),
              ),
            ),
          ],
        ),
      ),
    );
  }
}
注意事项
  1. 权限问题:无需申请额外权限(沙盒目录内操作),访问外部存储需申请权限;
  2. 大文件处理:大文件建议使用 readAsBytes()/writeAsBytes(),避免内存溢出;
  3. 路径拼接:建议使用 path 插件的 join() 方法拼接路径,适配不同平台;
  4. 异常处理:文件操作需捕获异常(如磁盘满、权限不足)。

2.5 数据安全与加密

核心概念

本地存储的敏感数据(如用户token、密码、个人信息)需加密后存储,常用加密算法:

  • AES:对称加密,适合加密完整数据;
  • SHA-256:哈希算法,适合加密密码(不可逆);
  • HMAC:带密钥的哈希算法,防止数据篡改。
前置依赖
flutter pub add encrypt crypto
核心加密方法
方法 作用 适用场景
AES(encrypt插件) 对称加密,可解密 敏感数据(Token、用户信息)
SHA-256(crypto) 哈希加密,不可逆 密码存储(不存储明文)
HMAC 带密钥的哈希,防篡改 数据完整性校验
案例代码
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:encrypt/encrypt.dart';
import 'package:crypto/crypto.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '数据加密示例',
      home: const EncryptionDemo(),
    );
  }
}

class EncryptionDemo extends StatefulWidget {
  const EncryptionDemo({super.key});

  @override
  State<EncryptionDemo> createState() => _EncryptionDemoState();
}

class _EncryptionDemoState extends State<EncryptionDemo> {
  // 加密密钥(建议从服务端获取,或设备唯一标识生成)
  final String _secretKey = 'my_32_char_secret_key_12345678'; // AES-256 需要32位密钥
  final TextEditingController _sensitiveDataController = TextEditingController();
  String _encryptedData = '';
  String _decryptedData = '';
  String _hashedPassword = '';

  // AES加密
  String _aesEncrypt(String plainText) {
    final key = Key.fromUtf8(_secretKey);
    final iv = IV.fromLength(16); // 16位初始化向量
    final encrypter = Encrypter(AES(key, mode: AESMode.cbc));
    final encrypted = encrypter.encrypt(plainText, iv: iv);
    return encrypted.base64;
  }

  // AES解密
  String _aesDecrypt(String encryptedText) {
    final key = Key.fromUtf8(_secretKey);
    final iv = IV.fromLength(16);
    final encrypter = Encrypter(AES(key, mode: AESMode.cbc));
    final decrypted = encrypter.decrypt(Encrypted.fromBase64(encryptedText), iv: iv);
    return decrypted;
  }

  // SHA-256哈希(密码加密)
  String _sha256Hash(String password) {
    final bytes = utf8.encode(password);
    final digest = sha256.convert(bytes);
    return digest.toString();
  }

  // 加密并保存到SharedPreferences
  Future<void> _encryptAndSave() async {
    final plainText = _sensitiveDataController.text;
    if (plainText.isEmpty) return;
    
    // 加密
    final encrypted = _aesEncrypt(plainText);
    // 哈希密码示例
    final hashedPwd = _sha256Hash('user_password_123');
    
    // 保存加密后的数据
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('encrypted_token', encrypted);
    
    setState(() {
      _encryptedData = encrypted;
      _hashedPassword = hashedPwd;
    });
  }

  // 从SharedPreferences读取并解密
  Future<void> _loadAndDecrypt() async {
    final prefs = await SharedPreferences.getInstance();
    final encrypted = prefs.getString('encrypted_token') ?? '';
    if (encrypted.isEmpty) {
      setState(() => _decryptedData = '无加密数据');
      return;
    }
    
    // 解密
    final decrypted = _aesDecrypt(encrypted);
    setState(() => _decryptedData = decrypted);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('数据安全与加密')),
      body: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            TextField(
              controller: _sensitiveDataController,
              decoration: const InputDecoration(hintText: '输入敏感数据(如Token)'),
            ),
            const SizedBox(height: 20),
            Row(
              children: [
                ElevatedButton(
                  onPressed: _encryptAndSave,
                  child: const Text('加密并保存'),
                ),
                const SizedBox(width: 20),
                ElevatedButton(
                  onPressed: _loadAndDecrypt,
                  child: const Text('读取并解密'),
                ),
              ],
            ),
            const SizedBox(height: 30),
            const Text('加密后的数据:', style: TextStyle(fontWeight: FontWeight.bold)),
            Text(_encryptedData),
            const SizedBox(height: 20),
            const Text('解密后的数据:', style: TextStyle(fontWeight: FontWeight.bold)),
            Text(_decryptedData),
            const SizedBox(height: 20),
            const Text('密码SHA-256哈希:', style: TextStyle(fontWeight: FontWeight.bold)),
            Text(_hashedPassword),
          ],
        ),
      ),
    );
  }
}
注意事项
  1. 密钥安全:密钥不要硬编码在代码中,建议从服务端获取或通过设备信息生成;
  2. AES模式:推荐使用CBC/GCM模式,IV(初始化向量)需随机生成并和密文一起存储;
  3. 哈希加盐:密码哈希时建议添加随机盐(salt),防止彩虹表攻击;
  4. 加密范围:仅加密敏感数据,普通数据无需加密(影响性能)。

三、综合应用案例:本地存储全方案电商APP

功能说明

整合本章所有技术,实现一个电商APP的本地存储方案:

  1. SharedPreferences:存储用户偏好设置(暗黑模式、语言)、登录状态(加密token);
  2. Hive:存储用户收藏的商品列表(自定义对象、加密);
  3. Drift/SQLite:存储本地订单记录(关系型数据);
  4. 文件存储:保存用户头像(图片文件)、APP日志;
  5. 数据加密:加密存储token、用户信息,密码哈希存储。

完整代码(核心整合版)

// 第一步:配置依赖(pubspec.yaml)
/*
dependencies:
  flutter:
    sdk: flutter
  shared_preferences: ^2.2.2
  hive: ^2.2.3
  hive_flutter: ^1.1.0
  drift: ^2.14.0
  sqlite3_flutter_libs: ^0.5.18
  path_provider: ^2.1.2
  path: ^1.8.3
  encrypt: ^5.0.1
  crypto: ^3.0.3
  image_picker: ^1.0.4 # 用于选择头像

dev_dependencies:
  hive_generator: ^2.0.1
  drift_dev: ^2.14.0
  build_runner: ^2.4.8
*/

// 第二步:定义数据模型和数据库
import 'dart:io';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'package:encrypt/encrypt.dart';
import 'package:crypto/crypto.dart';
import 'package:image_picker/image_picker.dart';

// ---------------------- 1. 加密工具类 ----------------------
class EncryptionUtil {
  static final String _secretKey = 'ecommerce_32_char_key_1234567890'; // 32位密钥
  static final IV _iv = IV.fromLength(16);

  // AES加密
  static String encryptAES(String plainText) {
    final key = Key.fromUtf8(_secretKey);
    final encrypter = Encrypter(AES(key, mode: AESMode.cbc));
    final encrypted = encrypter.encrypt(plainText, iv: _iv);
    return encrypted.base64;
  }

  // AES解密
  static String decryptAES(String encryptedText) {
    final key = Key.fromUtf8(_secretKey);
    final encrypter = Encrypter(AES(key, mode: AESMode.cbc));
    final decrypted = encrypter.decrypt(Encrypted.fromBase64(encryptedText), iv: _iv);
    return decrypted;
  }

  // SHA-256哈希
  static String sha256Hash(String input) {
    final bytes = utf8.encode(input);
    final digest = sha256.convert(bytes);
    return digest.toString();
  }
}

// ---------------------- 2. Hive 商品模型 ----------------------
part 'product.g.dart';

@HiveType(typeId: 0)
class Product extends HiveObject {
  @HiveField(0)
  late String id;

  @HiveField(1)
  late String name;

  @HiveField(2)
  late double price;

  @HiveField(3)
  late String imageUrl;

  Product({required this.id, required this.name, required this.price, required this.imageUrl});
}

// ---------------------- 3. Drift 订单数据库 ----------------------
class Orders extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get orderId => text()();
  TextColumn get productIds => text()(); // 商品ID列表,逗号分隔
  RealColumn get totalPrice => real()();
  DateTimeColumn get createTime => dateTime().withDefault(currentDateAndTime)();
}

part 'order_db.g.dart';

@DriftDatabase(tables: [Orders])
class OrderDatabase extends _$OrderDatabase {
  OrderDatabase() : super(_openConnection());

  @override
  int get schemaVersion => 1;

  // 订单CRUD
  Future<int> addOrder(OrdersCompanion order) => into(orders).insert(order);
  Future<List<Order>> getAllOrders() => select(orders).get();
  Stream<List<Order>> watchAllOrders() => select(orders).watch();
}

LazyDatabase _openConnection() {
  return LazyDatabase(() async {
    final dbFolder = await getApplicationDocumentsDirectory();
    final file = File(p.join(dbFolder.path, 'orders_db.sqlite'));
    return NativeDatabase(file);
  });
}

// ---------------------- 4. 主应用 ----------------------
void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // 初始化SharedPreferences
  final prefs = await SharedPreferences.getInstance();

  // 初始化Hive(加密存储收藏商品)
  await Hive.initFlutter();
  Hive.registerAdapter(ProductAdapter());
  final encryptionKey = EncryptionUtil.sha256Hash('hive_secret_key').substring(0, 32);
  final cipher = HiveAesCipher(utf8.encode(encryptionKey) as Uint8List);
  await Hive.openBox<Product>('favorite_products', encryptionCipher: cipher);

  // 初始化Drift数据库
  final orderDb = OrderDatabase();

  runApp(MyEcommerceApp(prefs: prefs, orderDb: orderDb));
}

class MyEcommerceApp extends StatelessWidget {
  final SharedPreferences prefs;
  final OrderDatabase orderDb;
  const MyEcommerceApp({super.key, required this.prefs, required this.orderDb});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '电商APP本地存储示例',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        brightness: prefs.getBool('dark_mode') ?? false ? Brightness.dark : Brightness.light,
      ),
      home: MainPage(prefs: prefs, orderDb: orderDb),
    );
  }
}

// ---------------------- 5. 主页面 ----------------------
class MainPage extends StatefulWidget {
  final SharedPreferences prefs;
  final OrderDatabase orderDb;
  const MainPage({super.key, required this.prefs, required this.orderDb});

  @override
  State<MainPage> createState() => _MainPageState();
}

class _MainPageState extends State<MainPage> {
  int _currentTab = 0;
  final Box<Product> _favoriteBox = Hive.box<Product>('favorite_products');
  final ImagePicker _imagePicker = ImagePicker();
  String _avatarPath = '';

  // 模拟商品数据
  final List<Product> _products = [    Product(id: '1', name: 'Flutter 实战教程', price: 59.9, imageUrl: 'https://example.com/1.jpg'),    Product(id: '2', name: 'Dart 进阶指南', price: 49.9, imageUrl: 'https://example.com/2.jpg'),  ];

  @override
  void initState() {
    super.initState();
    // 读取用户头像路径
    _avatarPath = widget.prefs.getString('avatar_path') ?? '';
    // 初始化登录状态(加密存储token)
    if (!widget.prefs.containsKey('user_token')) {
      final token = EncryptionUtil.encryptAES('user_123_token_xyz');
      widget.prefs.setString('user_token', token);
    }
  }

  // 切换暗黑模式
  void _toggleDarkMode(bool value) {
    widget.prefs.setBool('dark_mode', value);
    setState(() {
      // 重启应用生效(简化示例,实际可使用Provider)
      Navigator.pushReplacement(
        context,
        MaterialPageRoute(builder: (context) => MainPage(prefs: widget.prefs, orderDb: widget.orderDb)),
      );
    });
  }

  // 添加收藏商品
  void _addToFavorites(Product product) {
    _favoriteBox.add(product);
    if (mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('${product.name} 已加入收藏')),
      );
    }
  }

  // 下单
  void _createOrder() async {
    final order = OrdersCompanion(
      orderId: Value('ORD${DateTime.now().millisecondsSinceEpoch}'),
      productIds: Value(_products.map((p) => p.id).join(',')),
      totalPrice: Value(_products.fold(0.0, (sum, p) => sum + p.price)),
    );
    await widget.orderDb.addOrder(order);
    if (mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('订单创建成功')),
      );
    }
  }

  // 选择并保存头像
  Future<void> _pickAvatar() async {
    final XFile? image = await _imagePicker.pickImage(source: ImageSource.gallery);
    if (image == null) return;
    
    // 保存头像路径
    await widget.prefs.setString('avatar_path', image.path);
    setState(() => _avatarPath = image.path);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('电商APP')),
      body: IndexedStack(
        index: _currentTab,
        children: [
          // 首页(商品列表)
          ListView.builder(
            itemCount: _products.length,
            itemBuilder: (context, index) {
              final product = _products[index];
              return ListTile(
                title: Text(product.name),
                subtitle: Text('¥${product.price}'),
                trailing: IconButton(
                  icon: const Icon(Icons.favorite_border),
                  onPressed: () => _addToFavorites(product),
                ),
              );
            },
          ),
          // 收藏页面
          ValueListenableBuilder(
            valueListenable: _favoriteBox.listenable(),
            builder: (context, Box<Product> box, child) {
              if (box.isEmpty) return const Center(child: Text('暂无收藏商品'));
              return ListView.builder(
                itemCount: box.length,
                itemBuilder: (context, index) {
                  final product = box.getAt(index)!;
                  return ListTile(
                    title: Text(product.name),
                    subtitle: Text('¥${product.price}'),
                    trailing: IconButton(
                      icon: const Icon(Icons.delete, color: Colors.red),
                      onPressed: () => box.deleteAt(index),
                    ),
                  );
                },
              );
            },
          ),
          // 订单页面
          StreamBuilder(
            stream: widget.orderDb.watchAllOrders(),
            builder: (context, snapshot) {
              if (!snapshot.hasData) return const Center(child: CircularProgressIndicator());
              final orders = snapshot.data!;
              if (orders.isEmpty) return const Center(child: Text('暂无订单'));
              return ListView.builder(
                itemCount: orders.length,
                itemBuilder: (context, index) {
                  final order = orders[index];
                  return ListTile(
                    title: Text('订单号:${order.orderId}'),
                    subtitle: Text('总价:¥${order.totalPrice} | ${order.createTime.toString().substring(0, 10)}'),
                  );
                },
              );
            },
          ),
          // 我的页面
          Padding(
            padding: const EdgeInsets.all(20),
            child: Column(
              children: [
                // 头像
                GestureDetector(
                  onTap: _pickAvatar,
                  child: CircleAvatar(
                    radius: 50,
                    backgroundImage: _avatarPath.isNotEmpty ? FileImage(File(_avatarPath)) : null,
                    child: _avatarPath.isEmpty ? const Icon(Icons.person, size: 50) : null,
                  ),
                ),
                const SizedBox(height: 20),
                // 暗黑模式
                SwitchListTile(
                  title: const Text('暗黑模式'),
                  value: widget.prefs.getBool('dark_mode') ?? false,
                  onChanged: _toggleDarkMode,
                ),
                const SizedBox(height: 20),
                // 下单按钮
                ElevatedButton(
                  onPressed: _createOrder,
                  child: const Text('创建测试订单'),
                ),
                const SizedBox(height: 20),
                // 解密Token
                ElevatedButton(
                  onPressed: () {
                    final encryptedToken = widget.prefs.getString('user_token')!;
                    final decryptedToken = EncryptionUtil.decryptAES(encryptedToken);
                    ScaffoldMessenger.of(context).showSnackBar(
                      SnackBar(content: Text('解密Token:$decryptedToken')),
                    );
                  },
                  child: const Text('查看解密Token'),
                ),
              ],
            ),
          ),
        ],
      ),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentTab,
        onTap: (index) => setState(() => _currentTab = index),
        type: BottomNavigationBarType.fixed,
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: '首页'),
          BottomNavigationBarItem(icon: Icon(Icons.favorite), label: '收藏'),
          BottomNavigationBarItem(icon: Icon(Icons.shopping_bag), label: '订单'),
          BottomNavigationBarItem(icon: Icon(Icons.person), label: '我的'),
        ],
      ),
    );
  }
}

功能验证步骤

  1. SharedPreferences

    1. 切换“暗黑模式” → 重启页面生效;
    2. 查看“解密Token” → 展示加密存储的用户token;
    3. 选择头像 → 保存头像路径并展示。
  2. Hive

    1. 首页点击商品收藏按钮 → 收藏页面展示收藏的商品;
    2. 删除收藏商品 → 列表实时更新。
  3. Drift/SQLite

    1. 点击“创建测试订单” → 订单页面展示新创建的订单;
    2. 订单数据实时监听,自动刷新。
  4. 数据加密

    1. Hive的收藏商品使用AES加密存储;
    2. 用户token加密后存储在SharedPreferences;
    3. 密码使用SHA-256哈希(示例)。

关键注意事项

  • 所有本地存储方案最终都依赖设备文件系统,需注意磁盘空间和权限;
  • 加密是敏感数据的必要操作,避免明文存储用户信息;
  • 数据库和Hive需正确初始化和关闭,避免资源泄漏;
  • 跨平台路径处理使用path插件,避免平台兼容性问题。

vue-router 5.x 文件式路由

作者 米丘
2026年3月23日 22:34

Vue Router 内置了基于文件的路由插件。会自动根据页面组件生成路由和类型,因此不再需要手动维护 routes 数组。

项目中使用文件式路由

src/router/index.ts

import { createRouter, createWebHistory } from 'vue-router'
// 自动生成的路由(vite 插件注入)
import { routes } from 'vue-router/auto-routes'

console.log('routes', routes)
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
})

router.afterEach((to, from) => {
  document.title = to.meta.title ? `${to.meta.title} |Vite Vue3 平台 ` : 'Vite Vue3 平台'
})

export default router

vite.config.ts

import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
import VueRouter from 'vue-router/vite'

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    // 必须要在 vue 插件之前
    VueRouter({
      routesFolder: 'src/pages', // 默认 pages
      extensions: ['.vue'], // 匹配文件后缀
      dts: 'src/typed-router.d.ts', // 生成类型文件

       // 添加调试选项
      logs: true,

      // routeBlockLang: 'json5', // 路由块语言,默认 json
      importMode: 'async',
      root: process.cwd(),

      // 在配置文件写入前,手动修改路由配置(如添加全局路由守卫、调整路由元信息、过滤路由等)
      // beforeWriteFiles: (editedRoutes) => {
      //   console.log('beforeWriteFiles', editedRoutes)
      // },
      watch: true, // 开启路由块文件监听
      // 开启实验性功能
      experimental: {
        
      },
    }),
    vue(),
    vueJsx(),
    vueDevTools(),
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    },
  },
})

默认情况下,此插件会检查 src/pages 文件夹中的任何 .vue 文件,并根据文件名生成相应的路由结构。

image.png

vue-router/vite 插件有哪些 options 配置?

/**
 * vue-router plugin options.
 */
export interface Options {
  /**
   * Extensions of files to be considered as pages. Cannot be empty. This allows to strip a
   * bigger part of the filename e.g. `index.page.vue` -> `index` if an extension of `.page.vue` is provided.
   * 
   * 识别为路由页面的文件后缀(不能为空)
   * @default `['.vue']`
   */
  extensions?: string[]

  /**
   * Folder(s) to scan for files and generate routes. Can also be an array if you want to add multiple
   * folders, or an object if you want to define a route prefix. Supports glob patterns but must be a folder, use
   * `extensions` and `exclude` to filter files.
   * 
   * 扫描路由文件的目录(支持多目录 / 前缀)
   *
   * @default `"src/pages"`
   */
  routesFolder?: RoutesFolder

  /**
   * Array of `picomatch` globs to ignore. Note the globs are relative to the cwd, so avoid writing
   * something like `['ignored']` to match folders named that way, instead provide a path similar to the `routesFolder`:
   * `['src/pages/ignored/**']` or use `['**/ignored']` to match every folder named `ignored`.
   * 
   * 排除的文件 / 目录
   * @default `[]`
   */
  exclude?: string[] | string

  /**
   * Pattern to match files in the `routesFolder`. Defaults to `*\/*` plus a
   * combination of all the possible extensions, e.g. `*\/*.{vue,md}` if
   * `extensions` is set to `['.vue', '.md']`. This is relative to the {@link
   * RoutesFolderOption['src']} and
   * 
   * 匹配的文件模式
   *
   * @default `['*\/*']`
   */
  filePatterns?: string[] | string

  /**
   * Method to generate the name of a route. It's recommended to keep the default value to guarantee a consistent,
   * unique, and predictable naming.
   * 自定义路由名称生成逻辑
   */
  getRouteName?: (node: TreeNode) => string

  /**
   * Allows extending a route by modifying its node, adding children, or even deleting it. This will be invoked once for
   * each route.
   * 
   * 扩展 / 修改单个路由(增删改属性)
   *
   * @param route - {@link EditableTreeNode} of the route to extend
   */
  extendRoute?: (route: EditableTreeNode) => _Awaitable<void>

  /**
   * Allows to do some changes before writing the files. This will be invoked **every time** the files need to be written.
   *
   * 写入路由文件前修改整体路由树
   * @param rootRoute - {@link EditableTreeNode} of the root route
   */
  beforeWriteFiles?: (rootRoute: EditableTreeNode) => _Awaitable<void>

  /**
   * Defines how page components should be imported. Defaults to dynamic imports to enable lazy loading of pages.
   * 
   * 页面组件的导入方式(同步 / 异步)
   * @default `'async'`
   */
  importMode?: 'sync' | 'async' | ((filepath: string) => 'sync' | 'async')

  /**
   * Root of the project. All paths are resolved relatively to this one.
   * 
   * 项目根目录(所有路径相对此目录)
   * @default `process.cwd()`
   */
  root?: string

  /**
   * Language for `<route>` blocks in SFC files.
   * SFC 中 <route> 块的解析语言
   * @default `'json5'`
   */
  routeBlockLang?: 'yaml' | 'yml' | 'json5' | 'json'

  /**
   * Should we generate d.ts files or ont. Defaults to `true` if `typescript` is installed. Can be set to a string of
   * the filepath to write the d.ts files to. By default it will generate a file named `typed-router.d.ts`.
   * 是否生成类型文件(指定路径)
   * @default `true`
   */
  dts?: boolean | string

  /**
   * Allows inspection by vite-plugin-inspect by not adding the leading `\0` to the id of virtual modules.
   * 兼容 vite-plugin-inspect 调试
   * @internal
   */
  _inspect?: boolean

  /**
   * Activates debug logs.
   * 开启调试日志(查看路由生成过程)
   */
  logs?: boolean

  /**
   * @inheritDoc ParseSegmentOptions
   */
  pathParser?: ParseSegmentOptions

  /**
   * Whether to watch the files for changes.
   *
   * Defaults to `true` unless the `CI` environment variable is set.
   * 监听文件变化自动更新路由
   * @default `!process.env.CI`
   */
  watch?: boolean

  /**
   * Experimental options. **Warning**: these can change or be removed at any time, even it patch releases. Keep an eye
   * on the Changelog.
   */
  experimental?: {
    /**
     * (Vite only). File paths or globs where loaders are exported. This will be used to filter out imported loaders and
     * automatically re export them in page components. You can for example set this to `'src/loaders/**\/*'` (without
     * the backslash) to automatically re export any imported variable from files in the `src/loaders` folder within a
     * page component.
     * 自动导出数据加载器
     */
    autoExportsDataLoaders?: string | string[]

    /**
     * Enable experimental support for the new custom resolvers and allows
     * defining custom param matchers.
     * 自定义路径解析规则(如参数命名、可选参数标识)
     */
    paramParsers?: boolean | ParamParsersOptions
  }
}

实现 404 页面

要创建通配符路由,需要参数名称前添加 3 个点 (...),例如 src/pages/[...path].vue 将创建一个具有以下路径的路由:/:path(.*)。这将匹配任何路由。

src/pages/[...path].vue

<template>
  <div class="not-found-container">
    <div class="not-found-content">
      <div class="error-code">
        <span class="digit">4</span>
        <span class="digit">0</span>
        <span class="digit">4</span>
      </div>
      <h1 class="error-title">页面不存在</h1>
      <p class="error-description">抱歉,您访问的页面可能已被删除、移动或输入了错误的地址。</p>
      <router-link to="/home" class="back-home-btn"> 返回首页 </router-link>
    </div>
  </div>
</template>

<script lang="ts" setup>
defineOptions({
  name: 'NotFoundView',
})
</script>

image.png

嵌套路由

嵌套路由是通过在文件夹旁边定义一个 .vue 文件同名的来自动定义的。如果创建了 src/pages/role/index.vue 和 src/pages/role.vue 组件,src/pages/role/index.vue 将在 src/pages/role.vue 的 <RouterView> 中渲染。

src/pages/
├── role/
│   └── index.vue
└── roles.vue

image.png

动态路由

  1. 通过用括号包裹 参数名称 来添加 路由参数,例如 src/pages/users/[id].vue 将创建一个具有以下路径的路由:/users/:id
  2. 通过用额外的一对括号包裹 参数名称 来创建 可选参数,例如 src/pages/users/[[id]].vue 将创建一个具有以下路径的路由:/users/:id?
  3. 通过在右括号后添加加号 (+) 来创建 可重复参数,例如 src/pages/articles/[slugs]+.vue 将创建一个具有以下路径的路由:/articles/:slugs+

可选参数 src/pages/home/user.create.[[id]].vue

image.png

image.png

image.png

命名视图

通过在文件名后附加 @ + 名称来定义 命名视图

src/pages/home/dashboard.vu

<template>
  <div>
    <router-view />
    <router-view name="logs" />
  </div>
</template>
<script lang="ts" setup>
defineOptions({
  name: "HomeDashboardView",
});
</script>

src/pages/home/dashboard.vue 会渲染src/pages/home/dashboard/index.vuesrc/pages/home/dashboard/index@logs.vue组件的内容。

image.png

路由组

路由组(Route Groups)是一个用于纯粹组织代码的功能:用 () 包裹的文件夹名称不会出现在最终的路由路径中,可以按功能或模块自由归类页面文件,而不影响 URL 结构。

简单来说,路由组就是不会出现在 URL 中的文件夹。它的语法是在文件夹名称外加上括号,例如 (admin)

image.png

组件内如何修改路由配置?

直接在页面组件文件中覆盖路由配置。插件会拾取这些更改并反映在生成的 typed-router.d.ts 文件中。

definePage

用 definePage() 宏修改和扩展任何页面组件。这对于添加 meta 信息或修改路由对象很有用。

<script lang="ts" setup>
definePage({
  redirect: "/home",
  name: "layout",
  meta: {
    title: "Home",
  },
});
defineOptions({
  name: "IndexView",
});
</script>

image.png

SFC <route> 自定义块

默认情况下,语言是 JSON5(更灵活的 JSON 版本),但也支持 yaml、yml 和 JSON。

<route lang="yaml">
name: "role_index"
</route>
<route lang="json">
{
  "name": "user_details"
}
</route>

image.png

最后

  1. vue-router 官网
  2. vite8 + vue3 文件式路由 demo

第十四讲 网络请求与数据解析

作者 节点玩家
2026年3月23日 22:08

前言:

这一章是比较重要的交互,实际上是比较通用的代码,就是进行http请求和本地缓存的一些库,与后端交互或者纯app请求一些tts、转录之类的模型,都是需要的,跑不掉。

一、总览

本讲聚焦Flutter开发中网络通信数据处理的核心能力,解决以下关键问题:

  • 统一管理网络请求(GET/POST),避免重复代码
  • 处理请求头、Token认证、超时、异常等网络边界场景
  • 规范化JSON数据解析,避免手动解析的错误
  • 实现网络缓存、离线可用等提升用户体验的策略
  • 通过Dio封装和拦截器实现请求/响应的全局管控

让你的Flutter应用能稳定、高效、安全地与后端交互,并优雅处理数据

  1. 分层设计:业务层不直接接触原生网络层,通过封装的Dio工具类解耦
  2. 拦截器核心:请求发出/响应返回时的"中间件",统一处理通用逻辑
  3. 数据流转:网络响应→拦截器处理→JSON解析→模型类→业务层(可结合缓存)
  4. 异常闭环:所有网络错误在拦截器/工具类中统一捕获,避免业务层零散处理

二、核心技术拆解

1. Dio封装与拦截器

添加Dio依赖

1.1 核心属性说明
属性/方法 作用 常用值/场景
BaseOptions Dio基础配置 baseUrl(接口前缀)、connectTimeout(连接超时)、headers(默认头)
interceptors 拦截器列表 RequestInterceptor(请求拦截)、ResponseInterceptor(响应拦截)
get/post 请求方法 queryParameters(URL参数)、data(POST请求体)
DioException 异常类型 connectionTimeout(超时)、badResponse(服务端错误)
1.2 基础封装
import 'dart:math';

import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';

/// 全局Dio封装类
class HttpManager {
  static final HttpManager _instance = HttpManager._internal();
  factory HttpManager() => _instance;
  
  late Dio _dio;
  
  // 私有构造函数
  HttpManager._internal() {
    // 初始化Dio配置
    BaseOptions options = BaseOptions(
      baseUrl: "https://api.example.com", // 基础地址
      connectTimeout: const Duration(seconds: 10), // 连接超时
      receiveTimeout: const Duration(seconds: 5), // 接收超时
      headers: {
        "Content-Type": "application/json",
        "version": "1.0.0"
      }, // 默认请求头
    );
    
    _dio = Dio(options);
    
    // 添加拦截器
    _addInterceptors();
  }
  
  // 添加拦截器
  void _addInterceptors() {
    // 1. 请求拦截器:添加Token、日志
    _dio.interceptors.add(InterceptorsWrapper(
      onRequest: (RequestOptions options, RequestInterceptorHandler handler) {
        // 添加Token
        String? token = _getToken();
        if (token != null) {
          options.headers["Authorization"] = "Bearer $token";
        }
        
        // 开发环境打印请求日志
        if (kDebugMode) {
          print("请求URL:${options.uri}");
          print("请求参数:${options.data}");
        }
        
        handler.next(options); // 继续执行
      },
      
      // 2. 响应拦截器:统一处理响应、错误
      onResponse: (Response response, ResponseInterceptorHandler handler) {
        if (kDebugMode) {
          print("响应数据:${response.data}");
        }
        handler.next(response);
      },
      
      // 3. 错误拦截器:统一处理异常
      onError: (DioException e, ErrorInterceptorHandler handler) {
        if (kDebugMode) {
          print("请求错误:${e.message}");
        }
        
        // 错误处理逻辑(重试/提示)
        _handleError(e, handler);
      },
    ));
    
    // 可选:添加日志拦截器(更详细)
    if (kDebugMode) {
      _dio.interceptors.add(LogInterceptor(responseBody: true));
    }
  }
  
  // 获取本地Token(示例)
  String? _getToken() {
    // 实际项目中从SharedPreferences等存储中获取
    return "your_token_here";
  }
  
  // 错误处理与重试
  void _handleError(DioException e, ErrorInterceptorHandler handler) {
    switch (e.type) {
      case DioExceptionType.connectionTimeout:
      case DioExceptionType.receiveTimeout:
        // 超时错误,可实现重试逻辑
        _retryRequest(e.requestOptions, handler);
        break;
      case DioExceptionType.badResponse:
        // 服务器错误(4xx/5xx)
        if (e.response?.statusCode == 401) {
          // Token过期,跳转登录页
          // Navigator.pushReplacementNamed(context, "/login");
        }
        handler.next(e);
        break;
      default:
        handler.next(e);
    }
  }
  
  // 重试请求
  void _retryRequest(RequestOptions options, ErrorInterceptorHandler handler) {
    // 简单重试逻辑:最多重试1次
    int retryCount = 0;
    if (retryCount < 1) {
      retryCount++;
      _dio.request(options.path, data: options.data).then((response) {
        handler.resolve(response);
        retryCount = 0;
      }).catchError((error) {
        handler.next(error as DioException);
        retryCount = 0;
      });
    } else {
      retryCount = 0;
    }
  }
  
  // 暴露GET请求方法
  Future<T> get<T>(
    String path, {
    Map<String, dynamic>? params,
    Options? options,
  }) async {
    try {
      Response response = await _dio.get(path, queryParameters: params, options: options);
      return response.data as T;
    } on DioException catch (e) {
      throw _convertError(e);
    }
  }
  
  // 暴露POST请求方法
  Future<T> post<T>(
    String path, {
    dynamic data,
    Map<String, dynamic>? params,
    Options? options,
  }) async {
    try {
      Response response = await _dio.post(
        path,
        data: data,
        queryParameters: params,
        options: options,
      );
      return response.data as T;
    } on DioException catch (e) {
      throw _convertError(e);
    }
  }
  
  // 统一错误转换
  String _convertError(DioException e) {
    switch (e.type) {
      case DioExceptionType.connectionTimeout:
        return "网络连接超时,请检查网络";
      case DioExceptionType.receiveTimeout:
        return "数据接收超时";
      case DioExceptionType.badResponse:
        return "服务器错误(${e.response?.statusCode})";
      case DioExceptionType.connectionError:
        return "网络连接失败,请检查网络";
      default:
        return e.message ?? "未知错误";
    }
  }
  
  // 获取原始Dio实例(特殊场景使用)
  Dio get dio => _dio;
}
1.3 注意事项
  • 拦截器中必须调用handler.next()/handler.resolve()/handler.reject(),否则请求会卡住
  • Token过期处理需结合路由(需确保上下文可用,或使用全局状态管理)
  • 重试逻辑要限制次数,避免无限重试
  • 生产环境需关闭日志拦截器,防止敏感信息泄露

2. JSON序列化(json_serializable)

(1)配置依赖(pubspec.yaml)
dependencies:
  json_annotation: ^4.11.0

dev_dependencies:
  json_serializable: ^6.9.0
  build_runner: ^2.4.15
(2)模型类案例
import 'package:json_annotation/json_annotation.dart';

// 生成的文件命名:当前文件命.g.dart
part 'user_model.g.dart';

/// 用户模型类
@JsonSerializable()
class UserModel {
  // 字段名与JSON字段一致(不一致可通过@JsonKey指定)
  final int id;
  final String name;
  final String email;
  
  // 可选字段(JSON中可能不存在)
  @JsonKey(defaultValue: "")
  final String avatar;
  
  // 自定义字段映射(JSON字段是create_time,模型中是createTime)
  @JsonKey(name: "create_time")
  final String createTime;
  
  UserModel({
    required this.id,
    required this.name,
    required this.email,
    required this.avatar,
    required this.createTime,
  });
  
  // 从JSON解析为模型(自动生成)
  factory UserModel.fromJson(Map<String, dynamic> json) => _$UserModelFromJson(json);
  
  // 模型转换为JSON(自动生成)
  Map<String, dynamic> toJson() => _$UserModelToJson(this);
}
(3)生成序列化代码

执行终端命令:

flutter pub run build_runner build
# 或监听文件变化自动生成
flutter pub run build_runner watch
(4)注意事项
  • 模型类必须添加@JsonSerializable()注解
  • part 'xxx.g.dart'必须与文件名一致
  • 可选字段需通过@JsonKey(defaultValue: ...)指定默认值,避免解析空值报错
  • 嵌套模型类也需要添加注解并实现序列化方法

3. 缓存与离线策略

(1)核心思路
  • 缓存网络响应数据到本地(SharedPreferences/Hive)
  • 请求时先读缓存(展示旧数据),再请求网络(更新数据)
  • 可设置缓存过期时间,避免展示过期数据
(2)缓存工具类案例
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';

class CacheManager {
  static final CacheManager _instance = CacheManager._internal();
  factory CacheManager() => _instance;
  CacheManager._internal();
  
  late SharedPreferences _prefs;
  
  // 初始化
  Future<void> init() async {
    _prefs = await SharedPreferences.getInstance();
  }
  
  // 保存缓存(带过期时间,单位:秒)
  Future<void> setCache(String key, dynamic data, {int expireSeconds = 300}) async {
    final cacheData = {
      "data": data,
      "timestamp": DateTime.now().millisecondsSinceEpoch,
      "expire": expireSeconds * 1000
    };
    await _prefs.setString(key, json.encode(cacheData));
  }
  
  // 获取缓存
  dynamic getCache(String key) {
    String? cacheStr = _prefs.getString(key);
    if (cacheStr == null) return null;
    
    Map<String, dynamic> cacheData = json.decode(cacheStr);
    int timestamp = cacheData["timestamp"];
    int expire = cacheData["expire"];
    
    // 检查是否过期
    if (DateTime.now().millisecondsSinceEpoch - timestamp > expire) {
      removeCache(key); // 删除过期缓存
      return null;
    }
    
    return cacheData["data"];
  }
  
  // 删除缓存
  Future<void> removeCache(String key) async {
    await _prefs.remove(key);
  }
  
  // 清空所有缓存
  Future<void> clearAllCache() async {
    await _prefs.clear();
  }
}

三、综合应用案例

需求场景

实现一个"用户信息"模块,包含:

  1. 获取用户列表(GET请求,带Token)
  2. 提交用户信息(POST请求)
  3. 数据序列化到模型类
  4. 异常处理、超时重试
  5. 离线缓存用户列表

步骤1:初始化工具类

// 在main.dart中初始化
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  // 初始化缓存
  await CacheManager().init();
  
  runApp(const MyApp());
}

步骤2:定义API接口类

/// 业务API封装
class UserApi {
  static final HttpManager _http = HttpManager();
  static final CacheManager _cache = CacheManager();
  
  // 1. 获取用户列表(带缓存)
  Future<List<UserModel>> getUserList({int page = 1, int size = 10}) async {
    String cacheKey = "user_list_$page";
    
    // 1. 先读缓存(离线展示)
    dynamic cacheData = _cache.getCache(cacheKey);
    if (cacheData != null) {
      // 缓存数据转换为模型
      List<UserModel> cacheList = (cacheData as List)
          .map((e) => UserModel.fromJson(e))
          .toList();
      // 先返回缓存,再异步请求更新
      Future.delayed(Duration.zero, () => _fetchUserList(page, size));
      return cacheList;
    }
    
    // 2. 无缓存则请求网络
    return _fetchUserList(page, size);
  }
  
  // 实际请求用户列表
  Future<List<UserModel>> _fetchUserList(int page, int size) async {
    String cacheKey = "user_list_$page";
    try {
      // GET请求
      Map<String, dynamic> response = await _http.get(
        "/users",
        params: {"page": page, "size": size},
      );
      
      // 解析为模型列表
      List<UserModel> userList = (response["data"] as List)
          .map((e) => UserModel.fromJson(e))
          .toList();
      
      // 保存缓存(5分钟过期)
      _cache.setCache(
        cacheKey,
        response["data"],
        expireSeconds: 300,
      );
      
      return userList;
    } catch (e) {
      throw e.toString();
    }
  }
  
  // 2. 提交用户信息(POST请求)
  Future<bool> submitUserInfo(UserModel user) async {
    try {
      // POST请求:模型转JSON
      Map<String, dynamic> response = await _http.post(
        "/users/save",
        data: user.toJson(),
      );
      
      return response["success"] == true;
    } catch (e) {
      throw e.toString();
    }
  }
}

步骤3:业务页面使用

import 'package:flutter/material.dart';

class UserPage extends StatefulWidget {
  const UserPage({super.key});

  @override
  State<UserPage> createState() => _UserPageState();
}

class _UserPageState extends State<UserPage> {
  List<UserModel> _userList = [];
  bool _isLoading = true;
  String? _errorMsg;
  
  @override
  void initState() {
    super.initState();
    _loadUserList();
  }
  
  // 加载用户列表
  Future<void> _loadUserList() async {
    setState(() {
      _isLoading = true;
      _errorMsg = null;
    });
    
    try {
      List<UserModel> list = await UserApi().getUserList(page: 1);
      setState(() {
        _userList = list;
      });
    } catch (e) {
      setState(() {
        _errorMsg = e.toString();
      });
    } finally {
      setState(() {
        _isLoading = false;
      });
    }
  }
  
  // 提交用户信息示例
  Future<void> _submitUser() async {
    UserModel newUser = UserModel(
      id: 0, // 新增用户ID为0
      name: "张三",
      email: "zhangsan@example.com",
      avatar: "",
      createTime: DateTime.now().toString(),
    );
    
    try {
      bool success = await UserApi().submitUserInfo(newUser);
      if (success) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text("提交成功")),
        );
        _loadUserList(); // 重新加载列表
      }
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text("提交失败:$e"), backgroundColor: Colors.red),
      );
    }
  }
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("用户管理")),
      body: _buildBody(),
      floatingActionButton: FloatingActionButton(
        onPressed: _submitUser,
        child: const Icon(Icons.add),
      ),
    );
  }
  
  Widget _buildBody() {
    if (_isLoading) {
      return const Center(child: CircularProgressIndicator());
    }
    
    if (_errorMsg != null) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(_errorMsg!),
            ElevatedButton(
              onPressed: _loadUserList,
              child: const Text("重新加载"),
            ),
          ],
        ),
      );
    }
    
    return ListView.builder(
      itemCount: _userList.length,
      itemBuilder: (context, index) {
        UserModel user = _userList[index];
        return ListTile(
          title: Text(user.name),
          subtitle: Text(user.email),
          trailing: Text(user.createTime),
        );
      },
    );
  }
}

核心关键点

  1. Dio封装:通过单例模式统一管理Dio配置,拦截器处理Token、日志、异常、重试等通用逻辑
  2. JSON序列化:使用json_serializable自动生成解析代码,避免手动解析的错误,通过@JsonKey处理字段映射
  3. 完整链路:业务层→封装的Http工具→拦截器→网络请求→数据解析→缓存→业务层,实现请求-解析-缓存的闭环
  4. 离线策略:先读缓存展示数据,再异步请求更新,提升用户体验,同时设置缓存过期时间保证数据新鲜度

工程化建议

  • 所有API接口统一放在单独的api目录,按业务模块拆分
  • 模型类放在models目录,与API一一对应
  • 网络错误提示统一封装为Toast/SnackBar,避免重复代码
  • 生产环境关闭日志拦截器,添加接口加密/签名(可选)

手写 Zustand:从零实现 React 轻量级状态管理库

2026年3月23日 21:39

为什么选择 Zustand?

在 React 开发中,组件间通信一直是个令人头疼的问题。当组件层级复杂时,通过 props 层层传递状态不仅代码冗余,维护成本也直线上升。这时候就需要一个中央状态管理库来解决这个痛点。

相比老牌的 Redux,Zustand 的优势非常明显:

  • 极简 API:没有繁琐的 reducer、action、dispatch 概念
  • 零样板代码:不需要包裹 Provider,直接创建 store 即用
  • 性能优秀:基于订阅机制实现精准更新,避免无效渲染
  • 体积小巧:核心代码仅 1KB 左右

正因如此,Zustand 在 GitHub 上已经收获了 4 万+ Star,成为近年来最受欢迎的 React 状态管理方案之一。

核心原理拆解

要手写 Zustand,首先需要理解其三大核心机制:

1. 状态存储与管理

Zustand 采用闭包方式存储状态,通过 createStore 创建一个独立的状态容器:

javascript

const createStore = (createState) => {
  let state;  // 闭包变量,存储状态
  const getState = () => state;
  // ... 其他方法
}

这种设计让状态完全脱离 React 组件树,既可以在组件内使用,也可以在组件外直接操作。

2. 订阅发布模式

这是 Zustand 的灵魂所在。当状态改变时,如何通知所有使用该状态的组件更新?答案是观察者模式:

  • 发布者(Store) :维护一个订阅者列表 listeners
  • 订阅者(组件) :通过 subscribe 注册监听函数
  • 状态更新时:遍历执行所有订阅者的回调

javascript

const listeners = new Set();

const subscribe = (listener) => {
  listeners.add(listener);
  return () => listeners.delete(listener);  // 返回取消订阅函数
}

const setState = (partial, replace = false) => {
  // 更新状态后通知所有订阅者
  listeners.forEach(listener => listener(state, previousState));
}

3. 选择器(Selector)与精准更新

这是 Zustand 性能优化的关键。通过 selector 函数,组件可以只订阅自己关心的状态切片:

javascript

const count = useCounterStore((state) => state.count);

state.text 改变时,只订阅 count 的组件不会重新渲染。实现原理是在订阅回调中比较 selector 返回值:

javascript

api.subscribe((state, previousState) => {
  const newObj = selector(state);
  const oldObj = selector(previousState);
  if (newObj !== oldObj) {
    forceRender(Math.random());  // 仅当关心的状态变化才强制更新
  }
})

完整实现详解

第一步:创建 Store

createStore 函数负责初始化状态并返回操作 API:

javascript

const createStore = (createState) => {
  let state;
  const listeners = new Set();
  
  const getState = () => state;
  
  const setState = (partial, replace = false) => {
    const nextState = typeof partial === 'function' 
      ? partial(state) 
      : partial;
    
    if (!Object.is(nextState, state)) {
      const previousState = state;
      if (!replace) {
        // 默认浅合并,保留未修改的字段
        state = Object.assign({}, state, nextState);
      } else {
        // replace 模式直接替换整个 state
        state = nextState;
      }
      listeners.forEach(listener => listener(state, previousState));
    }
  }
  
  const subscribe = (listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  }
  
  const api = { setState, getState, subscribe };
  state = createState(setState, getState, api);
  return api;
}

关键细节:

  • Object.is() 判断状态是否真正改变,避免无效更新
  • setState 支持传入函数,方便基于旧状态计算新值
  • subscribe 返回取消订阅函数,符合 React useEffect 清理机制

第二步:实现 Hook 适配层

useStore 将订阅机制桥接到 React 组件:

javascript

const useStore = (api, selector) => {
  const [, forceRender] = useState(0);
  
  useEffect(() => {
    const unsubscribe = api.subscribe((state, previousState) => {
      const newObj = selector(state);
      const oldObj = selector(previousState);
      if (newObj !== oldObj) {
        forceRender(Math.random());  // 强制重渲染
      }
    });
    return unsubscribe;  // 组件卸载时自动取消订阅
  }, []);
  
  return selector(api.getState());
}

这里用了一个巧妙的技巧:通过修改 state 触发组件更新,而不是直接操作 DOM。

第三步:暴露便捷的 create API

javascript

export const create = (createState) => {
  const api = createStore(createState);
  
  const useBoundStore = (selector) => useStore(api, selector);
  
  // 将 API 方法挂载到 Hook 上,支持在组件外调用
  Object.assign(useBoundStore, api);
  
  return useBoundStore;
}

Object.assign 这一步很关键,它让我们可以:

  • 组件内:通过 useCounterStore(selector) 使用
  • 组件外:通过 useCounterStore.setState() 直接操作状态

实战验证

基于上面的实现,我们创建一个计数器和文本编辑器共存的案例:

javascript

const useCounterStore = create((set) => ({
  count: 0,
  text: '初始文本',
  increment: () => set((state) => ({ count: state.count + 1 })),
  updateText: (newText) => set({ text: newText }),
}));

CountDisplay 组件只订阅 count:

javascript

const CountDisplay = () => {
  console.log('CountDisplay 渲染了');
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);
  
  return (
    <div>
      <p>当前计数: {count}</p>
      <button onClick={increment}>增加</button>
    </div>
  );
};

TextDisplay 组件只订阅 text:

javascript

const TextDisplay = () => {
  console.log('TextDisplay 渲染了');
  const text = useCounterStore((state) => state.text);
  const updateText = useCounterStore((state) => state.updateText);
  
  return (
    <div>
      <p>当前文本: {text}</p>
      <input value={text} onChange={(e) => updateText(e.target.value)} />
    </div>
  );
};

验证结果:

  • 修改文本时,控制台只打印 TextDisplay 渲染了
  • 点击计数按钮时,控制台只打印 CountDisplay 渲染了

这证明了精准更新机制生效!没有使用的组件不会重新渲染,性能得到保障。

进阶:API 直接调用

得益于 Object.assign,我们可以在任何地方直接操作状态:

javascript

const handleBatchUpdate = () => {
  useCounterStore.setState((prev) => ({ 
    count: prev.count + 10, 
    text: '批量修改完成!' 
  }));
  
  // 同步读取最新状态(不触发渲染)
  console.log(useCounterStore.getState());
};

这在处理异步逻辑非 React 环境(如 WebSocket 回调)时非常有用。

源码阅读的价值

通过手写 Zustand,我们收获了什么?

1. 设计模式的实战应用

  • 观察者模式:订阅发布机制
  • 闭包:状态隔离与持久化
  • 高阶函数:create 返回定制化 Hook

2. React 性能优化技巧

  • 通过 selector 避免无效渲染
  • Object.is() 精准判断状态变化
  • useEffect 清理函数自动取消订阅

3. 框架设计思路

为什么 Zustand 这么简单?因为它:

  • 没有引入中间件、异步处理等复杂概念
  • 直接利用 JS 闭包和 React Hooks,没有额外抽象
  • API 设计符合直觉,学习成本极低

总结

Zustand 的核心只有 200 行代码,却解决了 React 状态管理的本质问题。通过手写实现,我们深刻理解了:

  • 状态管理 = 存储 + 订阅 + 通知
  • 性能优化 = 精准订阅 + 浅比较
  • 好的 API = 隐藏复杂度 + 暴露灵活性

当你下次在项目中使用 Zustand 时,不妨打开 DevTools 观察组件的渲染次数,你会发现这个 1KB 的小库,背后有着极其精妙的设计哲学。

❌
❌