阅读视图

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

uni-app实现本地MQTT连接

最近接到安卓端的需求,要求使用MQTT连接实现设备信息的收发。

可能有兄弟不太清楚 MQTT协议 是什么,简单地说它是一种轻量级的、基于发布/订阅模式的消息传输协议,广泛用于物联网(IoT)领域。

常见的操作就是连接后有N个设备订阅了主题A,这时候任意一台设备对主题A发布了一条信息,则当前N个设备都能收到这条消息。

至于其他的也不用知道的太清楚,反正知道一般是这么玩的就行。

假如你想知道的更为详细,可以询问 Trae

image.png

需求

  1. 实现MQTT连接
  2. 实现主题(Topic)订阅
  3. 能够发送消息
  4. 能够接收消息

实现方案

首先是安装 mqtt.js 插件和 uuid.js 插件。

npm i mqtt@3.0.0 uuid

注意: 这里一定要安装 mqtt v3.0 版本,我之前装了一个 v5.x 版本,有比较大的 API 改动。项目上时间紧张,遂没有再尝试。(有兴趣的朋友可以考虑用最新版实现)

uuid 只是起到生成 连接ID 和 消息ID 的作用,这里也可以直接使用时间戳的方式,但是为了保证不重复,这里便不再更换了。

// mqttClient.js 对 MQTT 的简单封装
let client = null;
/**
 * 初始化 MQTT 连接
 * @param {Object} options
 * @param {string} options.host - 服务器地址(不带协议)
 * @param {number} options.port - 端口(通常 WebSocket 是 8083、8084 或 443)
 * @param {string} options.clientId - 客户端 ID(建议唯一)
 * @param {string} [options.username] - 用户名
 * @param {string} [options.password] - 密码
 * @param {boolean} [options.useWss=false] - 是否使用 wss(HTTPS)
 */
export function initMqtt(options) {
const {
host,
port,
clientId,
username = '',
password = '',
useWss = false
} = options;

// 根据平台选择协议(App 和 H5 用 ws/wss,小程序用 wxs/wss)
let protocol = 'ws';
if (useWss) protocol = 'wss';

// #ifdef MP-WEIXIN
protocol = useWss ? 'wxs' : 'wxs';
// #endif

const url = `${protocol}://${host}:${port}/mqtt`; // 注意:路径可能是 / 或 /mqtt,根据你的 broker 配置

const connectOptions = {
clientId: clientId,
username: username,
password: password,
keepalive: 60, // 心跳间隔(秒)
reconnectPeriod: 5000, // 自动重连间隔(毫秒)
connectTimeout: 10000, // 连接超时
clean: true // 是否清除会话
};

// 引入 mqtt(必须用 min 版本,否则小程序报错)
const mqtt = require('mqtt/dist/mqtt.min.js');

client = mqtt.connect(url, connectOptions);

// 监听连接事件
client.on('connect', () => {
console.log('MQTT 连接成功');
});

client.on('reconnect', () => {
console.log('MQTT 正在重连...');
});

client.on('error', (err) => {
console.error('MQTT 连接错误:', err);
});

client.on('close', () => {
console.log('MQTT 连接已关闭');
});

return client;
}

/**
 * 订阅主题
 * @param {string} topic
 * @param {Function} callback - (topic, message) => {}
 */
export function subscribe(topic, callback) {
if (!client) {
console.warn('MQTT 客户端未初始化');
return;
}

client.subscribe(topic, (err) => {
if (!err) {
console.log(`订阅主题成功: ${topic}`);
} else {
console.error('订阅失败:', err);
}
});

// 监听消息(全局监听,需配合 topic 过滤)
client.on('message', (receivedTopic, message) => {
// if (receivedTopic === topic) {
try {
// 尝试解析 JSON
const payload = JSON.parse(message.toString());
callback(receivedTopic, payload);
} catch (e) {
// 非 JSON 消息
callback(receivedTopic, message.toString());
}
// }
});
}

/**
 * 发布消息
 * @param {string} topic
 * @param {string|object} message
 */
export function publish(topic, message) {
if (!client) {
console.warn('MQTT 客户端未初始化');
return;
}

const payload = typeof message === 'object' ? JSON.stringify(message) : message;
client.publish(topic, payload, {
qos: 1
}, (err) => {
if (err) {
console.error('发布失败:', err);
} else {
console.log(`发布成功: ${topic}`, payload);
}
});
}

/**
 * 断开连接
 */
export function disconnect() {
if (client) {
client.end(true); // true 表示强制断开
client = null;
console.log('MQTT 已断开');
}
}

我让 Trae 基于网上的封装简单进行了修改,生成了上述封装Js文件。

注意:在监听消息的部分,如果你的 Topic 是固定的,则请将 if (receivedTopic === topic)注释打开,如果不是,则注释掉,后续有详解。

现在进入到 page/index/index.vue 文件中

import {initMqtt, subscribe, publish, disconnect} from './mqttClient.js';
export default {
    data() {
        return {
            host: 'broker.emqx.io',
            messages: []
        }
    },
    mounted() {
        this.connect()
    },
    beforeDestroy() {
        this.disconnect()
    },
    methods: {
        // 连接
        connect() {
            const uuid = uuidv4();
            const client = initMqtt({
                host: this.host,
                port: 8083,
                clientId: uuid,
                username: '',
                password: '',
                useWss: false // 如果是 HTTPS/WSS,设为 true
            });
            if (client) {
                this.subscribeTopic();
            }
        },
        // 订阅 test/topic 主题
        subscribeTopic() {
            subscribe('test/topic', (topic, message) => {
                // 接收到的消息
                const msg = JSON.stringify(message);
                this.messages.unshift(msg);
            });
        },
        // 订阅 带通配符 的主题
        subscribeTopics() {
            subscribe('+/topic', (topic, message) => {
                // 接收到的消息
                const msg = JSON.stringify(message);
                this.messages.unshift(msg);
            });
        },
        // 发送消息到 test/topic 主题
        publishMessage() {
            publish('test/topic', {
                name: 'uni-app',
                time: new Date().toISOString(),
                msg: '测试信息'
            });
        },
        disconnect() {
            disconnect();
        }
    }
}

注意:在卸载页面的时候建议将 MQTT连接 断开,或者在 uni-app 中使用也可以使用 unload 方法断开连接。

另外订阅主题可以采用常规的,确定性的 Topic 主题名称,也可以采用 通配符(+) 的主题名称。

Trae还提醒我一定要注意,假如你的 Topic 为:

123/456/report 则你的通配符主题名称为 +/+/report

假如你的 Topic 为:

/123/456/report 则你的通配符主题名称为 +/+/+/report,因为他的第一位是 空字符串

结论

在开发 IoT 设备时这个协议用的比较广泛,如果不接触这个方面一般不会用上这个。

另外 MQTT 本身自带心跳自动重连机制,大致上相当于 WebSocket,这个部分一般不需要做特殊开发。

GDAL 实现空间分析

^ 关注我,带你一起学GIS ^

前言

空间分析基于空间关系,常见的如叠加、求差、取反等。在GIS开发中,空间分析作为其灵魂支柱,具有重要意义,是每一个GISer都必须要掌握的技能。

在之前的文章中讲了如何使用GDAL或者ogr2ogr工具将txt以及csv文本数据转换为Shp格式,本篇教程在之前一系列文章的基础上讲解如何使用GDAL 实现空间分析

如果你还没有看过,建议从以上内容开始。

1. 开发环境

本文使用如下开发环境,以供参考。

时间:2025年

系统:Windows 11

Python:3.11.7

GDAL:3.11.1

2. 空间分析

定义一个方法SpatialAnalysis用于判断要素间空间关系,该方法接收两个参数,其中sourcePath指向源数据路径,resultPath指向结果文件路径。

"""说明:GDAL 空间分析操作参数:    -sourcePath:Shp 数据路径    -resultPath:生成结果数据路径"""def SpatialAnalysis(sourcePath,resultPath):

接着是基础的常规操作,添加数据驱动,获取数据源,获取目标图层。

# 检查文件是否存在checkFilePath(sourcePath)# 添加数据驱动shpDriver = ogr.GetDriverByName("ESRI Shapefile")# 获取数据源shpDs = shpDriver.Open(sourcePath)# 获取图层shpLayer = shpDs.GetLayer(0)srs = shpLayer.GetSpatialRef()geomType = shpLayer.GetGeomType()

方法checkFilePath用于检查文件路径是否正常,接受一个路径参数。

"""说明:检查文件路径是否正常参数:    -filePath:文件数据路径"""def checkFilePath(filePath):    if os.path.exists(filePath):        print("文件数据路径存在")    else:        print("文件数据路径不存在,请检查!")

本文基于以下数据进行空间分析操作。对涉及面的操作分别获取面0和面1两个要素。GDAL中的几何处理操作,如叠加、缓冲、求差等均返回一个新的几何对象。

"""获取第一个图层要素"""# feature = shpLayer.GetNextFeature()# 根据FId获取指定要素feature1 = shpLayer.GetFeature(0)print(f"n面状要素~~~~~Id:{feature1.GetField('Id')}n")# 获取要素IdfeatId1 = feature1.GetField("Id")# 获取几何要素geom1 = feature1.GetGeometryRef()# print(f"面状几何:{geom1}")"""    获取第二个要素"""feature2 = shpLayer.GetFeature(1)geom2 = feature2.GetGeometryRef()featId2 = feature2.GetField("Id")

3. 空间叠加

Geometry对象上具有一个方法Intersection用于空间叠加操作,该方法接收另一个几何对象,并返回一个新的Geometry对象。

"""    空间操作之【叠加】"""resultGeom = geom1.Intersection(geom2)# print(f"面要素【{featId1}】与 面要素【{featId2}】相交结果:{resultGeom}")

如下面0和面1相交结果为图中高亮部分。

4. 空间缓冲

Geometry对象上具有一个方法Buffer用于空间缓冲区生成,该方法接收另一个几何对象,并返回一个新的Geometry对象。

"""    空间操作之【缓冲】"""resultGeom = geom1.Buffer(1)# print(f"面要素【{featId1}】缓冲结果:{resultGeom}")

如下面0缓冲结果为图中高亮部分。

如下线段缓冲结果为图中高亮部分。

如下点缓冲结果为图中高亮部分。

5. 空间联合

Geometry对象上具有一个方法Union用于合并几何对象,该方法接收另一个几何对象,并返回一个新的Geometry对象。

"""    空间操作之【联合】"""resultGeom = geom1.Union(geom2)# print(f"面要素【{featId1}】联合结果:{resultGeom}")

如下面0和面1联合结果为图中高亮部分。

6. 空间求差

Geometry对象上具有一个方法Difference用于求取目标几何对象差集,该方法接收另一个几何对象,并返回一个新的Geometry对象。

"""    空间操作之【求差】"""resultGeom = geom1.Difference(geom2)# print(f"面要素【{featId1}】联合结果:{resultGeom}")

如下面0和面1求差结果为图中高亮部分。

7. 对称差集

Geometry对象上具有一个方法SymmetricDifference用于几何对象交集取反操作,该方法接收另一个几何对象,并返回一个新的Geometry对象。

"""    空间操作之【求对称差集】"""resultGeom = geom1.SymmetricDifference(geom2)print(f"面要素【{featId1}】联合结果:{resultGeom}")

如下面0和面1求对称差集结果为图中高亮部分。

还有一些几何操作,如求等、求距离等内容本教程并未涉及,留给感兴趣的读者自行实践。

8. 创建新图层

几何运算完成之后,需要将分析结果保存到新的Shp图层。通过数据驱动方法CreateDataSource创建Shp数据源,再使用数据源方法CreateLayer创建图层即可。

"""    创建新图层"""targetDs = shpDriver.CreateDataSource(resultPath)targetLayer = targetDs.CreateLayer("result_layer",srs,ogr.wkbPolygon)featureDefn = shpLayer.GetLayerDefn()tarFeat = ogr.Feature(featureDefn)fieldCount = featureDefn.GetFieldCount()tarFeat.SetGeometry(resultGeom)for i in range(fieldCount):    fieldDefn = featureDefn.GetFieldDefn(i)    name = fieldDefn.GetName()    value = feature1.GetField(i)    print(f"字段名称:{name},字段值:{value}")    # tarFeat.SetField(i,value)    tarFeat.SetField(name,value)targetLayer.CreateFeature(tarFeat)

9. 注意事项

windows开发环境中同时安装GDALPostGIS,其中投影库PROJ的环境变量指向PostGIS的安装路径,在运行GDAL程序时,涉及到要素、几何与投影操作时会导致异常。具体意思为GDAL不支持PostGIS插件中的投影库版本,需要更换投影库或者升级版本。

RuntimeError: PROJ: proj_identify: D:Program FilesPostgreSQL13sharecontribpostgis-3.5projproj.db contains DATABASE.LAYOUT.VERSION.MINOR = 2 whereas a number >= 5 is expected. It comes from another PROJ installation.

解决办法为修改PROJ的环境变量到GDAL支持的版本或者在GDAL程序开头添加以下代码:

os.environ['PROJ_LIB'] = r'D:\Programs\Python\Python311\Libsite-packages\osgeo\data\proj'

视频效果

GDAL 实现空间分析操作

OpenLayers示例数据下载,请在公众号后台回复:ol数据

全国信息化工程师-GIS 应用水平考试资料,请在公众号后台回复:GIS考试

GIS之路 公众号已经接入了智能 助手,可以在对话框进行提问,也可以直接搜索历史文章进行查看。

都看到这了,不要忘记点赞、收藏 + 关注

本号不定时更新有关 GIS开发 相关内容,欢迎关注 


    

GeoTools 开发合集(全)

OpenLayers 开发合集

GDAL 实现空间分析操作

GDAL 空间关系解析

GDAL 实现数据空间查询

GDAL 实现数据属性查询

GDAL 实现创建几何对象

GDAL 实现自定义数据坐标系

GDAL 实现矢量数据读写

GDAL 数据类型大全

GDAL 实现 GIS 数据读取转换(全)

JavaScript 如何准确判断数据类型?5 种方法深度对比

在写js的时候,很容易因为数据类型没判断好而出错,比如这样的代码:

function calculate(a, b) {
    return a + b;
}
// 我以为是 10 + 20 = 30
calculate(10, 20); // 结果 30 对的 

// 实际上用户输入的是字符串和数字
calculate("10", 20); // 结果为 "1020" 

所以为了避免这种情况的出现,我们还是要去判断好数据类型。

JavaScript中,有几种方式来判断数据类型,以下是常用的方法:

1. typeof 操作符

最常用的类型判断方法,但有一些局限性:

typeof 42;           // "number"
typeof "hello";      // "string"
typeof true;         // "boolean"
typeof undefined;    // "undefined"
typeof null;         // "object" (历史遗留问题)
typeof {};           // "object"
typeof [];           // "object"
typeof function(){}; // "function"
typeof Symbol();     // "symbol"
typeof 42n;          // "bigint"

局限性

  • 无法区分数组、对象和 null
  • 函数返回 function
  • typeof 适合判断基本类型,但遇到对象类型就力不从心了

2. instanceof 操作符

用于检测构造函数的 prototype 属性是否出现在对象的原型链中:

[] instanceof Array;           // true
{} instanceof Object;          // true
new Date() instanceof Date;    // true
function(){} instanceof Function; // true

// 继承关系,数组也是对象
[] instanceof Object;          // true

instanceof 的局限性:

// 基本类型用不了
42 instanceof Number;          // false
"hello" instanceof String;     // false

在跨 iframe 或不同 window 环境下可能失效(因为构造函数不同)


3. Object.prototype .toString.call()

这是最准确、最可靠的方法,能识别所有内置类型!

Object.prototype.toString.call(42);           // "[object Number]"
Object.prototype.toString.call("hello");      // "[object String]"
Object.prototype.toString.call(true);         // "[object Boolean]"
Object.prototype.toString.call(null);         // "[object Null]"
Object.prototype.toString.call(undefined);    // "[object Undefined]"
Object.prototype.toString.call([]);           // "[object Array]"
Object.prototype.toString.call({});           // "[object Object]"
Object.prototype.toString.call(function(){}); // "[object Function]"
Object.prototype.toString.call(Symbol());     // "[object Symbol]"
Object.prototype.toString.call(42n);          // "[object BigInt]"

我们封装一个实用的工具函数:

function getRealType(value) {
    return Object.prototype.toString.call(value)
        .slice(8, -1)          // 截取"[object "和"]"之间的内容
        .toLowerCase();         // 转为小写,更友好
}

console.log(getRealType([]));        // "array"
console.log(getRealType(null));      // "null"
console.log(getRealType({}));        // "object"
console.log(getRealType(new Date())); // "date"

4. 专用方法

对于一些特殊类型,JavaScript提供了专门的判断方法:

判断数组:Array.isArray()

Array.isArray([]);     // true
Array.isArray({});     // false
Array.isArray("123");  // false

判断NaN:Number.isNaN()

// 注意区别!
isNaN("hello");        // true  ← 字符串不是数字,但这样判断容易误解
Number.isNaN("hello"); // false ← 更准确:只有真正的NaN才返回true
Number.isNaN(NaN);     // true

判断有限数字:Number.isFinite()

Number.isFinite(42);     // true
Number.isFinite(Infinity); // false  ← 无穷大不是有限数字
Number.isFinite("42");   // false  ← 字符串不是数字

编写健壮的函数

在实际开发中的应用:

场景1:安全的数字相加

function safeAdd(a, b) {
    // 确保两个参数都是数字类型
    if (typeof a !== 'number' || typeof b !== 'number') {
        throw new Error('参数必须是数字');
    }
    return a + b;
}

safeAdd(1, 2);     // 3
safeAdd(1, "2");   // 报错:参数必须是数字

场景2:处理多种数据类型

function processData(data) {
    // getRealType方法在第3点Object.prototype.toString.call()中有写
    const type = getRealType(data); 
    
    switch(type) {
        case 'array':
            return data.map(item => item * 2);
        case 'object':
            return Object.keys(data).length;
        case 'string':
            return data.toUpperCase();
        case 'number':
            return data * 2;
        default:
            return '不支持的数据类型';
    }
}

console.log(processData([1, 2, 3]));    // [2, 4, 6]
console.log(processData("hello"));      // "HELLO"
console.log(processData({a: 1, b: 2})); // 2

总结:选择合适的方法

场景 推荐方法 示例
判断基本类型 typeof typeof "hello" === "string"
判断数组 Array.isArray() Array.isArray([])
判断自定义对象 instanceof obj instanceof MyClass
精确类型判断 Object.prototype.toString.call() 见上文工具函数
特殊值判断 专用方法 Number.isNaN(), Number.isFinite()

本文首发于公众号:程序员大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

写前端久了,我用 Node.js 给自己造了几个省力小工具

我也是写了很久 TypeScript,才意识到这些写法不对

ThreadLocal 在实际项目中的 6 大用法,原来可以这么简单

重构了20个SpringBoot项目后,总结出这套稳定高效的架构设计

用changeset来管理你的npm包版本

简单介绍下,changeset是一个版本管理和生成更新日志的工具,超级适用于多包仓库,比如monorepo,可以在提交发布时,自动更新所有包的版本号,创建Tag,并且生成更新日志。

一、安装

进入项目之后执行命令,安装changeset,并且初始化

 pnpm add -D @changesets/cli
 pnpm changeset init

执行完之后,在项目中会新增一个.changeset目录,用于存放配置文件和临时的版本变更描述文件

.changeset/
 ├─ config.json
 └─ README.md

二、使用流程

在通用的版本管理流程中,通常会区分为:

  • 预发布版本(如alpha和beta)
  • 正式版本

预发布版本

预发布阶段的两级为alpha和beta,下面是大致区别:

维度 alpha(内测) beta(公测)
代码稳定性 随时可能大改 功能基本锁定,不会有大的调整
测试人群 团队内部 灰度用户
发布频率 每天/每周都能出包 节奏稍慢,某个阶段的版本
版本号示例 1.0.0-alpha.0 ➜ 1.0.0-alpha.1 … 1.0.0-beta.0 ➜ 1.0.0-beta.1 …
退出条件 达到功能完备 → 进入beta 连续几天无阻塞Bug → 发布正式版

相关命令:

pnpm changeset pre enter alpha   # 进入alpha模式,
pnpm changeset version          # 版本变成0.0.1-alpha.0

pnpm changeset pre enter beta   # 进入beta模式
pnpm changeset version          # 版本变成0.0.1-beta.0

# 结束预发布
pnpm changeset pre exit
pnpm changeset version          # 版本变成0.0.1

正式版本

当你完成某个包的开发,准备发版时,执行:

pnpm changeset

如果是多包仓库,终端会出现一个选择框,让你选择改过的包,

  1. 按空格选中你改过的包(有星号就算选中)→ 回车,

  2. 选包的更新级别,会依次出现major和minor,回车到下一步,如果都没选中,就默认为patch,输入本次更新的描述回车

    • patch:修复小bug(1.0.0→1.0.1)
    • minor:添加新功能(1.0.0→1.1.0)
    • major:破坏性的大版本调整,api级别的调整(1.0.0→2.0.0)

单包仓库就直接到了选择更新级别这一步,同样是输入描述,然后回车;

生成版本号,执行:

pnpm changeset version

你会发现.changeset文件夹中刚才生成的md文件都已经不见了,版本号也升好了。

发布

  • 登录npm
npm login
  • 发版
pnpm changeset publish

成功后,会在git创建对应的git tag,终端会给出每个包的版本号和 npm链接。

三、changeset总结

最后总结下,对changeset的整体感觉

优点

  1. 版本语义化,显式标注变更级别
  2. 每一次变更都会新建个文件,方便review
    • 列出受影响的包
    • 变更的级别,patch/minor/major
    • 变更的内容
  3. 自动生成tag、版本号changelog
  4. Monorepo依赖的自动推导,比如修改了a包,b包依赖了a包,那么b包也会更新版本

缺点

  1. 会有额外心智负担,
    • 每次变更都要执行changeset,会增加操作流程(其实我觉得这个不能算)
    • 思考版本类型
    • 流程容易漏
  2. 单包项目有点多余,没完全发挥作用

CSS 特殊符号 / 英文导致换行问题速查表

一、最推荐通用写法(90% 场景)

.text {
  word-break: normal;
  overflow-wrap: break-word;
  white-space: normal;
}

适用:

  • 中文 + 英文混排
  • URL / 特殊符号
  • 不希望英文被拆成字母

二、常见需求对应写法

以下场景覆盖:不拆单词 / 允许拆单词 / 特殊符号提前换行 等真实业务需求

1️⃣ 不希望单词被拆开

.text {
  word-break: normal;
  overflow-wrap: normal;
}

2️⃣ 单词太长允许必要时换行(推荐)

.text {
  overflow-wrap: break-word;
  word-break: normal;
}

3️⃣ 特殊符号(- _ / .)导致断行

.text {
  word-break: keep-all;
  overflow-wrap: break-word;
}

4️⃣ 完全不换行(一行显示)

.text {
  white-space: nowrap;
}

5️⃣ 一行显示,超出省略号

.text {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

6️⃣ URL / 长链接优雅断行

.text {
  word-break: normal;
  overflow-wrap: anywhere;
}

7️⃣ 代码 / hash / token 强制断行

.code {
  word-break: break-all;
  font-family: monospace;
}

8️⃣ 明确接受单词被拆开(强制任何位置换行)

.text {
  word-break: break-all;
}

适用:

  • 日志内容
  • 长 hash / token / ID
  • 空间极窄但必须完整展示

⚠️ 英文会被拆成字母,属于主动选择的行为


9️⃣ 特殊符号导致“提前换行”(如 - / _ .

.text {
  word-break: normal;
  overflow-wrap: normal;
}

说明:

  • 禁止在符号处断行
  • 让浏览器只在真正需要时换行

🔟 允许在特殊符号处优先换行(比拆字母更友好)

.text {
  overflow-wrap: anywhere;
  word-break: normal;
}

适用:

  • URL / 路径
  • aaa-bbb-ccc-ddd
  • 希望优先在符号处分行,而不是字母中间

---

## 三、Flex / Table 常见坑

### flex 子项内容被异常换行

```css
.item {
  min-width: 0;
  overflow-wrap: break-word;
}

四、不推荐写法(慎用)

⚠️ 以下写法不是不能用,而是必须明确知道后果

word-break: break-all;

❌ 会把英文拆成字母,仅适合日志 / code 场景


五、属性速记表

属性 作用
word-break 是否允许在单词内部断行
overflow-wrap 单词太长时是否允许换行(⭐推荐)
white-space 是否允许换行

📌 记住一句话:

优先用 overflow-wrap: break-word,避免 word-break: break-all

PowerShell 启动卡顿?内存飙升?原来是 800MB 的历史记录在作祟!

PowerShell 启动卡顿?内存飙升?原来是 800MB 的历史记录在作祟!

最近在开发过程中遇到一个非常诡异的问题:PowerShell 一启动,电脑就像被施了定身法一样卡顿。 打开任务管理器一看,好家伙,PowerShell 进程的内存占用像坐火箭一样蹭蹭往上涨,甚至在没有任何手动操作的情况下也是如此。

经过一番侦探式的排查,终于揪出了幕后真凶。今天就把这个排查过程和解决方案分享给大家,如果你的终端也经常卡顿,不妨检查一下这个问题。

🧐 问题现场

现象非常简单粗暴:

  • 双击启动 PowerShell(或者在 VS Code 中打开终端)。
  • 系统明显卡顿,鼠标移动都变得迟缓。
  • 查看任务管理器,PowerShell 进程占用内存极高(甚至达到 GB 级别)。
  • 等待许久后,终端才勉强可以输入命令。

🕵️‍♂️ 抽丝剥茧:排查过程

为了找到原因,我并没有急着重装系统(虽然这是万能大法),而是决定深入系统内部看一看。

1. 进程分析

首先,我使用 Get-Process 命令查看了 PowerShell 进程的状态。果不其然,WorkingSet(工作集内存)数值异常的高。

2. 环境检查

我怀疑是不是某个 Profile 配置文件或者加载的模块有问题。检查了 $PROFILE,发现并没有什么特殊的启动脚本。接着检查已加载的模块,一切似乎都很正常。

3. 关键发现

在排查 PowerShell 的常用模块 PSReadLine 时,我注意到了一个细节。这个模块负责管理我们的命令行历史记录、语法高亮等功能。它会把我们敲过的所有命令都保存在一个文本文件里。

我顺藤摸瓜找到了这个文件,结果让我大吃一惊:

文件路径: %APPDATA%\Microsoft\Windows\PowerShell\PSReadLine\ConsoleHost_history.txt 文件大小: 832 MB

没错,你没看错,一个纯文本的历史记录文件,竟然有 800多兆!这意味着里面可能保存了数百万行的命令历史。

真相大白: PowerShell 在启动时,PSReadLine 模块会尝试加载这个巨大的历史记录文件,以便提供“向上箭头”查找历史命令的功能。这就好比让你一口气背诵一本字典,不卡才怪!

🛠️ 一键解决

既然找到了病灶,治疗方案就非常简单了:让 PowerShell 放弃加载这个文件。

为了保险起见(万一里面有重要的历史命令呢),我没有直接删除它,而是选择了重命名。

操作步骤:

打开你的文件资源管理器,或者直接在命令行执行以下操作:

# 进入 PSReadLine 目录
cd "$env:APPDATA\Microsoft\Windows\PowerShell\PSReadLine"

# 将巨大的历史文件重命名备份
Rename-Item .\ConsoleHost_history.txt -NewName ConsoleHost_history.txt.bak

效果立竿见影: 再次启动 PowerShell,秒开!内存占用恢复到了几十 MB 的正常水平。系统瞬间恢复了丝般顺滑。

💡 避坑指南

问题解决了,但为什么会有这么大的历史文件呢?通常有以下几个原因:

  1. 日积月累: 从来没有清理过,数年的操作记录都在里面。
  2. 自动化脚本: 某些自动化脚本如果在 PowerShell 环境下疯狂循环执行命令,这些命令也会被记录下来。

建议:

  • 定期检查一下 %APPDATA%\Microsoft\Windows\PowerShell\PSReadLine\ 目录下的文件大小。
  • 如果你发现自己不需要保留那么久远的历史记录,可以考虑定期清理。
  • 如果那个 800MB 的备份文件里没有你需要的“传家宝”代码,果断删掉它释放空间吧!

希望这篇文章能帮到遇到同样问题的你。Happy Coding! 🚀

什么是MessageChannel

什么是MessageChannel MessageChannel允许我们在不同的浏览上下文,比如window.open()打开的窗口或者iframe等之间建立通信管道,并通过两端的端口(port1和p

第10章 SSE魔改

SSE(Server-Sent Events,服务器推送事件) 是一种基于标准HTTP协议的服务器到客户端的单向数据流技术。它允许服务器在建立初始连接后,通过一个持久的HTTP连接主动、连续地向客户端推送数据更新,而无需客户端重复发起请求。其核心机制是客户端使用 EventSource API 连接到指定端点后,服务器以 text/event-stream 格式持续发送事件流,每个事件由标识类型(event:)、数据(data:)和可选ID组成,客户端通过监听事件类型来实时处理数据,连接中断时还会借助最后接收的ID自动尝试重连。

与需要双向通信的WebSocket相比,SSE的典型优势在于协议轻量、天然支持自动重连与断点续传,且无需额外协议升级。它非常适合服务器主导的实时数据分发场景,如股市行情推送、新闻直播、社交媒体动态、任务进度通知等,浏览器兼容性广泛。但需要注意的是,SSE是单向通道(服务器→客户端),且主流实现中传输格式限于文本(二进制数据需编码),若需双向实时交互则仍需选择WebSocket。

通过以上对SSE的解释,我们可以想到现如今非常经典的例子,AI网站中输出文字的打字机效果(例如DeepSeek),一个字一个字的往外输出,这也是一种SSE。前端给后端发送一次消息,而后端可以给前端一直发消息。

10.1 初始化项目

我们采用Express来模拟SSE,因此需要如下3个步骤来初始化项目:

(1)创建index.html和index.ts文件。

(2)安装express和对应的声明文件,并引入index.ts文件中。

(3)安装cors和对应的声明文件,并引入index.ts文件中。

两个index文件用于展示效果以及编写SSE逻辑。

// 安装express
npm i express
// 安装 CORS(跨域资源共享中间件)
npm install cors
// 安装 Express 的 TypeScript 类型定义
npm install --save-dev @types/express
// 安装 CORS 的 TypeScript 类型定义
npm install --save-dev @types/cors

// 一次性安装所有依赖
npm install express cors @types/express @types/cors

对应的package.json文件中,将type字段设置为module,从而可以使用import引入写法。

// package.json
{
  "type": "module",
  "dependencies": {
    "@types/cors": "^2.8.19",
    "@types/express": "^5.0.6",
    "cors": "^2.8.5",
    "express": "^5.2.1"
  }
}

在index.ts文件使用ES模块语法引入依赖模块express和cors后,创建Express应用实例的app对象,然后在app对象中,通过use()方法挂载必要的全局中间件cors()和express.json(),用于处理跨域资源共享以及解析请求体中格式为JSON的数据。

// index.ts
import express from "express";
import cors from 'cors'

const app = express()
// 处理跨域
app.use(cors())
// 解析请求体中格式为JSON的数据
app.use(express.json())

最后,启动Express服务器,监听3000端口,完成SSE的项目初始化。

app.listen(3000, () => {
    console.log("Server is running on port 3000");
});

10.2 SSE逻辑实现

SSE要求接口必须是一个get请求,因此我们来定义一个get请求。

SSE的核心代码只有一行,将Content-Type设置为text/event-stream。通过将HTTP响应的内容类型明确声明为事件流格式,通知客户端(通常是浏览器)本次连接并非普通的请求-响应交互,而是一个需要保持开启、持续接收服务器推送事件的长连接通道。浏览器接收到这个特定头部后,会启动其内建的SSE处理机制(EventSource API),自动保持连接活性并准备以流式方式解析后续传入的数据。

返回数据的格式一定要遵循data: {实际数据}\n\n的形式。

// index.ts
app.get('/chat', (req, res) => {
  res.setHeader("Content-Type", "text/event-stream"); // 返回SSE
  // 不缓存
  res.setHeader("Cache-Control", "no-cache");
  // 持久化连接
  res.setHeader("Connection", "keep-alive");
  // 定时器,每秒返回一次时间,模拟后端连续地向客户端推送数据更新
  setInterval(() => {
    res.write(`data: ${new Date().toISOString()}\n\n`);
  }, 1000);
});

完成后端SSE的逻辑之后,前端需要如何接受后端传递过来的数据?通过浏览器内置的 EventSource API 来建立连接并接收后端SSE事件流数据就可以。

// index.html
const sse = new EventSource("http://localhost:3000/chat");
sse.onmessage = (event) => {
console.log(event.data);
};

此时启动后端服务器,打开index.html页面的控制台看流式输出时间,即前端可以实时接收后端返回的数据,如图10-1所示。

image-20251219033233783

图10-1 流式输出时间

此时打开网络选项卡,输出效果如图10-2所示。chat接口的EventStream会不断的接收message类型的消息,并展现对应的数据。

image-20251219033452742

图10-2 网络选项卡展示输出效果

10.3 SSE设置post请求

但一般在实际的项目中,是不会使用EventSource的,因为它必须是一个get请求,而在工作中经常使用的是post请求。那面对这种冲突的情况,应该如何去做?

如果我们只是在后端简单的将get请求直接改成post请求,然后重启服务去看效果的话,是无法生效的。

// index.ts
app.post('/chat', (req, res) => {
  //  省略...
});

chat接口修改成post请求如图10-3所示。请求方法依然为GET,网络状态则是404。

image-20251219034035386

图10-3 chat接口修改成post请求

面对后端chat接口修改为post请求不起效果的情况,我们只能在前端去魔改方案,不使用EventSource API来建立连接并接收后端SSE事件流数据。

我们在前端使用fetch()去接收chat接口返回的数据,此时从浏览器的的网络选项卡可以看到接通了,并且从响应选项中会不断打印出时间数据。

fetch("http://localhost:3000/chat", {
  headers: {
    "Content-Type": "application/json",
  },
  method: "POST",
  body: JSON.stringify({ message: "Hello, world!" }),
})
  .then(async response => {
    const reader = response.body.getReader(); // 获取流
    const decoder = new TextDecoder(); // 解码ASCII码值
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      console.log(value) // value是ASCII码
      const text = decoder.decode(value, { stream: true });
      console.log(text);
    }
  })
let a = [1]
let b = a[Symbol.iterator]();
console.log(b.next());
console.log(b.next());

现如今基本上都是通过fetch去魔改实现,将get请求修改成post请求也能传输数据。目前为止,没有更好的解决方法了。

在这段魔改的代码中,我们做了什么?

首先是设置请求头接收的内容类型以及请求方式。当接收到数据时,通过getReader()方法获取流,获取流得到的是一个Promise,因此需要通过await操作符去等待Promise兑现并获取它兑现之后值,通过read()方法去读取数据中的每一个流。

此时每一个流返回的是一个迭代器,迭代器是一个对象,内部有一个next()方法,该方法返回具有两个属性的对象:

(1)value:迭代序列的下一个值。

(2)done:如果已经迭代到序列中的最后一个值,则它为 true。如果 value 和 done 一起出现,则它就是迭代器的返回值。

所以迭代器对象可以通过重复调用next()方法显式地迭代。在while循环中持续调用reader.read()方法,这个方法返回的Promise在每次兑现时都提供一个类似迭代器next()方法的对象——包含value(当前数据块)和done(流是否结束)两个属性。通过循环判断done是否为false,我们可以持续读取Uint8Array格式的数据块,然后使用TextDecoder将其解码为可读文本,实现了对服务器推送数据流的实时逐块处理。

这种显式迭代的核心优势在于按需、增量地处理数据,避免了等待整个响应体完全到达才能开始处理。每次调用reader.read()都明确请求下一个数据块,直到done变为true表示流已结束。这与传统的一次性接收完整响应形成对比,特别适合处理SSE这种持续、长时间的数据流连接,确保了在处理服务器实时推送时内存使用的高效性和响应的即时性。

❌