普通视图

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

Windows搭建MongoDB(2):运行数据库

作者 WangHappy
2025年11月5日 17:22
前言

大家好,我是WangHappy,一名主业前端,副业全栈的程序员,在这里我会分享关于前端入门全栈的常用技术。 如果我的文章能让您有所收获,欢迎一键三连(评论,点赞,关注)。

启动MongoDB数据库服务

在上一篇我们完成了数据库的基础安装,现在我们要在本地启动数据库服务。步骤如下:

1.配置环境变量

在windows系统下找到 我的电脑(或者此电脑),右键依次选择 属性-- 高级系统设置 -- 高级 -- 环境变量。 见下图:

QQ截图20251104161923.png

找到系统变量中的 Path 一项,点击下方编辑,见下图:

QQ截图20251104162650.png

找到mongoDB的安装目录,将bin文件的路径添加到Path系统变量中,例如:D:\software\mongodb\bin, 注意一定要用反斜杠。

QQ截图20251104163135.png

添加完成后点击 确定 关闭所有弹窗。

2.启动服务

使用 win + r 打开运行弹窗,输入cmd启动终端,在终端执行以下命令:

mongod --dbpath "D:/mongoddb/data/db"

--dbpath 后面的文件路径就是安装mongoDB时配置的 Data Directory的文件存储路径。

输入命令后按回车,通常终端会返回一长串运行日志

此时我们新打开一个终端,输入以下命令:

netstat -ano | findstr :27017

27017通常是MongoDB服务运行的默认端口,按回车执行,如果mongoDB正常启动,则会返回下图信息:

QQ截图20251105143530.png

127.0.0.1 表示MongoDB正在本地(localhost)监听

listening 代表MongoDB数据库正在等待连接

11132 代表端口进程ID,每个人的运行的程序任务不同,所以进程ID也会不一样

至此,我们已经成功启动了MongodDB数据库服务。

3.mongosh连接数据库

接下来我们需要使用mongosh连接数据库,方便我们对数据库进行操作

进入 mongosh 下载页面

点击进行安装, 建议将mongosh安装在MongoDB的bin目录下

QQ截图20251105154843.png

需要提醒的是,之前 mongod 启动的数据库服务不要关闭,确认服务正常启动。

重新打开一个终端,执行 mongosh命令,返回内容如下图即代表连接成功:

QQ截图20251105161608.png

Access control is not enabled for the database. Read and write access to data and configuration is unrestricted

其中这段警告是提示我们目前数据库没有做认证权限,任何人都可以操作当前数据库,关于认证权限的问题,会在下一篇中详细讲述。

到目前为止,我们已经在windows系统下成功的启动并连接了MongoDB 数据库。

4.使用可视化工具管理数据库

数据库管理工具种类繁多,例如MongoDB Compass、Robo 3T、Studio 3T等等,主要功能大同小异,开发者自行选择即可。

在这里以Navicat Premium Lite 为例演示。

首先 访问官网 下载Navicat安装包

完成安装后,双击启动软件,点击左上角 连接 选择 MongoDB 数据库,配置如下:

QQ截图20251105170814.png

连接名称可自定义填写,配置完成后点击确定,双击左侧侧边栏添加的数据库名称,完成数据库连接。

此时,可以看到MongoDB下初始化的三个数据库,admin/congig/local

QQ截图20251105171235.png

到这里,我们就可以使用 Navicat工具管理MongoDB数据库了。

下一篇,我们将了解如何对MongdoDB数据库设置认证权限。

深入浅出 requestAnimationFrame:让动画更流畅的利器

作者 Drift_Dream
2025年11月5日 17:17

前言

在Web开发中,动画效果已经成为提升用户体验的重要组成部分。然而,实现流畅的动画并不是一件简单的事情。今天我们来聊聊JavaScript中一个专门为动画而生的API——requestAnimationFrame,它将帮助你创建更加流畅、高效的动画效果。

什么是requestAnimationFrame?

简单来说,requestAnimationFrame是浏览器提供的一个专门用于动画的API,它告诉浏览器你希望执行一个动画,并请求浏览器在下次重绘之前调用指定的函数来更新动画。

通俗易懂的比喻

想象一下,你和浏览器之间有这样的对话:

  • 你(开发者):"浏览器大哥,我有个动画想让你帮忙执行。"
  • 浏览器:"好的,不过我现在有点忙,等我准备好绘制下一帧画面的时候再叫你,这样可以吗?"
  • 你:"没问题!"

这就是requestAnimationFrame的工作方式——它不会在固定的时间间隔执行,而是在浏览器准备绘制下一帧时执行,这样就能保证动画与浏览器的绘制节奏同步。

为什么需要requestAnimationFrame?

requestAnimationFrame出现之前,我们通常使用setTimeoutsetInterval来实现动画:

// 传统的setInterval方式
setInterval(function() {
    // 更新动画
}, 16); // 大约60fps

这种方式存在几个问题:

  1. 时机不准确:浏览器可能因为各种原因延迟执行
  2. 资源浪费:即使页面被隐藏或最小化,动画仍在后台运行
  3. 性能问题:可能导致过度绘制或丢帧

使用方法

基本语法

let animationId;

function animate() {
    // 动画逻辑代码
    animationId = requestAnimationFrame(animate);
}

// 启动动画
animationId = requestAnimationFrame(animate);

// 停止动画
cancelAnimationFrame(animationId);

简单示例:往复移动一个小方块

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>往复移动一个小方块</title>
</head>
<style>
    #box {
        width: 50px;
        height: 50px;
        background-color: red;
        position: absolute;
        left: 0;
        top: 100px;
    }
</style>

<body>
    <div id="box"></div>
    <button onclick="startAnimation()">开始动画</button>
    <button onclick="stopAnimation()">停止动画</button>
</body>

<script>
    const box = document.getElementById('box');

    let animationId;
    let position = 0;
    let driect = 'right';

    function animate() {
        if (driect === 'right') {
            position += 2;
            if (position >= 400) {
                driect = 'left';
            }
        } else {
            position -= 2;
            if (position <= 0) {
                driect = 'right';
            }
        }
        box.style.left = position + 'px';
        animationId = requestAnimationFrame(animate);
    }

    // 开始动画
    function startAnimation() {
        if (!animationId) {
            animationId = requestAnimationFrame(animate);
        }
    }

    // 停止动画
    function stopAnimation() {
        if (animationId) {
            cancelAnimationFrame(animationId);
            animationId = null;
        }
    }
</script>

</html>

小球弹跳下落动画

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>复杂动画示例-小球弹跳下落</title>
</head>
<style>
    * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
    }

    .demo-container {
        background-color: white;
        border-radius: 10px;
        padding: 30px;
        box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
        margin-bottom: 30px;
    }

    .ball {
        width: 50px;
        height: 50px;
        background: #e74c3c;
        border-radius: 50%;
        position: relative;
        margin: 20px auto;
    }

    .controls {
        display: flex;
        gap: 10px;
        margin: 20px 0;
        justify-content: center;
    }

    button {
        padding: 10px 20px;
        background: #3498db;
        border: none;
        color: white;
        border-radius: 5px;
        cursor: pointer;
    }
</style>


<body>
    <div class="demo-container">
        <h2>自由落体 + 弹跳</h2>
        <div class="ball" id="ball"></div>
        <div class="controls">
            <button id="startBtn">开始动画</button>
            <button id="resetBtn">重置</button>
        </div>
    </div>

</body>
<script>
    // 创建动画类

    class BallAnimationClass {
        constructor() {
            this.position = 0; // 当前位置
            this.velocity = 0; // 当前速度
            this.gravity = 0.5; // 重力加速度
            this.damping = 0.8; // 能力损失系数

            this.isRuning = false;

            this.animationId = null;

            this.ball = document.getElementById('ball');
        }
        start() {
            if (this.isRuning) return;
            this.isRuning = true;
            this.animate();
        }

        reset() {
            this.isRuning = false;
            if (this.animationId) {
                cancelAnimationFrame(this.animationId);
            }
            this.position = 0;
            this.velocity = 0;

            this.ball.style.transform = `translateY(${this.position}px)`;
        }
        animate() {
            if (!this.isRuning) return;

            // 应用重力
            this.velocity += this.gravity;

            // 改变位置
            this.position += this.velocity;

            // 碰撞检测(地面在300px位置)
            if (this.position > 300) {
                this.position = 300;

                // 按0.8的系数减少下落高度
                this.velocity = -this.velocity * this.damping;

                if (Math.abs(this.velocity) < 0.5) {
                    this.velocity = 0;
                    this.isRuning = false;
                }
            }

            this.ball.style.transform = `translateY(${this.position}px)`;

            this.animationId = requestAnimationFrame(() => this.animate());
        }
    }

    const ballAnimation = new BallAnimationClass();

    document.getElementById('startBtn').addEventListener('click', () => {
        ballAnimation.start();
    })

    document.getElementById('resetBtn').addEventListener('click', () => {
        ballAnimation.reset();
    })

</script>

</html>

数字增加动画

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>数字计数动画</title>
</head>
<style>
    * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
    }

    body {
        max-width: 600px;
        margin: 50px auto;
        padding: 20px;
        text-align: center;
        background: #f5f7fa;
    }

    h1 {
        color: #2c3e50;
        margin-bottom: 30px;
    }

    .counter {
        font-size: 4rem;
        font-weight: 600;
        color: #3498db;
        margin: 30px 0;
        transition: color 0.3s;
    }

    .controls {
        margin: 20px 0;
    }

    button {
        padding: 10px 20px;
        margin: 0 10px;
        font-size: 1rem;
        background: #3498db;
        color: white;
        border: none;
        border-radius: 5px;
        cursor: pointer;
        transition: background 0.3s;
    }

    button:hover {
        background: #2980b9;
    }

    button:disabled {
        background: #bdc3c7;
        cursor: not-allowed;
    }

    .status {
        margin-top: 20px;
        color: #7f8c8d;
    }
</style>


<body>
    <h1>数字计数动画示例</h1>

    <div class="counter" id="counter">0</div>

    <div class="controls">
        <button id="startBtn">开始计数</button>
        <button id="pauseBtn" disabled>暂停</button>
        <button id="resetBtn">重置</button>
    </div>

    <div class="status" id="status">状态:等待开始</div>
</body>
<script>
    // 创建动画类
    class NumberCounter {
        constructor(element, targetValue = 1000, duration = 2000) {
            this.element = element;
            this.targetValue = targetValue;
            this.duration = duration;

            this.currentValue = 0;
            this.startTime = null;
            this.animationId = null;

            this.isRuning = false;
        }
        start() {
            if (this.isRuning) return;

            this.isRuning = true;
            this.startTime = performance.now();
            this.animate();

            document.getElementById('status').textContent = "状态:计数中...";
            document.getElementById('startBtn').disabled = true;
            document.getElementById('pauseBtn').disabled = false;
        }

        pause() {
            this.isRuning = false;
            if (this.animationId) {
                cancelAnimationFrame(this.animationId);
            }
            document.getElementById('status').textContent = "状态:已暂停";
            document.getElementById('startBtn').disabled = false;
            document.getElementById('pauseBtn').disabled = true;
        }

        reset() {
            this.pause();
            this.currentValue = 0;
            this.updateDisplay();
            document.getElementById('status').textContent = "状态:已重置";
        }
        animate() {
            if (!this.isRuning) return;
            const currentTime = performance.now();

            // 计算从点击【开始计数】过去了多久
            const elapsed = currentTime - this.startTime;

            // 计算在duration中占据的比例
            const progress = Math.min(elapsed / this.duration, 1);

            // 缓动函数(y = 1- (1-x)^3):开始较慢,中间加速,结束时又变慢
            const easedProgress = 1 - Math.pow((1 - progress), 3);

            // 计算当前值
            this.currentValue = Math.round(this.targetValue * easedProgress);

            this.updateDisplay();

            if (progress < 1) {
                this.animationId = requestAnimationFrame(() => this.animate());
            } else {
                this.isRuning = false;
                document.getElementById('status').textContent = "状态:完成!";
                document.getElementById('startBtn').disabled = false;
                document.getElementById('pauseBtn').disabled = true;
            }
        }

        updateDisplay() {
            this.element.textContent = this.currentValue.toLocaleString();

            // 添加简单的颜色变化
            const percent = this.currentValue / this.targetValue;
            const hue = 210 - percent * 60;
            this.element.style.color = `hsl(${hue}, 70%, 50%)`;
        }
    }

    const counter = new NumberCounter(
        document.getElementById('counter'), 1234567, 3000
    );

    // 开始动画
    document.getElementById('startBtn').addEventListener('click', () => {
        counter.start();
    })

    // 动画暂停
    document.getElementById('pauseBtn').addEventListener('click', () => {
        counter.pause();
    })

    // 动画重置
    document.getElementById('resetBtn').addEventListener('click', () => {
        counter.reset();
    })

</script>

</html>

什么是二义性,实际项目中又有哪些应用

2025年11月5日 17:08

箭头函数与普通函数的二义性

“二义性”,其实是普通函数里一个很典型的问题 —— 正因为普通函数的 this 是动态绑定的,导致在不同调用场景下,this 指向可能 “模糊不清”,出现 “同一个函数,调用方式不同,this 指向完全不一样” 的歧义;而箭头函数恰恰解决了这个 “二义性” 问题。

简单讲:普通函数的 this 有 “二义性”(指向不明确,依赖调用方式),箭头函数的 this 无 “二义性”(指向固定,只看定义时的上下文) ,这是两者在实际开发中最容易踩坑的核心差异。

1. 先看普通函数的 “二义性”:同一个函数,this 指向说变就变

普通函数的 this 没有固定归属,完全由 “怎么调用” 决定,哪怕是同一个函数,调用方式改了,this 指向立刻变,很容易出现预期外的结果(也就是 “二义性” 带来的坑)。

举个最常见的例子:

// 定义一个普通函数,想打印当前对象的 name
function logName() {
  console.log("当前 name:", this.name);
}

// 场景1:作为对象方法调用 → this 指向对象(符合预期)
const user1 = { name: "张三", logName: logName };
user1.logName(); // 输出:当前 name:张三(this 指向 user1)

// 场景2:把函数抽出来单独调用 → this 指向全局(不符合预期,出现二义性)
const logFn = user1.logName;
logFn(); // 浏览器中输出:当前 name:undefined(this 指向 window,window 没有 name)

// 场景3:用 setTimeout 调用 → this 还是指向全局(又变了)
setTimeout(user1.logName, 100); // 同样输出:当前 name:undefined

这里的 “二义性” 很明显:明明是同一个 logName 函数,只是调用方式从 “对象。方法” 改成 “单独调用”“定时器调用”,this 就从 “user1 对象” 变成了 “全局对象”,导致结果完全不符合预期 —— 这就是普通函数 this 二义性带来的问题。

2. 再看箭头函数:彻底消除 “二义性”,this 指向一锤定音

箭头函数的 this 只在 “定义的时候” 就绑定好了(继承外层代码块的 this),不管后续怎么调用,this 都不会变,完全没有歧义。

把上面的例子改成箭头函数,再看效果:

// 定义一个箭头函数(注意:这里要放在有明确 this 的环境里,比如普通函数内部)
const user2 = {
  name: "李四",
  // 箭头函数作为对象方法(虽然不推荐,但能体现 this 固定性)
  logName: () => {
    console.log("当前 name:", this.name);
  }
};

// 场景1:作为对象方法调用 → this 继承外层全局的 this(window)
user2.logName(); // 输出:当前 name:undefined(因为 window 没有 name)

// 场景2:抽出来单独调用 → this 还是全局的 this(没变)
const logFn2 = user2.logName;
logFn2(); // 依然输出:当前 name:undefined

// 场景3:定时器调用 → this 还是没变
setTimeout(user2.logName, 100); // 还是输出:当前 name:undefined

虽然这个例子里箭头函数的结果 “不对”(因为箭头函数不适合当对象方法),但能明确看到:不管怎么调用,箭头函数的 this 都没变化—— 它的 this 在定义时就绑定了外层的全局 this,后续调用方式再变,this 也不会改,完全没有普通函数的 “二义性”。

再看一个箭头函数的正确用法(解决二义性):

const user3 = {
  name: "王五",
  // 普通函数作为外层,有明确的 this(指向 user3)
  fetchData() {
    // 箭头函数定义在 fetchData 内部,this 继承 fetchData 的 this(即 user3)
    setTimeout(() => {
      console.log("用户 name:", this.name); // 这里的 this 绝对是 user3
    }, 100);
  }
};

user3.fetchData(); // 输出:用户 name:王五(没有任何二义性,结果完全可控)

如果这里的 setTimeout 回调用普通函数,this 会指向全局,导致输出 undefined;而箭头函数因为消除了二义性,this 固定指向 user3,结果完全符合预期。

3. 总结:“二义性” 的本质是 “this 绑定规则的差异”

  • 普通函数this 绑定是 “动态的”,依赖调用方式,所以有 “二义性”—— 同一个函数,调用场景变了,this 指向就变,容易踩坑。
  • 箭头函数this 绑定是 “静态的”,只看定义时的上下文,所以无 “二义性”——this 一旦绑定,后续不管怎么调用,都不会变,结果可控。

这也是为什么在需要稳定 this 的场景(比如异步回调、数组遍历),大家更愿意用箭头函数 —— 本质就是为了避免普通函数 this 二义性带来的意外。

二义性的广泛应用

在前端开发中,“二义性”(指语法或逻辑上存在多种可能的解释)并非仅适用于普通函数和箭头函数,而是广泛存在于 JavaScript 等前端语言的语法规则中。以下从多个场景详细讲解并举例,说明二义性的多样性:

一、函数相关的二义性(包含普通函数和箭头函数)

这是最常见的场景,但本质是函数声明 / 表达式的语法规则导致的歧义。

1. 普通函数:函数声明与表达式的歧义

JavaScript 中,function关键字既可以定义函数声明(有函数名,会提升),也可以定义函数表达式(无函数名或被包裹,不提升)。当上下文不明确时,解析器可能误判:

// 场景1:条件语句中的函数
if (true) {
  function foo() { return 1; } // 函数声明?
} else {
  function foo() { return 2; } // 函数声明?
}
foo(); // 结果在不同引擎中可能不同(早期规范未明确,存在歧义)
  • 问题:早期 ECMAScript 规范未明确 “条件语句中的 function 是声明还是表达式”,不同浏览器解析不同(如 Chrome 会提升后一个 foo,返回 2;部分旧浏览器可能返回 1)。

  • 消除歧义:用函数表达式明确意图:

    let foo;
    if (true) {
      foo = function() { return 1; }; // 明确为表达式
    } else {
      foo = function() { return 2; };
    }
    
2. 箭头函数:返回对象字面量的歧义

箭头函数的 “简洁体”(无{})默认返回表达式结果,但如果直接返回对象字面量,会被误解析为函数体的代码块:

// 错误示例:歧义
const getObj = () => { a: 1, b: 2 }; 
getObj(); // 返回undefined(解析器将{...}视为代码块,a:1是标签语句)

// 正确示例:用()包裹消除歧义
const getObj = () => ({ a: 1, b: 2 }); 
getObj(); // {a:1, b:2}(明确为对象字面量)
  • 原因:{}在 JavaScript 中既可以是对象字面量,也可以是代码块(如函数体、条件块),箭头函数简洁体中需用()强制解析为对象。

二、对象与解构的二义性

对象字面量和代码块都用{}表示,导致解析器可能混淆。

1. 解构赋值的歧义

单独的{ a } = obj会被误判为代码块(而非解构赋值):

// 错误示例:歧义
{a, b} = { a: 1, b: 2 }; // 语法错误(解析器认为{...}是代码块)

// 正确示例:用()包裹消除歧义
({a, b} = { a: 1, b: 2 }); // 正确解构,a=1, b=2
  • 原因:JavaScript 中,语句开头的{默认被解析为代码块(如{ console.log(1) }是独立代码块),而非对象或解构模式。
2. 对象字面量与标签语句的歧义

{}中的key: value可能被解析为标签语句(而非对象属性):

// 歧义场景
const obj = {
  foo: 1,
  bar: { baz: 2 } // 这是对象属性(正确)
};

// 但单独写时:
{ foo: 1, bar: 2 }; // 解析为代码块,其中foo:1和bar:2是标签语句(无实际意义)
  • 区别:在对象字面量上下文(如赋值右侧、函数参数)中,{...}是对象;在独立语句中,{...}是代码块,内部key: value被视为标签。

三、运算符的二义性

部分运算符有多重含义,需结合上下文判断。

1. 斜杠/:除法 vs 正则表达式

/既可以是除法运算符,也可以是正则表达式的开头:

// 场景1:明确的除法
const result = 10 / 2; // 5(除法)

// 场景2:明确的正则
const reg = /abc/g; // 正则表达式

// 场景3:歧义(需解析器判断)
const a = 10;
const b = /abc/g;
const c = a / b; // 解析为除法(10除以正则对象,结果为NaN)
  • 解析规则:当/左侧是表达式(如变量、数字)时,优先解析为除法;当/作为语句开头或赋值左侧时,解析为正则。
2. 加号+:加法 vs 字符串拼接 vs 正号

+可用于数字加法、字符串拼接、强制类型转换(正号):

// 歧义场景:开发者预期可能与实际结果不符
const a = 1 + 2 + '3'; // "33"(先1+2=3,再3+'3'=字符串拼接)
const b = '1' + 2 + 3; // "123"(从左到右字符串拼接)
const c = +'123'; // 123(正号强制转换为数字)
  • 逻辑歧义:虽语法无歧义,但弱类型导致的隐式转换可能让开发者误解结果(如新手可能认为'1' + 2是 3)。

四、其他场景的二义性

1. 模板字符串与普通字符串的逻辑歧义

模板字符串(`)虽语法明确,但复杂表达式中可能与字符串拼接产生逻辑混淆:

// 逻辑歧义(非语法)
const name = 'Alice';
const str1 = 'Hello ' + name + ', age ' + 20; // 普通拼接
const str2 = `Hello ${name}, age ${20}`; // 模板字符串
// 两者结果相同,但新手可能混淆模板字符串的变量插入规则
2. typeof与括号的歧义

typeof是运算符,但其优先级可能导致解析歧义:

typeof (1 + 2); // "number"(正确,先算1+2typeof 1 + 2; // "number2"(先算typeof 1 = "number",再拼接2
  • 原因:typeof优先级高于+(当+作为拼接时),导致运算顺序与预期不符。

总结

二义性的本质是 “语法规则允许多种解释”,前端开发中不仅限于函数(普通函数、箭头函数),还包括:

  • 对象与代码块的{}歧义;
  • 运算符(/+)的多义性;
  • 解构赋值的解析冲突;
  • 弱类型转换导致的逻辑歧义等。

LeetCode 142:环形链表 II —— 快慢指针定位环的起点(JavaScript)

作者 烟袅
2025年11月5日 17:00

🌟 题目描述

给定一个链表的头节点 head,返回链表开始入环的第一个节点。如果链表无环,则返回 null

💡 注意:不允许修改链表结构。

示例 1:

输入: head = [3,2,0,-4], pos = 1
输出: 返回索引为 1 的节点(值为 2)
解释: 链表有一个环,尾部连接到第二个节点。

示例 2:

输入: head = [1,2], pos = 0
输出: 返回索引为 0 的节点(值为 1)

示例 3:

输入: head = [1], pos = -1
输出: null

🔍 问题分析

这是一道经典的 环检测 + 环入口定位 问题。关键点:

  • 如何判断链表是否有环?
  • 如果有环,如何找到环的起始节点

直接遍历会陷入死循环,所以需要更聪明的方法——快慢指针(Floyd 循环查找算法)


💡 核心思路:Floyd 判圈算法

✅ 第一步:用快慢指针判断是否存在环

  • slow 指针每次走一步;
  • fast 指针每次走两步;
  • 若存在环,两者一定会相遇(因为快指针会追上慢指针);
  • 若无环,fast 会先到达 null

✅ 第二步:找到环的入口节点

当快慢指针相遇后,从头节点和相遇点同时出发,每次走一步,再次相遇的点就是环的入口

🧠 数学证明简述

设:

  • 链表头部到环入口距离为 a
  • 环的长度为 b
  • 相遇点距离入口为 c

则:

  • 慢指针走了:a + c
  • 快指针走了:a + c + k*b(k 是整数)
  • 因为快指针速度是慢指针的 2 倍:2(a + c) = a + c + k*b
  • 推出:a = k*b - c
  • 即:从头节点走 a 步,和从相遇点走 a 步,会在入口处相遇!

✅ JavaScript 实现(完整代码)

/**
 * Definition for singly-linked list.
 * function ListNode(val, next) {
 *     this.val = (val === undefined ? 0 : val)
 *     this.next = (next === undefined ? null : next)
 * }
 */

/**
 * @param {ListNode} head
 * @return {ListNode}
 */
var detectCycle = function(head) {
    let slow = head;
    let fast = head;

    // 第一步:快慢指针找相遇点
    while (fast && fast.next) {
        slow = slow.next;
        fast = fast.next.next;

        if (slow === fast) {
            // 找到环,进入第二步:找入口
            let x1 = head;  // 从头开始
            let x2 = fast;  // 从相遇点开始

            while (x1 !== x2) {
                x1 = x1.next;
                x2 = x2.next;
            }

            return x1; // 相遇点即为环的入口
        }
    }

    // 无环,返回 null
    return null;
};

🧠 图解演示(以 [3→2→0→-4→2] 为例)

链表:3  2  0  -4   2(环)
                   
      |             |
      -----------------

1. 快慢指针移动:
   slow: 3  2  0  -4  2
   fast: 3  0  2  -4  2  0  2  ...

2. 在节点 2 相遇(快慢指针相等)

3. 重置 x1 = head(3), x2 = 相遇点(2)
   x1: 3  2
   x2: 2  0  -4  2  ...
   在节点 2 再次相遇  返回该节点

📊 复杂度分析

项目 复杂度
时间复杂度 O(n) —— 最多遍历两次链表
空间复杂度 O(1) —— 只使用常数额外空间

优势:无需哈希表,不修改链表,适用于大链表场景。


🛠️ 面试常见问题

❓ “为什么相遇后,从头和相遇点同时走能相遇于入口?”

回答:这是由数学关系 a = k*b - c 决定的,两个指针走的路径总长相同,最终会在入口处相遇。

❓ “能否用哈希表解决?”

可以,但空间复杂度为 O(n),而快慢指针法是 O(1),更优。

❓ “如果链表很长,快指针会不会越界?”

不会,因为 while(fast && fast.next) 已经做了安全判断。

✅ 总结

其实这题就先设置两个指针慢的一步一步走而快的则两步两步走如果存在环则必定相遇,如果相遇就可以通过这幅图推出a=c+(n−1)(b+c)那就差不多能写出来了

image.png

Webpack打包提速95%实战:从20秒到1.5秒的优化技巧

作者 海云前端1
2025年11月5日 16:56

Webpack打包提速95%实战:从20秒到1.5秒的优化技巧

前端项目迭代到一定阶段,Webpack打包从秒级拖成分钟级的体验,很多开发者都深有体会。本地改行代码要等半天,CI构建频繁超时,这些问题曾严重影响团队效率。经过一轮针对性优化,我把打包时间从20秒压缩到1.5秒以内。这篇整理了最落地的核心方法,新手也能直接复用。

一、先搞懂优化的核心逻辑

Webpack打包变慢,根源无非两点:编译效率低,或者需要处理的任务过多。所有优化手段都围绕这两点展开,先明确方向再动手,避免盲目尝试。

核心思路有三个:提升编译效率,减少无效任务,用工具定位耗时点再精准优化。

二、六个立竿见影的优化方法

1. 多线程编译:给耗时任务分身

Webpack默认单线程运行,Babel转译、TS编译这类耗时操作会卡住主线程。用thread-loader给这些任务分配独立进程,能显著释放主线程资源。

注意不是所有Loader都适用。小项目或处理少量模块的Loader如css-loader,用了反而得不偿失,进程启动和通信会额外消耗资源。

module.exports = {
  module: {
    rules: [
      {
        test: /.js$/,
        use: [
          {
            loader: 'thread-loader',
            options: {
              workers: Math.max(require('os').cpus().length - 1, 2),
              workerParallelJobs: 50
            }
          },
          'babel-loader'
        ],
        exclude: /node_modules/
      }
    ]
  }
};

2. 缓存复用:让Webpack记住过往工作

每次打包都重新处理所有文件是极大浪费。缓存能让Webpack跳过未修改的文件,直接复用之前的编译结果。Webpack5提供两种缓存方式,根据场景选择。

缓存类型 存储位置 适用场景 默认状态
内存缓存 内存 本地开发热更新 启用
持久化缓存 磁盘 CI构建、多次冷启动 需手动配置
module.exports = {
  cache: {
    type: 'filesystem',
    cacheDirectory: './.cache/webpack',
    buildDependencies: {
      config: [__filename],
      plugins: ['./babel.config.js']
    }
  }
};

配合babel-loader单独缓存,按单个JS模块粒度缓存,效果会翻倍。

{
  loader: 'babel-loader',
  options: {
    presets: ['@babel/preset-env'],
    cacheDirectory: true
  }
}

3. 代码分割:拆分过大Chunk

把所有代码打包成一个文件,改一行就会导致整个文件重新编译,缓存失效。用splitChunks拆分代码,能最大化保留缓存效果。

核心是把第三方库、公共组件、入口文件分别拆成独立Chunk。

module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendors: {
          test: /[\/]node_modules[\/]/,
          priority: -10,
          name: 'vendors'
        },
        common: {
          name: 'common',
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true
        }
      }
    }
  }
};

4. Tree Shaking:剔除无用代码

项目中难免有未使用的代码,比如导入后未调用的函数。Tree Shaking能在打包时剔除这些代码,既减少工作量又缩小体积。

注意只对ES模块生效,CommonJS模块不支持。生产模式下配置后即可生效。

module.exports = {
  mode: 'production',
  optimization: {
    usedExports: true
  }
};

在package.json中添加配置,告诉Webpack哪些文件无副作用可安全删除。

{
  "sideEffects": false
}

5. 预打包第三方库:减少重复工作

react、antd等第三方库不会频繁变动,每次打包都重新处理很浪费。用DLL提前打包这些库,主项目打包时直接复用即可。

// webpack.dll.config.js
module.exports = {
  mode: 'production',
  entry: { vendor: ['react', 'react-dom'] },
  output: {
    path: './dll',
    filename: '[name]_[hash].dll.js',
    library: '[name]_[hash]'
  },
  plugins: [
    new CleanWebpackPlugin(),
    new webpack.DllPlugin({
      path: './dll/[name]-manifest.json',
      name: '[name]_[hash]'
    })
  ]
};

6. 先诊断再优化:避免盲目操作

优化前一定要用工具定位耗时点。speed-measure-webpack-plugin能量化每个Loader和Plugin的耗时,精准找到瓶颈。

const SpeedMeasurePlugin = require(speed-measure-webpack-plugin);
const smp = new SpeedMeasurePlugin();

module.exports = smp.wrap({
  mode: 'development',
  entry: './src/index.js'
});

三、实战效果:从20秒到1.5秒的蜕变

我接手的Taro React项目初始打包20.92秒,用speed-measure-webpack-plugin分析后,发现babel-loader耗时12.68秒,是主要瓶颈。

先给babel-loader加thread-loader和缓存,耗时直接降到3.2秒。再配置代码分割和DLL预打包,最终稳定在1.5秒以内,优化效果立竿见影。

四、最后提醒

优化没有万能方案,小项目不用堆砌所有手段。先诊断找到核心瓶颈,再针对性落地,才能用最少的成本达到最好的效果。

海云前端丨前端开发丨简历面试辅导丨求职陪跑

前端新人最怕的“居中问题”,八种CSS实现居中的方法一次搞懂!

2025年11月5日 16:45

页面上的登录弹窗、加载动画、提示框……几乎每一个项目都绕不开“居中”这个看似微不足道的需求。

但真正动手时,很多人却发现:一个简单的居中,竟然有这么多写法!position、flexgridtransform……每种方法都能实现居中,却又各有陷阱。今天,我们就一次性搞懂 8种最常用的CSS居中布局方案,彻底告别“对齐难题”!

理解居中的核心概念

要真正掌握CSS居中,必须先搞清两个关键问题:

1. 参照物是谁?

居中永远是“相对”而言的。你是希望元素在整个浏览器窗口中居中,还是只在父级容器内居中?绝大多数情况下,我们讨论的居中,其实是相对于父容器的居中。

2. 元素的特性是什么?

不同类型的元素,居中的方法并不相同。比如,块级元素(如divp)和内联元素(如spana)的对齐方式就完全不同。

此外,元素是否有固定宽高,也会影响具体的实现方式。

只有先理解这两个核心概念,才能在面对各种居中需求时,快速找到最合适的布局方法。

下面我们基于这个html结构来演示各种居中方案:

复制

<div class="parent">
    <div class="child">我要居中显示</div>
</div>1.2.3.

目标很明确:让child元素在parent容器中水平和垂直都居中。

方法1:Flex弹性布局(推荐首选)

复制

.parent {
display: flex;
justify-content: center;
align-items: center;
}1.2.3.4.5.

这是目前最推荐的方案。代码简洁明了,浏览器支持度很好。即使子元素的宽高不确定,也能完美居中。

如果你的项目只需要考虑现代浏览器,应该优先选择这个方法。

方法2:Grid网格布局(同样推荐)

复制

.parent {
display: grid;
place-items: center;
}1.2.3.4.

Grid布局提供了更简洁的写法,一行代码就能实现居中效果。Grid本身功能强大,在需要复杂布局时尤其有用。

需要注意的是,Grid在旧版浏览器中的支持度略低于Flexbox。

方法3:绝对定位 + 位移变换(兼容性优秀)

复制

.parent {
position: relative;
}

.child {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}1.2.3.4.5.6.7.8.9.10.

这个方法兼容性很好,支持到IE9以上。最大的优点是不需要知道子元素的宽高。

原理是这样的:先把元素的左上角移动到容器中心点,然后通过transform将元素向左上方移动自身尺寸的一半,这样元素的中心就与容器中心重合了。

方法4:绝对定位 + 自动边距(固定宽高适用)

复制

.parent {
position: relative;
}

.child {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
width: 200px;
height: 100px;
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.

这个方法利用了margin:auto在绝对定位下的特性。需要明确设置子元素的宽度和高度,浏览器会自动计算边距实现居中。

方法5:表格单元格方式(传统但有效)

复制

.parent {
display: table-cell;
width: 100vw;
height: 100vh;
vertical-align: middle;
text-align: center;
}

.child {
display: inline-block;
}1.2.3.4.5.6.7.8.9.10.11.

这个方法模拟了表格单元格的行为。vertical-align:middle实现垂直居中,text-align:center实现水平居中。

需要注意的是,父元素需要设置明确的宽高,子元素要设置为inline-block。

方法6:内联块 + 伪元素(特殊场景使用)

复制

.parent {
text-align: center;
}

.parent::before {
content: '';
display: inline-block;
height: 100%;
vertical-align: middle;
}

.child {
display: inline-block;
vertical-align: middle;
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.

创建一个全高的伪元素作为参照物,通过vertical-align实现垂直对齐。这个方法代码相对复杂,在不能用Flexbox或Grid时可以考虑。

方法7:JavaScript动态计算(最后的选择)

复制

functioncenterElement(child, parent) {
const parentWidth = parent.offsetWidth;
const parentHeight = parent.offsetHeight;
const childWidth = child.offsetWidth;
const childHeight = child.offsetHeight;

const top = (parentHeight - childHeight) / 2;
const left = (parentWidth - childWidth) / 2;

    child.style.position = 'absolute';
    child.style.top = top + 'px';
    child.style.left = left + 'px';
}1.2.3.4.5.6.7.8.9.10.11.12.13.

只有在所有css方案都无法满足特殊需求时,才考虑使用JavaScript。因为这会增加性能开销,还需要在窗口大小变化时重新计算。

方法8:视口居中(弹窗场景)

复制

.modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80%;
max-width: 600px;
}1.2.3.4.5.6.7.8.

对于需要在浏览器窗口中央显示的弹窗,使用position:fixed相对于视口定位,再结合transform实现居中。

如何选择合适的方法

现代项目毫无疑问应该首选Flexbox或Grid布局。它们代码简洁,功能强大,能够应对绝大多数场景。

如果需要支持旧版浏览器,绝对定位加transform是不错的选择。这个方法兼容性好,而且不要求元素有固定宽高。

当元素宽高固定时,绝对定位加margin:auto的方案也很实用,代码直观易懂。

表格单元格的方式虽然稍显过时,但在某些特殊情况下仍然有用。

JavaScript方案应该作为最后的选择,毕竟CSS解决方案性能更好,维护更简单。

实际应用建议

在日常开发中,建议建立自己的工具类库。比如:

复制

.center-flex {
display: flex;
justify-content: center;
align-items: center;
}

.center-transform {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}

.center-grid {
display: grid;
place-items: center;
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.

这样在需要居中时,直接添加对应的类名就可以了,大大提高开发效率。

总结

从最早的表格布局到如今的 FlexboxGrid,CSS居中方案的演变,其实正是前端布局思维的一次次升级。

掌握这些方法,不仅能让你从容应对各种实际项目中的对齐需求,更能让你真正理解 CSS 的布局模型与渲染逻辑

在实践中,多尝试、多组合、多复盘。当你熟悉每种方案的原理与适用场景后,居中将不再是难题,而是你驾驭网页布局的基本功。

行业拓展

分享一个面向研发人群使用的前后端分离的低代码软件——JNPF

基于 Java Boot/.Net Core双引擎,它适配国产化,支持主流数据库和操作系统,提供五十几种高频预制组件,内置了常用的后台管理系统使用场景和实用模版,通过简单的拖拉拽操作,开发者能够高效完成软件开发,提高开发效率,减少代码编写工作。

JNPF基于SpringBoot+Vue.js,提供了一个适合所有水平用户的低代码学习平台,无论是有经验的开发者还是编程新手,都可以在这里找到适合自己的学习路径。

此外,JNPF支持全源码交付,完全支持根据公司、项目需求、业务需求进行二次改造开发或内网部署,具备多角色门户、登录认证、组织管理、角色授权、表单设计、流程设计、页面配置、报表设计、门户配置、代码生成工具等开箱即用的在线服务。

💥不说废话,带你上手使用 qiankun 微前端并深入理解原理!

2025年11月5日 16:36

一. 前置

本文假设你已经:

  1. 熟悉至少一种现代前端框架(Vue 或 React)。
  2. 理解单页应用 的基本概念和开发模式。
  3. Webpack 的基本概念和基本原理。

前三四五章帮助快速理解微前端并且学会如何接入
后面章节为深入原理解析

二. 本文能给你带来什么

读完本文,你将获得:

  1. 理解微前端架构的价值
  2. 亲手搭建一个包含 Vue 主应用和 React/Vue 子应用的完整 qiankun 项目。
  3. 深入 qiankunJS 沙箱样式隔离底层原理 并能够解决实际简单问题。
  4. 掌握 应用 之间 数据传递状态同步 的各种方式和优缺点。

三. 简单说微前端要干什么

它主要解决了三大难题:

  1. 技术栈异构

整个项目被锁定在 单一技术栈 上, 微前端使得每个微应用都可以使用任何技术栈,由主应用分别进行加载。

  1. 降低维护成本,拆分巨石应用

大型应用所有代码耦合在一起,编译部署慢,新人上手成本高。微前端可以按业务拆分应用。

  1. 减少项目团队协作问题,团队独立开发与部署

多团队在 同个代码库协作代码冲突依赖问题 频发,发布流程又慢又危险。 微前端使得每个团队拥有自己微应用的代码库,可以独立决策、开发、部署。

四. qiankun 怎么运行的

一个简易流程图:

graph TD
    A[用户访问主应用] --> B[主应用启动]
    B --> C[注册子应用信息表]
    C --> D{监听 URL 变化}
    
    D --> E[用户点击导航/URL改变]
    E --> F[匹配子应用规则]
    F --> G{找到匹配的子应用?}
    
    G -->|否| D
    G -->|是| H[HTTP请求子应用入口地址]
    
    H --> I[获取子应用HTML文本]
    I --> J[解析HTML提取资源清单]
    J --> K[提取JS文件列表]
    J --> L[提取CSS文件列表]
    
    K --> M[创建JS沙箱环境]
    M --> N[在沙箱中执行JS代码]
    
    L --> O[动态插入CSS到head标签]
    O --> P[CSS在全局生效但会被管理]
    
    N --> Q[调用子应用mount函数]
    P --> Q
    Q --> R[子应用渲染到指定容器]
    
    R --> S[子应用运行中]
    S --> T{URL再次变化?}
    
    T -->|是| U[调用当前子应用unmount]
    U --> V[从head中移除子应用CSS]
    V --> W[清理JS沙箱]
    W --> F
    
    T -->|否| S
    
    style A fill:#e1f5fe
    style R fill:#c8e6c9
    style M fill:#fff3e0
    style O fill:#fce4ec
    style V fill:#ffebee

qiankun 的工作流程可以分解为以下几个核心步骤:

1. 主应用注册子应用信息

  • 在主应用启动时,你需要告诉 qiankun 你有哪些子应用,并提供几个关键信息:
  1. name: 给子应用起一个唯一的英文名,作为它的身份标识。
  2. entry: 子应用独立运行时的访问地址,比如 http://localhost:8081qiankun 会通过这个地址去获取子应用的资源。
  3. container: 主应用里一个空的 <div> 元素的 ID,告诉 qiankun 将来把这个子应用渲染到哪里。
  4. activeRule: 一个路径规则,比如 /app1。当浏览器 URL 匹配到这个规则时,qiankun 就知道该加载这个子应用了。

2. 监听路由变化动态加载子应用

  • 主应用启动 qiankun 后,它会开始监听浏览器地址栏的 URL 变化,并拿新的 URL 去和第一步注册的所有子应用的 activeRule 进行匹配。一旦匹配成功,就触发下一步。

3. 从 entry 获取子应用内容

  • qiankun 匹配到要加载的子应用后,它会向该子应用的 entry 地址发送一个 HTTP 请求并拿到返回的 index.html 文件。
  • 之后,qiankun 调用解析器一样,分析这个文本,从中找出所有 <script> 标签的 src 地址和 <link rel="stylesheet"><style> 标签里的 CSS 内容。
  • 至此,qiankun 就拿到了一份关于这个子应用需要加载的所有 JS 和 CSS 资源的清单。

4. 执行子应用JS

  1. qiankun 为子应用创建一个“代理”的 window 对象。

  2. 当子应用的 JS 代码尝试写入全局变量时(如 window.myVar = 1),Proxy 会拦截这个操作,并将这个变量存储在一个只属于当前子应用的内部对象里,而不会污染真实的 window 对象。

  3. 当子应用的 JS 代码尝试读取全局变量时(如 console.log(window.myVar)),Proxy 会先从那个内部对象里查找。如果找不到,它会安全地“穿透”到真实的 window 对象上去读取(比如读取 window.location)。

5. 插入子应用 CSS

  1. qiankun 默认以 <style> 标签的形式,将 css 直接插入到主文档的 <head> 之中
  2. 样式都是全局的,子应用之间、子应用与主应用之间会相互污染,怎么解决可以看下面精讲

6. 子应用生命周期

  • qiankun 要求每个子应用都必须导出几个特定的函数(bootstrap, mount, unmount)。
  • 在完成上述所有准备工作后,qiankun 会调用子应用的 mount 函数,并把前面指定的 container 容器作为参数传给它。子应用在 mount 函数里执行自己的渲染逻辑(比如 ReactDOM.render()createApp().mount()),把自己渲染到那个容器里。
  • 当 URL 变化,需要切换到另一个子应用时,qiankun 会先调用当前子应用的 unmount 函数,让它执行清理工作(比如销毁实例、清除定时器),然后再去加载新的子应用。

五. Vue 3 + React + qiankun 搭建流程

这一章带你运用上面的原理从头搭建一个超简单 qiankun 工程,并梳理运行流程

看效果:

全局效果.gif

前置

准备一个 webpack 作为打包器的主应用以及两个子应用(qiankunwebpack 支持更好更方便),并且要求都是单页应用。

第一步:主应用 Vue3 (main-app) 配置

  1. 安装 qiankun:
pnpm add qiankun

2. 创建 qiankun 配置文件(对应注册子应用): main-app/src/qiankun-setup.js

import { registerMicroApps, start } from 'qiankun';

const apps = [
  {
    name: 'sub-app-vue',
    entry: '//localhost:8081', // 你的 Vue 应用端口
    container: '#subapp-container',
    activeRule: '/vue-app',
  },
  {
    name: 'sub-app-react',
    entry: '//localhost:3000', // 你的 React 应用端口
    container: '#subapp-container',
    activeRule: '/react-app',
  },
];

export const setupQiankun = () => {
  registerMicroApps(apps);
  start();
};
  1. main.js 中启动 qiankun(对应qiankun开始监听路由):
// ... existing code ...
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import { setupQiankun } from './qiankun-setup'; // 引入

createApp(App).use(router).mount('#app');

setupQiankun(); // 启动
  1. App.vue 中设置容器和导航:
<template>
  <div id="main-app">
    <header>
      <router-link to="/">主页</router-link> |
      <router-link to="/vue-app">访问Vue子应用</router-link> |
      <router-link to="/react-app">访问React子应用</router-link>
    </header>
    <!-- 主应用路由渲染 -->
    <router-view />
    <!-- 子应用容器 -->
    <div id="subapp-container"></div>
  </div>
</template>
  // ... existing code ...

第二步:Vue 子应用 (sub-app-vue) 改造

  1. 配置 vue.config.js 以支持跨域和 qiankun 的模块规范。
const { defineConfig } = require('@vue/cli-service');
const { name } = require('./package.json');

module.exports = defineConfig({
  transpileDependencies: true,
  devServer: {
    port: 8081,
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  },
  configureWebpack: {
    output: {
      library: `${name}-[name]`, // 配合libraryTarget使用,挂载到全局时的名字
      libraryTarget: 'umd', // 把微应用打包成 umd 库格式,便于将导出的 bootstrap、mount、unmount 等函数挂载到全局
      chunkLoadingGlobal: `webpackJsonp_${name}`,// 懒加载函数名字,防止不同应用间在懒加载时的冲突
    },
  },
});

为接入 qiankun,需在 vue.config.js 中配置 Webpack:

  1. output.libraryTarget: 'umd':将子应用打包成通用模块,以便 qiankun 能识别并获取其导出的生命周期函数。
  2. output.library: '${name}-[name]':为打包后的库设置唯一名称,防止多子应用在全局 window 上的命名冲突。
  3. output.chunkLoadingGlobal: 'webpackJsonp_${name}':为 Webpack 懒加载函数设置唯一名称,避免不同应用间的 chunk 加载逻辑互相覆盖。
  1. 添加入口文件 public-path.js: sub-app-vue/src/public-path.js
if (window.__POWERED_BY_QIANKUN__) {
// 运行时动态修改所有静态资源路径
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

在 qiankun 架构中,子应用不知道自己的静态资源(如图片、JS chunk)应从何处加载,因为浏览器默认会从主应用域名请求,导致 404 错误。

为解决此问题,qiankun 向子应用注入了 window.__POWERED_BY_QIANKUN__ 标志位和 window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ 变量(值为子应用的 entry 地址)。子应用在入口文件判断若在 qiankun 环境中,就将这个注入的路径赋值给 Webpack 的 __webpack_public_path__。这样,所有资源请求的 URL 前缀都会在运行时被动态修正,从而确保能正确加载。

  1. 改造 main.js,导出 qiankun 需要的生命周期。
import './public-path'; // 必须放在顶部
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';

let instance = null;

function render(props = {}) {
  const { container } = props;
  instance = createApp(App);
  instance
    .use(router)
    .mount(container ? container.querySelector('#app') : '#app');
}

if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

export async function bootstrap() {
  console.log('[vue] vue app bootstraped');
}

export async function mount(props) {
  console.log('[vue] props from main framework', props);
  render(props);
}

export async function unmount() {
  instance.unmount();
  instance = null;
}

第三步:React 子应用 (sub-app-react) 改造

  1. 安装 react-app-rewired
npm install react-app-rewired --save-dev

react-app-rewired 能在不执行 "eject"(暴露配置)的前提下,通过创建 config-overrides.js 文件来覆盖 Webpack 配置。只需将 package.json 中的 react-scripts 命令替换为 react-app-rewired,即可轻松实现 outputdevServer 的定制,是 CRA 项目接入 qiankun 的必备工具。

  1. 修改 package.json scripts:
// ... existing code ...
"scripts": {
  "start": "react-app-rewired start",
  "build": "react-app-rewired build",
  "test": "react-app-rewired test",
  "eject": "react-scripts eject"
},
// ... existing code ...
  1. 创建 config-overrides.js: sub-app-react/config-overrides.js
const { name } = require('./package.json');

module.exports = {
  webpack: (config) => {
    config.output.library = `${name}-[name]`;
    config.output.libraryTarget = 'umd';
    config.output.chunkLoadingGlobal = `webpackJsonp_${name}`;
    return config;
  },
  devServer: (configFunction) => {
    return function (proxy, allowedHost) {
      const config = configFunction(proxy, allowedHost);
      config.headers = {
        'Access-Control-Allow-Origin': '*',
      };
      return config;
    };
  },
};
  1. 添加入口文件 public-path.js: sub-app-react/src/public-path.js
if (window.__POWERED_BY_QIANKUN__) {
  // eslint-disable-next-line no-undef
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
  1. 改造 index.js,导出生命周期。
import './public-path'; // 必须放在顶部
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

let root = null;

function render(props) {
  const { container } = props;
  const dom = container ? container.querySelector('#root') : document.getElementById('root');
  root = ReactDOM.createRoot(dom);
  root.render(
    <React.StrictMode>
    <App />
    </React.StrictMode>
  );
}

if (!window.__POWERED_BY_QIANKUN__) {
  render({});
}

export async function bootstrap() {
  console.log('[react] react app bootstraped');
}

export async function mount(props) {
  console.log('[react] props from main framework', props);
  render(props);
}

export async function unmount() {
  if (root) {
    root.unmount();
    root = null;
  }
}

六. JS 沙箱验证

JS 沙箱验证

Proxy Sandbox 是如何在不污染全局 window 的情况下工作的?我们来验证一下。

  1. 在 Vue 子应用中设置全局变量: sub-app-vue/src/views/HomeView.vue
<script setup>
import { onMounted } from 'vue';
onMounted(() => {
  window.myGlobalVar = '我来自Vue子应用';
  console.log('【Vue子应用】设置了全局变量 window.myGlobalVar');
});
</script>
  1. 在 React 子应用中读取该变量: sub-app-react/src/App.js
import React, { useEffect } from 'react';
// ...
function App(props) {
  useEffect(() => {
    console.log('【React子应用】尝试读取 window.myGlobalVar:', window.myGlobalVar);
  }, []);
  // ...
}
export default App;

实验步骤与结论

  • 先访问 Vue 子应用,控制台打印“设置了全局变量”。
  • 再切换到 React 子应用,控制台打印“尝试读取 window.myGlobalVar: undefined”。
  • 结论:Vue 子应用对 window 的写入被Proxy沙箱拦截,并储存在了自己的“独立化妆间”里,并未影响到真实的 window,因此 React 子应用无法读取到它。 样式隔离验证

七. CSS 沙箱验证

qiankun 通过三种不同的策略来解决样式冲突问题,我们来逐一讲解。

1. 动态样式隔离

这是 qiankun 默认启用的样式隔离方案。它的工作原理非常简单:

  1. 加载时qiankun 会遍历子应用 HTML 中的所有 <style> 和 <link> 标签,将 CSS 内容记录下来,然后动态创建新的 <style> 标签,把这些内容插入到主应用的 <head> 中。
  2. 卸载时qiankun 会找到并移除之前为该子应用添加的所有 <style> 标签。

它确保了在“单实例”场景下(即任何时候只有一个子应用在运行),后加载的应用样式不会被先前的应用样式污染。

缺点(这也是最关键的)

  1. 无法隔离主应用与子应用:由于子应用的样式被直接插入主文档,如果选择器命名不当(例如,.btn),主应用的样式很容易被子应用覆盖,反之亦然。
  2. 无法隔离多实例子应用:如果同一个子应用需要被实例化多次并同时展示,它们的样式会相互冲突,因为它们共享相同的 CSS 规则。

验证默认隔离的局限性

我们来复现一下主应用被污染的场景。

  1. 在主应用中定义一个全局样式main-app/src/App.vue
<template>
  <div id="main-app">
    <header>
      <!-- ... -->
      <button class="common-button">主应用按钮</button>
    </header>
    <!-- ... -->
    <div id="subapp-container"></div>
  </div>
</template>

<style>
/* 一个非常通用的类名 */
.common-button {
  background-color: #42b983; /* 绿色 */
  color: white;
  border: none;
  padding: 10px 20px;
  cursor: pointer;
}
</style>
  1. 在 Vue 子应用中定义一个同名但样式不同的类sub-app-vue/src/views/HomeView.vue
<template>
  <div>
    <h3>Vue 子应用</h3>
    <button class="common-button">Vue子应用按钮</button>
  </div>
</template>

<style>
/* 子应用也定义了一个同名类 */
.common-button {
  background-color: #f0ad4e; /* 橙色 */
  border-radius: 8px;
}
</style>

实验步骤与结论

  1. 启动主应用,主应用的按钮是绿色的。
  2. 切换到 Vue 子应用,你会发现子应用的按钮是橙色的,并且有圆角。
  3. 问题出现了:此时你再回头看主应用的按钮,它也变成了橙色圆角

录屏2025-11-04 19.32.08.gif

所以该方案并不推荐使用


2. Shadow DOM 沙箱

这是一种需要手动开启的、隔离性最强的方案。

  • 原理qiankun 会为承载子应用的 container 元素创建一个 Shadow DOM。子应用的所有 DOM 节点和 <style> 标签都会被添加到这个 Shadow DOM 内部。
  • 效果:Shadow DOM 提供了一个完全封装的“影子”文档树。内部的样式规则绝对不会泄露到外部主文档,外部的全局样式也绝对不会影响到内部。

如何开启

在主应用注册子应用时,设置 sandbox 配置项:

main-app/src/qiankun-setup.js

const apps = [
  {
    name: 'sub-app-vue',
    entry: '//localhost:8081',
    container: '#subapp-container',
    activeRule: '/vue-app',
    props: {
      // 在这里向子应用传递参数
    }
  },
  // ... 其他应用
];

export const setupQiankun = () => {
  registerMicroApps(apps);
  start({
    sandbox: {
      strictStyleIsolation: true // 此处开启 Shadow DOM
    }
  });
};

验证 Shadow DOM

开启 strictStyleIsolation: true 后,重复上面的实验:

  1. 启动主应用,按钮是绿色
  2. 切换到 Vue 子应用,子应用的按钮是橙色圆角
  3. 此时,主应用的按钮依然是绿色,未受任何影响。

打开开发者工具,你会看到 subapp-container 内部结构发生了变化:

<div id="subapp-container">
  #shadow-root (open)
  |-- <!-- 子应用的所有内容和样式都在这里 -->
  |-- <style> .common-button { ... } </style>
  |-- <div id="app">
  |     <button class="common-button">Vue子应用按钮</button>
  |   </div>
</div>

image.png

结论:Shadow DOM 提供了完美的样式隔离。

缺点

  • 兼容性:一些旧浏览器不支持 Shadow DOM。
  • 弹出层问题:子应用中的 modaldialog 等需要挂载到 document.body 的组件,其 DOM 会被渲染到 Shadow DOM 之外,导致无法应用 Shadow DOM 内部的样式。你需要额外处理这类组件的挂载节点。
  • 事件冒泡:某些事件可能无法穿透 Shadow DOM 边界,需要手动处理。

3. 作用域沙箱(Scoped CSS)

这是 qiankun 提供的另一种手动开启的折中方案。它不像 Shadow DOM 那样彻底,但能解决大部分问题且没有 Shadow DOM 的那些缺点。

  • 原理qiankun 会在运行时,为子应用动态添加的每个样式规则,自动增加一个基于 data-qiankun 属性的前缀选择器。
  • 效果:例如,子应用的规则 .common-button { color: orange; } 会被改写成 div[data-qiankun="sub-app-vue"] .common-button { color: orange; }qiankun 会确保子应用的容器 div 上有 data-qiankun="sub-app-vue" 这个属性。这样,样式规则就只对该子应用容器内的元素生效了。

如何开启

关闭 Shadow DOM(experimentalStyleIsolation: false 或不设置),然后在 start 函数中配置 sandbox

main-app/src/qiankun-setup.js

export const setupQiankun = () => {
  registerMicroApps(apps);
  start({
    sandbox: {
        experimentalStyleIsolation: true, // 这里开启
    }
  });
}

验证 Scoped CSS

使用上述配置,再次进行实验:

  1. 启动主应用,按钮是绿色
  2. 切换到 Vue 子应用,子应用的按钮是橙色圆角
  3. 主应用的按钮依然是绿色

打开开发者工具,检查 Vue 子应用的样式:

  1. 子应用的容器 div 会被添加一个属性:<div id="subapp-container" data-qiankun="sub-app-vue">...</div>

  2. 在 <head> 中,你会找到被 qiankun 改写后的样式:

    div[data-qiankun="sub-app-vue"] .common-button {
      background-color: #f0ad4e;
      border-radius: 8px;
    }
    

image.png

image.png

结论:Scoped CSS 通过动态添加属性选择器,实现了子应用样式的作用域限制,成功隔离了主应用和子应用、以及子应用之间的样式,且没有 Shadow DOM 的那些副作用。

缺点

  • 性能开销:相比默认策略,它需要遍历和改写所有 CSS 规则,有轻微的性能开销。
  • 复杂选择器:在极少数情况下,对于非常复杂的 CSS 选择器,改写可能会失败或产生非预期的效果。

八. 应用间常用通信方法

前两种基本够用,但有局限性 最好了解每种通信方式的优劣

1. 基于 initGlobalState 的官方通信方案 (推荐)

这是 qiankun 官方推荐并内置的通信机制,基于 发布-订阅模式 实现,适用于主子应用、子子应用间的复杂状态同步。

  • 原理qiankun 在内部维护一个全局状态对象。主应用通过 initGlobalState 初始化这个状态,并返回一个 actions 对象。子应用在 mount 生命周期钩子中通过 props 获取这个 actions 对象,然后可以使用 onGlobalStateChange 监听状态变化,或使用 setGlobalState 修改状态。任何一方的修改都会通知所有监听者。

使用步骤

  1. 主应用:初始化和修改状态 main-app/src/qiankun-setup.js
import { initGlobalState, registerMicroApps, start } from 'qiankun';

// 1. 定义一个初始 state
const initialState = {
theme: "light",
userInfo: {
name: "Admin",
role: "administrator",
},
event: null,
};


// 2. 初始化 state,返回 actions 对象
const actions = initGlobalState(initialState);

// 3. 监听 state 变化,并做出响应
actions.onGlobalStateChange((state, prev) => {
  // state: 当前最新的 state
  // prev: 变化前的 state
  console.log('【主应用】监听到全局状态变化:', state);
});


// 注册和启动 qiankun
export const setupQiankun = () => {
  registerMicroApps(/*...*/);
  start();
};
  1. 子应用:监听和修改状态 sub-app-vue/src/main.js
// ...
let instance = null;

function render(props) {
  const { container } = props;
  
 // 将 actions 保存下来,方便其他组件使用
// 方案一:挂载到全局 (简单但不推荐)
// app.config.globalProperties.$actions = actions;
// 方案二:通过 provide/inject (推荐)
instance = createApp(App);

if (actions) {
instance.provide("actions", actions);
}
  instance.use(router).mount(container ? container.querySelector('#app') : '#app');
}

// ...
export async function mount(props) {
  console.log('[vue] props from main framework', props);
  
  // 1. 在 mount 时注册监听
  props.onGlobalStateChange((state, prev) => {
    console.log('[vue-sub] 监听到全局状态变化:', state);
    // 更新子应用自己的状态...
    // 例如,如果子应用也有主题设置,可以根据全局 appTheme 来更新
    if (state.appTheme !== prev.appTheme) {
      // 执行子应用内部的主题切换逻辑
    }
  });

  render(props);
}

// ...
  1. 子应用组件内使用 sub-app-vue/src/views/HomeView.vue
<template>
  <div>
    <button @click="changeGlobalState">从Vue子应用修改全局状态</button>
    <button @click="toggleTheme">从Vue子应用切换主题</button>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, inject } from "vue";

// 通过 inject 获取全局 actions
const actions = inject("actions");

const theme = ref("");

// 监听全局状态变化的处理器
const stateChangeHandler = (state, prevState) => {
console.log("【Vue子应用】监听到全局状态变化:", state);
theme.value = state?.theme;
};

onMounted(() => {
// 组件挂载时,注册 onGlobalStateChange 监听
if (actions) {
// console.log("【Vue子应用】注册全局状态监听", actions);
// 初始化时,先主动获取一次当前 state
const currentState = actions.getGlobalState?.();
theme.value = currentState?.theme;
actions.onGlobalStateChange(stateChangeHandler, true); // 第二个参数为 true,立即执行一次
} 
});

onUnmounted(() => {
// 组件卸载时,注销监听
if (actions) {
actions.offGlobalStateChange(stateChangeHandler);
}
});
</script>

看效果:

录屏2025-11-05 11.15.20.gif

2. 基于 props 的单向通信

这是最简单的通信方式,用于主应用向子应用单向传递数据或方法。

  • 原理:在主应用 registerMicroApps 时,可以通过 props 属性传递任意数据或函数给子应用。子应用在 mount 生命周期钩子中,通过参数 props 接收这些数据。

使用步骤

  1. 主应用:通过 props 传递数据和函数 main-app/src/qiankun-setup.js
function showAlert(msg) {
  alert(`[主应用消息]: ${msg}`);
}

const apps = [
  {
    name: 'sub-app-react',
    entry: '//localhost:3000',
    container: '#subapp-container',
    activeRule: '/react-app',
    props: {
      // 传递静态数据
      mainAppName: 'MyMainApp',
      // 传递方法,让子应用可以调用主应用的能力
      showAlert,
      // 传递一个对象,子应用可以根据需要解构
      config: {
        apiBaseUrl: '/api',
        // ...
      }
    }
  },
  // ...
];

export const setupQiankun = () => {
  registerMicroApps(apps);
  start();
};
  1. 子应用:在 mount 时接收和使用 sub-app-react/src/index.js
// ...
function render(props) {
  const { container, mainAppName, showAlert, config } = props;
  
  // 可以在这里将 props 传递给根组件
  const dom = container ? container.querySelector('#root') : document.getElementById('root');
  root = ReactDOM.createRoot(dom);
  root.render(
    <React.StrictMode>
      <App mainAppName={mainAppName} showAlert={showAlert} config={config} />
    </React.StrictMode>
  );
}

// ...
export async function mount(props) {
  console.log('[react] props from main framework', props);
  render(props);
}
// ...
  1. 子应用组件内使用 sub-app-react/src/App.js
function App({ mainAppName, showAlert, config }) {
  const handleClick = () => {
    // 调用从主应用传来的方法
    showAlert(`Hello from ${mainAppName}'s React child! API URL: ${config.apiBaseUrl}`);
  };

  return (
    <div>
      <button onClick={handleClick}>调用主应用方法并显示配置</button>
    </div>
  );
}

3. 基于路由参数或路径

这是最简单、最通用的 Web 页面间通信方式。

  • 原理:将需要传递的信息编码到 URL 中。可以是 query string (?id=123),也可以是 path (/user/123)。主应用通过修改 URL 来“通知”子应用,子应用监听 URL 变化并解析参数来获取信息。

使用步骤

  1. 主应用:通过路由导航传递参数 main-app/src/App.vue
<template>
  <div>
    <!-- ... -->
    <router-link to="/vue-app?userId=1001&userName=Alice">访问Vue子应用并传参</router-link> |
    <router-link to="/react-app/detail/2002">访问React子应用并传路径参数</router-link>
  </div>
</template>
  1. 子应用:在组件内解析路由参数 sub-app-vue/src/views/HomeView.vue
<script setup>
import { useRoute } from 'vue-router';
import { onMounted } from 'vue';

const route = useRoute();

onMounted(() => {
  const userId = route.query.userId;
  const userName = route.query.userName;
  console.log('【Vue子应用】从 URL 获取到的 userId:', userId, 'userName:', userName); // "1001", "Alice"
});
</script>

sub-app-react/src/App.js (假设React路由已配置 '/detail/:id'

import React, { useEffect } from 'react';
import { useParams } from 'react-router-dom'; // 假设使用 react-router-dom

function App() {
  const { id } = useParams();

  useEffect(() => {
    console.log('【React子应用】从 URL 获取到的路径参数 id:', id); // "2002"
  }, [id]);

  return (
    <div>
      <h3>React子应用</h3>
      {/* ... */}
    </div>
  );
}

4. 基于 localStorage / sessionStorage

通过浏览器提供的本地存储机制,实现不同应用间的数据共享。

  • 原理:一个应用将数据存入 localStorage,另一个应用从 localStorage 读取数据。由于 localStorage 具备同源共享特性,因此在同一个主域名下,主应用和所有子应用都能访问到相同的数据。

使用步骤

  1. 发送方(例如主应用):存储数据 main-app/src/App.vue
<template>
  <button @click="saveDataToLocalStorage">主应用存储数据到 localStorage</button>
</template>
<script setup>
function saveDataToLocalStorage() {
  localStorage.setItem('shared_data_from_main', JSON.stringify({ userId: 'main_user_001', role: 'admin' }));
  console.log('【主应用】数据已存储到 localStorage');
  // 可以触发一个事件通知其他应用数据已更新
  window.dispatchEvent(new CustomEvent('localStorageUpdate', { detail: { key: 'shared_data_from_main' } }));
}
</script>
  1. 接收方(例如 Vue 子应用):读取数据 sub-app-vue/src/views/HomeView.vue
<template>
  <div>
    <button @click="readDataFromLocalStorage">Vue子应用读取 localStorage</button>
    <p v-if="sharedData">从 localStorage 读取到的数据: {{ sharedData }}</p>
  </div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';

const sharedData = ref(null);

function readDataFromLocalStorage() {
  const data = localStorage.getItem('shared_data_from_main');
  sharedData.value = data ? JSON.parse(data) : null;
  console.log('【Vue子应用】读取到 localStorage 数据:', sharedData.value);
}

// 为了实现响应式,监听 localStorage 的变化(非所有浏览器都支持 storage 事件监听所有 key)
// 更好的做法是结合 CustomEvent
const handleStorageUpdate = (event) => {
  if (event.detail && event.detail.key === 'shared_data_from_main') {
    readDataFromLocalStorage();
  }
};

onMounted(() => {
  readDataFromLocalStorage(); // 首次加载时读取
  window.addEventListener('localStorageUpdate', handleStorageUpdate);
});

onUnmounted(() => {
  window.removeEventListener('localStorageUpdate', handleStorageUpdate);
});
</script>

5. 基于状态管理工具 (Pinia/Vuex, Redux, Zustand等)

如果所有子应用都使用相同的前端框架(例如都是 Vue),并且都使用了相同的状态管理库,那么可以考虑使用一个共享的状态管理实例。

  • 原理:在主应用中初始化一个状态管理实例,并通过 props 或 initGlobalState 将其传递给子应用。子应用接收后,可以直接使用这个共享的实例来读写状态。

使用步骤

  1. 主应用:初始化状态管理实例并传递 (以 Vue/Pinia 为例) main-app/src/main.js
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import { setupQiankun } from './qiankun-setup';
import { createPinia } from 'pinia';

const app = createApp(App);
const pinia = createPinia(); // 创建一个 Pinia 实例

app.use(router).use(pinia).mount('#app');

// 将 Pinia 实例或特定的 Store 传递给 qiankun
setupQiankun(pinia); // 修改 qiankun-setup.js,使其能接收 pinia 实例

main-app/src/qiankun-setup.js

// ...
export const setupQiankun = (mainPiniaInstance) => { // 接收 pinia 实例
  registerMicroApps(apps.map(app => ({
    ...app,
    props: {
      ...app.props,
      mainPiniaInstance, // 将主应用的 Pinia 实例作为 props 传递
    },
  })));
  start();
};
  1. 子应用:接收并使用共享实例 sub-app-vue/src/main.js
// ...
import { createPinia } from 'pinia'; // 子应用也需要 Pinia

let instance = null;
let piniaInstance = null; // 存储 Pinia 实例

function render(props) {
  const { container, mainPiniaInstance } = props;

  instance = createApp(App);
  if (mainPiniaInstance) {
    // 如果主应用传递了 Pinia 实例,则使用它
    piniaInstance = mainPiniaInstance;
  } else {
    // 否则,子应用自己创建一个(独立运行或非 Vue 主应用时)
    piniaInstance = createPinia();
  }
  instance.use(piniaInstance).use(router).mount(container ? container.querySelector('#app') : '#app');
}
// ...
  1. 子应用组件内使用共享 Store sub-app-vue/src/components/MyComponent.vue
<template>
  <div>
    <p>共享计数器: {{ sharedCounter }}</p>
    <button @click="incrementSharedCounter">增加共享计数器</button>
  </div>
</template>
<script setup>
import { useSharedStore } from './stores/sharedStore'; // 假设定义了一个共享的 store

const sharedStore = useSharedStore();
const sharedCounter = computed(() => sharedStore.counter);

function incrementSharedCounter() {
  sharedStore.increment();
}
</script>

结论:此方案适用于技术栈同构的场景。它能提供非常细粒度的状态管理和响应性。缺点是强耦合于特定框架的状态管理库,限制了技术栈异构的优势。

推荐优先级

根据实践经验,通信方案的选择应遵循以下优先级:

  1. initGlobalState(最高优先级) :官方方案,功能强大且规范,适用于所有需要双向、响应式数据同步的场景。是构建健壮微前端通信系统的首选。
  2. props:适用于主应用向子应用进行初始化和能力注入的场景,简单明了,职责清晰。
  3. URL 参数:适用于传递与页面定位相关的简单状态,符合 Web 标准,对用户友好。
  4. BroadcastChannel / SharedWorker:适用于跨多个 Tab 或需要独立线程处理的复杂通信场景,提供更强大的解耦和性能优势,但实现相对复杂。
  5. localStorage / sessionStorage:适用于存储不频繁变动且需要持久化的简单数据,通常需要配合事件机制来通知变化。
  6. 状态管理工具 (Pinia/Vuex, Redux等) :适用于技术栈同构的场景,提供细粒度的响应式状态管理,但牺牲了技术栈异构的灵活性。

《uni-app跨平台开发完全指南》- 03 - Vue.js基础入门

2025年11月5日 16:23

Vue.js 基础

本系列是《uni-app跨平台开发完全指南》系列教程,旨在帮助开发者从零开始掌握uni-app开发。本章将深入讲解Vue.js的核心概念,为你后续的uni-app开发打下坚实基础。

为什么学习Vue.js对uni-app开发如此重要?

很多初学者可能会问:"我直接学uni-app不行吗?为什么要先学Vue.js?"

这里有个很重要的概念需要理解:uni-app的本质是基于Vue.js的跨端实现框架。更形象一点,如果说uni-app是整车制造,那么Vue.js就属于发动机。如果你不懂发动机原理,虽然也能开车,但一旦出现故障,就束手无策了。同样,不掌握Vue.js基础,在uni-app开发中遇到复杂问题时,你会很难找到根本解决方案。

一、Vue.js 简介与开发环境搭建

1.1 Vue.js 是什么?

简单来说,Vue.js是一个用于构建用户界面的渐进式JavaScript框架。所谓"渐进式",意味着你可以根据项目需求,逐步采用Vue.js的特性:

  • 可以在老项目中局部使用Vue.js增强交互
  • 也可以使用Vue.js全家桶开发完整的前端应用
  • 还可以用Vue.js开发原生移动应用(如uni-app)

1.2 环境准备:第一个Vue应用

让我们从最简单的HTML页面开始:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>我的第一个Vue应用</title>
    <!-- 引入Vue.js -->
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
</head>
<body>
    <!-- Vue实例挂载点 -->
    <div id="app">
        <h1>{{ message }}</h1>
        <button @click="reverseMessage">反转消息</button>
    </div>

    <script>
        // 创建Vue实例
        var app = new Vue({
            el: '#app', // 指定挂载元素
            data: {     // 定义数据
                message: 'Hello Vue!'
            },
            methods: {  // 定义方法
                reverseMessage: function() {
                    this.message = this.message.split('').reverse().join('');
                }
            }
        });
    </script>
</body>
</html>

代码解析

  • el: '#app':告诉Vue这个实例要控制页面中id为app的元素
  • data:定义这个Vue实例的数据,可以在模板中使用
  • {{ message }}:模板语法,将data中的message值渲染到页面
  • @click:事件绑定,点击时执行reverseMessage方法

二、Vue 核心概念

2.1 数据绑定

数据绑定是Vue最核心的特性之一,它建立了数据DOM之间的自动同步关系。

2.1.1 文本插值:{{ }}
<div id="app">
    <!-- 基本文本插值 -->
    <p>消息:{{ message }}</p>
    
    <!-- JS表达式 -->
    <p>计算:{{ number + 1 }}</p>
    <p>三元表达式:{{ isActive ? '激活' : '未激活' }}</p>
    <p>反转:{{ message.split('').reverse().join('') }}</p>
</div>

<script>
new Vue({
    el: '#app',
    data: {
        message: 'Hello Vue!',
        number: 10,
        isActive: true
    }
});
</script>

重要提示{{ }}中支持JavaScript表达式,但不支持语句(如iffor等)。

2.1.2 属性绑定:v-bind
<div id="app">
    <!-- 绑定HTML属性 -->
    <div v-bind:title="tooltip">鼠标悬停查看提示</div>
    
    <!-- 绑定CSS类 -->
    <div v-bind:class="{ active: isActive, 'text-danger': hasError }">
        动态类名
    </div>
    
    <!-- 绑定样式 -->
    <div v-bind:style="{ color: activeColor, fontSize: fontSize + 'px' }">
        动态样式
    </div>
    
    <!-- 简写 -->
    <img :src="imageSrc" :alt="imageAlt">
</div>

<script>
new Vue({
    el: '#app',
    data: {
        tooltip: '这是一个提示信息',
        isActive: true,
        hasError: false,
        activeColor: 'red',
        fontSize: 20,
        imageSrc: 'path/to/image.jpg',
        imageAlt: '示例图片'
    }
});
</script>

v-bind原理:当数据变化时,Vue会自动更新对应的DOM属性。

2.2 指令系统

指令是带有v-前缀的特殊属性,它们为HTML添加了动态行为。

2.2.1 条件渲染:v-if vs v-show
<div id="app">
    <!-- v-if:条件性地渲染一块内容 -->
    <p v-if="score >= 90">优秀!</p>
    <p v-else-if="score >= 60">及格</p>
    <p v-else>不及格</p>
    
    <!-- v-show:总是渲染,只是切换display -->
    <p v-show="isVisible">这个元素会显示/隐藏</p>
    
    <button @click="toggle">切换显示</button>
    <button @click="changeScore">改变分数</button>
</div>

<script>
new Vue({
    el: '#app',
    data: {
        score: 85,
        isVisible: true
    },
    methods: {
        toggle: function() {
            this.isVisible = !this.isVisible;
        },
        changeScore: function() {
            this.score = Math.floor(Math.random() * 100);
        }
    }
});
</script>

v-if 与 v-show 的区别

特性 v-if v-show
渲染方式 条件为false时不渲染DOM元素 总是渲染,只是切换display属性
切换开销 更高的切换开销(创建/销毁组件) 更高的初始渲染开销
适用场景 运行时条件很少改变 需要非常频繁地切换
2.2.2 列表渲染:v-for
<div id="app">
    <!-- 遍历数组 -->
    <ul>
        <li v-for="(item, index) in items" :key="item.id">
            {{ index + 1 }}. {{ item.name }} - ¥{{ item.price }}
        </li>
    </ul>
    
    <!-- 遍历对象 -->
    <ul>
        <li v-for="(value, key) in userInfo" :key="key">
            {{ key }}: {{ value }}
        </li>
    </ul>
    
    <!-- 遍历数字范围 -->
    <span v-for="n in 5" :key="n">{{ n }} </span>
</div>

<script>
new Vue({
    el: '#app',
    data: {
        items: [
            { id: 1, name: '苹果', price: 5 },
            { id: 2, name: '香蕉', price: 3 },
            { id: 3, name: '橙子', price: 4 }
        ],
        userInfo: {
            name: '张三',
            age: 25,
            city: '北京'
        }
    }
});
</script>

关键点

  • :key的重要性:为每个节点提供唯一标识,优化列表渲染性能
  • 可以使用(item, index)(value, key, index)语法
2.2.3 事件处理:v-on
<div id="app">
    <!-- 基本事件处理 -->
    <button v-on:click="counter += 1">点击次数: {{ counter }}</button>
    
    <!-- 方法事件处理器 -->
    <button @click="sayHello">打招呼</button>
    
    <!-- 内联处理器中的方法 -->
    <button @click="say('Hello', $event)">带参数的事件</button>
    
    <!-- 事件修饰符 -->
    <form @submit.prevent="onSubmit">
        <input type="text">
        <button type="submit">提交</button>
    </form>
    
    <!-- 按键修饰符 -->
    <input @keyup.enter="onEnter" placeholder="按回车键触发">
</div>

<script>
new Vue({
    el: '#app',
    data: {
        counter: 0
    },
    methods: {
        sayHello: function(event) {
            alert('Hello!');
            console.log(event); // 原生事件对象
        },
        say: function(message, event) {
            alert(message);
            if (event) {
                event.preventDefault();
            }
        },
        onSubmit: function() {
            alert('表单提交被阻止了!');
        },
        onEnter: function() {
            alert('你按了回车键!');
        }
    }
});
</script>

常用事件修饰符

  • .stop:阻止事件冒泡
  • .prevent:阻止默认行为
  • .capture:使用事件捕获模式
  • .self:只当事件是从侦听器绑定的元素本身触发时才触发回调
  • .once:只触发一次
  • .passive:告诉浏览器你不想阻止事件的默认行为
2.2.4 双向数据绑定:v-model
<div id="app">
    <!-- 文本输入 -->
    <input v-model="message" placeholder="编辑我">
    <p>消息是: {{ message }}</p>
    
    <!-- 多行文本 -->
    <textarea v-model="multilineText"></textarea>
    <p style="white-space: pre-line;">{{ multilineText }}</p>
    
    <!-- 复选框 -->
    <input type="checkbox" id="checkbox" v-model="checked">
    <label for="checkbox">{{ checked ? '已选中' : '未选中' }}</label>
    
    <!-- 多个复选框 -->
    <div>
        <input type="checkbox" id="jack" value="Jack" v-model="checkedNames">
        <label for="jack">Jack</label>
        <input type="checkbox" id="john" value="John" v-model="checkedNames">
        <label for="john">John</label>
        <input type="checkbox" id="mike" value="Mike" v-model="checkedNames">
        <label for="mike">Mike</label>
        <br>
        <span>选中的名字: {{ checkedNames }}</span>
    </div>
    
    <!-- 单选按钮 -->
    <div>
        <input type="radio" id="one" value="One" v-model="picked">
        <label for="one">One</label>
        <input type="radio" id="two" value="Two" v-model="picked">
        <label for="two">Two</label>
        <br>
        <span>选中的值: {{ picked }}</span>
    </div>
    
    <!-- 选择框 -->
    <select v-model="selected">
        <option disabled value="">请选择</option>
        <option>A</option>
        <option>B</option>
        <option>C</option>
    </select>
    <span>选中的值: {{ selected }}</span>
</div>

<script>
new Vue({
    el: '#app',
    data: {
        message: '',
        multilineText: '',
        checked: false,
        checkedNames: [],
        picked: '',
        selected: ''
    }
});
</script>

v-model原理:本质上是语法糖,它负责监听用户的输入事件以更新数据。

// v-model 相当于:
<input 
  :value="message" 
  @input="message = $event.target.value">

2.3 计算属性与监听器

2.3.1 计算属性:computed
<div id="app">
    <input v-model="firstName" placeholder="姓">
    <input v-model="lastName" placeholder="名">
    
    <!-- 使用计算属性 -->
    <p>全名(计算属性): {{ fullName }}</p>
    
    <!-- 使用方法 -->
    <p>全名(方法): {{ getFullName() }}</p>
    
    <!-- 示例代码 -->
    <div>
        <h3>购物车</h3>
        <div v-for="item in cart" :key="item.id">
            {{ item.name }} - ¥{{ item.price }} × {{ item.quantity }}
        </div>
        <p>总价: {{ totalPrice }}</p>
        <p>打折后: {{ discountedTotal }}</p>
    </div>
</div>

<script>
new Vue({
    el: '#app',
    data: {
        firstName: '张',
        lastName: '三',
        cart: [
            { id: 1, name: '商品A', price: 100, quantity: 2 },
            { id: 2, name: '商品B', price: 200, quantity: 1 }
        ],
        discount: 0.8 // 8折
    },
    computed: {
        // 计算属性:基于依赖进行缓存
        fullName: function() {
            console.log('计算属性 fullName 被调用了');
            return this.firstName + ' ' + this.lastName;
        },
        // 计算总价
        totalPrice: function() {
            return this.cart.reduce((total, item) => {
                return total + (item.price * item.quantity);
            }, 0);
        },
        // 基于其他计算属性的计算属性
        discountedTotal: function() {
            return this.totalPrice * this.discount;
        }
    },
    methods: {
        // 方法:每次重新渲染都会调用
        getFullName: function() {
            console.log('方法 getFullName 被调用了');
            return this.firstName + ' ' + this.lastName;
        }
    }
});
</script>

计算属性的依赖追踪流程:

graph TD
    A[访问计算属性] --> B{脏数据?}
    B -->|是| C[重新计算值]
    B -->|否| D[返回缓存值]
    C --> E[标记为干净数据]
    E --> D
    F[依赖数据变化] --> G[标记为脏数据]
    G --> A

计算属性特点

  • 基于它们的响应式依赖进行缓存
  • 只在相关响应式依赖发生改变时才会重新求值
  • 多次访问计算属性会立即返回之前的计算结果,而不必再次执行函数
2.3.2 监听器:watch
<div id="app">
    <input v-model="question" placeholder="输入问题">
    <p>答案: {{ answer }}</p>
    
    <!-- 示例 -->
    <input v-model="user.name" placeholder="用户名">
    <input v-model="user.age" type="number" placeholder="年龄">
    <p>用户信息变化次数: {{ changeCount }}</p>
</div>

<script>
new Vue({
    el: '#app',
    data: {
        question: '',
        answer: '我无法给你答案直到你提问!',
        user: {
            name: '',
            age: 0
        },
        changeCount: 0
    },
    watch: {
        // 简单监听:question发生变化时执行
        question: function(newQuestion, oldQuestion) {
            this.answer = '等待你停止输入...';
            this.getAnswer();
        },
        // 深度监听:对象内部属性的变化
        user: {
            handler: function(newVal, oldVal) {
                this.changeCount++;
                console.log('用户信息发生变化:', newVal);
            },
            deep: true, 
            immediate: true 
        }
    },
    methods: {
        getAnswer: function() {
            // 模拟异步操作
            setTimeout(() => {
                this.answer = '这是对你问题的回答';
            }, 1000);
        }
    }
});
</script>

计算属性 vs 监听器

场景 使用计算属性 使用监听器
数据派生 适用于现有数据计算新数据 不适用
异步操作 不支持异步 支持异步
性能优化 自动缓存 无缓存
复杂逻辑 声明式 命令式

三、组件化开发

组件化就像搭积木一样,把复杂的界面拆分成独立、可复用的部分。

3.1 组件注册与使用

3.1.1 全局组件
<div id="app">
    <!-- 使用全局组件 -->
    <my-button></my-button>
    <user-card 
        name="张三" 
        :age="25" 
        avatar="path/to/avatar.jpg">
    </user-card>
</div>

<script>
// 全局组件注册
Vue.component('my-button', {
    template: `
        <button class="my-btn" @click="onClick">
            <slot>默认按钮</slot>
        </button>
    `,
    methods: {
        onClick: function() {
            this.$emit('btn-click'); // 触发自定义事件
        }
    }
});

// 另一个全局组件
Vue.component('user-card', {
    props: ['name', 'age', 'avatar'], // 定义组件属性
    template: `
        <div class="user-card">
            <img :src="avatar" :alt="name" class="avatar">
            <div class="info">
                <h3>{{ name }}</h3>
                <p>年龄: {{ age }}</p>
            </div>
        </div>
    `
});

new Vue({
    el: '#app'
});
</script>

<style>
.my-btn {
    padding: 10px 20px;
    background: #4CAF50;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
}

.user-card {
    border: 1px solid #ddd;
    padding: 15px;
    margin: 10px 0;
    border-radius: 8px;
    display: flex;
    align-items: center;
}

.avatar {
    width: 50px;
    height: 50px;
    border-radius: 50%;
    margin-right: 15px;
}

.info h3 {
    margin: 0 0 5px 0;
}
</style>
3.1.2 局部组件
<div id="app">
    <product-list></product-list>
</div>

<script>
// 定义局部组件
var ProductList = {
    template: `
        <div class="product-list">
            <h2>商品列表</h2>
            <product-item 
                v-for="product in products" 
                :key="product.id"
                :product="product"
                @add-to-cart="onAddToCart">
            </product-item>
        </div>
    `,
    data: function() {
        return {
            products: [
                { id: 1, name: 'iPhone', price: 5999, stock: 10 },
                { id: 2, name: 'MacBook', price: 9999, stock: 5 },
                { id: 3, name: 'iPad', price: 3299, stock: 8 }
            ]
        };
    },
    methods: {
        onAddToCart: function(product) {
            console.log('添加到购物车:', product.name);
            // 这里可以调用Vuex或触发全局事件
        }
    }
};

// 子组件
var ProductItem = {
    props: ['product'],
    template: `
        <div class="product-item">
            <h3>{{ product.name }}</h3>
            <p>价格: ¥{{ product.price }}</p>
            <p>库存: {{ product.stock }}</p>
            <button 
                @click="addToCart" 
                :disabled="product.stock === 0">
                {{ product.stock === 0 ? '缺货' : '加入购物车' }}
            </button>
        </div>
    `,
    methods: {
        addToCart: function() {
            this.$emit('add-to-cart', this.product);
        }
    }
};

new Vue({
    el: '#app',
    components: {
        'product-list': ProductList,
        'product-item': ProductItem
    }
});
</script>

<style>
.product-list {
    max-width: 600px;
    margin: 0 auto;
}

.product-item {
    border: 1px solid #eee;
    padding: 15px;
    margin: 10px 0;
    border-radius: 5px;
}

.product-item h3 {
    color: #333;
    margin-top: 0;
}
</style>

3.2 组件通信

组件通信是组件化开发的核心,Vue提供了多种通信方式,用一张图来看下:

graph TB
    A[组件通信] --> B[父子通信]
    A --> C[兄弟通信]
    A --> D[跨级通信]
    A --> E[全局通信]
    
    B --> B1[Props Down]
    B --> B2[Events Up]
    B --> B3[v-model]
    B --> B4[refs]
    
    C --> C1[Event Bus]
    C --> C2[共同父级]
    
    D --> D1[Provide/Inject]
    D --> D2[attrs/listeners]
    
    E --> E1[Vuex]
    E --> E2[全局事件]

下面结合一段具体代码示例,带大家了解下组件间是如何通信的:

<div id="app">
    <h2>组件通信示例</h2>
    
    <!-- 1. 父子组件 -->
    <parent-component></parent-component>
    
    <!-- 2. 事件总线 -->
    <component-a></component-a>
    <component-b></component-b>
</div>

<script>
// 事件总线(用于非父子组件通信)
var eventBus = new Vue();

// 组件A
Vue.component('component-a', {
    template: `
        <div class="component">
            <h3>组件A</h3>
            <button @click="sendMessage">发送消息给组件B</button>
        </div>
    `,
    methods: {
        sendMessage: function() {
            eventBus.$emit('message-from-a', '你好,这是来自组件A的消息!');
        }
    }
});

// 组件B
Vue.component('component-b', {
    template: `
        <div class="component">
            <h3>组件B</h3>
            <p>收到消息: {{ receivedMessage }}</p>
        </div>
    `,
    data: function() {
        return {
            receivedMessage: ''
        };
    },
    mounted: function() {
        var self = this;
        eventBus.$on('message-from-a', function(message) {
            self.receivedMessage = message;
        });
    }
});

// 父组件
Vue.component('parent-component', {
    template: `
        <div class="parent">
            <h3>父组件</h3>
            <p>父组件数据: {{ parentData }}</p>
            
            <!-- 父传子:通过props -->
            <child-component 
                :message="parentData"
                @child-event="onChildEvent">
            </child-component>
            
            <!-- 子传父:通过自定义事件 -->
            <p>子组件消息: {{ childMessage }}</p>
        </div>
    `,
    data: function() {
        return {
            parentData: '来自父组件的数据',
            childMessage: ''
        };
    },
    methods: {
        onChildEvent: function(message) {
            this.childMessage = message;
        }
    }
});

// 子组件
Vue.component('child-component', {
    props: ['message'], // 接收父组件数据
    template: `
        <div class="child">
            <h4>子组件</h4>
            <p>收到父组件的消息: {{ message }}</p>
            <button @click="sendToParent">发送消息给父组件</button>
        </div>
    `,
    methods: {
        sendToParent: function() {
            this.$emit('child-event', '来自子组件的问候!');
        }
    }
});

new Vue({
    el: '#app'
});
</script>

<style>
.component, .parent, .child {
    border: 1px solid #ccc;
    padding: 15px;
    margin: 10px;
    border-radius: 5px;
}

.parent {
    background: #f0f8ff;
}

.child {
    background: #f9f9f9;
    margin-left: 30px;
}
</style>

四、生命周期函数

4.1 生命周期

Vue实例有一个完整的生命周期,包括创建、挂载、更新、销毁等阶段。每个阶段都提供了相应的生命周期钩子,让我们可以在特定阶段执行自定义逻辑。

sequenceDiagram
    participant P as Parent Component
    participant C as Child Component
    participant VD as Virtual DOM
    participant RD as Real DOM
    
    Note over P: 1. 父组件创建
    P->>C: 2. 创建子组件实例
    Note over C: 3. beforeCreate
    Note over C: 4. 初始化注入
    Note over C: 5. created
    C->>VD: 6. 编译模板为渲染函数
    Note over C: 7. beforeMount
    C->>RD: 8. 创建$el并挂载
    Note over C: 9. mounted
    Note over C: 10. 等待数据变化
    C->>C: 11. 数据变化
    Note over C: 12. beforeUpdate
    C->>VD: 13. 重新渲染
    VD->>RD: 14. 打补丁
    Note over C: 15. updated
    P->>C: 16. 销毁子组件
    Note over C: 17. beforeDestroy
    C->>C: 18. 清理工作
    Note over C: 19. destroyed

其实生命周期钩子函数不用刻意去记忆,实在不知道直接控制台打印看日志结果就行了,当然能记住最好~~~

4.2 生命周期钩子

<div id="app">
    <h2>用计时器来演示生命周期狗子函数</h2>
    <p>计数器: {{ count }}</p>
    <button @click="count++">增加</button>
    <button @click="destroy">销毁实例</button>
    
    <div v-if="showChild">
        <lifecycle-demo :count="count"></lifecycle-demo>
    </div>
    <button @click="showChild = !showChild">切换子组件</button>
</div>

<script>
Vue.component('lifecycle-demo', {
    props: ['count'],
    template: `
        <div class="lifecycle-demo">
            <h3>子组件 - 计数: {{ count }}</h3>
            <p>生命周期调用记录:</p>
            <ul>
                <li v-for="log in logs" :key="log.id">{{ log.message }}</li>
            </ul>
        </div>
    `,
    data: function() {
        return {
            logs: [],
            logId: 0
        };
    },
    
    // 生命周期钩子
    beforeCreate: function() {
        this.addLog('beforeCreate: 实例刚被创建,data和methods还未初始化');
    },
    
    created: function() {
        this.addLog('created: 实例创建完成,data和methods已初始化');
        // 这里可以调用API获取初始数据
        this.fetchData();
    },
    
    beforeMount: function() {
        this.addLog('beforeMount: 模板编译完成,但尚未挂载到页面');
    },
    
    mounted: function() {
        this.addLog('mounted: 实例已挂载到DOM元素,可以访问$el');
        // 这里可以操作DOM或初始化第三方库
        this.initializeThirdPartyLib();
    },
    
    beforeUpdate: function() {
        this.addLog('beforeUpdate: 数据更新前,虚拟DOM重新渲染之前');
    },
    
    updated: function() {
        this.addLog('updated: 数据更新完成,DOM已重新渲染');
        // 这里可以执行依赖于DOM更新的操作
    },
    
    beforeDestroy: function() {
        this.addLog('beforeDestroy: 实例销毁前,此时实例仍然完全可用');
        // 这里可以清理定时器、取消订阅等
        this.cleanup();
    },
    
    destroyed: function() {
        // 注意:在destroyed钩子中无法添加日志,因为组件已销毁
        console.log('destroyed: 实例已销毁,所有绑定和监听器已被移除');
    },
    
    methods: {
        addLog: function(message) {
            this.logs.push({
                id: this.logId++,
                message: message + ' - ' + new Date().toLocaleTimeString()
            });
        },
        
        fetchData: function() {
            // 模拟接口请求
            setTimeout(() => {
                this.addLog('数据获取完成');
            }, 100);
        },
        
        initializeThirdPartyLib: function() {
            this.addLog('三方库初始化完成');
        },
        
        cleanup: function() {
            this.addLog('清理工作完成');
        }
    }
});

new Vue({
    el: '#app',
    data: {
        count: 0,
        showChild: true
    },
    methods: {
        destroy: function() {
            this.$destroy();
            alert('Vue实例被销毁');
        }
    }
});
</script>

<style>
.lifecycle-demo {
    border: 2px solid #4CAF50;
    padding: 15px;
    margin: 10px 0;
    background: #f9fff9;
}

.lifecycle-demo ul {
    max-height: 200px;
    overflow-y: auto;
    background: white;
    padding: 10px;
    border: 1px solid #ddd;
}
</style>

4.3 生命周期使用场景总结

生命周期钩子 常见使用场景
created - API数据请求
- 事件监听器初始化
- 定时器设置
mounted - DOM操作
- 三方库初始化(如图表库)
- 插件初始化
updated - DOM依赖的操作
- 基于新状态的操作
beforeDestroy - 清除定时器
- 取消事件监听
- 清理三方库实例

五、响应式原理

响应式就是当数据发生变化时,视图会自动更新。这听起来很简单,但底层原理有着巧妙的设计。

5.1 原理

Vue的响应式系统基于三个核心概念:

5.1.1 数据劫持(Object.defineProperty)
// 简化的响应式原理
function defineReactive(obj, key, val) {
    // 递归处理嵌套对象
    observe(val);
    
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function() {
            console.log(`读取 ${key}: ${val}`);
            // 这里会进行依赖收集
            return val;
        },
        set: function(newVal) {
            if (newVal === val) return;
            console.log(`设置 ${key}: ${newVal}`);
            val = newVal;
            // 这里会通知依赖更新
            updateView();
        }
    });
}

function observe(obj) {
    if (!obj || typeof obj !== 'object') return;
    
    Object.keys(obj).forEach(key => {
        defineReactive(obj, key, obj[key]);
    });
}

// 测试
const data = { message: 'Hello', count: 0 };
observe(data);

// 现在data是响应式的
data.message = 'Hello Vue!';  // 控制台会输出:设置 message: Hello Vue!
console.log(data.message);    // 控制台会输出:读取 message: Hello Vue!
5.1.2 依赖收集与派发更新

Vue的响应式系统实际更为复杂,包含依赖收集和派发更新机制:

// 简化的Dep(依赖)类
class Dep {
    constructor() {
        this.subscribers = new Set();
    }
    
    depend() {
        if (activeUpdate) {
            this.subscribers.add(activeUpdate);
        }
    }
    
    notify() {
        this.subscribers.forEach(sub => sub());
    }
}

let activeUpdate = null;

function autorun(update) {
    function wrappedUpdate() {
        activeUpdate = wrappedUpdate;
        update();
        activeUpdate = null;
    }
    wrappedUpdate();
}

// 使用示例
const dep = new Dep();

autorun(() => {
    dep.depend();
    console.log('更新视图');
});

// 当数据变化时
dep.notify(); // 输出:更新视图

5.2 注意事项

5.2.1 数组更新检测
<div id="app">
    <h3>数组响应式注意事项</h3>
    <ul>
        <li v-for="item in items" :key="item.id">{{ item.name }}</li>
    </ul>
    
    <button @click="addItem">添加项目successed</button>
    <button @click="addItemWrong">添加项目error</button>
    <button @click="changeItemProperty">修改项目属性</button>
</div>

<script>
new Vue({
    el: '#app',
    data: {
        items: [
            { id: 1, name: '项目1' },
            { id: 2, name: '项目2' }
        ]
    },
    methods: {
        // 推荐使用数组变异方法
        addItem: function() {
            this.items.push({
                id: this.items.length + 1,
                name: '项目' + (this.items.length + 1)
            });
        },
        
        // 不推荐直接通过索引设置
        addItemWrong: function() {
            // 这种方式不会触发视图更新!
            this.items[this.items.length] = {
                id: this.items.length + 1,
                name: '项目' + (this.items.length + 1)
            };
            console.log('数组已修改,但视图不会更新');
        },
        
        // 对象属性的响应式
        changeItemProperty: function() {
            // Vue.set 或 this.$set 确保响应式
            this.$set(this.items[0], 'newProperty', '新属性值');
        }
    }
});
</script>
5.2.2 响应式API
// 响应式API
new Vue({
    data: {
        user: {
            name: '张三'
        },
        list: [1, 2, 3]
    },
    created() {
        // 添加响应式属性
        this.$set(this.user, 'age', 25);
        
        // 删除响应式属性
        this.$delete(this.user, 'name');
        
        // 数组响应式方法
        this.list = this.$set(this.list, 0, 100); // 替换第一个元素
        
        // 或者使用Vue.set全局方法
        Vue.set(this.list, 1, 200);
    }
});

六、项目实战:TodoList应用

用一个完整的TodoList应用来综合运用以上所学知识:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Vue.js TodoList应用</title>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            padding: 20px;
        }
        .todo-app {
            max-width: 500px;
            margin: 0 auto;
            background: white;
            border-radius: 10px;
            box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
            overflow: hidden;
        }
        .header {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            padding: 30px 20px;
            text-align: center;
        }
        .header h1 {
            margin-bottom: 10px;
            font-size: 2.5em;
        }
        .input-section {
            padding: 20px;
            border-bottom: 1px solid #eee;
        }
        .todo-input {
            width: 100%;
            padding: 15px;
            border: 2px solid #e1e1e1;
            border-radius: 8px;
            font-size: 16px;
            transition: border-color 0.3s;
        }
        .todo-input:focus {
            outline: none;
            border-color: #667eea;
        }
        .add-btn {
            width: 100%;
            padding: 15px;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            border: none;
            border-radius: 8px;
            font-size: 16px;
            cursor: pointer;
            margin-top: 10px;
            transition: transform 0.2s;
        }
        .add-btn:hover {
            transform: translateY(-2px);
        }
        .filters {
            display: flex;
            padding: 15px 20px;
            border-bottom: 1px solid #eee;
        }
        .filter-btn {
            flex: 1;
            padding: 10px;
            background: none;
            border: none;
            cursor: pointer;
            transition: all 0.3s;
            border-radius: 5px;
            margin: 0 5px;
        }
        .filter-btn.active {
            background: #667eea;
            color: white;
        }
        .todo-list {
            max-height: 400px;
            overflow-y: auto;
        }
        .todo-item {
            display: flex;
            align-items: center;
            padding: 15px 20px;
            border-bottom: 1px solid #f1f1f1;
            transition: background-color 0.3s;
        }
        .todo-item:hover {
            background-color: #f9f9f9;
        }
        .todo-item.completed {
            opacity: 0.6;
        }
        .todo-item.completed .todo-text {
            text-decoration: line-through;
        }
        .todo-checkbox {
            margin-right: 15px;
            transform: scale(1.2);
        }
        .todo-text {
            flex: 1;
            font-size: 16px;
        }
        .delete-btn {
            background: #ff4757;
            color: white;
            border: none;
            padding: 5px 10px;
            border-radius: 5px;
            cursor: pointer;
            transition: background-color 0.3s;
        }
        .delete-btn:hover {
            background: #ff3742;
        }
        .stats {
            padding: 15px 20px;
            text-align: center;
            color: #666;
            border-top: 1px solid #eee;
        }
        .empty-state {
            text-align: center;
            padding: 40px 20px;
            color: #999;
        }
    </style>
</head>
<body>
    <div id="app">
        <div class="todo-app">
            <!-- 头部 -->
            <div class="header">
                <h1>TodoList</h1>
                <p>Vue.js应用</p>
            </div>
            
            <div class="input-section">
                <input 
                    v-model="newTodo" 
                    @keyup.enter="addTodo"
                    placeholder="添加新任务..."
                    class="todo-input">
                <button @click="addTodo" class="add-btn">
                    添加任务
                </button>
            </div>
            
            <!-- 过滤器 -->
            <div class="filters">
                <button 
                    @click="filter = 'all'"
                    :class="['filter-btn', { active: filter === 'all' }]">
                    全部 ({{ totalTodos }})
                </button>
                <button 
                    @click="filter = 'active'"
                    :class="['filter-btn', { active: filter === 'active' }]">
                    待完成 ({{ activeTodos }})
                </button>
                <button 
                    @click="filter = 'completed'"
                    :class="['filter-btn', { active: filter === 'completed' }]">
                    已完成 ({{ completedTodos }})
                </button>
            </div>
            
            <!-- Todo列表 -->
            <div class="todo-list">
                <div v-if="filteredTodos.length === 0" class="empty-state">
                    {{ emptyMessage }}
                </div>
                
                <div 
                    v-for="todo in filteredTodos" 
                    :key="todo.id"
                    :class="['todo-item', { completed: todo.completed }]">
                    
                    <input 
                        type="checkbox" 
                        v-model="todo.completed"
                        class="todo-checkbox">
                    
                    <span class="todo-text">{{ todo.text }}</span>
                    
                    <button 
                        @click="removeTodo(todo.id)"
                        class="delete-btn">
                        删除
                    </button>
                </div>
            </div>
            
            <!-- 统计信息 -->
            <div class="stats">
                <span v-if="totalTodos > 0">
                    进度: {{ completionRate }}% ({{ completedTodos }}/{{ totalTodos }})
                </span>
                <span v-else>还没有任务,添加一个吧!</span>
            </div>
        </div>
    </div>

    <script>
        new Vue({
            el: '#app',
            data: {
                newTodo: '',           // 新任务输入
                todos: [],             // 任务列表
                filter: 'all',         // 当前过滤器
                nextId: 1              // 下一个任务ID
            },
            
            // 计算属性
            computed: {
                // 总任务数
                totalTodos() {
                    return this.todos.length;
                },
                
                // 活跃任务数
                activeTodos() {
                    return this.todos.filter(todo => !todo.completed).length;
                },
                
                // 已完成任务数
                completedTodos() {
                    return this.todos.filter(todo => todo.completed).length;
                },
                
                // 完成率
                completionRate() {
                    if (this.totalTodos === 0) return 0;
                    return Math.round((this.completedTodos / this.totalTodos) * 100);
                },
                
                // 过滤后的任务列表
                filteredTodos() {
                    switch (this.filter) {
                        case 'active':
                            return this.todos.filter(todo => !todo.completed);
                        case 'completed':
                            return this.todos.filter(todo => todo.completed);
                        default:
                            return this.todos;
                    }
                },
                
                // 空状态消息
                emptyMessage() {
                    switch (this.filter) {
                        case 'active':
                            return '没有待完成的任务!';
                        case 'completed':
                            return '还没有完成的任务!';
                        default:
                            return '还没有任务,添加一个吧!';
                    }
                }
            },
            
            methods: {
                // 添加新任务
                addTodo() {
                    if (this.newTodo.trim() === '') return;
                    
                    this.todos.push({
                        id: this.nextId++,
                        text: this.newTodo.trim(),
                        completed: false,
                        createdAt: new Date()
                    });
                    
                    this.newTodo = ''; 
                },
                
                // 删除任务
                removeTodo(id) {
                    this.todos = this.todos.filter(todo => todo.id !== id);
                }
            },
            
            // 生命周期钩子
            created() {
                console.log('TodoList应用已创建');
                // 加载本地存储的数据。。。
            },
            
            mounted() {
                console.log('TodoList应用已挂载');
            }
        });
    </script>
</body>
</html>

这个TodoList应用综合运用了:v-model@click@keyupv-ifv-forcomputed:class生命周期钩子

七、总结

7.1 核心概念

  1. 数据驱动:Vue的核心思想,数据变化自动更新视图
  2. 指令系统:v-bind, v-model, v-for, v-if等指令的强大功能
  3. 组件化:将UI拆分为独立可复用的组件
  4. 生命周期:理解组件从创建到销毁的完整过程
  5. 响应式原理:理解数据变化的侦测机制

7.2 组件设计原则

// 好的组件设计
Vue.component('user-profile', {
    props: {
        user: {
            type: Object,
            required: true,
            validator: function(value) {
                return value.name && value.email;
            }
        }
    },
    template: `
        <div class="user-profile">
            <img :src="user.avatar" :alt="user.name">
            <h3>{{ user.name }}</h3>
            <p>{{ user.email }}</p>
        </div>
    `
});

// 不好的组件设计(props验证不足,模板混乱)
Vue.component('bad-component', {
    props: ['user'],
    template: '<div>...</div>' // 模板过长,难以维护
});

7.3 状态管理建议

// 对于复杂应用,考虑使用Vuex
// 对于简单应用,合理组织组件间通信

// 好的状态组织
new Vue({
    data: {
        // 相关状态分组
        user: {
            profile: {},
            preferences: {}
        },
        ui: {
            loading: false,
            sidebarOpen: true
        }
    }
});

7.4 常见问题

常见问题 错误做法 正确做法
数组更新 this.items[0] = newValue this.$set(this.items, 0, newValue)
对象属性 this.obj.newProp = value this.$set(this.obj, 'newProp', value)
异步更新 直接操作DOM 使用this.$nextTick()
事件监听 不清理事件监听器 beforeDestroy中清理

结语

至此Vue.js基础就学习完了,想要掌握更多的Vue.js知识可去官网深入学习,掌握好Vue.js,uni-app学习就会事半功倍。


如果觉得本文对你有帮助,请一键三连(点赞、关注、收藏)支持一下!有任何问题欢迎在评论区留言讨论。

在 Vue 3 项目中使用 MQTT 获取数据

作者 蚘雨溪
2025年11月5日 16:22

在 Vue 3 项目中使用 MQTT 获取数据,需通过 MQTT.js 库实现与 MQTT 服务器的连接、订阅主题及消息处理。以下是分步指南:

一、初始化 Vue 3 项目

使用 Vue CLI 或 Vite 创建项目:

bash
1npm create vue@latest  # 使用 Vue CLI
2# 或
3npm create vite@latest my-vue3-mqtt -- --template vue

二、安装 MQTT.js 库

通过 npm 或 yarn 安装:

bash
1npm install mqtt
2# 或
3yarn add mqtt

三、集成 MQTT 到 Vue 3 组件

1. 创建 MQTT 连接

在组件中引入 mqtt 并建立连接:

javascript
1import { onMounted, onBeforeUnmount, ref } from 'vue';
2import mqtt from 'mqtt';
3
4export default {
5  setup() {
6    const client = ref(null);
7    const messages = ref([]);
8
9    const connectMqtt = () => {
10      const options = {
11        keepalive: 30,
12        clientId: `vue3_${Math.random().toString(16).slice(2)}`,
13        username: 'your_username', // 可选
14        password: 'your_password', // 可选
15        clean: true,
16      };
17
18      // 使用 WebSocket 协议(ws:// 或 wss://)
19      client.value = mqtt.connect('ws://your_mqtt_server:8083/mqtt', options);
20
21      client.value.on('connect', () => {
22        console.log('Connected to MQTT Broker');
23        // 订阅主题
24        client.value.subscribe('test/topic', { qos: 1 }, (err) => {
25          if (!err) console.log('Subscription successful');
26        });
27      });
28
29      client.value.on('message', (topic, message) => {
30        const data = JSON.parse(message.toString());
31        messages.value.push({ topic, data });
32        console.log(`Received: ${message.toString()} from ${topic}`);
33      });
34
35      client.value.on('error', (err) => {
36        console.error('MQTT Error:', err);
37      });
38
39      client.value.on('reconnect', () => {
40        console.log('Reconnecting...');
41      });
42
43      client.value.on('close', () => {
44        console.log('Disconnected from MQTT Broker');
45      });
46    };
47
48    onMounted(() => {
49      connectMqtt();
50    });
51
52    onBeforeUnmount(() => {
53      if (client.value) {
54        client.value.end();
55      }
56    });
57
58    return { messages };
59  }
60};

2. 发送消息(可选)

若需发布消息,可添加方法:

javascript
1const publishMessage = (topic, payload) => {
2  if (client.value) {
3    client.value.publish(topic, JSON.stringify(payload), { qos: 1 }, (err) => {
4      if (err) console.error('Publish failed:', err);
5      else console.log('Message published');
6    });
7  }
8};

四、模板中显示消息

在组件模板中渲染接收到的消息:

html
1<template>
2  <div>
3    <h2>MQTT Messages</h2>
4    <ul>
5      <li v-for="(msg, index) in messages" :key="index">
6        <strong>{{ msg.topic }}:</strong> {{ msg.data }}
7      </li>
8    </ul>
9  </div>
10</template>

五、关键配置说明

  1. 连接协议

    • 浏览器端必须使用 ws://(非加密)或 wss://(加密)协议。
    • 端口通常为 8083(ws)或 8084(wss),需与服务器配置一致。
  2. QoS 等级

    • 0:至多一次(可能丢失)。
    • 1:至少一次(可能重复)。
    • 2:只有一次(确保到达)。
  3. 断线重连

    • MQTT.js 默认自动重连,可通过 reconnectPeriod 调整重试间隔(毫秒)。
  4. 安全认证

    • 若服务器启用认证,需在 options 中配置 username 和 password

六、完整示例代码

javascript
1<script setup>
2import { ref, onMounted, onBeforeUnmount } from 'vue';
3import mqtt from 'mqtt';
4
5const client = ref(null);
6const messages = ref([]);
7
8const connectMqtt = () => {
9  const options = {
10    keepalive: 30,
11    clientId: `vue3_${Math.random().toString(16).slice(2)}`,
12    clean: true,
13  };
14
15  client.value = mqtt.connect('ws://your_mqtt_server:8083/mqtt', options);
16
17  client.value.on('connect', () => {
18    console.log('Connected');
19    client.value.subscribe('test/topic', { qos: 1 }, (err) => {
20      if (!err) console.log('Subscribed');
21    });
22  });
23
24  client.value.on('message', (topic, message) => {
25    messages.value.push({ topic, data: JSON.parse(message.toString()) });
26  });
27
28  client.value.on('error', (err) => {
29    console.error('Error:', err);
30  });
31};
32
33onMounted(() => {
34  connectMqtt();
35});
36
37onBeforeUnmount(() => {
38  if (client.value) client.value.end();
39});
40</script>
41
42<template>
43  <div>
44    <h2>MQTT Messages</h2>
45    <ul>
46      <li v-for="(msg, index) in messages" :key="index">
47        <strong>{{ msg.topic }}:</strong> {{ msg.data }}
48      </li>
49    </ul>
50  </div>
51</template>

七、常见问题解决

  1. 连接失败

    • 检查服务器地址是否为 ws:// 或 wss://
    • 确认端口和路径(如 /mqtt)是否正确。
  2. 消息乱码

    • 使用 message.toString() 转换 Uint8Array 为字符串。
  3. 跨域问题

    • 若服务器未配置 CORS,需通过代理或修改服务器配置解决。
  4. 性能优化

    • 高频消息时,使用防抖或节流减少渲染次数。
    • 合并消息或使用 QoS 0 降低开销。

从 WebSocket 到 SSE:实时通信的轻量化演进

作者 大知闲闲i
2025年11月5日 16:11

在实时 Web 应用开发领域,WebSocket 长期占据主导地位。但当应用场景只需要单向数据推送时,Server-Sent Events (SSE) 提供了更轻量、更优雅的解决方案。

===============================================================================================

为什么需要轻量化实时通信?

想象这些常见需求:

  • 实时数据大屏,持续更新业务指标

  • 新闻网站推送突发新闻

  • 后台系统发送任务完成通知

在这些场景中,数据流本质上是单向的——从服务器到客户端。使用 WebSocket 就如同为了寄送明信片而建立专用高速公路,功能过剩且成本高昂。

认识 Server-Sent Events (SSE)

SSE 允许服务器通过单一、持久的 HTTP 连接向客户端推送数据更新,其魅力在于极简设计。

核心优势

1. 基于标准 HTTP 协议

  • 无需特殊服务器支持

  • 天然兼容现有网络基础设施

  • 协议开销极小

2. 客户端实现极其简单

javascript

// 前端实现仅需几行代码
const eventSource = new EventSource('/updates');

eventSource.onmessage = function(event) {
    const data = JSON.parse(event.data);
    updateUI(data);
};

eventSource.onerror = function() {
    console.log('连接错误');
};

技术对比:SSE vs WebSocket

实战演示:实时时钟应用

后端实现 (Node.js + Express)

app.get('/time-stream', (req, res) => {
    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive',
        'Access-Control-Allow-Origin': '*'
    });

    // 每秒发送当前时间
    const timer = setInterval(() => {
        const data = {
            time: new Date().toISOString(),
            timestamp: Date.now()
        };
        
        res.write(`data: ${JSON.stringify(data)}\n\n`);
    }, 1000);

    // 客户端断开连接时清理
    req.on('close', () => {
        clearInterval(timer);
    });
});

前端实现

<!DOCTYPE html>
<html>
<head>
    <title>实时时钟 - SSE 演示</title>
    <style>
        .clock {
            font-family: 'Courier New', monospace;
            font-size: 24px;
            padding: 20px;
            background: #f5f5f5;
            border-radius: 8px;
            text-align: center;
        }
        .status {
            margin-top: 10px;
            font-size: 14px;
            color: #666;
        }
    </style>
</head>
<body>
    <div class="clock" id="timeDisplay">连接中...</div>
    <div class="status" id="status">准备建立 SSE 连接</div>

    <script>
        const timeDisplay = document.getElementById('timeDisplay');
        const statusDisplay = document.getElementById('status');

        function connectSSE() {
            const eventSource = new EventSource('/time-stream');
            
            eventSource.onopen = () => {
                statusDisplay.textContent = '已连接 - ' + new Date().toLocaleTimeString();
                statusDisplay.style.color = 'green';
            };

            eventSource.onmessage = (event) => {
                const data = JSON.parse(event.data);
                timeDisplay.textContent = new Date(data.time).toLocaleString();
            };

            eventSource.onerror = () => {
                statusDisplay.textContent = '连接错误 - ' + new Date().toLocaleTimeString();
                statusDisplay.style.color = 'red';
                
                // 3秒后自动重连
                setTimeout(() => {
                    eventSource.close();
                    connectSSE();
                }, 3000);
            };

            return eventSource;
        }

        // 初始化连接
        let sseConnection = connectSSE();
    </script>
</body>
</html>

生产环境最佳实践

1. 错误处理与重连

class RobustSSE {
    constructor(url, options = {}) {
        this.url = url;
        this.retryCount = 0;
        this.maxRetries = options.maxRetries || 5;
        this.connect();
    }

    connect() {
        this.eventSource = new EventSource(this.url);
        
        this.eventSource.onopen = () => {
            this.retryCount = 0;
            console.log('SSE 连接成功');
        };

        this.eventSource.onerror = () => {
            if (this.retryCount < this.maxRetries) {
                this.retryCount++;
                setTimeout(() => this.connect(), 1000 * this.retryCount);
            }
        };
    }
}

2. 性能优化建议

  • 合理设置连接超时时间

  • 使用连接池管理多个 SSE 连接

  • 实施消息频率限制

  • 考虑使用 Nginx 代理处理连接

适用场景分析

推荐使用 SSE

  • ✅ 实时数据仪表盘

  • ✅ 新闻/资讯推送

  • ✅ 系统状态监控

  • ✅ 价格变动通知

  • ✅ 社交媒体动态更新

推荐使用 WebSocket

  • ✅ 实时聊天应用

  • ✅ 多人在线游戏

  • ✅ 协同编辑工具

  • ✅ 实时音视频通信

结论

在技术选型时,我们应该基于实际需求选择最合适的工具。WebSocket 功能强大但复杂,SSE 轻量简洁且高效。

关键决策点:你的应用是否需要客户端向服务器发送实时数据?

如果答案是否定的,SSE 以其原生支持、自动重连和极简实现等优势,将成为提升开发效率和系统稳定性的理想选择。

通过拥抱合适的轻量化技术,我们能够在保证功能完整性的同时,显著降低系统复杂度和维护成本

关于表单,别做工具库舔狗

2025年11月4日 16:38

别做工具库舔狗,我喂自己袋盐 ————于晏 🤦‍♂️

聊聊表单方案的选择:原生表单和表单库到底该怎么选?可能对刚入门的兄弟们有点用,老鸟轻喷🤦‍♂️🤦‍♂️🤦‍♂️,为什么写这个低级得玩意,纯粹是因为需求变少,给自己找点事干,然后就是想加强一点对一些代码得自我思考能力,思考是思考了,但不多🤐


u=177994027,2966675515&fm=253&fmt=auto&app=138&f=JPEG.webp

背景 😊

最近写了个账号删除表单,字段不多:邮箱、删除原因(单选)、两个确认复选框。验证逻辑也简单:必填项检查 + 邮箱格式校验。一开始纠结要不要用 React Hook Form 这类表单库,最后还是用了原生表单 + useState 实现。

倒不是说表单库不好,只是稍微思考了一下:我的删号这个场景入口在app,然后app点击得时候通过我给出得地址带过来加密得用户userid+请求token,然后我这边解析出userid和token+表单字段提交给后端,然后其实我的项目本身做的就是一些app内嵌h5功能,分享,协议,这次加了个邀请,然后目前没有涉及到很复杂得表单,用第三方得表单库,我认为是冗余得(这个纯看自己了,冗余不冗余也不知道各位彦祖怎么看),这种场景其实很常见,例:当你跟面试官介绍你自己使用得工具库时,你使用echartsreact-echarts,然后react-reduxzustand得选择,为什么这样选择?理由是什么?业务决定?又或者性能?

Suggestion.gif


为什么要纠结这个? 🤔

刚学 React 的时候总觉得不用个啥库就不专业,写表单必须上 React Hook Form 或者 AntD Form。后来写多了才发现,简单场景下强行用库反而会:

  • 增加依赖体积(哪怕 10KB 也是额外加载)
  • 多一层学习成本(register、handleSubmit 这些 API 得记)
  • 调试变复杂(库的内部状态偶尔会让人摸不着头脑)

看看我这个删除账号表单的核心逻辑:

tsx

// 状态管理
const [email, setEmail] = useState("");
const [reason, setReason] = useState("");
const [confirm1, setConfirm1] = useState(false);
const [confirm2, setConfirm2] = useState(false);
const [errors, setErrors] = useState({
  email: "",
  reason: "",
  confirm1: "",
  confirm2: "",
});

// 验证逻辑
const handleSubmit = (e: React.FormEvent) => {
  e.preventDefault();
  const newErrors = { email: "", reason: "", confirm1: "", confirm2: "" };
  let valid = true;

  if (email && !/^[\w.-]+@[\w.-]+.\w+$/.test(email)) {
    newErrors.email = "Invalid email format";
    valid = false;
  }
  if (!reason) newErrors.reason = "Please tell us why you're leaving";
  if (!confirm1) newErrors.confirm1 = "Required confirmation";
  if (!confirm2) newErrors.confirm2 = "Required confirmation";

  setErrors(newErrors);
  if (valid) setShowModal(true);
};

这段代码虽然简单,但胜在:

  • 零依赖,移植性强
  • 逻辑直观,新手也能看懂
  • 样式完全自定义(那个渐变色背景和自定义复选框轻松实现)

如果换成 React Hook Form,代码量可能差不多甚至更多,反而显得冗余。


什么时候该用表单库? 🤔

但如果遇到下面这些场景,我肯定会毫不犹豫用表单库:

  1. 字段数量多(比如注册表单有 10 + 字段)

    • 原生需要写 N 个 useState 和 onChange,重复劳动
    • 表单库一行 register 就能搞定
  2. 复杂验证(跨字段校验、异步校验)

    • 比如 "密码强度校验"、"两次密码一致"
    • 用 Zod 配合 React Hook Form,声明式写法秒杀手动 if-else
  3. 动态表单(动态添加 / 删除字段组)

    • 比如 "紧急联系人" 可以添加多个
    • 原生需要手动管理数组状态,表单库自带数组处理

看看这种场景下的代码对比(简化版):

jsx

// 原生实现跨字段验证(确认密码)
const [password, setPassword] = useState('');
const [confirmPwd, setConfirmPwd] = useState('');
const [errors, setErrors] = useState({});

const validate = () => {
  const newErrors = {};
  if (password !== confirmPwd) {
    newErrors.confirmPwd = "两次密码不一致";
  }
  setErrors(newErrors);
  return Object.keys(newErrors).length === 0;
};

// React Hook Form + Zod实现
const schema = z.object({
  password: z.string().min(6),
  confirmPwd: z.string(),
}).refine(data => data.password === data.confirmPwd, {
  message: "两次密码不一致",
  path: ["confirmPwd"],
});

const { register, formState: { errors } } = useForm({
  resolver: zodResolver(schema)
});

明显能感觉到复杂场景下表单库的优势 —— 代码更简洁,逻辑更清晰。


我的选择标准(实战总结)

  1. 先看字段数量

    • ≤5 个字段:优先原生(除非有特殊需求)
    • ≥6 个字段:考虑表单库
  2. 再看验证复杂度

    • 只有必填 / 简单格式校验:原生足够
    • 有跨字段 / 异步校验:必须表单库
  3. 最后看样式需求

    • 高度定制化设计:原生 + CSS(避免 UI 库样式冲突)
    • 常规设计:可以用表单库 + UI 组件(如 MUI、Shadcn)
  4. tips🤔🤷‍♂️

    • 后台系统,复杂页面提交无脑工具库
    • 简单表单,涉及到较少表单逻辑原生

回到我那个删除账号表单:

  • 4 个字段(邮箱、原因、两个复选框)
  • 验证只有必填和邮箱格式
  • 样式有渐变背景、自定义复选框
  • 结论:原生实现是最优解 😴

我的表单展示

screencapture-localhost-3001-deletion-2025-11-04-16_36_23.png

常见误区

  1. 认为用表单库就是高级

    • 其实过度使用库会导致代码冗余,增加维护成本
  2. 原生表单一定比库性能好

    • 复杂表单场景下,表单库(如 React Hook Form 基于非受控组件)性能反而更好
  3. 样式和逻辑必须绑定

    • 最好分开处理:用 Tailwind/CSS Modules 写样式,用状态管理工具处理逻辑
  4. ~~ 追求一刀切方案~~ (思考)

    • 项目里完全可以混合使用:简单表单用原生,复杂表单用库 (彦祖们怎么看这个问题,我其实是喜欢统一)

最后想说的

你看看有人理你吗.gif

写代码和做事一样,讲究 "恰到好处"。不用盲目追求新技术新工具,也不能固守旧方法不变。

判断一个方案好不好,不是看它多先进,而是看它能不能用最少的代码解决问题,同时保证可维护性和用户体验。

就像我这个删除账号表单,用原生实现可能在某些人看来不够 "高级",但对当前场景来说,它就是最合适的方案。

从一开始纠结用不用库,到分析场景找到最优解,这个过程本身比结论更有价值吧。毕竟编程这东西,没有绝对的对错,只有适合不适合🤐🤐🤐

彻底搞懂 CSS 盒子模型 box-sizing:小白也能看懂的布局核心

作者 T___T
2025年11月4日 14:39

在 CSS 布局中,“盒子模型” 是绕不开的基础概念 —— 新手写布局时总遇到 “元素明明设了宽度,却还是撑破容器”“两个盒子死活不能同行排列” 的问题,本质都是没搞懂盒子模型的计算逻辑。今天就结合实际代码,从基础到进阶,把标准盒模型和怪异盒模型讲透,让小白也能精准控制元素布局!

一、先搞懂:页面上的 “盒子” 到底占多大地方?

不管是 div、span 还是 img,页面上所有元素都能看作一个 “盒子”。这个盒子的实际占位大小,由 4 个核心部分组成(从内到外):

  1. 内容区(content) :盒子的核心,用来放文字、图片等内容,我们用 width/height 直接设置;
  2. 内边距(padding) :内容区和边框之间的空隙,比如给按钮加 padding 能让文字不紧贴边框;
  3. 边框(border) :盒子的 “边框线”,border 的宽度和样式会影响盒子的视觉大小;
  4. 外边距(margin) :盒子和其他元素之间的 “空隙”,用来控制元素间距,不影响盒子自身大小。

⚠️ 关键提醒:margin 是盒子 “外部的空隙”,永远不包含在盒子自身的 “宽高” 里,只影响盒子和其他元素的距离;而 contentpaddingborder 才是决定盒子 “自身大小” 的核心 —— 这也是两种盒模型的核心区别所在!

二、两种盒子模型:标准盒模型 vs 怪异盒模型

CSS 中盒子模型分为两种,核心差异是「width/height 到底包含哪些部分」,我们结合之前的实际代码来拆解(重点!)。

先回顾之前的代码核心配置:

.container {
  width: 1200px; /* 父容器固定宽度 */
  margin: 0 auto; /* 水平居中 */
}
.box {
  width: 580px;    /* 设定宽度 */
  height: 100px;   /* 设定高度 */
  padding: 5px;    /* 内边距:上下左右各5px */
  border: 1px solid #000; /* 边框:上下左右各1px */
  margin: 0 10px;  /* 外边距:左右各10px */
  box-sizing: border-box; /* 怪异盒模型 */
  display: inline-block; /* 行内块,实现同行排列 */
}

1. 标准盒模型(默认):box-sizing: content-box

这是浏览器默认的盒模型,新手最容易踩坑的地方!

  • 核心规则width/height 只等于「内容区(content)」的大小,padding 和 border 会额外撑开盒子的总宽高。
  • 实际大小计算(用上面的代码举例):盒子自身总宽度 = 内容区宽度(width:580px) + padding 左右(5px+5px) + border 左右(1px+1px)= 580 + 10 + 2 = 592px;盒子在页面的占位宽度 = 自身总宽度(592px) + margin 左右(10px+10px)= 592 + 20 = 612px;
  • 布局坑点:如果父容器 .container 宽度是 1200px,两个这样的盒子占位总宽度是 612px×2=1224px,超过父容器宽度,第二个盒子会被挤到下一行,布局直接错乱!

简单说:标准盒模型下,width 只是 “内容的宽度”,padding 和 border 会让盒子 “变大”,新手很难精准控制尺寸。

2. 怪异盒模型(推荐):box-sizing: border-box

也叫 IE 盒模型(最早由 IE 浏览器实现,后来成为通用标准),是实际开发中必用的盒模型!

  • 核心规则width/height 已经包含了「内容区(content)+ padding + border」,padding 和 border 会向内压缩内容区,而不会让盒子自身变大。
  • 实际大小计算(还是用上面的代码):盒子自身总宽度 = width:580px(已经包含 content + padding + border);内容区实际宽度 = 设定宽度(580px) - padding 左右(10px) - border 左右(2px)= 568px;盒子在页面的占位宽度 = 自身总宽度(580px) + margin 左右(20px)= 600px;
  • 布局优势:两个盒子的占位总宽度是 600px×2=1200px,刚好填满父容器 .container,完美实现同行排列 —— 这也是之前的代码为什么要设置 box-sizing: border-box 的原因!

简单说:怪异盒模型下,width 就是盒子 “最终的自身宽度”,padding 和 border 只会挤内部内容,不会影响外部布局,尺寸控制超精准。

🎯一张表对比,再也不混淆:

盒模型类型 width/height 包含范围 盒子自身总宽计算方式 新手友好度 实际开发使用率
标准盒模型(默认) 仅内容区(content) content + padding + border ❌ 容易踩坑 10%(极少用)
怪异盒模型(推荐) content + padding + border 直接等于设定的 width ✅ 精准可控 90%(必用)

三、小白实战:为什么一定要用怪异盒模型?

举个新手最常遇到的场景:做一个 300px 宽的按钮,带 2px 边框和 15px 内边距。

用标准盒模型(坑!):

.button {
  width: 300px;
  padding: 15px;
  border: 2px solid #000;
  /* box-sizing: content-box; 默认 */
}
  • 按钮实际宽度 = 300(content) + 15×2(padding) + 2×2(border)= 334px;
  • 如果你父容器是 300px 宽,按钮会直接撑破容器,超出显示!

✅用怪异盒模型(稳!):

.button {
  width: 300px;
  padding: 15px;
  border: 2px solid #000;
  box-sizing: border-box; /* 关键设置 */
}
  • 按钮实际宽度 = 300px(包含 content + padding + border);
  • 内容区宽度 = 300 - 30(padding) - 4(border)= 266px;
  • 按钮完美适配 300px 父容器,不管怎么调 padding 和 border,按钮总宽都不变!

四、进阶技巧:全局统一盒模型(实战必用)

实际开发中,我们不会给每个元素单独设置 box-sizing: border-box,而是用通配符选择器全局统一,避免遗漏:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box; /* 全局启用怪异盒模型 */
}
  • 作用:让页面所有元素都使用怪异盒模型,布局时不用反复计算 padding 和 border,效率翻倍;
  • 补充:如果个别元素需要用标准盒模型,再单独给它设置 box-sizing: content-box 即可(极少场景)。

五、深入拓展:盒模型和文档流、定位的关系(进阶)

小白掌握上面的内容已经能应对 80% 布局,但想进阶还要知道:盒模型的 “占位计算” 会受「文档流」和「定位」影响:

1. 文档流中的元素(默认情况)

像 div(块级元素)、inline-block(行内块元素),都会遵循盒模型的占位规则 ——margin 会影响周围元素的位置(比如块级元素的 margin-top 会推开上方元素)。

2. 脱离文档流的元素(position 特殊值)

当元素设置 position: absolute(绝对定位)或 position: fixed(固定定位)时:

  • 元素会脱离文档流,不再占据原来的 “位置”,margin 不会影响其他元素的布局;
  • 但元素自身的大小计算,依然遵循 box-sizing 的规则(比如 width:200px + box-sizing: border-box,自身总宽还是 200px);
  • 实际占位由 top/left 等属性控制,但自身大小的计算逻辑不变。

举个例子:

.box {
  position: absolute;
  top: 100px;
  left: 100px;
  width: 200px;
  padding: 20px;
  border: 3px solid #000;
  box-sizing: border-box;
}
  • 元素自身总宽 = 200px(包含 content + padding + border);
  • 内容区宽度 = 200 - 40(padding) - 6(border)= 154px;
  • 元素会定位在页面 (100px, 100px) 处,自身大小不受文档流影响,但盒模型计算规则没变。

✅总结:新手必记的 3 个要点

  1. 页面上所有元素都是 “盒子”,占位由 content + padding + border + margin 组成;
  2. 浏览器默认是标准盒模型,容易踩坑,实际开发必用 box-sizing: border-box(怪异盒模型);
  3. 全局设置 * { box-sizing: border-box },能让布局尺寸精准可控,效率翻倍。

掌握盒子模型,你就能解决 80% 的 CSS 布局问题 —— 后续学习 Flex、Grid 等高级布局,也需要以盒子模型为基础。现在就打开代码编辑器,试试修改 paddingborder 看看两种盒模型的差异,实践一次就再也忘不掉!

🔁 JavaScript中的迭代全攻略 - for/while/迭代器/生成器/for await...of详解

2025年11月4日 09:42

🎯 学习目标:全面掌握 JavaScript 的迭代语句与迭代协议,能在不同数据结构与同步/异步场景下选择正确的遍历方式,并写出高性能、可维护的代码。

📊 难度等级:中级
🏷️ 技术标签:#JavaScript #迭代器 #生成器 #for-of #for-in #for await...of
⏱️ 阅读时间:约9分钟


🌟 引言

在日常 JavaScript 开发中,遍历数据“看起来都差不多”,但一旦涉及对象、Map/Set、类数组、异步数据流、性能优化,就容易踩坑:

  • 数组用 for...in 导致顺序和性能问题;
  • 把对象当可迭代结构用 for...of 直接报错;
  • 自定义迭代器和生成器不会用,错过优雅的惰性计算;
  • 大数据流用 for await...of 才是正确姿势,却不熟悉协议细节。

今天我用 7 个核心技巧,系统讲清“语句与协议”的边界与最佳实践,帮你写出更高效、可控的迭代代码。


💡 核心技巧详解

1. for vs while:基础循环的选择

🔍 应用场景

索引驱动的数组遍历、需要精细控制起止与步长、或在条件驱动下执行循环。

❌ 常见问题

while 忘记更新计数器,或数组越界导致死循环/错误。

// ❌ 计数器更新缺失可能导致死循环
let i = 0;
while (i < 3) {
  console.log('loop:', i);
  // 缺失 i++
}

✅ 推荐方案

/**
 * 使用 for 循环安全遍历数组
 * @param {any[]} list - 任意元素数组
 * @returns {any[]} 同步收集的结果
 */
const traverseWithFor = (list) => {
  const results = [];
  for (let i = 0; i < list.length; i++) {
    // 索引明确,性能稳定
    results.push(list[i]);
  }
  return results;
};

/**
 * 使用 while 循环在条件驱动下遍历
 * @param {number} start - 起始值
 * @param {number} end - 结束值(不含)
 * @returns {number[]} 生成的序列
 */
const traverseWithWhile = (start, end) => {
  const seq = [];
  let i = start;
  while (i < end) {
    seq.push(i);
    i += 1; // 确保条件推进
  }
  return seq;
};

💡 核心要点

  • for 适合索引明确的顺序遍历;
  • while 适合条件驱动的循环,注意推进条件;
  • 数据量大时优先 for,可微调步长与边界以获得稳定性能。

2. for...of vs for...in:遍历语义不要混用

🔍 应用场景

for...of 用于“可迭代对象”(数组、字符串、Map、Set、生成器等);for...in 用于对象的“可枚举属性键”。

❌ 常见问题

对数组使用 for...in 导致遍历到原型属性、顺序不稳定;对普通对象使用 for...of 直接报错。

// ❌ 对数组使用 for...in(遍历索引字符串,顺序可能受影响)
const arr = [10, 20, 30];
for (const k in arr) {
  console.log('index string:', k); // '0', '1', '2'
}

// ❌ 对对象使用 for...of(非可迭代,抛错)
// for (const v of { a: 1 }) {} // TypeError: {} is not iterable

✅ 推荐方案

/**
 * 遍历可迭代对象(数组/字符串/Map/Set)
 * @param {Iterable<any>} iterable - 可迭代对象
 * @returns {any[]} 收集到的值
 */
const collectIterableValues = (iterable) => {
  const values = [];
  for (const v of iterable) {
    values.push(v);
  }
  return values;
};

/**
 * 遍历对象自有可枚举属性键
 * @param {Record<string, any>} obj - 普通对象
 * @returns {string[]} 键列表
 */
const collectOwnKeys = (obj) => {
  const keys = [];
  for (const k in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, k)) keys.push(k);
  }
  return keys;
};

💡 核心要点

  • for...of 读取“值”;for...in 读取“键”;
  • 对象遍历更推荐 Object.keys/values/entries 保持可控与可读;
  • 避免对数组使用 for...in,不稳定且性能较差。

3. 迭代协议:可迭代与迭代器

🔍 应用场景

自定义数据结构的迭代行为,支持惰性求值与可控遍历。

✅ 推荐方案

/**
 * 自定义可迭代范围 [start, end)
 * @param {number} start - 起始
 * @param {number} end - 结束(不含)
 * @returns {Iterable<number>} 可迭代范围
 */
const createRange = (start, end) => ({
  [Symbol.iterator]: () => {
    let i = start;
    return {
      next: () => (i < end ? { value: i++, done: false } : { value: undefined, done: true })
    };
  }
});

💡 核心要点

  • 可迭代对象需实现 [Symbol.iterator] 返回迭代器;
  • 迭代器需实现 next() 返回 { value, done }
  • 惰性迭代避免一次性创建大量中间数据,利于性能与内存。

4. 生成器(Generator):更优雅的迭代器写法

🔍 应用场景

用更简洁的语法生成迭代序列,支持 yield 惰性输出与中断。

/**
 * 生成器创建斐波那契序列(前 n 个)
 * @param {number} n - 个数
 * @returns {Iterable<number>} 斐波那契序列
 */
const fibonacci = function* (n) {
  let a = 0, b = 1, i = 0;
  while (i < n) {
    yield a;
    [a, b] = [b, a + b];
    i += 1;
  }
};

💡 核心要点

  • 生成器本质是迭代器,语法更简洁;
  • yield 提供惰性产生值的能力;
  • 可搭配 for...of 直接遍历。

5. 异步迭代与 for await...of:数据流的正确遍历

🔍 应用场景

分页加载、流式文件读写、网络请求批次处理等异步数据源。

/**
 * 异步生成器:模拟批次拉取数据
 * @param {number} batches - 批次数
 * @returns {AsyncIterable<number>} 异步可迭代数据
 */
const fetchBatches = async function* (batches) {
  for (let i = 1; i <= batches; i++) {
    await new Promise((r) => setTimeout(r, 5));
    yield i; // 每批返回一个结果
  }
};

/**
 * 使用 for await...of 收集异步迭代结果
 * @param {AsyncIterable<any>} asyncIterable - 异步可迭代
 * @returns {Promise<any[]>} 收集到的值
 */
const collectAsync = async (asyncIterable) => {
  const out = [];
  for await (const chunk of asyncIterable) {
    out.push(chunk);
  }
  return out;
};

💡 核心要点

  • for await...of 遍历 AsyncIterable/AsyncGenerator
  • 避免将 Promise[] 直接用 for await...of,应先 Promise.all 或转为异步迭代;
  • 异步迭代是处理大数据流的内存友好方案。

6. 遍历不同数据结构:Map/Set/字符串/类数组

/**
 * 遍历 Map/Set/字符串/类数组的统一收集器
 * @param {any} target - 目标数据结构
 * @returns {any[]} 收集到的值
 */
const collectValues = (target) => {
  if (target instanceof Map) return Array.from(target.entries());
  if (target instanceof Set) return Array.from(target.values());
  if (typeof target === 'string') return Array.from(target);
  if (typeof target.length === 'number') return Array.from(target); // NodeList/HTMLCollection
  return [];
};

💡 核心要点

  • Map 默认遍历 [key, value]Set 遍历值;
  • 字符串是可迭代对象,可被 for...of 按字符遍历;
  • 类数组(如 NodeList)可用 Array.from 转正,避免 for...in

7. 性能与边界:选择正确的迭代策略

🎯 实战建议

  • 大数组性能更稳定的通常是索引 for
  • 需要可读性与语义清晰,优先 for...of
  • 对象遍历优先 Object.keys/entries,避免 for...in 的原型链干扰;
  • 大数据流与分页拉取用 for await...of,避免一次性内存爆炸;
  • 迭代中修改容器(增删元素)要谨慎,优先生成快照或用惰性策略。

📊 技巧对比总结

技巧 使用场景 优势 注意事项
for 索引驱动数组 性能稳定,可控 注意边界与步长
while 条件驱动循环 灵活 防止死循环
for...of 可迭代对象 语义清晰 不适用于普通对象
for...in 对象键遍历 简单 避免用于数组,过滤原型链
迭代协议 自定义结构 惰性、可控 正确实现 next()
生成器 简洁迭代器 语法优雅 正确使用 yield
for await...of 异步数据流 内存友好 仅用于异步可迭代

🎯 实战应用建议

最佳实践

  1. 使用 for...of 遍历集合类型,提升语义与可读性。
  2. 对象遍历使用 Object.entriesfor...of 组合处理键值对。
  3. 大数据流统一封装为 AsyncGenerator,用 for await...of 消费。
  4. 根据性能需求选择 forfor...of,避免对数组使用 for...in

性能考虑

  • 大数组在热路径中优先 for
  • 遍历中避免闭包捕获大对象,及时释放引用;
  • 生成器/异步生成器的惰性策略可显著降低内存峰值。

💡 总结

这 7 个迭代技巧覆盖了“语句与协议”的完整谱系:从 for/whilefor...of/for...in,再到迭代器/生成器与异步迭代。掌握它们后,你的代码将:

  1. 更语义化,遍历意图清晰;
  2. 更高性能,避免不必要的中间数据与错误用法;
  3. 更易维护,统一封装迭代行为并复用。

🔗 相关资源


💡 今日收获:理解迭代语句与协议的边界,选择合适的遍历方式,写出可读、可控、性能稳定的迭代代码。

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

单点登录中权限同步的解决方案及验证策略

2025年11月4日 09:36

sso单点登录的权限变更同步的三种核心方案(实时同步、半实时同步、被动同步)

一、实时同步:权限变更时主动通知子应用

核心逻辑:权限一旦变更,立即通过 “主动推送” 通知相关子应用,子应用实时更新本地权限数据。适用场景:紧急权限变更(如用户离职被移除所有权限、临时禁止访问敏感系统),要求 “立即生效”。

1. 实现流程

以 “管理员在权限中心移除用户 A 对「财务系统」的「审批权限」” 为例:

exported_image.png

2. 关键技术:WebHook 回调
  • 权限中心配置:提前录入各子应用的 “权限同步接口”(WebHook 地址),例如财务系统的接口为 https://finance-app.example.com/api/permission/sync
  • 推送格式:权限中心向子应用接口发送 POST 请求,携带用户 ID、应用 ID、最新权限列表。

权限中心推送代码示例(Node.js)

// 权限中心:当权限变更时触发
async function onPermissionChange(userId, appId, newPermissions) {
  // 1. 先更新权限中心数据库(省略)
  // 2. 获取子应用的 WebHook 地址(从配置中读取)
  const webhookUrl = getAppWebhook(appId); // 如 "https://finance-app.example.com/api/permission/sync"
  // 3. 向子应用推送最新权限
  try {
    await fetch(webhookUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        userId: userId, // "user123"
        appId: appId,   // "finance-app"
        permissions: newPermissions, // ["view", "export"](移除了"approve")
        timestamp: Date.now(),
        sign: generateSign(newPermissions) // 签名,防止篡改
      })
    });
    console.log(`向${appId}同步权限成功`);
  } catch (err) {
    // 失败重试机制(如存入消息队列,5分钟后重试)
    addToRetryQueue({ userId, appId, newPermissions });
    console.error(`同步失败,已加入重试队列:${err.message}`);
  }
}

子应用接收代码示例(财务系统,Node.js/Express)

// 财务系统:接收权限同步的接口
app.post('/api/permission/sync', async (req, res) => {
  const { userId, permissions, sign } = req.body;
  // 1. 验证签名(防止伪造请求)
  if (!verifySign(permissions, sign)) {
    return res.status(403).send('签名无效');
  }
  // 2. 更新本地缓存(如 Redis)中用户的权限
  await redisClient.set(
    `finance:permission:${userId}`, 
    JSON.stringify(permissions), 
    'EX', 
    86400 // 缓存1天
  );
  // 3. (可选)如果用户在线,强制刷新其页面权限
  pushToUserSocket(userId, { type: 'permissionUpdate', permissions });
  res.send({ code: 0, msg: '同步成功' });
});
3. 注意
  • 优点:实时性 100%,权限变更后子应用立即生效。

  • 注意事项

    • 必须实现 “重试机制”(如消息队列),防止子应用临时下线导致同步失败。
    • 接口需加签名验证,防止恶意请求篡改权限。

二、半实时同步:本地凭证过期时同步

核心逻辑:子应用的本地凭证(如 Token)设置短期有效期,过期后需向 SSO / 权限中心 “刷新凭证”,此时获取最新权限。适用场景:非紧急权限变更(如新增普通操作权限),可接受 5-30 分钟延迟。

1. 实现流程

以 “用户 A 的「财务系统」权限新增了「导出报表」权限,10 分钟后生效” 为例:

image.png

2. 关键技术:短期 Token + 刷新机制
  • 本地凭证设计:子应用的 Token 包含过期时间(如 10 分钟)和刷新令牌(refreshToken,有效期 7 天)。
  • 刷新流程:Token 过期后,用 refreshToken 向 SSO 中心换取新 Token,同时获取最新权限。

子应用前端代码示例(Vue)

// 财务系统前端:请求拦截器,处理Token过期
axios.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;
    // 如果是401(Token过期)且未重试过
    if (error.response.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;
      try {
        // 1. 用refreshToken向SSO中心刷新凭证
        const { data } = await axios.post('https://sso.example.com/refresh', {
          refreshToken: localStorage.getItem('finance_refreshToken'),
          appId: 'finance-app'
        });
        // 2. 保存新Token和权限(包含新增的"export")
        localStorage.setItem('finance_token', data.newToken);
        localStorage.setItem('finance_permissions', JSON.stringify(data.permissions));
        // 3. 用新Token重试原请求
        originalRequest.headers.Authorization = `Bearer ${data.newToken}`;
        return axios(originalRequest);
      } catch (err) {
        // 刷新失败(如refreshToken过期),强制跳转登录
        localStorage.removeItem('finance_token');
        window.location.href = 'https://sso.example.com/login?redirect=https://finance-app.example.com';
      }
    }
    return Promise.reject(error);
  }
);

SSO 中心刷新接口代码示例(Node.js)

// SSO中心:处理子应用的Token刷新请求
app.post('/refresh', async (req, res) => {
  const { refreshToken, appId } = req.body;
  // 1. 验证refreshToken有效性(从数据库/Redis查询)
  const user = await verifyRefreshToken(refreshToken);
  if (!user) {
    return res.status(401).send('refreshToken无效');
  }
  // 2. 向权限中心查询该用户在子应用的最新权限
  const permissions = await permissionCenter.getPermissions(user.id, appId);
  // 3. 生成新的子应用Token(包含权限)
  const newToken = jwt.sign(
    { 
      userId: user.id, 
      appId: appId, 
      permissions: permissions, // ["view", "export"]
      exp: Math.floor(Date.now() / 1000) + 600 // 10分钟后过期
    },
    'finance_app_secret' // 子应用专属密钥
  );
  res.send({
    newToken: newToken,
    permissions: permissions,
    refreshToken: refreshToken // 可复用旧refreshToken,或生成新的
  });
});
3. 注意
  • 优点:实现简单,无需主动推送,子应用和权限中心耦合低。

  • 注意

    • Token 有效期需合理设置(太短影响体验,太长延迟高,推荐 10-30 分钟)。
    • 刷新令牌(refreshToken)需妥善保管(如存在 HttpOnly Cookie),防止泄露。

三、被动同步:关键操作时校验最新权限

核心逻辑:子应用在执行敏感操作(如删除数据、审批)时,不依赖本地缓存,临时向权限中心查询最新权限。适用场景:高安全级别操作(如财务审批、订单删除),必须确保权限是 “当前最新”。

1. 实现流程

以 “用户 A 尝试审批财务单据,此时权限已被移除” 为例:

image.png

2. 关键技术:实时校验接口

子应用在敏感操作的后端接口中,同步调用权限中心的 “权限校验接口”,确保结果实时。

财务系统后端代码示例(审批接口)

// 财务系统:审批单据接口(敏感操作)
app.post('/api/approve-bill', async (req, res) => {
  const { billId } = req.body;
  const userId = req.user.id; // 从本地Token中解析用户ID
  // 1. 被动同步:向权限中心校验最新权限
  const hasPermission = await checkPermission(userId, 'finance-app', 'approve');
  if (!hasPermission) {
    return res.status(403).send('无审批权限,请联系管理员');
  }
  // 2. 权限通过,执行审批逻辑(省略)
  await billService.approve(billId, userId);
  res.send({ code: 0, msg: '审批成功' });
});

// 调用权限中心校验的函数
async function checkPermission(userId, appId, action) {
  const response = await fetch(
    `https://permission.example.com/check?userId=${userId}&appId=${appId}&action=${action}`,
    { headers: { 'Authorization': 'SSO_TOKEN' } } // 子应用在SSO的身份凭证
  );
  const data = await response.json();
  return data.allowed; // true/false
}

权限中心校验接口代码示例

// 权限中心:校验用户是否有某个操作的权限
app.get('/check', async (req, res) => {
  const { userId, appId, action } = req.query;
  // 1. 从数据库查询用户在该应用的最新权限
  const userPermissions = await db.query(
    'SELECT permissions FROM user_app_permissions WHERE user_id = ? AND app_id = ?',
    [userId, appId]
  );
  // 2. 判断是否包含目标操作权限
  const allowed = userPermissions.length > 0 
    && userPermissions[0].permissions.includes(action);
  res.send({ allowed: allowed });
});
3. 注意
  • 优点:安全性最高,确保敏感操作的权限一定是最新的。

  • 注意事项

    • 会增加接口调用次数,可能影响性能(可加缓存,但需设置极短过期时间,如 10 秒)。
    • 仅用于关键操作,避免所有接口都走被动同步(否则性能损耗过大)。

三种方案的核心差异和选择依据:

方案 实时性 实现复杂度 适用场景 典型举例
实时同步 立即生效 中(需推送 + 重试) 紧急权限移除、用户离职 禁止访问财务系统
半实时同步 延迟 5-30 分钟 低(依赖 Token 过期) 新增普通权限、权限微调 增加 “导出报表” 权限
被动同步 操作时实时 低(接口校验) 高敏感操作(审批、删除) 财务单据审批、订单删除

实际项目中通常 “组合使用”:用半实时同步覆盖大部分场景,实时同步处理紧急情况,被动同步兜底敏感操作,兼顾效率和安全性。

极端情况下导致的失效

这三种同步方案在极端场景下确实可能失效,核心原因通常是 “网络异常”“系统故障” 或 “设计漏洞”

一、实时同步:极端失效场景与应对

实时同步的核心依赖 “权限中心主动推送 → 子应用接收处理” 的链路,任何一个环节断裂都会导致失效。

1. 极端失效场景
(1)子应用服务临时下线 / 网络中断
  • 场景:权限中心推送权限变更时,子应用刚好在重启(如发布新版本),或子应用与权限中心之间的网络中断(如机房光缆故障)。
  • 后果:子应用未收到同步请求,权限变更未生效(例如用户已被移除 “审批权限”,但子应用仍保留旧权限,用户可继续审批)。
(2)推送请求被篡改 / 伪造
  • 场景:攻击者拦截权限中心的推送请求,篡改内容(如给普通用户添加 “管理员权限”),或伪造推送请求(冒充权限中心发送虚假权限)。
  • 后果:子应用执行错误的权限更新,导致权限泄露或越权操作。
(3)重试机制失效
  • 场景:权限中心的重试队列(如 Kafka)因磁盘满、服务崩溃等原因无法工作,推送失败后无法重试。
  • 后果:权限变更彻底丢失,子应用长期使用旧权限。
2. 解决方案
  • 针对 “子应用下线 / 网络中断”

    1. 权限中心实现 “持久化重试队列”(如用 Redis 或数据库存储待推送任务,而非内存队列),子应用恢复后自动重试。
    2. 子应用启动时主动 “拉取全量权限”(如调用 https://permission.example.com/full-sync?appId=finance-app),补充遗漏的同步。
  • 针对 “请求篡改 / 伪造”

    1. 所有推送请求必须加签名校验(如用权限中心的私钥对请求体签名,子应用用公钥验签),示例:

      // 权限中心签名
      const sign = crypto.createHmac('sha256', PRIVATE_KEY)
        .update(JSON.stringify(reqBody))
        .digest('hex');
      // 子应用验签
      const valid = crypto.createHmac('sha256', PUBLIC_KEY)
        .update(JSON.stringify(reqBody))
        .digest('hex') === reqSign;
      
    2. 推送接口启用 HTTPS,防止中间人攻击窃取请求内容。

  • 针对 “重试机制失效”

    1. 重试队列添加 “告警机制”(如重试超过 3 次未成功,触发短信 / 邮件告警给运维)。
    2. 每日凌晨执行 “全量权限比对”(权限中心与子应用对账),发现差异后自动同步。

二、半实时同步:极端失效场景与应对

半实时同步依赖 “Token 过期 → 刷新获取新权限” 的链路,极端场景下会因 “Token 未过期” 或 “刷新失败” 导致失效。

1. 极端失效场景
(1)Token 未过期,权限已变更(延迟窗口期内的风险)
  • 场景:子应用 Token 有效期设为 30 分钟,用户 A 的 “审批权限” 在 Token 生成后 10 分钟被移除,但 Token 未过期,用户仍能使用旧权限。
  • 后果:权限变更延迟 20 分钟生效,期间用户可越权操作(如继续审批单据)。
(2)refreshToken 失效 / 被窃取
  • 场景:用户的 refreshToken 因过期(如 7 天有效期到了)或被攻击者窃取,导致 Token 过期后无法刷新,或攻击者用窃取的 refreshToken 获取新权限。

  • 后果

    • 正常用户:Token 过期后被强制登出,体验差;
    • 攻击者:可能用窃取的 refreshToken 长期获取权限。
(3)SSO / 权限中心故障,刷新失败
  • 场景:Token 过期时,SSO 中心或权限中心因服务器崩溃、数据库故障无法提供刷新服务。
  • 后果:所有用户无法刷新 Token,被强制登出,子应用无法使用。
2. 解决方案
  • 针对 “Token 未过期的延迟风险”

    1. 缩短 Token 有效期(如从 30 分钟改为 5 分钟),减少越权窗口;
    2. 关键操作叠加 “被动同步”(如用户点击 “审批” 时,即使 Token 未过期,也临时校验最新权限),兜底延迟风险。
  • 针对 “refreshToken 失效 / 被窃取”

    1. refreshToken 存储在 HttpOnly + Secure Cookie 中(禁止前端 JS 访问),防止 XSS 攻击窃取;
    2. 实现 “refreshToken 单设备登录”(用户在新设备登录时,旧设备的 refreshToken 立即失效),防止多设备泄露;
    3. 给 refreshToken 加 “设备标识”(如浏览器 UA、IP 段),异常设备使用时触发二次验证(如短信验证码)。
  • 针对 “SSO / 权限中心故障”

    1. 子应用实现 “Token 降级策略”:若 SSO 故障,临时延长 Token 有效期(如额外延长 1 小时),并提示 “当前系统维护,部分功能受限”;
    2. SSO / 权限中心部署多实例集群,避免单点故障。

三、被动同步:极端失效场景与应对

被动同步的核心依赖 “操作时实时调用权限中心校验”,极端场景下会因 “权限中心不可用” 或 “校验结果被篡改” 失效。

1. 极端失效场景
(1)权限中心服务崩溃 / 网络中断
  • 场景:用户执行 “删除订单” 操作时,子应用调用权限中心校验接口,但权限中心因服务器宕机、网络中断无法响应。

  • 后果:子应用无法判断用户是否有权限,可能出现两种极端情况:

    • 拒绝操作:正常用户无法使用关键功能(如客服无法删除无效订单);
    • 允许操作:存在越权风险(如普通用户删除订单)。
(2)校验接口超时导致用户体验差
  • 场景:权限中心因高并发(如秒杀活动期间大量校验请求)导致接口响应延迟(超过 5 秒)。
  • 后果:用户点击操作后长时间等待,体验崩溃,甚至重复点击导致系统异常。
(3)校验结果被中间人篡改
  • 场景:攻击者拦截子应用与权限中心的校验请求,将 “不允许”(allowed: false)改为 “允许”(allowed: true)。
  • 后果:用户越权执行敏感操作(如删除全量订单)。
2. 解决方案
  • 针对 “权限中心不可用”

    1. 实现 “降级熔断” 策略:若权限中心连续 3 次超时 / 报错,自动触发降级 —— 允许 “已缓存过的合法权限” 继续操作(如 10 秒内校验过的用户),拒绝新用户操作,并提示 “系统临时维护”;
    2. 权限中心部署异地多活集群(如北京、上海机房各部署一套),子应用优先调用本地机房接口,本地故障时自动切换异地接口。
  • 针对 “校验接口超时”

    1. 给校验接口设置短超时时间(如 2 秒),超时后触发降级;
    2. 加本地缓存(如 Redis),缓存 10 秒内的校验结果(同一用户同一操作,10 秒内不重复调用权限中心),减少请求量。
  • 针对 “校验结果被篡改”

    1. 校验接口启用 HTTPS,防止中间人窃听和篡改;

    2. 权限中心返回校验结果时附带数字签名(如用私钥签名),子应用验签通过后才认可结果,示例:

      // 权限中心返回结果
      const result = { allowed: true, sign: 'xxx' }; // sign 是对 { allowed: true } 的签名
      // 子应用验签
      const valid = verifySign(result.allowed, result.sign);
      if (!valid) { throw new Error('校验结果无效'); }
      

四、通用防失效原则(所有方案都适用)

  1. 避免单点故障:权限中心、SSO 中心、子应用均部署多实例集群,网络用多链路冗余(如电信 + 联通光缆)。
  2. 关键操作日志审计:所有权限变更、权限校验操作记录详细日志(用户 ID、操作时间、权限内容、IP 地址),即使失效也能追溯问题。
  3. 定期演练故障恢复:每月模拟 “权限中心崩溃”“网络中断” 等场景,测试降级策略是否生效,避免实战时手忙脚乱。

总结(没有绝对安全,但有绝对防御)

没有任何方案能 100% 避免极端失效,但通过 “冗余设计(多实例 / 多链路)+ 降级策略(故障时兜底)+ 安全校验(防篡改)  ”,可以将失效概率降到极低,且即使失效也能最小化损失。

八千字 《宠知汇》HarmonyOS应用案例热点技术解析

作者 万少
2025年11月4日 09:27

《宠知汇》应用案例热点技术解析

开篇

《宠知汇》作为一款深度适配 HarmonyOS 的宠物主题应用,汇聚专业养宠知识,构建全场景养护生态,为

宠物主人打造覆盖宠物生命周期的一站式智慧服务入口。

依托鸿蒙系统的分布式能力与智能化特性,让科学养宠更简单、更便捷。

《宠知汇》借助 HarmonyOS 的跨设备优势,实现手机、平板、智慧屏等多终端的无缝协同,成为宠物主人身边的 移动养宠管家

第一章:借助“一多”能力,实现全设备适配

《宠知汇》通过 HarmonyOS 的一次开发,多端部署能力,实现了应用在不同设备形态上的完美适配。基于统一的 ArkTS 开发框架,应用能够智能识别设备类型和屏幕尺寸,自动调整界面布局和交互方式。

image-20251030205757380

需要掌握一次开发、多端部署的知识,就需要了解一多和三层架构的关系。

  • 一次开发、多端部署是目标
  • 三层架构是具体实现

三层架构

想象你要开发一个应用,就像建一栋能适配不同住户(设备)的智能公寓楼。三层架构就是把这栋楼分成三个部分,每层各司其职:

  1. 公共能力层(地下室和基础设施)
    • 这是整栋楼的“地基”,包含水管、电网、网络线路等共享资源
    • 在应用中,就是公共组件:比如统一的按钮样式、网络请求工具、数据管理库等。所有上层功能都能调用这些基础能力,避免重复造轮子。
    • 通俗说:就像楼里的公用健身房和停车场,所有住户都能用,但不需要每家自己修。
  2. 基础特性层(标准户型模块)
    • 这层是预先设计好的功能模块,比如卧室、厨房、卫生间。每个模块独立且完整(高内聚),能灵活组合。
    • 在应用中,就是核心功能:比如登录模块、支付模块、视频播放模块等。这些模块可以打包成 功能包(Feature HAP),根据需要安装到不同设备。
    • 通俗说:就像乐高积木,你可以用同样的积木块拼出不同房子,比如手机版用少量积木,平板版用更多积木。
  3. 产品定制层(个性化装修和入口)
    • 这层是针对不同住户(设备)的个性化定制。比如给年轻人的公寓用现代风格,给家庭的用温馨风格。
    • 在应用中,就是设备专属的UI和配置:比如手机的简洁界面、平板的分屏布局、PC的窗口模式。这一层编译成 入口包(Entry HAP),作为用户直接交互的界面。
    • 通俗说:同样是卧室模块,在手机上显示为底部标签,在平板上显示为侧边栏,但背后的功能(睡觉)是一样的。

image-20251030210533247

为什么这样分层?

  • 维护简单:如果水管坏了(公共能力层更新),只需修一次,全楼受益。
  • 灵活扩展:想新增一个“智能厨房”(新功能),只需在基础特性层开发,然后轻松添加到不同户型(设备)。
  • 复用性强:同样的厨房模块,既能用在公寓,也能用在别墅(不同设备)。

实际开发中,可以参考以下表格来了解三层架构。

表格已提炼每一层的核心维度,覆盖开发中最需关注的 产物、作用和规则,便于快速查阅和对比。

架构层级 核心编译产物 核心作用 关键规则
产品定制层 Entry 类型 HAP(最终打包为.app 文件) 作为应用主入口,针对不同设备定制 UI 和功能 支持一次编译生成多 HAP 组合,用于应用市场上架
基础特性层 1. 需 Ability:Feature 类型 HAP2. 无需 Ability:HAR 包 / HSP 包 提供独立功能模块,支撑上层产品需求 按 “是否需 Ability”“是否需按需加载” 选择产物
公共能力层 HAR 包 提供公共基础能力(接口 / 工具),提升复用率 仅允许产品定制层、基础特性层依赖,禁止反向依赖

对应 《宠知汇》目录结构

image-20251030211355391

一次开发、多端部署

根据一次开发、多端部署的理念,一多又可以分为三种一多,它们分别是 界面级一多工程级一多能力级一多

  1. 工程级一多基础和骨架。它决定了代码怎么组织、怎么打包。
  2. 功能级一多神经和肌肉。它让应用能感知并适应设备的硬件能力,确保功能上的兼容性。
  3. 界面级一多外表和皮肤。它最终决定了用户在屏幕上看到的是什么样子,确保视觉和交互的完美体验。

其中工程级一多,就是上面刚刚描述过的三层架构。

功能级一多

手机上支持拍照,手表上不支持,那么一套代码下,该如何完美解决?

答案是借助 即SystemCapability,缩写为SysCap

和硬件相关的如摄像、麦克风、wifi等都是系统的能力。每一个系统能力对应多个API,支持某个系统能力也等于支持这些API调用。

系统能力又分为支持能力集联想能力集要求能力集三个核心概念。

  • 支持能力集:设备具备的系统能力集合,在设备配置文件中配置。
  • 要求能力集:应用需要的系统能力集合,在应用配置文件中配置。
  • 联想能力集:开发应用时DevEco Studio可联想的API所在的系统能力集合,在应用配置文件中配置。

开发多设备应用时,工程中默认的要求能力集是多个设备支持能力集的交集,默认的联想能力集是多个设备支持能力集的并集。

如果某个系统能力没有写入应用的要求能力集中,那么在使用前需要判断设备是否支持该系统能力。

  • 方法1:canIUse()接口帮助开发者来判断该设备是否支持某个特定的syscap。

    if (canIUse('SystemCapability.Communication.NFC.Core')) {
      hilog.info(0x0000'Index'`该设备支持SystemCapability.Communication.NFC.Core`);
    } else {
      hilog.error(0x0000'Index'`该设备不支持SystemCapability.Communication.NFC.Core`);
    }
    

    Index.ets

  • 方法2:开发者可通过import的方式将模块导入,若当前设备不支持该模块,import的结果为undefined,开发者在使用其API时,需要判断其是否存在。

    import { nfcController } from '@kit.ConnectivityKit';
    
    @Entry
    @Component
    struct Index {
      // ...
    
      canIUseNfc(): void {
        if (canIUse('SystemCapability.Communication.NFC.Core')) {
          hilog.info(0x0000'Index'`该设备支持SystemCapability.Communication.NFC.Core`);
        } else {
          hilog.error(0x0000'Index'`该设备不支持SystemCapability.Communication.NFC.Core`);
        }
        // ...
      }
      // ...
    }
    

配置联想能力集和要求能力集

DevEco Studio会根据创建的工程所支持的设备自动配置联想能力集和要求能力集,同时也支持开发者修改。

// syscap.json
{
    "devices": {
        "general": [            
            "default",
            "tablet"
        ],
        "custom": [            
            {
                "Custom Device": [
                    "SystemCapability.Communication.SoftBus.Core"
                ]
            }
        ]
    },
    "development": {             
        "addedSysCaps": [
            "SystemCapability.Communication.NFC.Core"
        ]
    },
    "production": {              
        "addedSysCaps": [],      
        "removedSysCaps": []     
    }
}

另外,实际开发中,如果想要实现某一段功能代码,可以根据用户系统的API版本进行调整,

可以使用   @kit.BasicServicesKit中的sdkApiVersion来实现,如

if(deviceInfo.sdkApiVersion===20){
      console.log("API20")
    }

或者在ArkUI中使用

if (this.sdkApiVersion >= 20) {
Text('应用20')
} else {
Text('应用不是20')
}

界面级一多

界面一多指的是如何使用一套代码,实现适配多种宽度不同的设备,HarmonyOS中提供的解决方案主要有

  1. 自适应布局
  2. 响应式布局

所谓的自适应布局可以理解为就是页面布局元素可以跟随屏幕的大小变化而等比例变化。

响应式布局则是可以根据不同的条件,实现屏幕布局更大的变化,如

  1. 大屏幕,一行显示4个卡片
  2. 小屏幕,一行显示1个卡片。

自适应布局比较好实现,像flex容器、grid、线性布局等可以跟随屏幕宽度变化而变化的容器,都可以轻易实现自适应布局,结合尺寸的百分百单位,或者layoutWeight属性,页面元素也可以直接实现自适应布局。

针对常见的开发场景,方舟开发框架提炼了七种自适应布局能力,这些布局可以独立使用,也可多种布局叠加使用。

自适应布局类别 自适应布局能力 使用场景 实现方式
自适应拉伸 拉伸能力 容器组件尺寸发生变化时,增加或减小的空间全部分配给容器组件内指定区域 Flex布局的flexGrow和flexShrink属性
均分能力 容器组件尺寸发生变化时,增加或减小的空间均匀分配给容器组件内所有空白区域 Row组件、Column组件或Flex组件的justifyContent属性设置为FlexAlign.SpaceEvenly
自适应缩放 占比能力 子组件的宽或高按照预设的比例,随容器组件发生变化。 基于通用属性的两种实现方式:- 将子组件的宽高设置为父组件宽高的百分比- layoutWeight属性
缩放能力 子组件的宽高按照预设的比例,随容器组件发生变化,且变化过程中子组件的宽高比不变 布局约束的aspectRatio属性
自适应延伸 延伸能力 容器组件内的子组件,按照其在列表中的先后顺序,随容器组件尺寸变化显示或隐藏。 基于容器组件的两种实现方式:- 通过List组件实现- 通过Scroll组件配合Row组件或Column组件实现
隐藏能力 容器组件内的子组件,按照其预设的显示优先级,随容器组件尺寸变化显示或隐藏。相同显示优先级的子组件同时显示或隐藏 布局约束的displayPriority属性
自适应折行 折行能力 容器组件尺寸发生变化时,如果布局方向尺寸不足以显示完整内容,自动换行 Flex组件的wrap属性设置为FlexWrap.Wr

更加常用的其实是响应式布局

响应式布局有四种实现方式,其中它们又可以互相搭配和关联,它们分别是断点、媒体查询以及栅格布局以及响应式工具。

**断点 (Breakpoints)**将设备的窗口宽度(或高宽比)划分为几个关键的范围(区间)

  • xs (超小): 0 ~ 320vp (智能穿戴)
  • sm (小): 320vp ~ 600vp (手机竖屏)
  • md (中): 600vp ~ 840vp (平板竖屏/折叠屏)
  • lg (大): 840vp ~ 1440vp (平板横屏/PC)
  • xl (超大): 1440vp及以上 (大屏设备)

image-20251030221518090

image-20251030221518090

实际开发中,可以监听屏幕尺寸的变化,然后把断点存储在全局中,方便使用。宠知汇 就是采用这种方式。

宠知汇的屏幕适配流程

1.  V2 状态管理设计

文件:commons/base/src/main/ets/utils/BreakpointSystem.ets

全局状态类定义:

// 全局断点状态管理(V2)
@ObservedV2
class BreakpointState {
  @Trace currentBreakpoint: string = 'sm';
}

// 创建全局单例
const breakpointState = new BreakpointState();

// 导出供其他模块使用
export { breakpointState };

关键设计要点:

  • 使用 @ObservedV2 装饰器标记可观察类
  • 使用 @Trace 装饰器追踪 currentBreakpoint 属性变化
  • 全局单例模式确保状态一致性
  • 导出 breakpointState 供其他模块直接订阅

断点配置:

interface Breakpoint {
  name: string;
  size: number;
}

private breakpoints: Breakpoint[] = [
  { name: 'xs', size: 0 },      // 超小屏(0-320vp)
  { name: 'sm', size: 320 },    // 小屏(320-600vp)
  { name: 'md', size: 600 },    // 中屏/平板(600-840vp)
  { name: 'lg', size: 840 }     // 大屏/折叠屏(840vp+)
];

断点划分遵循 HarmonyOS 官方建议,覆盖主流设备尺寸:

  • xs/sm:手机竖屏模式
  • md:平板或手机横屏
  • lg:大屏平板、折叠屏展开状态

2.  窗口尺寸监听机制

在 API 18 中,推荐使用 window.on('windowSizeChange') 替代已废弃的 mediaquery.matchMediaSync

// 注册断点监听
public register(windowClass: window.Window): void {
  try {
    this.windowClass = windowClass;
    this.uiContext = windowClass.getUIContext();
    
    // 获取初始窗口尺寸并计算断点
    const windowProperties = windowClass.getWindowProperties();
    const initialWidth = this.uiContext.px2vp(windowProperties.windowRect.width);
    const initialBreakpoint = this.calculateBreakpoint(initialWidth);
    this.updateCurrentBreakpoint(initialBreakpoint);

    // 监听窗口尺寸变化
    this.windowSizeChangeCallback = (size: window.Size) => {
      if (this.uiContext) {
        const newWidth = this.uiContext.px2vp(size.width);
        const newBreakpoint = this.calculateBreakpoint(newWidth);
        this.updateCurrentBreakpoint(newBreakpoint);
      }
    };

    windowClass.on('windowSizeChange'this.windowSizeChangeCallback);
    console.info('[BreakpointSystem] Window size change listener registered');
  } catch (err) {
    console.error(`[BreakpointSystem] Failed to register: ${JSON.stringify(err)}`);
  }
}

关键技术点:

  1. 使用 window.getWindowProperties() 获取窗口实际尺寸
  2. 通过 UIContext.px2vp() 进行像素到虚拟像素的转换
  3. 监听窗口尺寸变化事件,支持分屏、折叠屏等场景

3. V2 状态更新机制

状态初始化:

// 注册断点监听时初始化状态
const windowProperties = windowClass.getWindowProperties();
const initialWidth = this.uiContext.px2vp(windowProperties.windowRect.width);
const initialBreakpoint = this.calculateBreakpoint(initialWidth);

// 直接更新 V2 状态
this.state.currentBreakpoint = initialBreakpoint;
// 同步到 AppStorage(兼容旧版订阅)
AppStorage.setOrCreate<string>('currentBreakpoint', initialBreakpoint);

窗口尺寸变化响应:

this.windowSizeChangeCallback(size: window.Size) => {
  if (this.uiContext) {
    const newWidth = this.uiContext.px2vp(size.width);
    const newBreakpoint = this.calculateBreakpoint(newWidth);
    
    // 仅在断点实际变化时更新
    if (this.state.currentBreakpoint !== newBreakpoint) {
      // V2 状态自动触发响应式更新
      this.state.currentBreakpoint = newBreakpoint;
      // 同步到 AppStorage
      AppStorage.setOrCreate<string>('currentBreakpoint', newBreakpoint);
      console.info(`[BreakpointSystem] Breakpoint changed to: ${newBreakpoint}`);
    }
  }
};

V2 状态管理优势:

  1. 精准追踪@Trace 装饰器实现属性级别的变化追踪
  2. 自动更新:状态变化自动触发订阅组件的 UI 刷新
  3. 性能优化:仅在断点实际变化时触发更新,避免不必要的重渲染
  4. 双向兼容:V2 状态与 AppStorage 双向同步,支持新旧两种订阅方式

4. 应用生命周期集成

文件:products/phone/src/main/ets/entryability/EntryAbility.ets

export default class EntryAbility extends UIAbility {
  private breakpointSystem: BreakpointSystemnew BreakpointSystem();

  onWindowStageCreate(windowStagewindow.WindowStage): void {
    windowStage.loadContent('pages/Index'(err) => {
      if (err.code) {
        hilog.error(DOMAIN'testTag''Failed to load the content. Cause: %{public}s', 
                   JSON.stringify(err));
        return;
      }
      // 注册断点监听,传入 Window 实例
      this.breakpointSystem.register(windowStage.getMainWindowSync());
    });
  }

  onWindowStageDestroy(): void {
    // 注销断点监听
    this.breakpointSystem.unregister();
    hilog.info(DOMAIN'testTag''Breakpoint system unregistered');
  }
}

生命周期管理:

  • 在 onWindowStageCreate 中注册断点监听
  • 在 onWindowStageDestroy 中注销监听,避免内存泄漏

5. 断点值映射工具

// 断点类型映射
export interface BreakpointTypeOption<T> {
  xs?: T;
  sm?: T;
  md?: T;
  lg?: T;
}

// 断点值映射工具类
export class BreakpointType<T> {
  private options: BreakpointTypeOption<T>;

  constructor(options: BreakpointTypeOption<T>) {
    this.options = options;
  }

  getValue(breakpoint: string): T | undefined {
    if (breakpoint === 'xs'return this.options.xs;
    if (breakpoint === 'sm'return this.options.sm;
    if (breakpoint === 'md'return this.options.md;
    if (breakpoint === 'lg'return this.options.lg;
    return undefined;
  }
}

BreakpointType 提供了类型安全的断点值映射机制,支持任意类型的响应式配置。

6. UI V2 组件状态管理

文件:products/phone/src/main/ets/pages/HomePage.ets

组件状态定义:

// 页面状态类(V2)
@ObservedV2
class HomePageState {
  @Trace currentPetIndex: number = 0;
  @Trace showPetTip: boolean = false;
  @Trace petTipText: string = '';
}

// 组件定义
@Component
export struct HomePage {
  state: HomePageState = new HomePageState();
  @StorageProp('currentBreakpoint') currentBreakpoint: string = 'sm';

  build() {
    Column() {
      Scroll() {
        Column({
          space: new BreakpointType({
            xs: 16,
            sm: 20,
            md: 24,
            lg: 28
          }).getValue(this.currentBreakpoint) || 20
        }) {
          // 页面内容
        }
        .padding({
          left: new BreakpointType({
            xs16,
            sm16,
            md24,
            lg32
          }).getValue(this.currentBreakpoint) || 16,
          right: new BreakpointType({
            xs16,
            sm16,
            md24,
            lg32
          }).getValue(this.currentBreakpoint) || 16
        })
      }
    }
  }
}

通过 @StorageProp 订阅全局断点状态,根据不同断点动态调整:

  • 间距:xs(16) → sm(20) → md(24) → lg(28)
  • 内边距:xs/sm(16) → md(24) → lg(32)

页面间距与尺寸适配:

build() {
  Column() {
    Scroll() {
      Column({
        spacenew BreakpointType({
          xs16,
          sm20,
          md24,
          lg28
        }).getValue(this.currentBreakpoint) || 20
      }) {
        // 页面内容
      }
      .padding({
        leftnew BreakpointType({
          xs16,
          sm16,
          md24,
          lg32
        }).getValue(this.currentBreakpoint) || 16,
        rightnew BreakpointType({
          xs16,
          sm16,
          md24,
          lg32
        }).getValue(this.currentBreakpoint) || 16
      })
    }
  }
}

通过 @StorageProp 订阅全局断点状态,根据不同断点动态调整:

  • 间距:xs(16) → sm(20) → md(24) → lg(28)
  • 内边距:xs/sm(16) → md(24) → lg(32)

7. 栅格布局响应式配置

Grid() {
  GridItem() { this.buildFeatureCard('领养''🐾', ...) }
  GridItem() { this.buildFeatureCard('寄养''🏡', ...) }
  GridItem() { this.buildFeatureCard('医疗''🏥', ...) }
  GridItem() { this.buildFeatureCard('美容''✨', ...) }
}
.columnsTemplate(
  new BreakpointType({
    xs'1fr 1fr',           // 手机:2列
    sm'1fr 1fr',           // 小屏:2列
    md'1fr 1fr 1fr 1fr',   // 平板:4列
    lg'1fr 1fr 1fr 1fr'    // 大屏:4列
  }).getValue(this.currentBreakpoint) || '1fr 1fr'
)
.rowsTemplate(
  new BreakpointType({
    xs'1fr 1fr',           // 手机:2行
    sm'1fr 1fr',
    md'1fr',               // 平板:1行
    lg'1fr'
  }).getValue(this.currentBreakpoint) || '1fr 1fr'
)
.columnsGap(
  new BreakpointType({
    xs12,
    sm12,
    md16,
    lg20
  }).getValue(this.currentBreakpoint) || 12
)

响应式布局策略:

  • 手机端(xs/sm):2列2行,紧凑布局
  • 平板端(md/lg):4列1行,横向展开

8. 字体与图标尺寸适配

Text('爱宠之家')
  .fontSize(new BreakpointType({
    xs20,
    sm20,
    md24,
    lg28
  }).getValue(this.currentBreakpoint) || 20)
  .fontWeight(FontWeight.Bold)

Image($r('app.media.pet_heart'))
  .width(new BreakpointType({
    xs32,
    sm32,
    md40,
    lg48
  }).getValue(this.currentBreakpoint) || 32)
  .height(new BreakpointType({
    xs32,
    sm32,
    md40,
    lg48
  }).getValue(this.currentBreakpoint) || 32)

9. 条件布局切换

// 故事卡片 - 在平板上使用横向布局
if (this.currentBreakpoint === 'md' || this.currentBreakpoint === 'lg') {
  Row({ space: new BreakpointType({ md: 16, lg: 20 }).getValue(this.currentBreakpoint) || 16 }) {
    this.buildStoryCard('小橘的新家''...''2小时前')
    this.buildStoryCard('旺财的康复日记''...''1天前')
  }
} else {
  Column({ space: 12 }) {
    this.buildStoryCard('小橘的新家''...''2小时前')
    this.buildStoryCard('旺财的康复日记''...''1天前')
  }
}

根据断点切换布局方向,提升大屏设备的空间利用率。

10. 内容宽度约束

.width('100%')
.constraintSize({
  maxWidth: this.currentBreakpoint === 'xs' || this.currentBreakpoint === 'sm' ? '100%' :
    this.currentBreakpoint === 'md' ? 800 : 1200
})

在大屏设备上限制内容最大宽度,避免内容过度拉伸导致阅读体验下降。

11. 全局状态订阅模式

直接订阅 V2 状态

import { breakpointState } from 'base';

@ComponentV2
export struct SomePage {
  // 直接引用全局状态
  private breakpoint = breakpointState;
  
  build() {
    Text(this.breakpoint.currentBreakpoint)
  }
}

第二章:HarmonyOS 智能体,实现智能化宠物服务

《宠知汇》背后依靠海量的宠物饲养百科技术,包含有宠物种类、喂养食物、陪伴技巧、注意事项等等专业知识,利用HarmonyOS上的智能体知识打造出配套可用的智能体,给广大HarmonyOS用户提供贴心帮助。

利用 Agent  Framework Kit 功能实现在应用内拉起相关智能体,第一时间提供给用户使用的宠物饲养知识。

开发者接入智能体只需要两个步骤

  1. 小艺智能体平台中创建智能体
  2. 你的应用中引入智能体

image-20251031101602362

创建智能体

1. 登录小艺智能体平台

developer.huawei.com/consumer/cn…

登录华为账号即可

图片

2. 快速创建智能体

在小艺智能体平台,智能体是一个最终的产品,智能体可以通过知识库、工作流、和资源来增强自身的功能。

image-20251031095213872

3. 配置智能体

图片

刚入门的小伙伴可以选择 单Agent (LLM模式)

image-20251031095711444

智能体提供了很多配置项

这些配置从交互开场、多模态体验、功能扩展、自动化流程、数据管理及用户引导等方面,全方位定制智能体的交互方式、功能范围与运行逻辑,助力其实现个性化、高效且专业的服务。

配置项 作用
开场对话 设置智能体与用户初次交互的开场白,建立初始沟通场景。
输入文件设置 配置可上传的文件类型、大小等,支持智能体处理用户上传的文件。
用户问题建议 开启后为用户提供问题示例或引导,帮助用户发起提问。
快捷指令 可设置最多 10 条快捷指令,用户通过指令快速触发特定功能或流程,提升交互效率。
背景图片 设置智能体界面的背景图片,美化界面、营造特定氛围。
角色声音 开启后智能体以特定声音交互,增强语音交互的体验感和角色辨识度。
插件 可配置最多 20 个插件,扩展智能体功能,如接入工具、调用外部服务等。
工作流 可配置最多 20 个工作流,定义复杂任务的自动化处理流程。
触发器 可设置最多 10 个触发器,满足特定条件时自动触发相应动作或对话。
关联应用 可关联最多 10 个应用,实现与应用的数据交互或功能联动。
知识库 可配置最多 10 个知识库,存储特定领域知识,支持智能体专业回答问题。
变量 定义和管理交互中的动态数据,支持个性化、动态交互逻辑。
长期记忆 开启后智能体记住长期交互信息,保持对话的连续性。

4. 发布上架智能体

如果都编辑完毕了,可以申请上架了

image-20251031100022405

如果上架成功,想要让这个智能体给你的鸿蒙应用服务,需要在配置页面中,添加你的应用

image-20251031100222985

最后需要记住这个智能体的agentId,编码中需要用到的。

image-20251031100340244

代码中集成

1. 导入依赖

import { BusinessErrorfrom '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FunctionComponent, FunctionControllerfrom '@kit.AgentFrameworkKit';

2. 声明控制器与智能体标识

// 智能体控制器(用于监听对话框打开/关闭等事件)
private agentController: FunctionController = new FunctionController();

// 智能体实例标识(需替换为实际可用的 agentId)
private agentId: string = 'agenta6912c10c332438aa25f7e25da306eba';

3. 生命周期中注册/释放事件监听

aboutToAppear() {
  // 初始化事件监听器
  this.initAgentListeners();
}

aboutToDisappear() {
  // 释放事件监听器,避免内存泄漏
  this.agentController?.off('agentDialogOpened');
  this.agentController?.off('agentDialogClosed');
}

// 统一初始化监听
initAgentListeners() {
  this.agentController?.on('agentDialogOpened'this.onAgentOpenedCallback);
  this.agentController?.on('agentDialogClosed'this.onAgentClosedCallback);
}

// 回调方法
onAgentOpenedCallback = () => {
  hilog.info(0x0001'HomePage''智能体弹窗已打开');
}
onAgentClosedCallback = () => {
  hilog.info(0x0001'HomePage''智能体弹窗已关闭');
}

4. 在页面中渲染 FunctionComponent

@Builder
buildAIAssistantButton() {
  // 放置位置可根据页面布局自行调整
  Column() {
    FunctionComponent({
      agentIdthis.agentId,
      onError(err: BusinessError) => {
        hilog.error(0x0001'HomePage'`智能体错误: ${JSON.stringify(err)}, message: ${err.message}`);
      },
      options: {
        title'你的宠物百科',
        isShowShadowtrue
      },
      controllerthis.agentController
    })
  }
  .margin({
    right16// 可结合断点系统做响应式
    bottom80
  })
}

说明:

  • agentId 用于绑定具体的智能体能力。
  • controller 负责事件监听与交互控制。
  • onError 对接错误处理与日志记录。
  • options 可配置标题与视觉效果等。

5. 完整最小示例(页面片段)

@Component
export struct HomePage {
  private agentController: FunctionControllernew FunctionController();
  private agentIdstring = 'agenta6912c10cxxxxxxxxxxxxxx';

  aboutToAppear() {
    this.initAgentListeners();
  }

  aboutToDisappear() {
    this.agentController?.off('agentDialogOpened');
    this.agentController?.off('agentDialogClosed');
  }

  initAgentListeners() {
    this.agentController?.on('agentDialogOpened'() => {
      hilog.info(0x0001'HomePage''智能体弹窗已打开');
    });
    this.agentController?.on('agentDialogClosed'() => {
      hilog.info(0x0001'HomePage''智能体弹窗已关闭');
    });
  }

  build() {
    // ...页面主体内容

    // 在右下角叠加智能体入口
    Stack({ alignContent: Alignment.BottomEnd }) {
      // ...其他内容
      this.buildAIAssistantButton()
    }
    .width('100%')
    .height('100%')
  }

  @Builder
  buildAIAssistantButton() {
    Column() {
      FunctionComponent({
        agentIdthis.agentId,
        onError(err: BusinessError) => {
          hilog.error(0x0001'HomePage'`智能体错误: ${JSON.stringify(err)}, message: ${err.message}`);
        },
        options: {
          title'你的宠物百科',
          isShowShadowtrue
        },
        controllerthis.agentController
      })
    }
    .margin({ right16, bottom80 })
  }
}

6. 常见问题与建议

  • 智能体 API 能力提示:部分设备或 API 版本可能提示"系统能力不支持",请确保目标设备及系统版本支持 AgentFramework 能力。
  • agentId 必须为有效的智能体标识;如无效会触发 onError
  • 事件监听务必在 aboutToDisappear 释放,避免资源泄漏。
  • 放置位置与样式可结合现有断点系统进行响应式适配。

第三章:其他优秀Harmony6 特性

1. enableDataDetector - 智能实体识别

全新的智能实体识别能力,能够自动识别文本中的电话号码、邮箱、网址、地址等实体信息。

实际应用

Text('邮箱:yeah126139163@163.com')
  .enableDataDetector(true)  // 启用实体识别
  .dataDetectorConfig({
    // 支持识别类型:PHONE_NUMBER、URL、EMAIL、ADDRESS、DATE_TIME
    types: [],  // 空数组表示识别所有类型
    onDetectResultUpdate(result: string) => {
      // 识别结果回调处理
    }
  })

2. showCommentDialog - 应用商店评论对话框

拿已经上架 作品演示

拿已经上架 作品演示

鸿蒙6新增的应用商店评论API,可在应用内直接调起评论对话框。

const uiContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
commentManager.showCommentDialog(uiContext).then(() => {
  hilog.info(0'TAG'"评论对话框显示成功");
}).catch((error: BusinessError<Object>) => {
  // 处理用户短期内已评论的情况
  this.getUIContext().getPromptAction().showToast({ 
    message`您短期内已经评论过了` 
  });
});

3. shaderStyle - 着色器样式渲染

鸿蒙6引入的GPU加速着色器渲染技术,支持渐变色、阴影等高级视觉效果。

Text('应用名称')
  .shaderStyle({
    colors: [[Color.Red, 0], [Color.Orange, 1]],  // 渐变色配置
    direction: GradientDirection.Right            // 渐变方向
  })

4. animationCurve - 动画曲线优化

鸿蒙6优化的动画曲线系统,提供更自然流畅的动画效果。

Tabs({ index: $$this.currentTabIndex })
  .onChange((index: number) => {
    this.currentTabIndex = index;
  })
  .animationCurve(Curve.EaseInOut)  // 缓入缓出动画曲线

5. 文本翻牌动效 contentTransition

image-20251031090516298

@Entry
@ComponentV2
struct ScheduleDetailPage {
  @Local number: number = 5;
  @Local numberTransition: NumericTextTransition = new NumericTextTransition({ flipDirection: FlipDirection.DOWN, enableBlur: false });

  build() {
    Column() {
      Text(this.number + "")
        .borderWidth(1)
        .fontSize(40)
        .contentTransition(this.numberTransition)
      Button("chang number")
        .onClick(() => {
          this.number++;
        })
        .margin(10)
    }
    .height('100%')
    .width('100%')
  }
}

结言

以极小的代码代价让《宠知汇》接入了鸿蒙新的特性,通过引入了一多、智能体能力,HarmonyOS应用上实现了其他系统没有的创新体验,带给用户丝滑、流畅的体验。

鸿蒙组件分级指南:从细胞到思维的系统化角色解析

2025年11月5日 14:44

初级组件(基础单元层)

作用:搭建界面“细胞结构”,承载最基础的交互与展示功能 包含内容

  • 系统组件Text(文字)、Button(按钮)、Image(图片)
  • 布局方式Column(纵向布局)、Row(横向布局)、Stack(层叠布局)
  • 装饰器@Component(定义组件)、@Entry(入口组件)、@State(组件内部状态)

开发者角色

  • 负责搭建基础页面框架,类似“细胞合成师”
  • 通过组合基础组件实现简单功能(如静态页面、点击计数)
  • 需掌握链式调用配置属性(如.fontSize(20).width('100%')

类比

  • Text组件 → 细胞的DNA,承载信息传递功能
  • Column布局 → 骨骼系统,支撑内容垂直排列
  • @State装饰器 → 短期记忆,记录组件内部临时状态

示例场景

@Entry @Component  
struct BasicPage {  
  @State count: number = 0;  
  build() {  
    Column() {  
      Text(`点击次数: ${this.count}`)  
      Button('+1')  
        .onClick(() => { this.count++ })  
    }  
  }  
}  

中级组件(功能器官层)

作用:构建复杂功能模块,实现数据流动与UI复用 包含内容

  • 布局扩展Grid(网格布局)、List(列表)、Flex(弹性布局)
  • 自定义组件:通过@Builder封装可复用UI块
  • 装饰器@Prop(单向数据传递)、@Link(双向数据绑定)、@Styles(样式复用)

开发者角色

  • 负责模块化开发,类似“器官工程师”
  • 设计父子组件通信逻辑(如表单数据传递)
  • 需掌握响应式编程思想(数据驱动UI更新)

类比

  • List布局 → 消化系统,有序处理列表数据流
  • @Builder函数 → 肌肉组织,重复调用完成特定动作
  • @Link装饰器 → 神经系统,实现父子组件双向控制

示例场景

// 父组件  
@Component  
struct ParentComp {  
  @State total: number = 0;  
  build() {  
    Column() {  
      ChildComp({ childCount: $total })  
      Text(`总和: ${this.total}`)  
    }  
  }  
}  

// 子组件  
@Component  
struct ChildComp {  
  @Link childCount: number;  
  build() {  
    Button('子组件+1')  
      .onClick(() => { this.childCount++ })  
  }  
}  

高级组件(系统协调层) 作用:统筹全局架构,实现跨组件通信与性能优化 包含内容

  • 高级布局RelativeContainer(相对布局)、WaterFlow(瀑布流)
  • 状态管理@Provide/@Consume(跨层级共享)、@Observed(深度监听对象)
  • 能力扩展:自定义弹层、分布式组件、原子化服务

开发者角色

  • 负责系统架构设计,类似“大脑指挥官”
  • 制定全局状态管理策略(如用户登录态同步)
  • 需优化组件渲染性能(如使用LazyForEach加载长列表)

类比

  • @Provide/@Consume → 血液循环系统,跨层级传递养分(数据)
  • RelativeContainer → 关节系统,灵活协调组件相对位置
  • 原子化服务 → 思维逻辑,独立运行且可组合

实战策略

  1. 状态分层

    • 局部状态用@State,跨页面用PersistentStorage
    • 全局共享数据使用AppStorage
  2. 性能优化

    • 复杂布局层级不超过5层
    • 列表滑动场景启用cachedCount预加载
  3. 工程化实践

    • 使用HAR包封装通用组件库
    • 通过ViewModel分离UI与业务逻辑

总结对比表

层级 组件类型 开发者角色 类比模型 核心目标
初级 基础系统组件 细胞合成师 细胞与骨骼 搭建静态框架
中级 自定义功能模块 器官工程师 肌肉与神经 实现数据流动
高级 分布式服务 大脑指挥官 思维与血液循环 统筹全局性能

通过类比与融合将知识点串联起来,以应用为主体、以函数比行为、以布局为框架、以通信为神经、以组件为血肉,共同搭建软件生命体。

next框架打包.next文件夹部署

2025年11月5日 14:26

前端小小白,刚开始使用AI辅助编程前端项目,使用的是React框架,让AI实现功能的时候它使用到了动态路由,我目前的理解就是将一个单传的前端项目变成了全栈前后端一体的项目,导致npm build执行完之后并没有生成期望的dist文件夹或output文件夹,而是将所有内容打包到了.next文件夹中,并且不只是单纯的路径变化,这种打包方式没有生成静态页面入口,而是打出来了一个server.js文件作为入口,连静态页面都没部署过的我,真的被难死了。还好,claude大法好,我不断提问和测试,终究还是解决了,记录一下解决方案。

部署方案:使用 standalone 模式

第一步:本地构建

在你的本地项目目录执行:

npm run build

构建完成后,你会看到:

Compiled successfully
✓ Linting and checking validity of types
✓ Collecting page data
✓ Generating static pages
✓ Finalizing page optimization

并且项目路径下会有.next文件夹

第二步:准备部署文件

构建成功后,需要准备以下文件:

部署包结构:
├── server.js              # 来自 .next/standalone/server.js
├── package.json           # 来自 .next/standalone/package.json  
├── .next/
│   └── server/           # 来自 .next/server/
│   └── static/           # 来自 .next/static/
│   └── xxx               # 来自 .next下其他文件
└── public/               # 来自根目录的 public/

第三步:打包上传

使用 PowerShell 脚本

创建 package-deploy.ps1

Write-Host "正在打包部署文件..." -ForegroundColor Green

# 创建临时目录
if (Test-Path deploy-temp) { Remove-Item -Recurse -Force deploy-temp }
New-Item -ItemType Directory -Path deploy-temp | Out-Null

# 复制文件
Write-Host "复制 standalone 文件..." -ForegroundColor Yellow
Copy-Item -Path ".next/standalone/*" -Destination "deploy-temp/" -Recurse -Force

Write-Host "复制 static 文件..." -ForegroundColor Yellow
New-Item -ItemType Directory -Path "deploy-temp/.next" -Force | Out-Null
Copy-Item -Path ".next/static" -Destination "deploy-temp/.next/" -Recurse -Force

Write-Host "复制 public 文件..." -ForegroundColor Yellow
Copy-Item -Path "public" -Destination "deploy-temp/" -Recurse -Force

# 创建压缩包
Write-Host "创建 deploy.zip..." -ForegroundColor Yellow
Compress-Archive -Path "deploy-temp/*" -DestinationPath "deploy.zip" -Force

# 清理
Remove-Item -Recurse -Force deploy-temp

Write-Host "打包完成!文件: deploy.zip" -ForegroundColor Green
Write-Host "文件大小: $((Get-Item deploy.zip).Length / 1MB) MB" -ForegroundColor Cyan

运行:

powershell -ExecutionPolicy Bypass -File package-deploy.ps1

第四步:服务器部署

1. 上传文件到服务器
# 使用 scp 上传(Linux/Mac)
scp deploy.zip user@your-server:/path/to/app/

# 或使用 WinSCP、FileZilla 等工具
2. 在服务器上解压并运行
# SSH 登录服务器
ssh user@your-server

# 进入应用目录
cd /path/to/app

# 解压文件
unzip deploy.zip

# 查看文件结构(确认正确)
ls -la
# 应该看到:server.js, package.json, .next/, public/

# 直接运行(测试)
HOSTNAME=0.0.0.0 PORT=3889 node server.js

# 或使用 PM2(生产环境推荐)
HOSTNAME=0.0.0.0 PORT=3889 pm2 start server.js --name your-app
pm2 save
pm2 startup

直接运行成功显示如下: 在这里插入图片描述 使用 PM2 运行成功显示如下: 在这里插入图片描述 成功后可以使用pm2 status查看状态: 在这里插入图片描述

PM命令

  • 停止并删除错误的进程
pm2 stop your-app
pm2 delete your-app
  • 查看 PM2 状态
pm2 status
  • 查看错误日志
pm2 logs your-app --err --lines 50
  • 查看所有日志
pm2 logs wps-app --lines 50
  • 查看进程详情
pm2 show your-app

全自动,解放双手

按照目前的部署方式,每次都有一步步手动操作,麻烦且容易出错,所以编写一个自动化脚本,保证不出错情况下解放双手!

  • 创建部署应用相关配置文件deploy-config.json
    {
      "server": {
        "host": "192.168.41.11",
        "user": "root",
        "port": 22,
        "deployPath": "/opt/module/wps/test3"
      },
      "app": {
        "name": "your-app",
        "port": 3889,
        "pm2Name": "your-app"
      },
      "build": {
        "command": "npm run build",
        "outputDir": ".next"
      }
    }
    
  • 打包部署脚本 该脚本是powershell脚本,在windows环境运行,xxx.ps1,脚本执行需要依赖deploy-config.json
    # ====================================
    # Next.js Standalone Auto Deploy Script (Windows PowerShell)
    # ====================================
    
    # Stop on error
    $ErrorActionPreference = "Stop"
    
    # Read configuration file
    Write-Host "=====================================" -ForegroundColor Cyan
    Write-Host "  Next.js Auto Deploy Script" -ForegroundColor Cyan
    Write-Host "=====================================" -ForegroundColor Cyan
    Write-Host ""
    
    if (-not (Test-Path "deploy-config.json")) {
        Write-Host "Error: deploy-config.json not found" -ForegroundColor Red
        Write-Host "Please create configuration file first" -ForegroundColor Yellow
        exit 1
    }
    
    $config = Get-Content "deploy-config.json" | ConvertFrom-Json
    
    Write-Host "Deploy Configuration:" -ForegroundColor Green
    Write-Host "  Server: $($config.server.user)@$($config.server.host)" -ForegroundColor White
    Write-Host "  Path: $($config.server.deployPath)" -ForegroundColor White
    Write-Host "  Port: $($config.app.port)" -ForegroundColor White
    Write-Host ""
    
    # Confirm deployment
    $confirm = Read-Host "Continue with deployment? (y/n)"
    if ($confirm -ne "y" -and $confirm -ne "Y") {
        Write-Host "Deployment cancelled" -ForegroundColor Yellow
        exit 0
    }
    
    Write-Host ""
    
    # ====================================
    # Step 1: Build Project
    # ====================================
    Write-Host "=====================================" -ForegroundColor Cyan
    Write-Host "Step 1/5: Build Project" -ForegroundColor Cyan
    Write-Host "=====================================" -ForegroundColor Cyan
    
    try {
        Write-Host "Executing: $($config.build.command)" -ForegroundColor Yellow
        Invoke-Expression $config.build.command
        
        if ($LASTEXITCODE -ne 0) {
            throw "Build failed"
        }
        
        Write-Host "Build successful" -ForegroundColor Green
        Write-Host ""
    } catch {
        Write-Host "Build failed: $_" -ForegroundColor Red
        exit 1
    }
    
    # ====================================
    # Step 2: Package Files
    # ====================================
    Write-Host "=====================================" -ForegroundColor Cyan
    Write-Host "Step 2/5: Package Files" -ForegroundColor Cyan
    Write-Host "=====================================" -ForegroundColor Cyan
    
    try {
        # Create temp directory
        $tempDir = "deploy-temp"
        if (Test-Path $tempDir) {
            Remove-Item -Recurse -Force $tempDir
        }
        New-Item -ItemType Directory -Path $tempDir | Out-Null
        
        # Copy standalone files
        Write-Host "Copying standalone files..." -ForegroundColor Yellow
        $standalonePath = ".next/standalone"
        if (-not (Test-Path $standalonePath)) {
            throw "Cannot find .next/standalone directory. Please ensure next.config.ts has output: 'standalone'"
        }
        Copy-Item -Path "$standalonePath/*" -Destination $tempDir -Recurse -Force
        
        # Copy static files
        Write-Host "Copying static files..." -ForegroundColor Yellow
        $staticPath = ".next/static"
        if (Test-Path $staticPath) {
            $nextDir = Join-Path $tempDir ".next"
            if (-not (Test-Path $nextDir)) {
                New-Item -ItemType Directory -Path $nextDir | Out-Null
            }
            Copy-Item -Path $staticPath -Destination $nextDir -Recurse -Force
        }
        
        # Copy public files
        Write-Host "Copying public files..." -ForegroundColor Yellow
        if (Test-Path "public") {
            Copy-Item -Path "public" -Destination $tempDir -Recurse -Force
        }
        
        # Create archive
        Write-Host "Creating deploy.zip..." -ForegroundColor Yellow
        $zipPath = "deploy.zip"
        if (Test-Path $zipPath) {
            Remove-Item -Force $zipPath
        }
        Compress-Archive -Path "$tempDir/*" -DestinationPath $zipPath -Force
        
        $zipSize = [math]::Round((Get-Item $zipPath).Length / 1MB, 2)
        Write-Host "Package successful (Size: $zipSize MB)" -ForegroundColor Green
        Write-Host ""
        
        # Cleanup temp directory
        Remove-Item -Recurse -Force $tempDir
    } catch {
        Write-Host "Package failed: $_" -ForegroundColor Red
        if (Test-Path $tempDir) {
            Remove-Item -Recurse -Force $tempDir
        }
        exit 1
    }
    
    # ====================================
    # Step 3: Upload to Server
    # ====================================
    Write-Host "=====================================" -ForegroundColor Cyan
    Write-Host "Step 3/5: Upload to Server" -ForegroundColor Cyan
    Write-Host "=====================================" -ForegroundColor Cyan
    
    # Check if scp is available
    $scpTest = Get-Command scp -ErrorAction SilentlyContinue
    if (-not $scpTest) {
        Write-Host "Error: scp command not found. Please install OpenSSH client." -ForegroundColor Red
        exit 1
    }
    
    $uploadSuccess = $false
    $maxRetries = 3
    $retryCount = 0
    
    while (-not $uploadSuccess -and $retryCount -lt $maxRetries) {
        try {
            if ($retryCount -gt 0) {
                Write-Host ""
                Write-Host "Retry attempt $retryCount of $($maxRetries - 1)..." -ForegroundColor Yellow
            }
            
            Write-Host "Uploading to $($config.server.host)..." -ForegroundColor Yellow
            
            $scpTarget = "$($config.server.user)@$($config.server.host):$($config.server.deployPath)/deploy.zip"
            
            # Upload file
            & scp -P $config.server.port deploy.zip $scpTarget
            
            if ($LASTEXITCODE -eq 0) {
                $uploadSuccess = $true
                Write-Host "Upload successful" -ForegroundColor Green
                Write-Host ""
            } else {
                $retryCount++
                if ($retryCount -lt $maxRetries) {
                    Write-Host "Upload failed. Please check password and try again." -ForegroundColor Yellow
                }
            }
        } catch {
            $retryCount++
            if ($retryCount -lt $maxRetries) {
                Write-Host "Upload failed: $_" -ForegroundColor Red
                Write-Host "Please try again..." -ForegroundColor Yellow
            }
        }
    }
    
    if (-not $uploadSuccess) {
        Write-Host ""
        Write-Host "Upload failed after $maxRetries attempts" -ForegroundColor Red
        Write-Host ""
        Write-Host "Tips:" -ForegroundColor Yellow
        Write-Host "  1. Check if server address and port are correct" -ForegroundColor Yellow
        Write-Host "  2. Verify your password" -ForegroundColor Yellow
        Write-Host "  3. Consider setting up SSH key authentication" -ForegroundColor Yellow
        exit 1
    }
    
    # ====================================
    # Step 4: Extract Files
    # ====================================
    Write-Host "=====================================" -ForegroundColor Cyan
    Write-Host "Step 4/5: Extract Files" -ForegroundColor Cyan
    Write-Host "=====================================" -ForegroundColor Cyan
    
    Write-Host "Extracting files on server..." -ForegroundColor Yellow
    
    $sshTarget = "$($config.server.user)@$($config.server.host)"
    $deployPath = $config.server.deployPath
    $pm2Name = $config.app.pm2Name
    $port = $config.app.port
    
    # Step 1: Extract files
    & ssh -p $config.server.port $sshTarget "cd $deployPath && unzip -o deploy.zip && rm deploy.zip"
    Write-Host "Files extracted" -ForegroundColor Green
    Write-Host ""
    
    # ====================================
    # Step 5: Start Application
    # ====================================
    Write-Host "=====================================" -ForegroundColor Cyan
    Write-Host "Step 5/5: Start Application" -ForegroundColor Cyan
    Write-Host "=====================================" -ForegroundColor Cyan
    
    Write-Host "Starting application with PM2..." -ForegroundColor Yellow
    
    # Step 2: Stop old process
    & ssh -p $config.server.port $sshTarget "pm2 stop $pm2Name 2>/dev/null; pm2 delete $pm2Name 2>/dev/null; true"
    
    # Step 3: Start new process with HOSTNAME=0.0.0.0 to fix IPv6 listening issue
    & ssh -p $config.server.port $sshTarget "cd $deployPath && HOSTNAME=0.0.0.0 PORT=$port pm2 start server.js --name $pm2Name"
    
    # Step 4: Save PM2 config
    & ssh -p $config.server.port $sshTarget "pm2 save"
    
    Write-Host ""
    Write-Host "Verifying application status..." -ForegroundColor Yellow
    
    # Verify deployment
    & ssh -p $config.server.port $sshTarget "pm2 list"
    
    Write-Host ""
    Write-Host "Application started" -ForegroundColor Green
    Write-Host ""
    
    # ====================================
    # Complete
    # ====================================
    Write-Host "=====================================" -ForegroundColor Green
    Write-Host "  Deployment Successful!" -ForegroundColor Green
    Write-Host "=====================================" -ForegroundColor Green
    Write-Host ""
    Write-Host "Application Info:" -ForegroundColor Cyan
    Write-Host "  URL: http://$($config.server.host):$($config.app.port)" -ForegroundColor White
    Write-Host "  PM2 Name: $($config.app.pm2Name)" -ForegroundColor White
    Write-Host ""
    Write-Host "Common Commands:" -ForegroundColor Cyan
    Write-Host "  View logs: ssh $($config.server.user)@$($config.server.host) 'pm2 logs $($config.app.pm2Name)'" -ForegroundColor White
    Write-Host "  View status: ssh $($config.server.user)@$($config.server.host) 'pm2 status'" -ForegroundColor White
    Write-Host "  Restart app: ssh $($config.server.user)@$($config.server.host) 'pm2 restart $($config.app.pm2Name)'" -ForegroundColor White
    Write-Host ""
    
    # Cleanup local files
    if (Test-Path "deploy.zip") {
        Remove-Item -Force "deploy.zip"
    }
    
    Write-Host "Press any key to exit..."
    $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')
    
    

圆满,大功告成!

从零搭建小程序首页:新手也能看懂的结构解析与实战指南

作者 ouma_syu
2025年11月5日 14:21

一、从全局到页面:小程序是怎么组织起来的?

小程序和普通网页不太一样,它有一套自己的结构规则。

你会看到三个关键的“全局文件”:

文件 用来做什么
app.json 小程序的配置中心 —— 你所有页面、导航栏、tabBar 都在这里统一管理
app.js 应用入口 —— 小程序启动时做的事都在这里(比如登录、缓存)
app.wxss 全局样式文件 —— 所有页面都会继承它

理解了这部分,你就知道“小程序不是一个页面,而是一个完整 App”。


二、页面是如何工作的?(Page 架构)

每个页面都由四个文件组成:
wxml(结构) + wxss(样式) + js(逻辑) + json(配置)

你写的逻辑主要集中在:

Page({
  data: { /** 页面数据 */ },
  methods: { /** 事件方法 */ }
})

只要 data 改变,界面就会自动更新 —— 这就是小程序的数据驱动

对于新手来说,它的好处是:

  • 写界面 → 只关心数据
  • 事件逻辑 → 写在 Page 里
  • 页面跳转 → 用 wx.navigateTo

看起来简单,其实内部做了很多“自动更新”的事情。


三、首页长什么样?我们拆成几个模块就不难了

一个典型的小程序首页通常包含这些部分:

1. 搜索框(入口)

你使用了 Vant 的 van-search,样式统一、免手搓组件,对新手非常友好。

2. 分类网格(8 个菜单)

使用 wx:for 循环,通过数据自动渲染:

<block wx:for="{{menus}}">
  <navigator>
    <image src="{{item.typePic}}" />
    <view>{{item.typeName}}</view>
  </navigator>
</block>

这种结构非常适合新手入门:
数据驱动,改数据比改结构简单得多。

3. Swiper 轮播图

用于展示广告或主推内容。

4. 文章列表(信息流)

这部分是首页内容的主体,通过图片 + 文案组合,让用户快速浏览你的小程序内容价值。


四、样式如何适配?rpx 是你必须掌握的基础能力

小程序中的 rpx 是“响应式单位”:
不管手机宽窄,小程序都会帮你等比例适配

也就是说:

  • 设计图标注多少,你就写多少
  • 不会在不同手机上变形
  • 新手不用考虑复杂的适配方案

例如:

.article-column__img {
  width: 100%;
  height: 290rpx;
}

不用想太多,就是等比例缩放。


五、事件与交互(非常关键)

你在搜索框外层包了一个 bindtap,点击时跳转到搜索页:

<view bindtap="toSearch">
  <van-search />
</view>

对应 Page 里的方法:

toSearch(){
  wx.navigateTo({
    url:'/pages/search/search'
  })
}

对新手来说,这里要理解的重点只有一个:

  • wxml 里绑定事件
  • js 里写方法
  • 页面就能跳过去了

总结:从结构到交互,让你真正理解一个小程序首页是怎么跑起来的

本文从小程序的整体结构开始,带你一步步拆解首页是如何“跑”起来的。我们先了解了全局配置如何决定应用的基本框架,再看到页面文件如何各司其职:

  • WXML 负责结构
  • WXSS 负责样式
  • JS 管理数据与事件
  • JSON 做页面级配置

接着,我们将这些知识落地到真实的首页场景里,包括轮播图、分类宫格和内容信息流等常见模块。你不仅看到“写出来是什么样”,也理解了“为什么这样写”——比如为什么要数据驱动页面、为什么要用 wx:for,以及为什么组件库能提升效率。

对于一个正在学习小程序的新手来说,这些能力其实已经足够让你搭建出一个可上线、可扩展、也容易维护的首页。更重要的是,你现在能把“看别人代码”变成“自己能写出来”,这是学习前端道路上非常关键的一步。

如果你希望进一步提升,我们可以继续深入:做搜索页、做详情页、做全局状态管理、做接口数据联调……一步一步,你就能构建出一个完整的小程序产品。

深入解析 JavaScript 引擎与作用域机制

2025年11月5日 13:47

JavaScript 引擎概述

JavaScript 作为一门流行的编程语言,其执行依赖于 JavaScript 引擎。常见的 JavaScript 引擎主要有两类:

  1. 浏览器内置引擎:如 Chrome 的 V8 引擎、Firefox 的 SpiderMonkey 等
  2. Node.js 环境:基于 V8 引擎构建的服务器端 JavaScript 运行环境

以 V8 引擎为例,它本质上是一段庞大的程序,能够读取并执行 JavaScript 代码,是 JavaScript 能够在不同环境中运行的核心动力。

JavaScript 代码的执行过程

JavaScript 代码的执行并非简单的逐行读取运行,而是会先经过编译阶段(代码梳理过程),主要包含三个步骤:

  1. 分词 / 词法分析:将源代码分解成有意义的词法单元(如变量名、关键字、运算符等)
  2. 解析 / 语法分析:将词法单元转换为抽象语法树(AST),验证代码语法的正确性,识别有效标识符
  3. 代码生成:将 AST 转换为可执行的机器码

函数的本质与作用

在 JavaScript 中,函数是代码组织的重要方式,形如function fn() {}的结构就是一个函数体。函数存在的核心意义在于:

  • 封装特定逻辑代码块
  • 实现代码的复用与模块化
  • 延迟代码执行(只有调用函数时,内部代码才会运行)

作用域详解

作用域决定了变量和函数的可访问范围,JavaScript 中主要有三种作用域类型:

1. 全局作用域

在所有函数和代码块之外声明的变量,具有全局作用域,在程序的任何地方都能访问。

2. 函数作用域

在函数内部声明的变量(包括函数参数)属于函数作用域,仅在函数内部可访问。

var a = 10
function foo(b) { // 形参b属于foo函数作用域
    var a = 20 // 函数内部的a属于foo函数作用域
    function bar() {
        console.log(a + b)
    }
    bar()
}
foo(2) // 实参传递,输出22

3. 块级作用域

{}配合letconst声明的变量,形成块级作用域,变量仅在当前代码块内可访问。

{
    const a = 100 // 块级作用域内的常量
    var b = 200 // 函数作用域变量(穿透代码块)
}
console.log(b); // 输出200,var声明的变量可访问
// console.log(a); // 报错,a在块级作用域外不可访问

作用域查找规则

作用域遵循由内往外查找的原则,外层作用域不能访问内层作用域的变量。当在当前作用域找不到变量时,会向上级作用域查找,直到全局作用域。

let 关键字与暂时性死区

let关键字带来了块级作用域的特性,同时引入了 "暂时性死区"(TDZ)概念:

当一个{}代码块中存在let x声明时,在该代码块中访问x时:

  • 只能访问当前块内部声明的x
  • 在声明之前访问会触发错误(暂时性死区)
  • 无法访问外部作用域的同名变量
let a = 1
if(true){ 
    console.log(a); // 报错:Cannot access 'a' before initialization
    let a = 2 // 块级作用域内的声明,形成暂时性死区
}

变量声明的差异

var 声明的特性(函数作用域)

// 变量提升示例(scope/1.js)
var a
console.log(a) // 输出undefined
a = 1

// 代码块穿透示例(scope/4.js)
if(true){
    var a = 1
}
console.log(a); // 输出1,var声明穿透代码块

const和let 声明的特性(块级作用域,常量)

// scope/3.js

//let a = 1
//a = 2
//console.log(a);//a 为2,let 可以修改

const a = 3
a = 4 // 报错:Assignment to constant variable
console.log(a);//const 不可被修改

总结

理解 JavaScript 的引擎工作原理和作用域机制,是写出高质量代码的基础:

  • 代码执行前会经过编译阶段的词法分析、语法分析和代码生成
  • 作用域控制着变量的可见性和生命周期
  • 合理使用letconstvar,理解它们在作用域上的差异
  • 掌握作用域链的查找规则和暂时性死区特性,可有效避免常见的变量访问错误

##小练习

试着自己判断一下输出结果吧 D16297D9-5A60-49D2-9EFF-2D3BE0B41B83.png

4F30EFE6-45FA-4C2F-9D1C-5777870B9FC2.png

565893EB-E13C-4739-AFA2-C8ADF4592C25.png

85DB301D-AE67-4F93-860A-487E2FF75886.png

❌
❌