阅读视图

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

每日一题-两个字符串的最小ASCII删除和🟡

给定两个字符串s1 和 s2,返回 使两个字符串相等所需删除字符的 ASCII 值的最小和 

 

示例 1:

输入: s1 = "sea", s2 = "eat"
输出: 231
解释: 在 "sea" 中删除 "s" 并将 "s" 的值(115)加入总和。
在 "eat" 中删除 "t" 并将 116 加入总和。
结束时,两个字符串相等,115 + 116 = 231 就是符合条件的最小和。

示例 2:

输入: s1 = "delete", s2 = "leet"
输出: 403
解释: 在 "delete" 中删除 "dee" 字符串变成 "let",
将 100[d]+101[e]+101[e] 加入总和。在 "leet" 中删除 "e" 将 101[e] 加入总和。
结束时,两个字符串都等于 "let",结果即为 100+101+101+101 = 403 。
如果改为将两个字符串转换为 "lee" 或 "eet",我们会得到 433 或 417 的结果,比答案更大。

 

提示:

  • 0 <= s1.length, s2.length <= 1000
  • s1 和 s2 由小写英文字母组成

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 数据读取转换(全)

代码高亮、数学公式、流程图... Astro 博客进阶全指南

1.jpg 你刚用 Astro 搭好博客,开始写第一篇技术文章。想要高亮代码的某一行?不行。想加个可折叠的警告框提醒读者注意事项?做不到。想在算法讲解里加个数学公式?完全没思路。

我也遇到过这种尴尬。用纯 Markdown 写了几篇文章后发现,能表达的东西太有限了。看别人的技术博客,代码块可以标注重点、可以展示代码改动前后的对比,文章里还能嵌入交互式的组件,自己却只能干巴巴地贴代码。

好在 Astro 提供了 MDX 这个"增强版 Markdown"。说白了,MDX 让你在写文章的时候可以用组件、可以写 JSX,能做的事情一下子多了好几倍。

这篇文章我会分享 7 个 Astro Markdown/MDX 的进阶用法,从最基础的环境配置到代码高亮、自定义组件、数学公式、流程图,每个技巧都配了完整的代码和配置步骤。读完这篇,你的技术博客能从"能看"升级到"专业"。

第一部分:基础升级 - 从 Markdown 到 MDX

为什么要用 MDX?

Markdown 和 MDX 的区别,其实就像自行车和电动车——都能骑,但体验完全不同。

纯 Markdown 只能写文本、代码块、图片这些静态内容。你想加个提示框?得用 HTML 硬编码。想在文章里嵌入一个可交互的组件?基本没戏。

MDX 就不一样了,它是"Markdown + JSX"的结合体。你可以:

  • 导入和使用组件:直接在 .mdx 文件里 import 任何 Astro 组件或 React/Vue 组件
  • 写 JSX 表达式:在文章里用 {variable} 插入变量,甚至写循环和条件判断
  • 自定义元素样式:把标准的 <h1> 替换成你自己的样式化组件

举个具体例子。你想在文章里加个警告框,用纯 Markdown 得这样写:

<div class="warning">
  <p>注意:这个操作会删除所有数据!</p>
</div>

用 MDX 就能这样:

import Alert from '@/components/Alert.astro';

<Alert type="warning">
  注意:这个操作会删除所有数据!
</Alert>

看出区别了吗?MDX 让你的文章更像是在"组装积木",而不是"写代码"。

5分钟配置 MDX 环境

配置 MDX 其实超简单,三步搞定。

第一步:安装集成包

打开终端,在你的 Astro 项目里运行:

npx astro add mdx

Astro CLI 会自动帮你安装 @astrojs/mdx 并更新配置文件。这个命令会问你几个问题(要不要更新配置、要不要安装依赖),全部选 Yes 就行。

第二步:验证配置

装完后,打开 astro.config.mjs,应该能看到这样的代码:

import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';

export default defineConfig({
  integrations: [mdx()],
});

如果没有自动添加,手动加上就行。

第三步:测试 MDX 是否生效

src/pages/src/content/ 目录下创建一个 test.mdx 文件:


---

title: 测试MDX

---

# 这是 MDX 测试

普通的 Markdown 文本。

export const greeting = "你好";

现在可以用变量了:{greeting}!

<div style="padding: 1rem; background: #f0f0f0;">
  这是一个 JSX 元素
</div>

运行 npm run dev,访问对应页面,如果能看到变量和 JSX 元素正常显示,说明 MDX 已经配置成功了。

关于 .md 和 .mdx 文件的共存

装了 MDX 集成后,你的 .md 文件还是正常工作的,不用担心。Astro 会根据文件扩展名自动选择处理方式:

  • .md 文件:按标准 Markdown 处理
  • .mdx 文件:按 MDX 处理,支持组件和 JSX

我的建议是:普通文章用 .md,需要用组件的文章用 .mdx。不是所有文章都需要 MDX 的能力。

第二部分:代码高亮的进阶技巧

配置代码高亮主题(Shiki)

Astro 默认用 Shiki 做代码高亮,这已经很不错了。但默认主题是 github-dark,你可能想换个更符合自己博客风格的。

Shiki vs Prism 该选哪个?

老实讲,我推荐 Shiki。它是 Astro 默认的方案,支持 100 多种编程语言和主题,而且是服务端渲染的,不需要加载额外的 JavaScript。Prism 也不错,但需要引入 CSS 文件,配置相对麻烦一点。

切换内置主题

打开 astro.config.mjs,在 markdown 配置里加上 shikiConfig

import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';

export default defineConfig({
  integrations: [mdx()],
  markdown: {
    shikiConfig: {
      theme: 'dracula', // 可选:github-dark, nord, monokai, dracula 等
    },
  },
});

Shiki 支持超多主题,我常用的有:

  • github-dark / github-light - GitHub 风格
  • dracula - 经典的紫黑配色
  • nord - 清冷的北欧风
  • one-dark-pro - VSCode 默认深色主题

你可以在 Shiki 主题预览 里挑一个自己喜欢的。

实现浅色/深色双主题切换

如果你的博客支持深浅色模式切换,Shiki 可以配置双主题:

markdown: {
  shikiConfig: {
    themes: {
      light: 'github-light',
      dark: 'github-dark',
    },
  },
},

这样配置后,Shiki 会根据 CSS 的 prefers-color-scheme 或你自定义的主题切换逻辑自动应用对应的代码高亮主题。

高亮特定行和代码注释

写教程的时候,经常需要标注"看这一行很重要"或者展示"代码改了哪些地方"。Shiki Transformers 可以实现这些功能。

高亮重点代码行

先安装 Shiki 的 transformers:

npm install shiki

然后在配置里启用 transformerNotationHighlight

import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import { transformerNotationHighlight } from '@shikijs/transformers';

export default defineConfig({
  integrations: [mdx()],
  markdown: {
    shikiConfig: {
      theme: 'github-dark',
      transformers: [transformerNotationHighlight()],
    },
  },
});

现在可以在代码块里用 // [!code highlight] 注释来标记需要高亮的行:

```javascript
function hello() {
  console.log('这行是普通的');
  console.log('这行会高亮显示'); // [!code highlight]
}
```

展示代码变更(Diff 风格)

在对比"修改前/后"的代码时,可以用 transformerNotationDiff

import { transformerNotationDiff, transformerNotationHighlight } from '@shikijs/transformers';

markdown: {
  shikiConfig: {
    theme: 'github-dark',
    transformers: [
      transformerNotationHighlight(),
      transformerNotationDiff(),
    ],
  },
},

用法:

```javascript
function calculate(a, b) {
  return a + b; // [!code --]
  return a * b; // [!code ++]
}
```

-- 的行会显示为红色(删除),带 ++ 的行显示为绿色(新增)。这个功能写代码教程时超级实用。

聚焦特定代码

还有一个 transformerNotationFocus,可以让其他代码"变灰",只突出你想强调的部分:

import { transformerNotationFocus } from '@shikijs/transformers';

// 添加到 transformers 数组
transformers: [
  transformerNotationFocus(),
],

使用 // [!code focus] 标记:

```javascript
function process() {
  console.log('这行会变灰');
  console.log('这行正常显示'); // [!code focus]
  console.log('这行也变灰');
}
```

升级到 Expressive Code(可选)

如果你觉得 Shiki 的功能还不够,可以试试 Expressive Code。它是社区开发的增强版代码展示方案,提供了更多开箱即用的功能:

  • 代码块标题
  • 一键复制按钮
  • 行号显示
  • 终端窗口样式
  • 代码对比(Side-by-Side)

安装超简单

npx astro add astro-expressive-code

Astro CLI 会自动配置好一切。装完后,你的代码块就自动带上这些功能了,不需要额外配置。

什么时候用 Expressive Code?

说实话,我一开始用的是默认的 Shiki,后来发现读者经常想复制代码,就换成了 Expressive Code。如果你的博客主要是写教程、分享代码,Expressive Code 能让读者体验好很多。

但如果只是偶尔放点代码,用 Shiki + Transformers 就够了,不用增加额外的依赖。

第三部分:嵌入自定义组件

在 MDX 中导入和使用组件

MDX 最强大的地方就是可以直接在文章里用组件。我经常用这个功能做提示框、代码对比、可折叠区域等。

创建一个警告框组件

先在 src/components/ 目录下创建一个 Alert.astro


---

interface Props {
  type?: 'info' | 'warning' | 'error';
}

const { type = 'info' } = Astro.props;

const styles = {
  info: 'bg-blue-50 border-blue-200 text-blue-800',
  warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
  error: 'bg-red-50 border-red-200 text-red-800',
};

---

<div class={`border-l-4 p-4 ${styles[type]}`}>
  <slot />
</div>

在 MDX 文章中使用

在你的 .mdx 文件里导入并使用它:


---

title: 我的技术文章

---

import Alert from '@/components/Alert.astro';

# 文章标题

这是普通的文章内容。

<Alert type="warning">
  注意:运行这个命令前请备份数据!
</Alert>

<Alert type="info">
  提示:你也可以在组件里用 **Markdown 语法**,很方便。
</Alert>

看到了吗?在 <Alert> 组件里,你还可以继续用 Markdown 语法(如加粗、链接等),MDX 会自动处理。

使用 React/Vue 组件

MDX 不仅支持 Astro 组件,也可以用 React、Vue 等框架组件。不过要注意加上 client: 指令:

import Counter from '@/components/Counter.tsx';

<Counter client:load initialCount={0} />

client:load 表示这个组件会在页面加载时在客户端运行。如果不加,组件只会服务端渲染,交互功能不生效。

组件文件组织建议

我习惯把文章里常用的组件放在 src/components/mdx/ 目录下,这样好管理:

src/
├── components/
│   ├── mdx/
│   │   ├── Alert.astro
│   │   ├── CodeCompare.astro
│   │   ├── Callout.astro
│   │   └── Tabs.astro
│   └── ...其他组件

映射 Markdown 语法到自定义组件

这个功能有点"黑魔法"的感觉——你可以把 Markdown 的标准元素(如 h1、a、img)替换成你自己的组件。

为什么要这么做?

比如你想给所有的标题加个锚点图标,或者给外部链接自动加个"↗"标记,手动一个个加太麻烦了。用组件映射,写标准 Markdown 就自动应用你的样式。

实战:自定义标题组件

先创建一个 CustomHeading.astro


---

interface Props {
  level: 1 | 2 | 3 | 4 | 5 | 6;
  id?: string;
}

const { level, id } = Astro.props;
const Tag = `h${level}` as any;

---

<Tag id={id} class="group relative">
  <slot />
  {id && (
    <a href={`#${id}`} class="ml-2 opacity-0 group-hover:opacity-100 transition-opacity">
      #
    </a>
  )}
</Tag>

在 MDX 中使用映射

在你的 .mdx 文件里,导出一个 components 对象:


---

title: 文章标题

---

import CustomHeading from '@/components/CustomHeading.astro';

export const components = {
  h2: (props) => <CustomHeading level={2} {...props} />,
  h3: (props) => <CustomHeading level={3} {...props} />,
};

## 这是二级标题

鼠标悬停在标题上,会出现 # 锚点链接。

### 这是三级标题

所有 h2 和 h3 都自动应用了自定义样式。

实战:给外部链接加图标

创建 ExternalLink.astro


---

interface Props {
  href?: string;
}

const { href } = Astro.props;
const isExternal = href?.startsWith('http');

---

<a href={href} target={isExternal ? '_blank' : undefined} rel={isExternal ? 'noopener noreferrer' : undefined}>
  <slot />
  {isExternal && <span class="ml-1 text-xs">↗</span>}
</a>

映射使用:

import ExternalLink from '@/components/ExternalLink.astro';

export const components = {
  a: ExternalLink,
};

[这是内部链接](/about)
[这是外部链接](https://example.com) ← 会自动加 ↗ 图标

全局配置映射(高级)

如果你想让所有 MDX 文件都用同一套组件映射,可以在 astro.config.mjs 里配置。不过这个需要自定义 MDX 插件,稍微复杂一点,我一般是在单个文件里配置就够了。

第四部分:数学公式和图表集成

集成 KaTeX 展示数学公式

如果你写算法、数学或数据科学类的文章,肯定需要展示公式。KaTeX 是目前最好的选择,比 MathJax 快很多,而且支持服务端渲染。

安装 KaTeX

需要安装三个包:

npm install remark-math rehype-katex katex
  • remark-math:解析 LaTeX 语法
  • rehype-katex:渲染公式为 HTML
  • katex:KaTeX 核心库

配置 Astro

打开 astro.config.mjs,添加这两个插件:

import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';

export default defineConfig({
  integrations: [mdx()],
  markdown: {
    remarkPlugins: [remarkMath],
    rehypePlugins: [rehypeKatex],
  },
});

引入 KaTeX 样式

这一步很重要,不然公式渲染不出来。在你的布局文件(如 src/layouts/MarkdownLayout.astro)的 <head> 里加上:

<link
  rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/katex@0.16.4/dist/katex.min.css"
  crossorigin="anonymous"
/>

在文章中使用公式

配置好后,就可以在 Markdown/MDX 里写公式了。

行内公式(用单个 $ 包裹):

质能方程:$E = mc^2$

二次方程的解:$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$

块级公式(用双 $$ 包裹):

$$
\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}
$$

$$
\sum_{i=1}^{n} i = \frac{n(n+1)}{2}
$$

常见问题:公式不显示

如果公式不显示或样式不对,检查这几点:

  1. KaTeX CSS 是否正确引入(F12 看 Network 面板)
  2. rehype-katex 的版本是否兼容(试试降到 6.x 版本)
  3. 公式语法是否正确(去 KaTeX 支持列表 确认)

集成 Mermaid 绘制流程图和图表

Mermaid 可以用代码画流程图、时序图、甘特图等,特别适合技术文档。

三种集成方案对比

社区有几种 Mermaid 集成方案,我简单说说区别:

方案 渲染方式 SEO 配置难度 推荐指数
rehype-mermaid 服务端 ⭐⭐⭐⭐⭐
astro-diagram 服务端 ⭐⭐⭐⭐
astro-mermaid 客户端 ⭐⭐⭐

我推荐 rehype-mermaid,服务端渲染,SEO 友好,生成的是静态 SVG。

安装 rehype-mermaid

npm install rehype-mermaid

配置

astro.config.mjs 里添加:

import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import rehypeMermaid from 'rehype-mermaid';

export default defineConfig({
  integrations: [mdx()],
  markdown: {
    rehypePlugins: [
      [rehypeMermaid, { strategy: 'img-svg' }]
    ],
  },
});

strategy: 'img-svg' 表示生成 SVG 图片,这是最稳定的方案。

在文章中画图

mermaid 代码块就行:

流程图示例

```mermaid
graph TD
    A[开始] --> B{是否安装MDX}
    B -->|是| C[配置代码高亮]
    B -->|否| D[安装MDX]
    D --> C
    C --> E[完成]
```

时序图示例

```mermaid
sequenceDiagram
    用户->>浏览器: 访问页面
    浏览器->>服务器: 请求HTML
    服务器->>浏览器: 返回渲染后的页面
    浏览器->>用户: 显示内容
```

构建时生成

运行 npm run build 时,Mermaid 图表会在构建阶段生成为 SVG,最终页面里是静态图片,加载速度快,也不需要客户端 JavaScript。

注意事项

如果构建时报错"找不到 Puppeteer",可能需要额外配置。试试安装 playwright:

npm install -D playwright

或者换用 astro-diagram 方案,它内置了浏览器环境。

第五部分:高级技巧和最佳实践

Content Collections 的 MDX 优化

如果你用 Astro 的 Content Collections 管理博客文章(强烈推荐),MDX 文件可以获得更好的类型支持和开发体验。

Content Collections 是什么?

简单说,就是把文章放在 src/content/ 目录下,Astro 会自动识别、验证 frontmatter,并提供类型安全的 API 来读取内容。

配置 Content Collections

src/content/config.ts 定义你的集合:

import { defineCollection, z } from 'astro:content';

const blog = defineCollection({
  type: 'content', // 表示这是内容文件(Markdown/MDX)
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.date(),
    tags: z.array(z.string()).optional(),
    draft: z.boolean().default(false),
  }),
});

export const collections = { blog };

在 MDX 中使用

MDX 文件的 frontmatter 会被自动验证:


---

title: Astro MDX 进阶教程
description: 学习 MDX 的高级用法
pubDate: 2025-12-02
tags: [Astro, MDX, 教程]

---

import Alert from '@/components/Alert.astro';

# {frontmatter.title}

<Alert type="info">
  发布日期:{frontmatter.pubDate.toLocaleDateString()}
</Alert>

自动生成目录

Content Collections 提供了 getHeadings() 方法,可以获取文章的所有标题,用来生成目录:


---

import { getEntry } from 'astro:content';

const entry = await getEntry('blog', 'my-mdx-article');
const { Content, headings } = await entry.render();

---

<aside>
  <h2>目录</h2>
  <ul>
    {headings.map(h => (
      <li style={`margin-left: ${(h.depth - 1) * 1}rem`}>
        <a href={`#${h.slug}`}>{h.text}</a>
      </li>
    ))}
  </ul>
</aside>

<article>
  <Content />
</article>

这个功能在写长文时特别实用,读者可以快速跳转到感兴趣的章节。

性能优化和常见陷阱

MDX 很强大,但用不好也会拖慢网站。这里分享几个要注意的点。

避免过度使用客户端组件

MDX 里可以用 React/Vue 组件,但别忘了加 client:* 指令。如果不加,组件只会服务端渲染,交互功能不生效;如果滥用 client:load,会增加大量 JavaScript,拖慢页面加载。

我的建议:

  • 静态内容用 Astro 组件(如 Alert、Callout)
  • 需要交互的用 client:visible(可见时才加载)或 client:idle(空闲时加载)
  • 除非必要,不用 client:load

图片优化

在 MDX 里插入图片,别直接用 <img>,用 Astro 的 Image 组件:


---

title: 我的文章

---

import { Image } from 'astro:assets';
import cover from './cover.jpg';

<Image src={cover} alt="封面图" width={800} height={600} />

Astro 会自动优化图片(压缩、生成 WebP、懒加载等),性能好很多。

MDX 的 optimize 选项

如果你的站点有很多 MDX 文件,构建很慢,可以试试开启 optimize 选项:

export default defineConfig({
  integrations: [
    mdx({
      optimize: true,
    }),
  ],
});

这个选项会通过内部的 rehype 插件优化 MDX 输出,加快构建速度。不过可能会改变生成的 HTML 结构,用之前先测试一下。

常见错误和解决方案

错误 原因 解决方案
MDX 组件不显示 忘了导入组件 检查 import 语句
交互组件不工作 缺少 client: 指令 加上 client:load
代码高亮不生效 Shiki 配置错误 检查 astro.config.mjs
数学公式不渲染 KaTeX CSS 未引入 在 layout 里加 CSS 链接
构建很慢 MDX 文件太多 开启 optimize 选项

结论

说了这么多,总结一下核心要点:

快速回顾 7 个技巧

  1. 配置 MDX 环境 - 一行命令搞定,5分钟上手
  2. 切换代码高亮主题 - 用 Shiki 配置你喜欢的风格
  3. 高亮和注释代码 - 用 Transformers 标注重点、展示改动
  4. 嵌入自定义组件 - 让文章更生动,从 Alert 到交互式 Demo
  5. 映射 Markdown 元素 - 批量定制标题、链接等默认样式
  6. 展示数学公式 - KaTeX 让你的算法讲解更专业
  7. 绘制流程图 - Mermaid 用代码画图,服务端渲染

现在就选一个最感兴趣的功能,在你的博客上试试吧。配置过程中遇到问题也别慌,官方文档和社区都很友好,搜一下基本都能找到答案。

话说回来,技术博客最重要的还是内容本身。这些工具只是让你的表达更清晰、更专业,真正吸引读者的,是你的观点和经验。祝你的 Astro 博客越来越好!

原文首发自个人博客

正难则反:计算最多保留的 ASCII 之和(Python/Java/C++/Go)

用 $s_1$ 和 $s_2$ 的 ASCII 值之和,减去保留的 ASCII 之和的最大值,就是删除字符的 ASCII 值之和的最小值。

计算最多保留的 ASCII 之和,方法和 1143. 最长公共子序列 一样:

  • 1143 题,$s_1[i] = s_2[j]$ 时,都保留,最长公共子序列长度增加 $1$。
  • 本题,$s_1[i] = s_2[j]$ 时,都保留,保留的 ASCII 之和增加 $\text{ASCII}(s_1[i])\cdot 2$。

所以只需把 1143 题的 $+1$ 改成 $+\text{ASCII}(s_1[i])\cdot 2$。

也可以改成 $+\text{ASCII}(s_1[i])$,最后返回时再乘以 $2$。

###py

class Solution:
    def minimumDeleteSum(self, s1: str, s2: str) -> int:
        n, m = len(s1), len(s2)
        total = sum(map(ord, s1)) + sum(map(ord, s2))

        f = [[0] * (m + 1) for _ in range(n + 1)]
        for i, x in enumerate(s1):
            for j, y in enumerate(s2):
                if x == y:
                    f[i + 1][j + 1] = f[i][j] + ord(x)
                else:
                    f[i + 1][j + 1] = max(f[i][j + 1], f[i + 1][j])
        return total - f[n][m] * 2

###java

class Solution {
    public int minimumDeleteSum(String s1, String s2) {
        int total = s1.chars().sum() + s2.chars().sum();

        char[] s = s1.toCharArray();
        char[] t = s2.toCharArray();
        int n = s.length;
        int m = t.length;

        int[][] f = new int[n + 1][m + 1];
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (s[i] == t[j]) {
                    f[i + 1][j + 1] = f[i][j] + s[i];
                } else {
                    f[i + 1][j + 1] = Math.max(f[i][j + 1], f[i + 1][j]);
                }
            }
        }
        return total - f[n][m] * 2;
    }
}

###cpp

class Solution {
public:
    int minimumDeleteSum(string s1, string s2) {
        int n = s1.size(), m = s2.size();
        int total = reduce(s1.begin(), s1.end(), 0) + reduce(s2.begin(), s2.end(), 0);

        vector f(n + 1, vector<int>(m + 1));
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (s1[i] == s2[j]) {
                    f[i + 1][j + 1] = f[i][j] + s1[i];
                } else {
                    f[i + 1][j + 1] = max(f[i][j + 1], f[i + 1][j]);
                }
            }
        }
        return total - f[n][m] * 2;
    }
};

###go

func minimumDeleteSum(s1, s2 string) int {
n, m := len(s1), len(s2)
total := 0
for _, c := range s1 {
total += int(c)
}
for _, c := range s2 {
total += int(c)
}

f := make([][]int, n+1)
for i := range f {
f[i] = make([]int, m+1)
}
for i, x := range s1 {
for j, y := range s2 {
if x == y {
f[i+1][j+1] = f[i][j] + int(x)
} else {
f[i+1][j+1] = max(f[i][j+1], f[i+1][j])
}
}
}
return total - f[n][m]*2
}

空间优化:

###py

# 更快的写法见【Python3 手写 max】
class Solution:
    def minimumDeleteSum(self, s1: str, s2: str) -> int:
        m = len(s2)
        total = sum(map(ord, s1)) + sum(map(ord, s2))

        f = [0] * (m + 1)
        for x in s1:
            ord_x = ord(x)
            pre = 0  # f[0]
            for j, y in enumerate(s2):
                tmp = f[j + 1]
                if x == y:
                    f[j + 1] = pre + ord_x
                else:
                    f[j + 1] = max(f[j + 1], f[j])
                pre = tmp
        return total - f[m] * 2

###py

class Solution:
    def minimumDeleteSum(self, s1: str, s2: str) -> int:
        m = len(s2)
        total = sum(map(ord, s1)) + sum(map(ord, s2))

        f = [0] * (m + 1)
        for x in s1:
            ord_x = ord(x)
            pre = 0  # f[0]
            for j, y in enumerate(s2):
                tmp = f[j + 1]
                if x == y:
                    f[j + 1] = pre + ord_x
                elif f[j] > f[j + 1]:
                    f[j + 1] = f[j]
                pre = tmp
        return total - f[m] * 2

###java

class Solution {
    public int minimumDeleteSum(String s1, String s2) {
        int total = s1.chars().sum() + s2.chars().sum();

        char[] s = s1.toCharArray();
        char[] t = s2.toCharArray();
        int m = t.length;

        int[] f = new int[m + 1];
        for (char x : s) {
            int pre = 0; // f[0]
            for (int j = 0; j < m; j++) {
                int tmp = f[j + 1];
                if (x == t[j]) {
                    f[j + 1] = pre + x;
                } else {
                    f[j + 1] = Math.max(f[j + 1], f[j]);
                }
                pre = tmp;
            }
        }
        return total - f[m] * 2;
    }
}

###cpp

class Solution {
public:
    int minimumDeleteSum(string s1, string s2) {
        int m = s2.size();
        int total = reduce(s1.begin(), s1.end(), 0) + reduce(s2.begin(), s2.end(), 0);

        vector<int> f(m + 1);
        for (char x : s1) {
            int pre = 0; // f[0]
            for (int j = 0; j < m; j++) {
                int tmp = f[j + 1];
                if (x == s2[j]) {
                    f[j + 1] = pre + x;
                } else {
                    f[j + 1] = max(f[j + 1], f[j]);
                }
                pre = tmp;
            }
        }
        return total - f[m] * 2;
    }
};

###go

func minimumDeleteSum(s1, s2 string) int {
m := len(s2)
total := 0
for _, c := range s1 {
total += int(c)
}
for _, c := range s2 {
total += int(c)
}

f := make([]int, m+1)
for _, x := range s1 {
pre := 0 // f[0]
for j, y := range s2 {
tmp := f[j+1]
if x == y {
f[j+1] = pre + int(x)
} else {
f[j+1] = max(f[j+1], f[j])
}
pre = tmp
}
}
return total - f[m]*2
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(nm)$,其中 $n$ 是 $s_1$ 的长度,$m$ 是 $s_2$ 的长度。
  • 空间复杂度:$\mathcal{O}(m)$。

专题训练

见下面动态规划题单的「§4.1 最长公共子序列(LCS)」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

最长公共子序列 (LCS):「模版」&「输出」

如果想要查看作者更多文章,可以点击此处!!!🔥🔥🔥

为了本篇文章更好的观感,可以点击此处!!!

1143. 最长公共子序列

583. 两个字符串的删除操作

712. 两个字符串的最小ASCII删除和


最长递增子序列」针对一个字符串,求出其最长递增子序列 (废话文学!!) 详细介绍可见 动态规划设计:最长递增子序列

最长重复子数组」针对两个数组,求出其最长重复子数组 (子数组必须要连着) 详细介绍可见 最长重复子数组

而我们今天要介绍的是「最长公共子序列」,它是针对两个字符串,求出其最长公共子序列 (子序列可以不用连着)

模版归纳

首先结合题目 最长公共子序列,归纳总结出「最长公共子序列」问题的模版

毫无疑问这种类型的题目需要使用「DP」去解决!!这里给出一个例子 s1 = "abcde", s2 = "ace" ,下面所有分析都围绕该样例展开

先给出 dp[][] 数组的定义:dp[i][j] 表示子串 s1[0..i]s2[0..j] 最长公共子序列的长度

那么「状态转移方程」是什么呢?

  • 如果 s1[i] = s2[j]dp[i][j] = dp[i - 1][j - 1] + 1
  • 如果 s1[i] != s2[j]dp[i][j] = Math.max(dp[i][j - 1], dp[i - 1][j])

为什么就是这样转移的呢?直接看下图:

5.svg

那么「base case」是什么呢?

6.svg

如上图粉色标记出来的就是 base case。橙色标记出来的是相等的情况,其余是不等的情况

完整模版

###java

public int longestCommonSubsequence(String text1, String text2) {
    int n1 = text1.length(), n2 = text2.length();
    int[][] dp = new int[n1 + 1][n2 + 1];
    for (int i = 1; i <= n1; i++) {
        for (int j = 1; j <= n2; j++) {
            char c1 = text1.charAt(i - 1);
            char c2 = text2.charAt(j - 1);
            // 相等情况
            if (c1 == c2) dp[i][j] = dp[i - 1][j - 1] + 1;
            // 不等情况
            else dp[i][j] = Math.max(dp[i][j - 1], dp[i - 1][j]);
        }
    }
    return dp[n1][n2];
}

✨ 如何输出最长公共子序列

「最长公共子序列」问题基本都是要求返回一个最值即可,但是有时候面试官喜欢不按常理出牌,让你输出最长公共子序列

我们可以通过构造出来的二维 dp 数组来得到最长公共子序列。如下图所示,从最后一个点开始往左上角的方向遍历

7.svg

如果 s1[i] = s2[j],那么当前字符肯定在最长公共子序列中;否在我们就向左或者向上遍历

至于选择「向左」还是「向上」的方向,这就要和构造 dp 的时候联系起来。我们是挑了一个最大值,所以遍历的方向也是谁大就往谁的方向遍历

###java

public int longestCommonSubsequence(String text1, String text2) {
    
    // 同上面的模版
    
    /* ------- print ------- */
    int i = n1, j = n2;
    StringBuffer sb = new StringBuffer();
    while (i > 0 && j > 0) {
        char c1 = text1.charAt(i - 1);
        char c2 = text2.charAt(j - 1);
        if (c1 == c2) {
            sb.append(c1);
            // 向左上角遍历
            i--; j--;
        } else {
            // 向上
            if (dp[i - 1][j] > dp[i][j - 1]) i--;
            // 向左
            else j--;
        }
    }
    System.out.println(sb.reverse());
    /* ------- end ------- */
    return dp[n1][n2];
}

两个字符串的最小ASCII删除和

题目详情可见 两个字符串的最小ASCII删除和

其实这个题目的底层也是「最长公共子序列」,只是问法稍微变化了一点

「需要被删除的字符 = 原字符串 - 最长公共子序列」

结合这个题目我们把 dp[][] 数组的定义稍微改改:dp[i][j] 表示子串 s1[0..i]s2[0..j] 最小 ASCII 删除和

那么「状态转移方程」是什么呢?(有点逆过程的意思!!!)

  • 如果 s1[i] = s2[j]dp[i][j] = dp[i - 1][j - 1] (不需要被删除)
  • 如果 s1[i] != s2[j]dp[i][j] = Math.min(dp[i - 1][j] + s1[i], dp[i][j - 1] + s2[j])

那么「base case」是什么呢?

8.svg

如上图粉色标记出来的就是 base case,e 表示 e 的 ASCII 值

###java

public int minimumDeleteSum(String s1, String s2) {
    int n1 = s1.length(), n2 = s2.length();
    int[][] dp = new int[n1 + 1][n2 + 1];
    // base case
    for (int i = 1; i <= n1; i++) dp[i][0] = dp[i - 1][0] + s1.charAt(i - 1);
    for (int i = 1; i <= n2; i++) dp[0][i] = dp[0][i - 1] + s2.charAt(i - 1);
    for (int i = 1; i <= n1; i++) {
        for (int j = 1; j <= n2; j++) {
            int c1 = s1.charAt(i - 1);
            int c2 = s2.charAt(j - 1);
            // 相等情况
            if (c1 == c2) dp[i][j] = dp[i - 1][j - 1];
            // 不等情况
            else dp[i][j] = Math.min(dp[i][j - 1] + c2, dp[i - 1][j] + c1);
        }
    }
    return dp[n1][n2];
}

LCS的dp解法转化而来 C++

首先给出LCS的模板解法:

int longestCommonSubsequence(string text1, string text2)
{
    int LCS[text1.size() + 1][text2.size() + 1];
    memset(LCS,0, sizeof(LCS));

    for (int i = 1; i <= text1.size(); ++i)
        for (int j = 1; j <= text2.size(); ++j)
        {
            if(text1[i - 1] == text2[j - 1])
                LCS[i][j] = LCS[i - 1][j - 1] + 1;
            else
                LCS[i][j] = max(LCS[i - 1][j],LCS[i][j - 1]);
        }
    return LCS[text1.size()][text2.size()];
}

那么如何改造这个模板来让他适应我们的问题呢?
因为在求LCS的时候我们是按照构造一个dp[i][j]表示以str1的第i项为结尾,str2的第j项为结尾,那么就会有:(LCS(i,j) <=> dp[i][j])

--------------------------------------
 * if(str1.n == str2.m):
 *      LCS(n,m) = LCS(n - 1,m - 1) + 1
 * else
 *      LCS(n,m) = max{LCS(n - 1,m),LCS(n,m - 1)}
--------------------------------------

所以我们就会有一种想法,对这个LCS求解的dp过程在进行一次约数,肯定可以得到我们的目标LCS

int minimumDeleteSum(string s1, string s2)
{
    int len1 = s1.length();
    int len2 = s2.length();

    int dp[len1 + 1][len2 + 1];
    memset(dp,0, sizeof(dp));

    for (int i = 1; i <= len1; ++i)
        for (int j = 1; j <= len2; ++j)
        {
            if(s1[i - 1] == s2[j - 1])
                dp[i][j] = dp[i - 1][j - 1] + s1[i - 1];
            else
                dp[i][j] = max(dp[i - 1][j],dp[i][j - 1]);
        }

    int sum = 0;
    for (int i = 0; i < len1; ++i)
        sum += s1[i];
    for (int i = 0; i < len2; ++i)
        sum += s2[i];
    return sum - 2 * dp[len1][len2];
}

别忘了最后返回的值是两个string的ASCII和减去两个LCS的ASCII的sum哦

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! 🚀

Typescript之类型总结大全

TypeScript 中的基本类型

TypeScript 的基本类型涵盖了 JavaScript 的原始类型,并添加了一些 TypeScript 特有的类型。


1. JavaScript 原始类型(Primitive Types)

这些是 JavaScript 原有的基本数据类型,TypeScript 为它们提供了类型注解。

boolean - 布尔值

let isDone: boolean = true;
let isLoading: boolean = false;

number - 数字

TypeScript 中的所有数字都是浮点数,支持十进制、十六进制、二进制和八进制。

let decimal: number = 6;
let hex: number = 0xf00d;      // 十六进制
let binary: number = 0b1010;   // 二进制
let octal: number = 0o744;     // 八进制
let float: number = 3.14;
let infinity: number = Infinity;
let notANumber: number = NaN;

string - 字符串

let name: string = "张三";
let sentence: string = `你好,${name}!`;  // 模板字符串

bigint - 大整数(ES2020+)

表示大于 2^53 - 1 的整数。

let big: bigint = 9007199254740991n;
let big2: bigint = BigInt(9007199254740991);

symbol - 符号(ES2015+)

创建唯一的标识符。

typescript

let sym1: symbol = Symbol();
let sym2: symbol = Symbol("description");

2. 特殊原始类型

null - 空值

let n: null = null;

undefined - 未定义

let u: undefined = undefined;

注意:在 strictNullChecks 模式下,null 和 undefined 只能赋值给它们自己或 any 类型。


3. TypeScript 特有类型

any - 任意类型

关闭类型检查,兼容所有类型。

let notSure: any = 4;
notSure = "可能是字符串";
notSure = false;  // 没问题

unknown - 未知类型

比 any 更安全的类型,使用时需要类型检查或断言。

let value: unknown;

// 需要类型检查后才能使用
if (typeof value === "string") {
    console.log(value.length);
}

// 或使用类型断言
let str: string = (value as string);

void - 空类型

表示函数没有返回值。

function warnUser(): void {
    console.log("警告信息");
    // 没有 return 语句
}

never - 永不存在的值

表示永远不会发生的类型,用于总是抛出异常或无限循环的函数。

// 抛出错误
function error(message: string): never {
    throw new Error(message);
}

// 无限循环
function infiniteLoop(): never {
    while (true) {}
}

object - 非原始类型

表示非原始类型的值(不是 number, string, boolean, symbol, null, undefined)。

let obj: object = {};
let arr: object = [];
let func: object = function() {};

4. 数组类型

有两种表示方式:

类型 + 方括号

let list: number[] = [1, 2, 3];
let strings: string[] = ["a", "b", "c"];

泛型 Array<类型>

let list: Array<number> = [1, 2, 3];
let strings: Array<string> = ["a", "b", "c"];

只读数组

let readonlyArr: ReadonlyArray<number> = [1, 2, 3];
// readonlyArr.push(4);  // ❌ 错误:只读数组不能修改

5. 元组(Tuple)

表示已知元素数量和类型的数组。

// 定义元组类型
let tuple: [string, number];
tuple = ["hello", 10];  // ✅ 正确
// tuple = [10, "hello"];  // ❌ 错误:类型不匹配

// 访问元组元素
console.log(tuple[0].substring(1));  // "ello"
console.log(tuple[1].toFixed(2));    // "10.00"

// 可选元素(3.0+)
let optionalTuple: [string, number?];
optionalTuple = ["hello"];           // ✅ 正确
optionalTuple = ["hello", 42];       // ✅ 正确

// 剩余元素
let restTuple: [string, ...number[]];
restTuple = ["hello", 1, 2, 3];      // ✅ 正确

6. 枚举(Enum)

数字枚举

enum Direction {
    Up = 1,      // 从 1 开始
    Down,        // 自动递增为 2
    Left,        // 3
    Right        // 4
}

let dir: Direction = Direction.Up;

字符串枚举

enum Direction {
    Up = "UP",
    Down = "DOWN",
    Left = "LEFT",
    Right = "RIGHT"
}

常量枚举(编译时完全删除)

const enum Colors {
    Red,
    Green,
    Blue
}

let color = Colors.Red;  // 编译后:let color = 0;

7. 字面量类型

字符串字面量

type EventType = "click" | "scroll" | "mousemove";
let event: EventType = "click";  // ✅
// event = "hover";  // ❌ 错误

数字字面量

type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
let roll: DiceRoll = 3;  // ✅
// roll = 7;  // ❌ 错误

布尔字面量

type Truthy = true;
let isTrue: Truthy = true;
// isTrue = false;  // ❌ 错误

8. 类型推断与联合类型

类型推断

let x = 3;            // x 被推断为 number
let y = "hello";      // y 被推断为 string

联合类型

let id: string | number;
id = "abc123";  // ✅
id = 123;       // ✅
// id = true;   // ❌ 错误

// 类型守卫
if (typeof id === "string") {
    console.log(id.toUpperCase());
} else {
    console.log(id.toFixed(2));
}

9. 类型别名

// 基本类型别名
type ID = string | number;
type Point = {
    x: number;
    y: number;
};

let userId: ID = "user-123";
let position: Point = { x: 10, y: 20 };

类型总结表

类型 示例 描述
boolean let isDone: boolean = true; 布尔值
number let count: number = 10; 所有数字类型
string let name: string = "John"; 字符串
bigint let big: bigint = 100n; 大整数
symbol let sym: symbol = Symbol(); 唯一标识符
null let n: null = null; 空值
undefined let u: undefined = undefined; 未定义
any let anything: any = 4; 任意类型
unknown let unsure: unknown = 30; 未知类型
void function(): void {} 无返回值
never function error(): never {} 永不存在的值
object let obj: object = {}; 非原始类型
Array<T> let list: number[] = [1, 2, 3]; 数组
[T1, T2] let tuple: [string, number]; 元组
enum enum Color { Red, Green } 枚举

实用示例

// 完整示例
function processInput(input: string | number | boolean): string {
    if (typeof input === "string") {
        return `字符串: ${input.toUpperCase()}`;
    } else if (typeof input === "number") {
        return `数字: ${input.toFixed(2)}`;
    } else {
        return `布尔值: ${input}`;
    }
}

// 严格空值检查
function greet(name: string | null): string {
    if (name === null) {
        return "你好,访客!";
    }
    return `你好,${name}!`;
}

// 使用 never 进行穷尽检查
type Shape = "circle" | "square" | "triangle";

function getArea(shape: Shape): number {
    switch (shape) {
        case "circle":
            return Math.PI;
        case "square":
            return 1;
        case "triangle":
            return 0.5;
        default:
            // 确保处理了所有情况
            const _exhaustiveCheck: never = shape;
            return _exhaustiveCheck;
    }
}

TypeScript 的基本类型系统提供了强大的类型安全保证,帮助开发者在编译时捕获错误,提高代码质量。

Web 仔用 Node 像 Java 一样写后端服务

当Node.js遇上Java的优雅架构,会碰撞出怎样的火花?

作为一名前端开发者,我们常常被Java开发者"鄙视":"Node.js就是个玩具,写写前端还行,做后端?算了吧!" 但事实真的如此吗?通过一个完整的manage-system-server项目实践,我将向大家展示如何用Node.js构建出媲美Java Spring的企业级后端服务,Node.js也可以写得这么"Java范儿"!今天就来跟大家分享这个基于 Koa + TypeScript + TypeORM 的现代化后端架构。

🏗️ 架构设计:Spring Boot的Node.js版

这个项目采用了典型的分层架构,让我想起了Java Spring Boot的优雅设计:

src/
├── app/
│   ├── controllers/    # 控制器层 - 类似Spring的@Controller
│   ├── entity/         # 数据实体 - 类似JPA Entity
│   ├── service/        # 业务服务层 - 类似@Service
│   └── req-validate/   # 请求验证 - 类似DTO验证
├── config/             # 配置管理
├── decorator/          # 装饰器 - 类似Spring注解
├── middles/            # 中间件 - 类似Spring拦截器
└── tools/              # 工具类

🎯 核心特性:Java范儿的Node.js实现

1. 依赖注入:告别require的混乱

项目使用 typedi 实现依赖注入,让代码组织更加清晰:

@Service()
class UserService {
  constructor(private readonly roleService: RoleService) {}
  
  public async userList(req: IListReq) {
    // 业务逻辑
  }
}

2. 声明式控制器:注解驱动的API设计

基于 routing-controllers 的控制器设计,让API定义变得优雅:

@JsonController()
@Service()
class UserController {
  constructor(private readonly userService: UserService) {}

  @Post(Api.USER_LIST)
  @ApiAuth(ModuleEnum.USER, OperationEnum.QUERY)
  public async userList(@Body({ validate: true }) body: IListReq) {
    return await this.userService.userList(body);
  }
}

3. 数据实体:TypeORM的ORM魔法

实体类设计借鉴了JPA的思想,支持数据库字段映射和生命周期钩子:

@Entity('user')
class UserEntity {
  @PrimaryGeneratedColumn({ name: 'id' })
  id?: number;

  @Column({ name: 'login_name' })
  loginName: string;

  @Column({ name: 'password', select: false })
  password?: string;

  @BeforeInsert()
  @BeforeUpdate()
  private encryptFields() {
    // 插入/更新前自动加密密码
    this.password = AesTools.encryptData(this.password);
  }
}

4. 数据验证:类级别的参数校验

使用 class-validator 实现请求参数验证,类似Spring的@Valid:

class IUserAddReq {
  @IsString({ message: 'loginName接收类型为string' })
  @IsNotEmpty({ message: 'loginName不能为空' })
  loginName: string;

  @IsString({ message: 'password接收类型为string' })
  @IsNotEmpty({ message: 'password不能为空' })
  @IsDecryptPwd({ message: 'password密码错误,请确认加密方式' })
  password: string;
}

5. 权限控制:基于装饰器的细粒度权限

自定义权限装饰器实现方法级别的权限控制:

function ApiAuth(module: ModuleEnum, operation: OperationEnum) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    Authorized(`${module}_${operation}`)(target, propertyKey, descriptor);
  };
}

🔧 技术栈深度解析

核心依赖分析

package.json 可以看出项目的技术选型思路:

{
  "dependencies": {
    "koa": "^2.15.0",           // 轻量级Web框架
    "typeorm": "0.3.20",         // 强大ORM框架
    "routing-controllers": "^0.10.4", // 注解式路由
    "class-validator": "^0.14.1", // 数据验证
    "typedi": "^0.10.0",         // 依赖注入容器
    "reflect-metadata": "^0.1.13" // 反射元数据支持
  }
}

开发工具链

项目配备了完整的开发工具链:

  • TypeScript 5.7.2: 类型安全的JavaScript超集
  • ESLint + Prettier: 代码规范和格式化
  • TypeORM迁移: 数据库版本管理
  • 热重载开发: nodemon实时监控文件变化

🚀 实际应用示例

完整的用户管理流程

让我们看一个完整的用户添加流程:

1. 控制器层接收请求

@Post(Api.USER_ADD)
@ApiAuth(ModuleEnum.USER, OperationEnum.ADD)
public async userAdd(
  @CurrentLoginName() loginName: string,
  @Body({ validate: true }) body: IUserAddReq,
) {
  return await this.userService.addUser(loginName, body);
}

2. 服务层业务逻辑

public async addUser(creatorName: string, userInfo: IUserAddReq) {
  // 密码解密
  const decryptPwd = AesTools.decryptData(userInfo.password);
  
  // 检查登录名重复
  const hasUser = await this.checkDuplicateLoginName(userInfo.loginName);
  if (hasUser) {
    return CommonTools.returnError(CodeEnum.USER_LOGIN_NAME_SAME);
  }
  
  // 创建用户实体
  const insertUser = new UserEntity({
    password: decryptPwd,
    username: userInfo.username,
    loginName: userInfo.loginName,
    creator: creatorName,
  });
  
  // 保存到数据库
  const resp = await getRepository(UserEntity).insert(insertUser);
  return CommonTools.returnData({ id: resp.generatedMaps[0].id });
}

3. 统一的错误处理

export class ErrorMiddleware implements KoaMiddlewareInterface {
  async use(ctx: ICtxRouterContent, next: Next): Promise<void> {
    try {
      await next();
    } finally {
      // 统一处理各种错误类型
      if (ctx.status === HttpCode.BAD_REQUEST) {
        // 参数校验错误处理
        ctx.body = CommonTools.returnData(errors, CodeEnum.COMMON_PARAMS_ERROR);
      }
    }
  }
}

💡 架构优势总结

1. 代码可维护性

  • 分层清晰: Controller-Service-Entity明确分工
  • 类型安全: TypeScript全程保驾护航
  • 依赖注入: 松耦合的组件关系
  • 共享类型定义:前后端共享DTO和接口定义

2. 开发效率

  • 注解驱动: 减少样板代码
  • 热重载: 快速开发调试
  • 迁移工具: 数据库版本化管理
  • 统一技术栈:前后端都使用TypeScript,减少学习成本
  • 快速迭代:前端开发者可以直接参与后端开发
  • 问题定位:前后端问题定位更加高效

3. 企业级特性

  • 权限控制: 细粒度的API权限管理
  • 数据加密: 自动的敏感数据加密
  • 错误处理: 统一的异常处理机制

4. 扩展性

  • 微服务就绪: 模块化架构支持微服务拆分
  • 多租户支持: 内置数据隔离机制
  • 缓存集成: Redis缓存提升性能

🎉 结语

写完这个项目后让我深刻体会到,Node.js生态已经足够成熟,完全可以胜任复杂的企业级应用开发。通过借鉴Java生态的优秀设计模式,我们可以在保持JavaScript灵活性的同时,获得Java级别的工程化能力。

作为"Web仔",我们不再需要羡慕Java程序员的那套"重型装备"。在Node.js的世界里,我们同样可以写出优雅、健壮、可维护的后端代码!

技术不分贵贱,优雅的代码才是王道!


项目地址:github.com/chencjfeng/…
作者:ChenJF
邮箱:chencjfeng@163.com

本文基于实际项目代码分析,所有示例代码均可运行。欢迎Star和贡献!

React useState 原理和异步更新

useState 的基本原理

useState 是 React 的一个 Hook,它的核心原理基于以下几点:

1. 闭包和链表结构

React 内部使用链表来存储组件的所有 Hook 状态。每次组件渲染时,React 会按照 Hook 调用的顺序遍历这个链表:

// 简化的内部实现概念
let hooks = [];
let currentHook = 0;

function useState(initialValue) {
  const hookIndex = currentHook;
  
  // 初始化或获取已有状态
  if (hooks[hookIndex] === undefined) {
    hooks[hookIndex] = initialValue;
  }
  
  const setState = (newValue) => {
    hooks[hookIndex] = newValue;
    render(); // 触发重新渲染
  };
  
  currentHook++;
  return [hooks[hookIndex], setState];
}

这就是为什么 Hook 必须在组件顶层调用,不能在条件语句或循环中使用 - 因为 React 依赖调用顺序来匹配状态。

异步更新机制

2. 批量更新(Batching)

React 不会立即更新状态,而是将多个 setState 调用合并成一次更新:

function Counter() {
  const [count, setCount] = useState(0);
  
  const handleClick = () => {
    setCount(count + 1); // count = 0 + 1
    setCount(count + 1); // count = 0 + 1 (还是读取的旧值)
    setCount(count + 1); // count = 0 + 1
    // 最终 count = 1,而不是 3
  };
  
  return <button onClick={handleClick}>{count}</button>;
}

为什么这样设计?

  • 性能优化:避免不必要的重复渲染
  • 保持一致性:确保在一次事件处理中看到的状态是一致的

3. 函数式更新

如果需要基于前一个状态更新,使用函数形式:

const handleClick = () => {
  setCount(prev => prev + 1); // 1
  setCount(prev => prev + 1); // 2
  setCount(prev => prev + 1); // 3
  // 最终 count = 3
};

4. React 18 的自动批处理

在 React 18 之前,只有在事件处理器中才会批处理。React 18 扩展到了所有场景:

// React 18 中,这些也会被批处理
setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // 只触发一次重新渲染
}, 1000);

fetch('/api').then(() => {
  setData(newData);
  setLoading(false);
  // 只触发一次重新渲染
});

实际应用场景

场景 1: 需要立即读取更新后的值

const [count, setCount] = useState(0);

const handleClick = () => {
  setCount(count + 1);
  console.log(count); // 还是旧值 0
  
  // 解决方案:使用 useEffect
  useEffect(() => {
    console.log(count); // 新值 1
  }, [count]);
};

场景 2: 依赖多个状态更新

const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);

const fetchUser = async () => {
  setLoading(true);
  const data = await api.getUser();
  setUser(data);
  setLoading(false);
  // React 会批量处理这些更新,只渲染一次
};

场景 3: 复杂状态管理

对于复杂的状态逻辑,考虑使用 useReducer:

const [state, dispatch] = useReducer(reducer, initialState);

// 一次 dispatch 可以更新多个相关状态
dispatch({ type: 'FETCH_SUCCESS', payload: data });

关键要点

  1. 状态更新是异步的 - 不要期望 setState 后立即读取新值
  2. 使用函数式更新 - 当新状态依赖旧状态时
  3. 批量更新提升性能 - React 会自动优化多次 setState
  4. 保持 Hook 调用顺序 - 不要在条件语句中使用 Hook
  5. 状态是不可变的 - 更新对象或数组时要创建新的引用

这些机制让 React 能够高效地管理组件状态,同时保持 UI 的一致性和可预测性。

用 Electron 写了一个 macOS 版本的 wallpaper(附源码、下载地址)

Mac 上一直未能找到免费且好用的类似 Wallpaper Engine 的动态壁纸软件。所以直接想着自己搞一个。

本文主要记录核心技术点的实现,包括“将窗口置于桌面图标下层”、“多显示器支持”、“系统托盘常驻”。

效果展示:

output.gif

功能支持

  • 控制中心 (Dashboard):提供独立的控制面板,可为每个显示器单独管理壁纸。
  • 丰富的媒体支持
    • 图片:支持 JPG, PNG, GIF, WebP 等常见格式。
    • 视频:支持 MP4, WebM, MKV, MOV (自动静音循环播放)。
    • HTML:支持将任意本地 HTML 文件设置为交互式壁纸。
  • 自动保存状态:应用重启后,自动恢复上次设置的壁纸配置。
  • 历史记录:自动记录最近使用的壁纸,方便快速切换回之前的喜爱内容。
  • 多显示器支持:自动检测接入的显示器,并支持多屏独立设置。
  • 在线画廊:内置在线资源库,可一键下载包括《绝区零》、《原神》、《鸣潮》等热门游戏的高清静态与动态壁纸。

核心技术实现

1. 将窗口置于桌面图标下层

这是最核心的功能。Electron 的 BrowserWindow 提供了一个 type: 'desktop' 属性,在 macOS 上会将窗口置于最底层。

const win = new BrowserWindow({
  type: 'desktop', // 关键配置:设置为桌面类型
  enableLargerThanScreen: true,
  frame: false,    // 无边框
  show: false,     // 先隐藏,加载完再显示
  webPreferences: {
    nodeIntegration: true,
    contextIsolation: false,
    webSecurity: false // 允许加载本地资源
  }
});

仅仅这样还不够,为了保证交互体验,我们需要让这个窗口无法被聚焦和移动,像真正的壁纸一样。

2. 多显示器支持

现在的开发环境通常都有多个屏幕。通过 Electron 的 screen 模块获取所有显示器,并为每个显示器创建一个独立的 BrowserWindow 实例。

const { screen } = require('electron');

const displays = screen.getAllDisplays();

displays.forEach((display) => {
  createWallpaperWindow(display);
});

function createWallpaperWindow(display) {
  const { x, y, width, height } = display.bounds;
  const win = new BrowserWindow({
    x, y, width, height, // 填满当前屏幕
    // ... 其他配置
  });
  
  // 加载资源
  win.loadFile('path/to/wallpaper.html');
}

3. 资源获取 (爬虫)

为了解决壁纸来源问题,内置了一个简单的爬虫(基于 Node.js fetch),抓取米游社等平台的同人图和官方壁纸。

主要难点在于处理接口的 Referer 校验和数据分页。

4. 甚至支持关闭主窗口后常驻后台

为了不让应用在关闭设置面板(主窗口)时直接退出,需要利用 Tray 模块和拦截 window-all-closed 事件。

// 拦截主窗口关闭事件,改为隐藏
mainWindow.on('close', (event) => {
  if (!app.isQuitting) {
    event.preventDefault();
    mainWindow.hide();
  }
});

// 实现托盘菜单
const tray = new Tray(iconPath);
const contextMenu = Menu.buildFromTemplate([
  { label: '打开面板', click: () => showMainWindow() },
  { label: '退出', click: () => {
      app.isQuitting = true;
      app.quit();
    } 
  }
]);
tray.setContextMenu(contextMenu);

构建与分发

1. 双架构打包

为了同时支持 M1/M2/M3 (Apple Silicon) 和旧款 Intel Mac,配置了 electron-builder 的 universal 构建或分别构建。

package.json 配置:

"mac": {
  "target": {
    "target": "default",
    "arch": ["x64", "arm64"]
  }
}

2. "应用已损坏,无法打开" 的解决

因为没有购买 Apple 昂贵的开发者证书进行签名,编译出的 .app 在别人的电脑上运行时会被 macOS Gatekeeper 拦截,提示“应用已损坏”。

这是一个常见问题,解决方法是移除苹果的隔离属性。用户需要执行一次终端命令:

sudo xattr -rd com.apple.quarantine /Applications/Wallpaper-Mac.app

源码与下载

项目已开源,虽然功能简单,但足以满足日常动态壁纸需求(支持视频、图片)。

欢迎 Star 和 PR。

鸿蒙开发:那些让我熬秃头的“灵异事件”

Hello,兄弟们,我是 V 哥!

咱们干鸿蒙开发的,平时是不是觉得自己像个法师?特别是刚从 Android 或者 Vue 转过来的兄弟,面对 ArkTS 这一套声明式 UI,有时候真觉得自己是在做法术。

代码写得行云流水,点个运行——啪! 白屏了。 再点一下——啪! 崩溃了。 最气人的是,有时候逻辑明明看着没问题,它就是跟你玩“薛定谔的猫”。

今天,V 哥我就把这几个月积攒的**“鸿蒙开发 Bug 悬案卷宗”**给大伙儿抖搂抖搂。这几个 Bug,当初可是折磨了 V 哥整整三天三夜,红喝了半箱,头发掉了好几把。咱们复盘一下,看看你有没有踩过这几个坑!


悬案一:人间蒸发的 UI 更新(@State 的失忆症)

📃 案发现场

那是一个月黑风高的夜晚,V 哥我在写一个列表页。数据从后端拿回来,是个 JSON 数组。我把它存到了 @State 装饰的变量里。

@State dataList: UserModel[] = [];

// 网络请求回来后
this.dataList = response.data;

逻辑没毛病吧?我看着日志,数据确实赋值进去了,长度也变了。但是!界面上死活不刷新! 就像死机了一样,哪怕我把手机屏幕戳个洞,它也不动一下。

🔍 侦破过程

V 哥我当时就懵了,难道是 ArkUI 抽风了?我开始疯狂打 Log,发现 dataList 的内存地址确实变了。

这时候,V 哥我突然想起了一句老话:鸿蒙的观察机制,有时候比前女友还难伺候。

原来啊,我在别的地方,为了图省事,直接操作了数组内部的某个属性,比如: this.dataList[0].name = 'V哥最帅';

或者我在赋值前,对数组做了一些深拷贝的操作,但拷贝得不够“彻底”。在 ArkTS 里,如果你只是修改了对象的深层属性,而没有触发对象本身的引用变化,或者嵌套对象没加 @Observed,UI 渲染引擎就会选择性失明:“哦,还是那个对象,不用动,懒得刷。”

✅ 终极解决方案

兄弟们记住了,遇到对象数组刷新,要么老老实实地整体替换引用,要么就用对装饰器!

  1. 简单粗暴法: 每次都 new 一个新数组,或者用展开符 [..., newData] 强制换个地址。
  2. 专业治本法(推荐): 你的 Model 类必须用 @Observed 装饰,然后在组件里用 @ObjectLink 去接!
@Observed
export class UserModel {
  name: string = '';
  age: number = 0;
}

// 组件里
@Component
struct UserItem {
  @ObjectLink user: UserModel; // 注意这里!
  
  build() {
    Text(this.user.name)
  }
}

用了 @ObjectLink,那叫一个丝滑,对象里哪怕改了个标点符号,界面立马跟着变!


悬案二:真机上的“幽灵点击”(事件冒泡的背刺)

📃 案发现场

为了赶进度,V 哥我写了一个复杂的列表,每个 Item 里面有个“删除”按钮,外面整个 Item 也是可以点击进入详情页的。

在模拟器上跑得好好的,点删除,删除;点 Item,跳转。V 哥我美滋滋地装到真机上测试。

结果,诡异的事情发生了:我点“删除”按钮,它不仅把数据删了,还特么给我跳到了详情页!

我都想把手机吃了,明明点的是按钮,为什么会触发父容器的点击事件?

🔍 侦破过程

刚开始以为是手机屏幕坏了,或者手指太粗。后来发现,这是典型的事件冒泡问题。

在鸿蒙的 ArkUI 里,点击事件的传递机制有时候会跟你“捉迷藏”。在模拟器上可能因为响应速度快或者渲染机制不同,不明显。但在真机上,特别是如果你手抖了一下,点击事件就会像坐火箭一样,从子组件(按钮)直接冒泡传到了父组件(ListItem),触发两次点击行为。

✅ 终极解决方案

给可能触发冲突的子组件事件里,加一句咒语,把它截胡!

Button('删除')
  .onClick((event: ClickEvent) => {
    // 你的删除逻辑...
    console.info('执行删除');
  })
  // 重点来了!加上这一句,告诉父组件:到此为止,别往上传了!
  .hitTestBehavior(HitTestMode.None) 

或者,在 onClick 的回调里根据业务逻辑判断,但在 UI 声明里,hitTestBehavior 是最物理、最有效的“结界”。加上这一行代码,世界瞬间清净了。


悬案三:模拟器是亲儿子,真机是捡来的?(资源加载的时差)

📃 案发现场

这个 Bug 简直让我怀疑人生。我在 DevEco Studio 的 Previewer 里预览,图片显示完美,动画流畅。装到华为真机上一跑——图片全是裂开的默认图!

我检查了路径,common/images/xxx.png,没错啊!权限也给了,网络也通了。为什么真机上就是加载不出来?

🔍 侦破过程

V 哥我当时盯着屏幕看了半小时,突然灵光一闪:是不是加载时机的问题?

在模拟器里,因为电脑性能强,IO 读写快,图片往往在界面渲染出来之前就已经加载好了。但在真机上,也就是个移动设备,读取本地资源文件是需要时间的。

我的代码逻辑是: Image(this.imagePath)

this.imagePath 是在 aboutToAppear() 生命周期里异步去获取并赋值的。真机渲染组件的时候,这个变量还是空的或者是初始值,等它拿到值了,Image 组件已经摆烂不渲染了。

✅ 终极解决方案

这叫“异步竞态问题”。解决方法有两个,V 哥推荐第二种。

  1. 加 Loading 占位: 用个 if 判断,数据没回来前显示个转圈圈的 Loading。
  2. 给 Image 组件加个 Key(绝招):
Image(this.imagePath)
  .objectFit(ImageFit.Cover)
  // 加上这个 key!每次 imagePath 变了,强制 Image 组件销毁重绘!
  .key(this.imagePath) 

一旦你加了 .key(this.imagePath),这就相当于告诉系统:“兄弟,路径变了,这已经不是刚才那张图了,你赶紧重新加载一下!” 这一招,对解决真机资源加载滞后、不刷新的问题,百试百灵


悬案四:WebViewController 的“黑屏诅咒”

📃 案发现场

在鸿蒙里嵌入 H5 页面很常见吧?V 哥我当时用 Web 组件加载一个第三方的 URL。

开发阶段一切正常。结果到了测试环境,页面偶尔一进去就是黑屏,啥也没有,控制台还不报错! 简直就是见了鬼。

🔍 侦破过程

这种不报错的 Bug 最难搞。后来 V 哥我发现,这跟 H5 页面的加载速度和 Web 组件的初始化有关。

当 Web 组件还没完全准备好,或者 H5 页面内部 JS 执行出错卡住了,鸿蒙这边的 Web 内核有时候就会“死机”,呈现一片死寂的黑色。

✅ 终极解决方案

咱们得像带孩子一样,盯着它!

  1. 监听生命周期: 必须配合 onPageEndonError 事件。
  2. 注入诊断脚本: 在 H5 加载前,注入一段 JS 去探活。
Web({ src: this.url, controller: this.controller })
  .onPageEnd(() => {
    // 页面加载结束了,如果还是黑屏,说明出问题了
    console.info("页面加载结束");
  })
  .onErrorReceive((event) => {
    // 捕获错误
    console.error("Web加载失败: " + event.getError().toString());
    // 这里可以弹个窗,或者加载一个本地错误的 HTML
    this.controller.loadUrl('resource:///rawfile/error.html');
  })
  .domStorageAccess(true)

最关键的一招:不要在 Controller 没初始化完成的时候就急着 loadUrl。如果你是在 aboutToAppear 里初始化 Web 组件,最好延时个几百毫秒,或者确保 Controller 实例化完毕再操作。给它一点喘息的时间,黑屏就消失了。


V 哥总结陈词

兄弟们,这就是 V 哥亲测的鸿蒙开发四大“悬案”。

其实总结下来,鸿蒙开发虽然新,但万变不离其宗:

  1. 状态管理要搞清引用关系(Observed/ObjectLink 用起来)。
  2. 事件传递要防冒泡(hitTestBehavior 设起来)。
  3. 真机性能要考虑时差(Key 和 Loading 加起来)。
  4. 混合开发要做好容错(生命周期监听起来)。

遇到 Bug 别慌,别砸键盘,更别怀疑人生。把这些坑踩平了,你就是鸿蒙圈里的老司机!

*我是V哥,关注我,一起搞鸿蒙呀!手搓了三本鸿蒙教材,学完即可体系化掌握鸿蒙开发。 *

从vue3 watch开始理解Vue的响应式原理

从vue3 watch开始理解Vue的响应式原理

前言

vue源代码的内容非常之多,包括模板解析(把vue文件的写法解析成一个函数用来插入dom或者调整dom),依赖收集,响应式,事件处理,插槽,组件,指令,等等等。刚开始看时不知道从何看起,这里分享一个个人的学习思路,我们可以从一些关键的api开始逐步学习,这里推荐 reactive,watch。明白了这两个api时如何工作的,那vue的响应式系统就基本理解了。

let`s start

// demo.js
import { reactive, watch } from './vue-core.js'
const user = reactive({
  name: 'Alice',
  age: 25
})
watch(user, (newValue, oldValue) => {
  console.log(
    `User changed from ${oldValue.name},${oldValue.age} to ${newValue.name},${newValue.age}`
  )
})
setTimeout(() => {
  user.name = 'Bob'
  user.age = 30
}, 1000)

朴素的示例,如果user发生了变化。那么就会触发watch的回调。脱离vue框架,我们需要如何设计呢,首先想到的简单逻辑就是拦截user的set和get。无论是通过Proxy还是Object的set,原理是一样的。

let activeSub = null // 当前活跃的订阅者
const targetMap = new WeakMap()

export function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      track(target, key)
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      const res = Reflect.set(target, key, value, receiver)
      trigger(target, key)
      return res
    }
  })
}
// 简化版watch实现,只考虑监听一个reactive对象(类似上面的user例子)的变化
export function watch(source, cb) {
  const getter = () => {
    for (const key in source) {
      source[key]
    }
    return source
  }
  let oldValue
  const effect = new ReactiveEffect(getter)
  const job = () => {
    const newValue = effect.run()
    //todo 对比newValue和oldValue是否有变化
    cb(newValue, oldValue)
    oldValue = newValue
  }
  effect.schedule = job
  oldValue = effect.run()
  console.log('old', oldValue)
}

// 示例解释:搜集user中每个key的下游依赖,当user的name或age变化时,触发对应的依赖更新,从而调用watch的回调函数。
function track(target, key) {
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = new Dep()))
  }
  dep.track()
}
function trigger(target, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  const dep = depsMap.get(key)
  if (dep) {
    dep.trigger()
  }
}

function Dep() {
  this.subs = new Set()
  this.track = () => {
    // 借助全局变量activeSub记录当前活跃的订阅者
    if (activeSub) {
      this.subs.add(activeSub)
    }
  }
  this.trigger = () => {
    this.subs.forEach((sub) => {
      sub.notify()
    })
  }
}

function ReactiveEffect(fn) {
  this.fn = fn
  this.notify = null // 回调函数, 可类比于watch的cb用来辅助理解
  this.schedule = null
  this.run = () => {
    // 标记当前活跃的订阅者为this 通过fn触发每个ref或者reactive对象的getter
    // 从而触发track函数,将当前活跃的订阅者this添加到dep的subs中
    const preActiveSub = activeSub
    activeSub = this
    const value = this.fn()
    activeSub = preActiveSub
    return value
  }
  // 上游的ref或者reactive对象触发setter时,会通过收集者调用trigger函数,从而触发schedule函数,在watch中就是cb函数
  this.trigger = () => {
    this.schedule?.()
  }
  this.notify = () => {
    this.trigger()
  }
}


从上面的demo中我们初步实现了user变动时调用一个回调,deps表示“依赖”,通俗的理解就是有多少个下游正在依赖user,例如computed,watch,render函数等等这些的最终结果都是根据上游来变动的。

3a7131ea44dd9bfb4726e7f3af8f988ce8045f8de77b904d636c9d9b0ff74207.png

这里面其实就两个概念需要理解,一个是依赖收集者 Dep,一个是响应副作用管理器 ReactiveEffect。借助例子,Dep 理解为用来管理user中每个key都有哪些watch正在监听。而 ReactiveEffect 理解为当user的某个key发生变化时,需要调用哪些watch的回调函数。

调用watch的过程其实关注的就是 oldValue = effect.run() 这一段代码。在创建watch时,会立即调用effect.run(),从而触发user的getter,从而触发track函数,将当前活跃的订阅者effect添加到user.name和user.age的dep中。这样,当user.name或user.age发生变化时,就会触发trigger函数,从而调用effect的notify函数,从而调用watch的回调函数。

组件渲染其实就是一个特殊的watch,watch的回调函数就是渲染函数。当user的name或age发生变化时,就会触发渲染函数,从而更新dom。

🚀 React Router 7 + Vercel 部署全指南

一、 开发环境准备 (本地)

  1. 推荐包管理器:使用 pnpm 替代 npm 以获得更快的构建速度和更清晰的依赖管理。

如果已经使用npm构建,那么先删掉node_modules文件夹,安装pnpm

pnpm install
  1. Node.js 版本限制

package.json 中显式指定:

    • JSON
"engines": { "node": "20.x" }
  1. 静态模式配置 (SPA)

若不需要 SSR(服务端渲染),在 react-router.config.ts 中设置:

    • TypeScript
export default { ssr: false } satisfies Config;
  1. 关联github及vercel

本地代码提交到github,同时登录vercel平台,将vercel与github关联上,后续你在本地改动了代码并提交到github后,访问vercel平台的项目域名就能看到改动的内容了


二、 静态资源与图标 (Icon)

  1. 文件存放:所有的图片、SVG 图标必须放在根目录的 public/ 文件夹下。
  2. 页面引入

app/root.tsxlinks 函数中配置:

    • TypeScript
export const links: LinksFunction = () => [
  { rel: "icon", type: "image/svg+xml", href: "/logo.svg" },
];

三、 Vercel 部署关键点 (最重要)

如果你遇到 now-phpFunction Runtimes 报错,请检查以下配置:

vercel.json 配置(强制静态重写,防止 404):

  1. JSON
{
  "framework": "vite",
  "rewrites": [{ "source": "/(.*)", "destination": "/" }]
}
  1. Vercel 后台设置
    • Framework Preset: 优先选择 Vite;如果被锁定为 React Router,请确保手动开启 Build and Output Settings
    • Build Command: pnpm build
    • Output Directory: dist(如果在脚本中手动移动了产物)或 build/client
    • Install Command: pnpm install

四、 自定义域名

  1. 修改 Vercel 默认域名
    • Settings -> Domains -> Edit,可以直接修改 xxx.vercel.app 的前缀。
  1. 绑定独立域名
    • Domains 页面输入你购买的域名。
    • DNS 配置
      • A 记录:指向 76.76.21.21
      • CNAME 记录:指向 cname.vercel-dns.com

💡 核心避坑心得

  • 清理缓存:部署报错时,尝试删除 Vercel 项目重新导入,并确保 pnpm-lock.yaml 是最新的。
  • 路径大小写:Vercel 的 Linux 环境对文件名大小写敏感,确保 import 路径与文件名完全一致。
❌