阅读视图

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

制作一面飘扬的彩虹旗🌈🌈🌈

写在开头

Hello,各位小伙伴好呀!👋

今是2025年06月26日 周四夜 22:40:10,夜已深,无心睡眠。🌌

这周也已经过去一半了,工作日无什么大事发生,平平常常,每天三点一线,工作上也还行,无什么不好的事情,也没什么压力,能愉快的迎来周末的到来。😋

然后,记录一下上周末,一个不错的周末,和好友小酌一杯(上一次喝酒还是在上一次呢😗),然后还去下了两次馆子吃大餐,无图,光顾着吃了。😵

回到正题,本次要分享的是一面七彩飘扬的"旗帜"。效果如下,请诸君按需食用哈。

062701.gif

🎯关键原理

上来咱们先来整原理,因为呢,这种案例是一眼望过去就应该能通晓其背后原理的,起码你要做到能纯手工撸出来才行喔。😉

那么,要实现这种旗帜"飘扬"的效果,核心思路其实很简单:把旗帜拆分成多个等宽的 "竖条",让每个竖条以不同的延迟上下摆动,就能形成风吹般的"飘扬"效果了。 🌊

这里小编将用到了 CSS 的 animation-delay 属性:给每个竖条设置递增的延迟时间,比如第一个延迟 100ms,第二个 200ms,第三个 300ms……, 这样它们就会像多米诺骨牌一样依次摆动,视觉上就形成了 “风拂过旗帜” 的动态感啦~ ✨

那么,最关键代码如下:

@keyframes oscillate {
  from { 
    transform: translateY(20px); 
  }
  to { 
    transform: translateY(-20px); 
  }
}
.column {
  /* 每个.column的延时将被动态设置 */
  animation: oscillate 500ms infinite alternate ease-in-out;
}

这个 CSS 能让咱们实现一堆上下移动的列,虽然还比较丑😂。效果如下:

062601.gif

然后,为了旗帜的"飘扬"效果,咱们需要给它们分别添加不同的动画延时时长(animation-delay),也非常简单,如下:

<div id="flag-container">
    <div class="column" style="animation-delay: 100ms"></div>
    <div class="column" style="animation-delay: 200ms"></div>
    <div class="column" style="animation-delay: 300ms"></div>
    <div class="column" style="animation-delay: 400ms"></div>
    <div class="column" style="animation-delay: 500ms"></div>
    <div class="column" style="animation-delay: 600ms"></div>
    <div class="column" style="animation-delay: 700ms"></div>
</div>

然后呢,就可以得到如下的效果:

062602.gif

应该是有那味了吧?🤔🤔🤔

🏳️‍🌈绘制旗帜

基础的动画咱们算是有方向了,接下来,我们要来绘制一面彩色旗帜,这说来也挺简单👌,小编第一想法就是给每个列创建一堆 div,再给不同的背景颜色,如下:

<div id="flag-container">
    <div class="column" style="animation-delay: 100ms">
        <div style="background-color: hsl(0, 100%, 50%);"></div>
        <div style="background-color: hsl(30, 100%, 50%);"></div>
        <div style="background-color: hsl(60, 100%, 50%);"></div>
        <div style="background-color: hsl(120, 100%, 25%);"></div>
        <div style="background-color: hsl(180, 100%, 50%);"></div>
        <div style="background-color: hsl(240, 100%, 50%);"></div>
        <div style="background-color: hsl(270, 100%, 50%);"></div>
    </div>
    <!-- ... -->
</div>

效果:

062603.gif

效果确实挺好。✅

但是呢,如果旗帜列数多、色条复杂,就会疯狂堆砌 DOM 节点。比如一面 8 色 15 列的旗帜,就需要 120 个 DOM 节点!这就会消耗大量的DOM节点,这显然不够优雅。❌

Google 开发者文档中,明确说明:

建议保持整个页面的 DOM 节点数在 1500 个以下,深度不超过 32 层,单个父节点子元素不超过 60 个。⏰

所以,这并不是一个完美方案,那么,有没有既能解决需求又比较优雅的方案呢❓

当然有啦❗咱们可以使用背景的 线性渐变(linear-gradient) 来解决。

先来看一个小案例:

image.png

对应代码:

<style>
.box1 {
    width: 150px;
    height: 60px;
    margin-bottom: 50px;
    aspect-ratio: 3 / 2;
    background: linear-gradient(
        to bottom,
        hsl(0, 100%, 50%), /* 赤 */ 
        hsl(30, 100%, 50%), /* 橙 */ 
        hsl(60, 100%, 50%) /* 黄 */ 
    );
}
.box2 {
    width: 150px;
    height: 60px;
    aspect-ratio: 3 / 2;
    background: linear-gradient(
        to bottom,
        hsl(0, 100%, 50%) 0% 33.3%, /* 赤 */
        hsl(30, 100%, 50%) 33.3% 66.7%, /* 橙 */
        hsl(60, 100%, 50%) 66.7% 100%  /* 黄 */
    );
}
</style>

<div class="box1"></div>
<div class="box2"></div>
  • .box1 是普通渐变,颜色会平滑过渡,这就没什么好说的吧。👌

  • .box2 才是重点!每个颜色都指定了起始位置和结束位置,比如 hsl(0, 100%, 50%) 0% 33.3% 表示:

    1. 从 0% 开始显示赤色,到 33.3% 时仍显示赤色;
    2. 33.3% 处立即切换到橙色,直到 66.7%;
    3. 以此类推,从而实现了无过渡的实色分块。

另一种等价写法:

background: linear-gradient(
   to bottom,
   hsl(0, 100%, 50%) 0%,
   hsl(0, 100%, 50%) 33.3%,
   hsl(30, 100%, 50%) 33.3%,
   hsl(30, 100%, 50%)  66.7%,
   hsl(60, 100%, 50%) 66.7%,
   hsl(60, 100%, 50%) 100% 
);

这样一来,原本需要 N 个 DOM 节点的色条,现在只用一行 CSS 就能搞定,不仅减少了 DOM 数量,还能通过 JS 动态生成渐变字符串,适配任意颜色组合🎨,这就很灵活,完美。🥳

💯细节消除

最麻烦的核心部分已经讲完啦,剩下的就是一些细节问题了。咱们接下来重点是把能动态调控的细节都交给 JS,比如,动态创建旗帜、不同延时、不同颜色渐变、不同飘扬幅度等等,让旗帜效果更灵活可控。

1️⃣飘扬幅度

还记得最开始写的 CSS 动画吗?当时用的是硬编码的20px幅度,虽然方便,但不够灵活,它让旗帜每列都固定了一个飘扬幅度运动,现在小编想让每个列都有一个略微不一样的飘扬幅度,这样旗帜的飘扬效果估计会更好玩一点。🤔

这里咱们选择用 CSS 变量 + JS 的组合拳来实现,首先,在 CSS 中定义动画,用 var(--billow) 作为摆动幅度的变量,如:

@keyframes oscillate {
  from {
    transform: translateY(var(--billow));
  }
  to {
    transform: translateY(calc(var(--billow) * -1));
  }
}

然后,通过 JS 给每一列动态设置不同的 --billow 值, 比如让中间列幅度小,边缘列幅度大,模拟旗帜中心固定、边缘摆动更明显的物理特性:

for (let i = 0; i < numOfColumns; i++) {
    // 动态创建旗帜
    const column = document.createElement("div");
    column.className = "column";
    // 不同飘扬幅度
    column.style.setProperty("--billow", `${(i + 1) * billow}px`);
    // ...
    flag.appendChild(column);
}

这样每一列的摆动幅度就有了层次感。💯 咱们也可以在页面上动态控制 billow 的变化,如:

062702.gif

2️⃣渐变

在之前绘制旗帜的部分,咱们手动编写了渐变的代码:

background: linear-gradient(
    to bottom,
    hsl(0, 100%, 50%) 0% 33.3%, /* 赤 */
    hsl(30, 100%, 50%) 33.3% 66.7%, /* 橙 */
    hsl(60, 100%, 50%) 66.7% 100%  /* 黄 */
);

这种硬编码方式有两个小缺点:

  • 颜色数量固定,想新增颜色得手动计算🧮百分比;
  • 要换一种旗帜时,得重新写一套渐变逻辑。

这有点麻烦,咱们可以用 JS 写一个自动生成渐变的函数,如:

const RAINBOW_COLORS = [
    "hsl(0, 100%, 50%)", // 赤
    "hsl(30, 100%, 50%)", // 橙
    "hsl(60, 100%, 50%)", // 黄
    "hsl(120, 100%, 25%)", // 绿
    "hsl(180, 100%, 50%)", // 青
    "hsl(240, 100%, 50%)", // 蓝
    "hsl(270, 100%, 50%)", // 紫
];

/**
 * @name 生成渐变字符串
 * @param {Array<string>} colors 颜色数组
 * @returns {string} 渐变字符串
 */
function generateGradientString(colors) {
    const numOfColors = colors.length;
    // 每个颜色占据的百分比区间
    const segmentHeight = 100 / numOfColors;

    const gradientStops = colors.map((color, index) => {
        const from = index * segmentHeight;
        const to = (index + 1) * segmentHeight;
        // 格式:颜色 起始% 结束%
        return `${color} ${from}% ${to}%`;
    });

    return `linear-gradient(to bottom, ${gradientStops.join(", ")})`;
}

const gradient = generateGradientString(RAINBOW_COLORS);
console.log(gradient);
/* 输出:
* hsl(0, 100%, 50%) 0% 14.285714285714285%, 
* hsl(30, 100%, 50%) 14.285714285714285% 28.57142857142857%, 
* ...
*/

3️⃣间隙

有时候浏览器会因为像素取整问题,在竖条之间留下细微间隙。

062703.gif

解决办法是让旗帜总宽度能被竖条数整除:

const friendlyWidth = Math.round(width / numOfColumns) * numOfColumns;
// 例如:宽度200px,12列,200/12=16.666px,取整为17px×12=204px

👉完整源码

传送门





至此,本篇文章就写完啦,撒花撒花。

image.png

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。

跨端通信终结者|看我是如何保证多端消息一致性的 🔥

本文当时为了专利过审下架了,现在放回来 ~

本文写作于兔年之前,心境上却是有诸多感叹,大名鼎鼎的网络库 AFNetworking 停止维护了,iOS 开发早已是昨日黄花,移动端也从夕阳走向落幕了 ~

lQLPJxf-k1hlNyzNAaPNA7Gw8JmC9IJ2K5gDwlduWYCXAA_945_419.png

认清现实,摆正心态,拥抱变化,跨端开发确实比原生开发更值得卷 (o_ _)ノ 。作为一个码了10年的老派程序员,卷也要选择优雅的卷。Flutter \ Web \ Rust and so on ... 各种花里胡哨的跨端语言,怎么有机的结合在一起,这确实是一门学问。

起因

言归正传 ~

从小故事开始

我有个薛定谔状态的朋友,他入职了一家公司里做 iOS 开发,一天他接到一个业务需求,需要配合 Web 开发修改一下 windTrack 埋点桥接实现,通过对代码进行字符串搜索,他发现在不同模块里有4个可能是 windTrack 桥接的插件,但好在是3个已经被标记成过时,看起来他只需要处理未过时的那个即可。

故事嘛都有戏剧性,在修改打包后提供给 Web 开发同学测试时发现,修改并没有生效 ... 多方查找问题后发现,windTrack桥接最终会调用到一个标记过时的方法里 ...

绝望难过伤心动态表情包.gif

问题的本质

后续这个朋友如何处理暂且不表,我们来聊聊为什么会出现这样的问题?

本质上是可维护性的缺失,为什么项目里会存留4个具有相同能力的方法呢?因为它经手了 N(N > 4)位开发同学,这在业务快速增长时期,确实是存在的现象,有人在维护、有人在新增、有人在重构...懂得都懂。

思维升华

靠人约束规范是不可控的,加再多的 Code Review 流程也是不可控的,因人而异就会因人而劣化,直至规范形同虚设。这也包括维护文档这件事,理论上每个跨端桥接都要求有完善的使用文档,但现实上,文档的约束比代码更难,毕竟也没有 Docs Review 流程。

分析

我们虽然明了问题的本质,但还是需要具体的分析问题、解决问题。

现有开发方式

以 iOS - Web 通信桥接为例。

例:iOS 实现

底层调用上,基本都是通过 WKWebviewWKScriptMessageHandler 代理来监听 web -> iOS 的调用,通过主动调用 JS callbackId 的方式来回调消息。

部分代码截图:

image.png

image.png

从收到的 message.body 解析出调用的方法名、方法参数以及回调的 callbackId 等字符串。

然后我们都会或多或少的封装下,用各种方式找到具体的实现方法。这里就不展开讲了,基本就是用配置的方式、用代码反射的方式这样来做解耦。

像笔者公司上,就是用的代码反射,如下图:

image.png

实现方法上就可以转变为:

- (void)windTrack:(NSDictionary *)params completeBlock:(void (^)(id _Nullable result))completeBlock {
    ...
}

同理,Flutter 也有一套相同的实现方式,只不过 Flutter Channel 比 CallbackId 更优雅些。

例:Web 调用

在 Web 端使用层面,需要一些代码来隐藏 callbackId 以及抹平 iOS 双端调用差别,这里不做过多说明,还是以 windTrack 为例:

Bridge.call('report/windTrack', { ...params }).then(result => {})

一般来说,Web 开发同学也会把上述调用方法再二次封装一下来使用。

存在问题

可能多数同学或者以前的笔者也会觉得这样实现并没有什么问题,实现方式上大同小异,基本也都是这样来开发的。

但结合我们要求的可维护性来看,是有着以下弊端的:

  • 字符串类型的方法名字段做桥的连接标识位,这个属于人为强行定义,并不是可靠的。虽然可以增加一些运行时校验来检查,但也是环节滞后的测试手段。
  • 参数并没有明确定义,统一都是 Map 对象,虽然也可以增加明确方法注释来缓解这个问题,但需要多端对齐参数及其类型,意外时常发生,最多也是增加运行时校验来断言。
  • Flutter、Web 已定义了大量的桥接方法,也说不上来哪个到底有用,哪个已经没用了,对于原生桥接实现方法来说,就是一个也不敢删,生怕线上出现问题。何况还有命中到作废方法这种奇葩。
  • 最为重要的是,从开发到维护,沟通上的成本居高不下,还会因人而废,一旦出现问题,排查起来十分头疼。

思考

我们总结下上述的问题,来想一下对开发最友好的是什么样的?

不需要写方法匹配

不需要写使用文档

不需要手动解析 Map 入参

不需要手动拼装 Map 回调

这在解决方案上,当然会想到用跨端工具链(说到跨端工具链,我们是在说什么?)的方式来实现。

简单来说:一次定义,多端实现,任意调用。

这也类似笔者前几篇文章中写到的 Flutter 多引擎渲染组件 中跨端处理方式。

举例

我们还是以故事里的windTrack为例:

方法提供者只需要实现 interface windTrack(int eventId, String eventName, Map attributes)

方法使用者直接调用 windTrack(eventId, eventName, attributes)

剩下的过程开发都不需要接入,都有工具链进行生成。

解决方案

实现效果

先看下最终实现的效果。

image.png

如上图,只要定义 YAML,就会根据上述定义,来生成多端的使用代码。

再来看看 iOS 和 Web 端生成的效果

iOS 支撑服务效果

生成的 iOS 组件库

image.png

这里只需开发接口实现层

@interface GNBReportServiceImplement () <GNBReportServiceObserver>

@end

@implementation GNBReportServiceImplement

- (instancetype)init {
    self = [super init];
    if (self) {
        [GNBManager.sharedInstance.reportService addObserver:self];
    }
    return self;
}

// MARK: - GNBReportServiceObserver
- (void)reportProviderDidWindTrack:(nullable NSString *)eventId event:(nullable NSString *)event detailInfo:(nullable NSDictionary *)detailInfo completedBlock:(nonnull void (^)(NSError *))completedBlock {
    ...
}

@end

Web 组件调用效果

生成的 Web 组件

image.png

注册后即可使用

GNB.init(new GNBAppRegister());
...
GNB.report.windTrack(...);

可以看到,流程上就转变为:全局定义 + 各端具体实现 API + 各端调用 API。大大的增强可维护性,间接的降低了人力成本,又能为老板省钱了[手动狗头]。

总体架构

1.png

专有名词解释

[GNB] Gaoding Native Bridge,稿定本地通信方案

上图是从能力视角描述了我们实现了什么,下图是从流程视角,来表明整个过程是如何运转起来的。

image.png

详细设计

定义规范

组件定义采用 YAML 标准化语言定义。

文件命名以用插件模块名称,比如 user.yaml 、 report.yaml 。

APIs 定义
定义 说明
name 名称
note 说明
params 参数 List[name: 名称note: 说明type: 类型default:  默认值required: 是否必填,默认非必填,有默认值也为非必填]
callback 回调 List[name: 名称note: 说明type: 类型required: 是否必填,默认非必填]
Classes 定义
定义 说明
name 名称
note 说明
properties 属性列表[name: 名称note: 说明type: 类型]
TYPE 支持
YAML 定义 Flutter(dart) iOS(objectivec) Android(kt) Web(ts)
String String NSString * String string
int/long/double/bool number NSNumber * Number number
Map Map NSDictionary * Map any
List<String> List<String> NSArray<NSString *> List<String> array<string>
List<int/long/double/bool> List<number> NSArray<NSNumber *> List<Number/Boolean> array<number>
List<Class> List<Class> NSArray<Class > List<Class> array<Interface>
Class Class Class * Class Interface
Any dynamic id Any any

number 类型说明 定义上还是用 int/long/double/bool 来定义,但为了数据传输安全,所以各端用 number 类型来承接,且会在注释上会带上当前的精度说明。至于为什么定义上不直接使用 number,这是为 Rust 扩展考虑,Rust 上数值类型是明确精度(比如 f64),并没有提供 number 泛型。

Class 说明为了不增加拆箱复杂度, List<Class> 只能在 Class 中定义,不能直接在 params / callback 中使用。

Any 说明 Any 类型尽量不要使用,前期为了过渡,后续会禁用掉。

示例
####
classes:
  - name: UserInfo
    note: 用户信息定义
    properties:
      - { name: userId, note: 用户 ID, type: String }
####
apis:
  - name: fetchUserInfo
  - note: 获取当前用户信息
  - callback:
      - name: userInfo
        note: 用户信息
        type: UserInfo

抽象服务

对 iOS / Android 来说,是能力的实现方。

当前并不会改变以前的 channel 或者 bridge 底层实现形式,只会在这个基础上另外封装。

封装上,因为可以自动生成了,所以不再需要主动注册插件,也不需要写动态调用的代码,直接构建 map 对象注册各个方法转发,且去生成相应的 Service。

Service 提供 Observer 作为需实现的 API。

好处是可以在任意模块、代码监听来提供服务实现。

当然,前期我们还是会把实现层都写在一个模块里统一管理。

image.png

调用入口

以 iOS Web 容器为例

image.png

在 WKWebview 的 script 消息接收代理中调用我们生成的 GNB 模块的入口 GNBManager.sharedInstance execute:params:completedBlock: 方法即可。

image.png

因为 GNB 模块的代码是自动生成的,所以可以无视一些复杂度规范,直接用 if else 来进行判断后直接命中方法,不再需要动态反射等不好维护的解耦手段。

接口代理

上图中,执行入口会通过方法名称命中到 XXXService,这里我们来了解下,service 是如何做的,这也是抽象服务的关键设计。

还是以 windTrack 为例:

@implementation GNBReportService

- (void)windTrack:(NSDictionary *)params completedBlock:(void (^)(NSDictionary *result))completedBlock {
    [self notifyObserversWithSelector:@selector(reportProviderDidWindTrack:event:detailInfo:completedBlock:), params[@"eventId"], params[@"event"], params[@"detailInfo"], ^(NSError *error) {
        NSDictionary *_result = [GNBUtils resultWithData:@{
        } error:error]; // 自动装箱
        GDBlockCall(completedBlock, _result);
    }];
}

@end

入口命中后会触发一个观察者通知方法,通知给监听者,这里除了模块解耦外,最主要的是做了装箱拆箱,把入参拆箱,把出参装箱,当然这也是自动生成的,所以可以保证它是可靠的。

// MARK: - Observer
@protocol GNBReportServiceObserver <NSObject>

/// 埋点上报
///
/// - Parameter eventId: <String> 事件 ID
/// - Parameter event: <String> 事件定义
/// - Parameter detailInfo: <Map> 详细内容
/// - Parameter completedBlock: 回调
- (void)reportProviderDidWindTrack:(nullable NSString *)eventId event:(nullable NSString *)event detailInfo:(nullable NSDictionary *)detailInfo completedBlock:(void (^)(NSError *error))completedBlock;

@end

使用上,接口实现者实现上述代理即可。

调用组件

调用组件 Web 的比较简单,因为只需要构造 TS interface 即可。相对的 Flutter 较为麻烦,因为 Class <-> Map 是比较重的。

还是以 Web 为例。

调用入口
image.png

入口可以根据环境注册不同的 Register,以适应不同的宿主环境(Wap / 小程序 / APP),其中 GNBAppRegister 也是自动生成的,Wap / 小程序的实现代码需要手动补充。


export interface GNBRegister {  
  /**
   * 报告相关
   */
  report: GNBReport
  
  ...
}

/**
 * Gaoding Native Bridge
 */
export class GNB {
  private static _register?: GNBRegister

  static get report(): GNBReport {
    assert(GNB._register, 'GNB 必须注册使用')
    assert(GNB._register!.report, 'report 未实现')
    return GNB._register!.report
  }
  
  ...
  
  /**
   * 初始化 GNB
   * @param register 注册者
   */
  static init(register: GNBRegister): void {
    GNB._register = register
  }
}
调用服务

再顺着调用入口看下来,会生成如下的GNBAppRegister

export class GNBAppRegister implements GNBRegister {
  report = {
    windTrack(eventId?: string, event?: string, detailInfo?: any): Promise<GNBReportWindTrackResponse> {
      return bridge.call('GNB_report/windTrack', {
        'eventId': eventId,
        'event': event,
        'detailInfo': detailInfo,
      })
    },
  }
  ...
}

服务定义生成在 bridges 文件夹中

image.png

代码生成

选择 python 作为开发语言,更为通用。

image.png

生成流程上:

  1. 解析 YAML 生成 DSL Model
  2. 拷贝资源文件
  3. 生成 iOS / Android / Web / Flutter 代码
  4. 构建产物包

image.png

具体代码实现不在文章中表述了(掘金不爱大段代码 ~),这里着重讲一下实现难点和思考。

DSL Model

YAML 定义是一种标准的 DSL 语言,但在使用上,用 Map[XXX] 对脚本来言并不好维护,也不够优雅,所以在生成前,我们会做一个 DSL 模型,来承载数据结构。

model.py

class PropertyModel:
    ...
class GNBAPIModel:
    ...
class GNBClassModel:
    ...
class ModuleInfo:
    ...
image.png

简单示意,我们把 YAML 映射到 Model 的过程。

api.py

class API:
    ...
    @staticmethod
    def get_modules() -> list[ModuleInfo]:
        """
        获取模块信息
        """
        modules = []
        for file_name in os.listdir(Define.yaml_dir):
            with open(Define.yaml_dir + '/' + file_name) as f:
                json = yaml.load(f.read(), Loader=yaml.FullLoader)
                info = ModuleInfo(file_name, json.get('note'),
                                  json.get('apis'), json.get('classes'))
                modules.append(info)
        return modules
编码转换

整个生成上,重头戏就是处理各种编码的转换。

首先是类型转换,对基础类型、引用类型、自定义类型进行转换。

这里不同的生成器根据上述 YAML 类型定义,使用不同的类型转换工具方法。

例如 iOS 类型转换工具方法:

image.png

其中较为麻烦的是对自定义模型的处理,在装拆箱中需要有相应的 toJSON / toModel 方法。

在类型处理之外,还提供了如下的工具方法:

def oc_array_class_type(type: str) -> str:
    """
    返回 NSArray<Class> 中的 Class
    """

def oc_to_json(type: str, name: str) -> str:
    """
    获取 oc 的序列化
    """

def oc_assign(type: str) -> str:
    """
    获取 OC 修饰符
    """

def oc_import(name: str, prefix: str = '\n') -> str:
    """
    获取 OC 引用
    """
 
def oc_property(name: str, type: str, note: str = '', **optional) -> str:
    """    
    获取 OC 属性行
    """

def oc_protocol(name: str, note: str = '') -> GenContainer:
    """
    获取 OC 代理块
    """

def oc_interface(
    name: str,
    note: str = '',
    extends: str = 'NSObject',
) -> GenContainer:
    """
    获取 OC interface
    """

def oc_implementation(name: str) -> GenContainer:
    """
    获取 OC implementation
    """
  
def oc_method(name: str,
              note: str = 'no message',
              params: list[PropertyModel] = [],
              callback: list[PropertyModel] = []) -> GenContainer:
    """
    获取 OC 方法
    """

def oc_notification_method(name: str,
                           params: list[PropertyModel] = [],
                           callback: list[PropertyModel] = []) -> GenContainer:
    """
    获取 OC 响应 Observer 方法
    """
  
def oc_block(callback: list[PropertyModel] = []) -> list[str]:
    """
    获取 OC 响应的 Block 值
    """
   
def oc_assert_required(params: list[PropertyModel] = []) -> list[str]:
    """
    获取 OC 必填 Assert
    """
   
def oc_lazy_getter(name: str, type: str) -> str:
    """
    获取 OC 懒加载的 Getter

    Args:
        name (str): 名称
        type (str): 类型
    """
代码格式化

其实有想到用第三方格式化工具,比如 Web 使用 prettier 来格式化生成代码,但现有的就有4种语言,找齐可用的格式化插件有些不现实,特别是 iOS 的格式化。

好在这个项目格式化还不算复杂,提供一些格式化工具方法即可优雅的封装起来。

utils/ios.py

def format_line(line: list[str], prefix='') -> str:
    """
    格式化文本行

    Args:
        line (list[str]): 文本行
        prefix (str, optional): 每行前缀. Defaults to ''.
    """
    text = f'\n{prefix}'.join(line)
    text = text.replace(f'\t', '    ')
    return text

在生成上就优雅的多,比如生成 GNBReportService.h 中的定义头:

image.png

结合编码转换提供的工具类,这样写即可。

def get_header_methods(self, module: ModuleInfo) -> str:
        """
        返回 Service 的方法定义
        """
        line = []
        for api in module.apis:
            line.append(self.get_method_define(api.name) + ';')
        return format_line(line, '\n')
产物包

image.png

对于不同的环境,使用不同的产物包模版。

iOS:cocoapods

Android:gradle

Flutter:FlutterPlugin

Web:npm

其中 Web 比较特别,我们希望直接依赖产物,所以在生成脚本的最后一句构建产物包中,还会执行响应的 Web 构建命令。

main.sh

# Web build

printf "[gnb-codegen]: web building ...\n"

cd ../../components/gaoding_native_bridge/web/gaoding-native-bridge

yarn

yarn build

直接生成产物到 /lib 中,把整个流程自动化起来。

image.png

当然,现在更多的是 monorepo 大仓的形式,所以不会打成远程包,而是采用application-services的方式本地依赖。

后续上也完全可以很简单的指定远端仓库,增加下各个语言的仓库推送命令,生成二方库来使用。

Schema 校验

有心的看官们可能有注意到,如何限定 YAML 的编写呢,这个如果不符合标准,生成出来的东西完全是不可用的。

这里就要介绍下大名鼎鼎的 jsonschema,我们常用的 package.json 也好,pubspec.yaml 也好,都是根据这个规范来检查我们在里面的配置项。

当然,这个不发布也是可以直接使用的。

我们先构建一个 gnb.schema.json 文件

image.png

其中比较有意思的就是自定义类型的判断:

"pattern": "^(String|int|long|double|bool|Map|List|List<(String|int|long|double|bool)>|Any|GNB(?:[A-Z][a-z]+)+)$",

可以看到,是通过正则匹配类型是否正确的,而自定义类型就是GNB开头作为类型名称的才可以,也是一种取巧设计。

然后我们在工程中的 .vscode/settings.json 文件进行配置即可生效:

{
  ...
  "yaml.schemas": {
    "gnb.schema.json": "*.yaml"
  }
}

image.png

题外话:jsonshema 也可以用于后端接口参数校验。

生成在线文档

还有架构图上提到的生成文档能力,这个笔者在 Flutter 多引擎渲染组件 已经用 Ruby 实现过一次,这次是用 python 重写(不为别的,就是折腾)。

套路上也差不多,先看下效果:

image.png

Docs 在线文档用的 VuePress2 编辑,生成相应的 markdown 文件即可。

image.png

生成上比生成代码简单的多,这里不做过多阐述。

总结

这篇文章笔者个人觉得对比前些篇文章会更抽象一些,用的也是 Web 和 iOS 双端举例,限于篇幅,没有 Flutter 和 Android 的代码展示,但原理都是相同的,希望大家能了解到其中的思想 ~

整体方案来说并不只是在通信上的抽象,优势还在于可以很方便的替换底层通信实现。无论是 bridge 还是 channel,甚至可以换成 ffi 或者 protobuf 这样的通信形式,都不会影响上层的服务调用及支撑实现。

后续生成上也会对更多的平台进行支持,比如增加 Rust 的支撑服务,让 Rust 直接与 Web / Flutter 通信,毕竟终端工程师 ~= 全干工程师[手动狗头]。

可能会有同学疑问,这些生成的组件包是怎么通过 monorepo 结合到大仓里的,这里是用了application-services的建设方案,这个后续会另起一篇文章阐述 ~

本方案还在落地过程中,当落地后会把生成代码工具开源共享 ~


感想

本来笔者想靠本文升到创作 Lv4,达成年前定的小目标。但硬靠着前些篇文章的积累就已经达到了 🎉 。

后续写作上,就不不不不参加日更活动了 (o_ _)ノ ,文章上更加精益求精(长篇大论)~ 给自己定的 2023 年目标是 20 篇文章即安好 ~


感谢阅读,如果对你有用请点个赞 ❤️

中秋节GIF动图引导在看提示.gif

像素风绘画软件Aseprite编译

在开发中,有些像什么独游呀,美术真是过不去的坎,画不出精美的画作。 那有没有什么简单上手,又可以让人瞬间了解是什么元素的画作呢???

有的兄弟有的,像素风画作,独游受众。

那像素风就不得不搬出这个工具了。

Aseprite 是专业的像素艺术创作工具,虽然提供付费版本,但开源版本可自行编译使用。 steam上可以下载付费版本,也不贵。40个馒头就可以拿下。😄

那如果是学生党,连这些费用都掏不出来的话(没有恶意)🐊

那就是手动编译这个开源的软件了

1. 编译开原版本的准备工作

Visual Studio 2022(社区版免费)

  • 安装时勾选:

    • 使用 C++ 的桌面开发
    • Windows 10/11 SDK(最新版)
  • 下载地址

image.png

🛠️ CMake 安装(≥3.16)

CMake 是一个跨平台的构建系统生成器,用于控制软件编译过程。

Windows 安装方法:
  1. 官方安装包(推荐):

    • 下载地址:cmake.org/download/
    • 选择 Windows x64 Installer
    • 重要:安装时勾选 Add CMake to the system PATH
  2. 包管理器安装

    # Chocolatey 安装
    choco install cmake --installargs 'ADD_CMAKE_TO_PATH=System'
    
    # Scoop 安装
    scoop install cmake
    
macOS 安装:
# Homebrew 安装
brew install cmake

# MacPorts 安装
sudo port install cmake
Linux 安装:
# Ubuntu/Debian
sudo apt update && sudo apt install cmake

# Fedora
sudo dnf install cmake

# Arch Linux
sudo pacman -S cmake

⚡ Ninja 安装

Ninja 是一个小型快速的构建系统,通常与 CMake 配合使用提高编译速度。

Windows 安装:
  1. 官方二进制

    • 下载地址:github.com/ninja-build…
    • 下载 ninja-win.zip
    • 解压后将 ninja.exe 放入系统 PATH 路径(如 C:\Windows
  2. 包管理器安装

    # Chocolatey
    choco install ninja
    
    # Scoop
    scoop install ninja
    
macOS 安装:
# Homebrew
brew install ninja
Linux 安装:
# Ubuntu/Debian
sudo apt install ninja-build

# Fedora
sudo dnf install ninja-build

# Arch Linux
sudo pacman -S ninja

在环境变量中添加指向

✅ 验证安装

cmake --version
# 应显示类似: cmake version 3.28.3

ninja --version
# 应显示类似: 1.11.1

2.编译方法

编译方法的见官方说明

在GitHub下载Aseprite 为Aseprite编译好的Skia版本 下载aseprite-m102版本 

Skia下载后置和源码目录同级别

image.png

1.可以使用cmake-gui进行编译

image.png

image.png

image.png

1.可以使用命令行进行编译

1.在cmd中引入自己的VS环境路径

image.png

2.使用CMake构建

cmake -DCMAKE_BUILD_TYPE=RelWithDebInfo -DLAF_BACKEND=skia -DSKIA_DIR=D:\dowload\Skia-Windows-Release-x64 -DSKIA_LIBRARY_DIR=D:\dowload\Skia-Windows-Release-x64\out\Release-x64 -DSKIA_LIBRARY=D:\dowload\Skia-Windows-Release-x64\out\Release-x64\skia.lib -G Ninja ..

替换为自己的路径

image.png

构建完成的目录,就可以进行打包

ninja aseprite

image.png

到了这里就完成编译了 image.png

结语

三方汉化包

主题

在首选项添加扩展就可以了

vite+vue-ts 如何在项目中设置菜单页面的权限

在业务系统的后台中,通常需要根据用户的角色给予对应的权限。 必要功能:

  1. 增删改查权限配置
  2. 对应接口配置(用与后端权限判断)
  3. 自定义权限配置
  4. 名称,图标,路由修改
  5. 是否外链
  6. 是否显示
  7. 排序

配置对应页面文件 综上考虑使用动态配置页面,

声明路由节点对象:

/**
 * 路由项接口定义
 * 描述单个路由节点的完整结构和权限信息
 */
interface RouterItem {
    /** 是否有新增权限 */
    add: boolean
    /** 新增操作相关的 API 接口列表 */
    addApi: string[]
    /** 是否需要认证 */
    auth: boolean
    /** 路由分类标识 */
    classify: number
    /** 组件文件路径,用于动态导入组件 */
    componentName: string
    /** Vue 组件实例 */
    component: any
    /** 创建时间戳 */
    createTime: number
    /** 是否有删除权限 */
    del: boolean
    /** 删除操作相关的 API 接口列表 */
    delApi: string[]
    /** 是否有编辑权限 */
    edit: boolean
    /** 编辑操作相关的 API 接口列表 */
    editApi: string[]
    /** 路由唯一标识 */
    id: number
    /** 是否有列表查看权限 */
    list: boolean
    /** 列表查询相关的 API 接口列表 */
    listApi: string[]
    /** 路由名称,用于路由跳转 */
    name: string
    /** 父级路由 ID,用于构建层级关系 */
    parentId: number
    /** 路由路径 */
    path: string
    /** 排序权重,数值越小越靠前 */
    sort: number
    /** 路由状态:1-启用,0-禁用 */
    state: number
    /** 路由显示标题 */
    title: string
    /** 子路由列表 */
    children: Array<RouterItem>
}

数据库结果与上一致,树状数据结构

用户账号——》角色关联——》目录结构

通过代码添加到路由中

 import { useRouter } from 'vue-router'
 const router = useRouter()
 router.addRoute(pageRoutes)

通过嵌套组件渲染菜单结构

<template>
    <template v-for="item, index in prop.data">
        <el-sub-menu index="1" v-if="item.children && item.children.length > 0">
            <template #title>
                <Icon v-if="item.icon" :name="item.icon"></Icon>
                <span>{{ t(item.title) }}</span>
            </template>
            <MenuItem :data="item.children">
            </MenuItem>
        </el-sub-menu>
        <el-menu-item :index="item.path" v-else>
            <Icon v-if="item.icon" :name="item.icon"></Icon>
            <template #title>{{ t(item.title) }}</template>
        </el-menu-item>
    </template>
</template>

<script lang="ts" setup>
import { useI18n } from 'vue-i18n';

const { t } = useI18n()

const prop = defineProps({
    data: {
        type: Array<any>,
        default: []
    }
})

</script>

<template>
    <div class="menu-box">
        <el-icon v-show="!isCollapse" @click="() => isCollapse = true">
            <CaretLeft />
        </el-icon>
        <el-icon v-show="isCollapse" @click="() => isCollapse = false">
            <CaretRight />
        </el-icon>
    </div>
    <el-menu style="height: 100%;" router :default-active="route.path" class="el-menu-vertical-demo"
        :collapse="isCollapse" @open="handleOpen" @close="handleClose">
        <MenuItem :data="pageRoutes.children" />
    </el-menu>
</template>

<script lang="ts" setup>
import { ref } from 'vue'

import {
    CaretLeft,
    CaretRight

} from '@element-plus/icons-vue'


import { useRoute } from 'vue-router'

import MenuItem from '@/components/MenuItem.vue'
import initEachRouter from '@/utils/router'
const route = useRoute()
const permission = JSON.parse(localStorage.getItem('permission') || '[]')
const pageRoutes = initEachRouter(permission)


const { menu } = defineProps({
    menu: {
        type: Array,
        default: () => []
    }
})
console.log(menu)



const isCollapse = ref(false)
const handleOpen = (key: string, keyPath: string[]) => {
    console.log(key, keyPath)
}
const handleClose = (key: string, keyPath: string[]) => {
    console.log(key, keyPath)
}
</script>

<style>
.menu-box {
    position: absolute;
    top: 50vh;
    width: 28px;
    height: 28px;
    display: flex;
    justify-content: center;
    align-items: center;
    border-radius: 50%;
    font-size: 20px;
    right: -14px;
    z-index: 1000;
    box-shadow: 0 0 3px #aaa;
    background-color: #fff;
}
</style>

前端E2E测试实践

背景

公司近期正在大力推进代码质量管理,要求前端项目也需覆盖一定比例的单元测试。作为试点,我选取了一个 Vue3 项目,开始编写相关的单元测试用例。测试过程中发现,对于纯 JavaScript 工具函数这类纯逻辑模块,单元测试确实效果显著,能够帮助及时发现边界场景和潜在 bug,提升代码鲁棒性。

然而,在尝试为 UI 页面或组件逻辑编写单元测试时,收效却并不明显。由于大多数真实依赖(如路由、接口请求、第三方 UI 组件)都需要 mock 替代,这使得测试环境与真实运行环境存在较大差异,导致测试用例的覆盖深度和准确性受到较大限制。比如一个典型的课程列表页面,我们希望验证接口返回的数据能否正确渲染在页面中。如果接口数据是 mock 的,即便页面渲染逻辑出现了问题或业务逻辑被修改,只要 mock 数据结构没变,测试用例依然可能通过。这在一定程度上削弱了测试的有效性和实际意义。使用单元测试去验证 UI 页面或组件逻辑更多是形式上的覆盖,难以真正还原复杂的业务场景,其带来的质量提升也较为有限。

因此,我认为在当前前端项目中,UI 层的逻辑和行为更适合通过端到端(E2E)测试进行验证。与单元测试不同,E2E 测试是在尽可能还原真实使用场景的前提下,从用户的视角出发,通过自动化脚本完整地走一遍功能流程,以验证系统的整体协作是否符合预期。

比如课程列表页面的测试,我们可以通过 E2E 脚本打开页面、模拟用户操作,等待接口真实返回后,直接校验页面是否正确渲染关键内容。这种方式不再依赖大量 mock,而是尽量模拟用户实际的使用路径,对组件的交互行为、异步数据加载、页面跳转逻辑等都能进行更真实有效的验证,从而真正守住产品质量的“最后一道防线”。

对于偏重交互逻辑、异步渲染和多个模块协同的 UI 页面,单靠单元测试并不能提供足够的信心保障。引入 E2E 测试,不仅提升了覆盖的真实度,也有助于开发、测试和产品多方在验收逻辑时达成一致预期。

E2E测试方案选择

项目采用的是Vue3+Vite+Pnpm+Jenkins技术方案,我调研了三种E2E测试方案:Cypress,Playwright, Nightwatch。Nightwatch是第一个出局的。

Nightwatch的出局理由

1. 历史包袱与社区关注度下降

  • Nightwatch 曾是 Selenium 时代 E2E 测试的先锋,但随着 Playwright 和 Cypress 的崛起,它的社区关注度逐渐被分流。
  • 更新节奏较慢,生态活跃度偏低,很多问题要靠文档 + GitHub Issues 自助解决。

2. CI/CD 适配性不如 Playwright

  • 在 Jenkins 这样的 CI 平台中,Nightwatch 通常仍需要手动配置 webdriver 或 chromedriver 的路径,对 Linux Headless 环境不够“零配置友好 。
  • Playwright 一条命令就能在无头模式下装好依赖,Nightwatch 则可能需要配置 xvfb 或显示环境模拟。

3. 调试与可视化体验较弱

  • 缺少像 Cypress 那样的 GUI 调试器,也没有 Playwright 的 VSCode 插件或 codegen 录制能力。
  • 开发过程中如果出现 flaky 测试,定位问题会更加费时。

4. Vue 3 生态适配并不主流

  • 虽然支持 Vue 组件测试(通过 @nightwatch/vuevite-plugin-nightwatch),但文档、教程数量明显比 Playwright 和 Cypress 少。
  • 一些高级功能(比如模拟 Router、Pinia 状态)仍然偏向自定义和繁琐。

Cypress的出局理由

在Cypress和Playwright之间,我权衡了好久,对比来对比去,最终选择了Playwright。

  • Cypress vs Playwright 对比速览
特性 Cypress Playwright
定位 专注前端交互测试 更广泛的 E2E 自动化解决方案(包括多浏览器)
浏览器支持 Chromium(默认)、Firefox、Edge Chromium、Firefox、WebKit(含 Safari)
测试稳定性 强依赖浏览器内部机制(Runs in-browser) 在 Node 中执行,支持更底层的控制
调试体验 超强 GUI 界面,所见即所得 CLI 和 VSCode 插件,调试功能逐步完善
速度 中等 更快,支持并发执行
CI/CD 友好度 良好 极佳,原生无头模式、并发执行
API 灵活性 简洁易学(略微限制) 高度灵活(功能强大)
网络拦截与 mock 支持但相对有限 网络层控制非常强,可模拟几乎一切请求
学习曲线 更平缓,文档易读 稍陡,但上手也不难
  • Cypress vs Playwright 在 Jenkins 中的表现
对比维度 Cypress Playwright
安装依赖 简单,需安装 Chrome 或 Electron 简单,需安装 Node 支持即可
Jenkins 环境依赖 需要 GUI 支持(或用 xvfb) 原生支持无头模式,无需 GUI 环境
执行效率 较慢,默认单线程 支持多线程并发测试(提高速度)
报告集成 支持 mochawesome、allure 报告等 同样支持 allure,生成结构更丰富
测试稳定性 有时遇到 flaky 测试(需显式等待) 控制更底层,等待机制稳定
适配 Jenkins Agent 略微挑剔(GUI、Chrome等) 更通用,适配 headless 环境更自然

尽管 Cypress 在“本地调试体验”上几乎是无敌的(所见即所得、UI 漂亮、开发者友好),但:

  1. 项目部署使用的是 Jenkins: 在无头、Docker、云服务器等 CI 环境中,Cypress 的 GUI 依赖是一种负担。虽然可以用 xvfb 等解决方案,但本质上还是“为适配 CI 而生”的 Playwright 更自然。
  2. Vite + pnpm 组合强调速度和效率: 而 Cypress 的执行模型本身是单线程,加载和执行 E2E 用例时,容易形成瓶颈。Playwright 原生支持并发 runner,更适合这种构建速度导向的现代前端。
  3. 复杂交互的能力局限: 如果要测试登录流程、OAuth 重定向、多页面嵌套,Playwright 在“脚本灵活性”和“控制浏览器底层行为”方面表现明显更强。
  4. 可扩展性: Playwright 还能轻松用于测试 Electron 应用、移动模拟器、API 请求,具有更高的拓展空间。

如何用Playwright做E2E测试?

step1 安装依赖

pnpm add -D @playwright/test
pnpm exec playwright install

pnpm exec playwright install命令会安装好几种无头浏览器,如果不需要那么多

image.png

只想安装chromium,执行下面这句:

npx playwright install chromium

step2 创建playwriht.config.ts配置文件

新建一个名为playwriht.config.ts的文件,配置如下

import { defineConfig } from '@playwright/test'

export default defineConfig({
  testDir: './tests', // 测试用例文件夹
  timeout: 30 * 1000, // 每个测试最大超时
  use: {
    baseURL: 'http://localhost:5173',
    browserName: 'chromium',
    headless: true,
    screenshot: 'only-on-failure',
    video: 'retain-on-failure'
  },
  webServer: {
    command: 'pnpm dev',
    port: 5173,
    reuseExistingServer: !process.env.CI,
  },
  reporter: [['html', { open: 'never' }]]
})

可以根据实际需要把 testDir 改成你存放测试脚本的目录,比如 e2e/specs/ 等。

step3 在package.json添加测试指令

{
  "scripts": {
    "dev": "vite",
    "test:e2e": "playwright test",
    "test:e2e:report": "playwright show-report"
  }
}

step4 编写e2e测试用例

在项目根目录下创建tests/login.spec.ts,编写登录页面 E2E 测试用例

import { test, expect } from '@playwright/test';

test('用户登录成功跳转到首页', async ({ page }) => {
  // dev线上登录
  await page.goto('https://dev.example.com/cas/login/#/login?appId=xxx');

  // 填写账号密码(用测试账号)
  await page.fill('input[id="credential"]', '账户');
  await page.fill('input[id="secret"]', '密码');
  await page.click('div.btn:has-text("登录")');

  // 登录成功后,初始 URL 为 /#/?tk=xxx,然后再 JS 重定向或路由跳转到 #/model,需要等待一段时间
  await page.waitForFunction(() => location.hash.includes('model'), { timeout: 8000 });
  await expect(page).toHaveURL(/\/data\/ai-platform-frontend\/#\/model/);

  // 验证用户名是否正确显示
  await expect(page.locator('.layout-header')).toContainText('李二');
});


运行pnpm test:e2e运行测试, 测试成功之后,运行pnpm test:e2e:report查看测试报告

  • 成功测试报告:

image.png

  • 失败测试报告

失败时,有截图和录屏,可以查看具体卡在了哪里。

image.png

image.png

step5 Jenkinsfile e2e 流程

pipeline {
  agent any

  environment {
    HOME = '/root'  // 避免 playwright 安装权限问题
  }

  stages {
    stage('检出代码') {
      steps {
        checkout scm
      }
    }
    
    stage('安装依赖') {
      steps {
        sh 'corepack enable'
        sh 'pnpm install'
        sh 'npx playwright install --with-deps'
      }
    }

    stage('运行 E2E 测试') {
      steps {
        sh 'pnpm test:e2e'
      }
    }

    stage('生成报告') {
      steps {
        sh 'pnpm test:e2e:report'
      }
    }
  }

  post {
    always {
      // 将测试报告归档
      archiveArtifacts artifacts: '**/playwright-report/**', allowEmptyArchive: true
    }
  }
}

最后

在现代前端开发中,测试策略从不只是代码覆盖率的游戏。尤其是在 UI 页面和组件交互丰富的场景中,端到端(E2E)测试更贴近“用户真实使用的角度 ,比单元测试更能暴露问题、保障体验。

尽管单元测试可以快速验证某个函数或组件在某种输入下是否返回预期输出,但它往往忽略了跨组件协同、路由跳转、DOM 渲染顺序、浏览器兼容性、真实 API 行为等维度。换句话说,单元测试关注的是代码是否能跑,而 E2E 测试关注的是用户是否能用。

以登录流程为例,单元测试也许能验证按钮是否存在、方法是否被调用,但无法验证登录按钮点击后,是否真的跳转、是否写入 cookie、是否显示用户信息……而这些,恰恰才是用户真正关心的结果。

因此,在 UI 页面和组件测试中,E2E 测试不仅仅是补充单测的工具,更是保障系统端到端完整性的关键一环。它模拟的是“人和浏览器之间的真实协作” ,而不是脱离上下文的函数行为。这种“以用户为中心”的测试方式,不仅提升了系统的健壮性,也让团队可以更有信心地交付每一个前端功能。个人认为,前端测试应该以E2E测试为主, 单元测试为辅。给各位掘友留个彩蛋,这是一个非常提效编写测试用例的命令,感兴趣可以研究下。

npx playwright codegen https://your.site.domain 

js循环依赖——bug记录

背景

开发过程中,遇到了一个js循环依赖的情况,由于在debug过程中,没有特别关注到引用的逻辑问题,导致该问题花了点时间才发现,故此写文章记录下

如图所示 image.png

案例

文件结构 image.png

a.js

import { funcB } from './b.js';

export function funcA() {
  console.log('funcA calls funcB:');
  funcB();
}

b.js

import { funcA } from './a.js';

export function funcB() {
  console.log('funcB calls funcA:');
  funcA();
}

funcB();

执行

node b.js

执行结果 image.png

加载过程详解(步骤顺序):

  1. Node 开始执行 b.js
  2. b.js 最顶部是:
    import { funcA } from './a.js';
    
    所以 Node 去加载 a.js
  3. 在加载 a.js 的过程中,它又写着:
    import { funcB } from './b.js';
    
    又回头 import 了 b.js
  4. ⚠️ 这时循环依赖形成了
    • b.js 的执行被暂停,只创建了一个空的 module namespace 对象
    • a.js 中能获取的 funcB 是一个 未初始化的引用,值是 undefined
  5. Node 接着执行 a.js 的剩余部分:
    • 声明 funcA,没问题
    • 完成后返回到原来的 b.js 加载流程
  6. 回到 b.js
    • 声明 funcB
    • 然后执行:
      funcB();
      
      ⬇️
    • 打印 "funcB calls funcA:"
    • 执行 funcA()
  7. 进入 funcA()
    • 打印 "funcA calls funcB:"
    • 尝试调用 funcB()

ES Module 的核心机制

这里举个例子

import { funcB } from './b.js';

export function funcA() {
  console.log('funcA calls funcB:');
  funcB();
}

简洁地说,import时,整个b.js会被加载 & 执行一遍,不管你只导入了一个函数

但 👉 只会执行一次(模块是单例) ,并且:

  • 只执行模块顶层代码;
  • 不会调用任何导出的函数,除非你主动调用(比如 funcB());
  • 只会执行一次,即使多个文件都 import 它(模块缓存机制);
  • 只导入你声明的部分(如 funcB),其余导出不进入当前作用域。

模块加载过程(ESM 的标准行为)

  1. 解析依赖图:分析模块里的 import 声明,递归分析依赖
  2. 模块加载:
    • 加载整个依赖模块文件(如 b.js
    • 执行它的顶层代码(不是函数体内部代码)
    • 构建其导出对象
  3. 执行导入模块(如 a.js)的顶层代码

什么是“模块顶层代码”?

模块文件中写在模块最外层、非函数体/类体/回调内部的代码

// example.js
console.log('✅ 顶层代码执行了'); // ✅ 顶层代码

const x = 1;                     // ✅ 顶层代码

function test() {               // ✅ 函数声明也是顶层代码(但内部内容不会执行)
  console.log('❌ 函数里的代码不会自动执行');
}

if (x === 1) {
  console.log('✅ 条件判断在顶层,也会执行');  // ✅ 顶层条件语句
}

test(); // ✅ 手动调用函数,才会执行函数体里的代码

假设你这样 import:

import { test } from './example.js';

执行过程:

  • example.js 会被 加载一次
  • 其中的顶层语句如 console.log(...)const x = 1都会立刻执行
  • test() 函数体内部的 console.log('...')不会执行,除非你手动调用

什么不是“顶层代码”?

类型 是否是顶层代码 是否会自动执行
函数体内部的代码 ❌ 否 ❌ 否
类内部的方法 ❌ 否 ❌ 否
回调函数内部的代码 ❌ 否 ❌ 否
setTimeout(() => {}) ❌ 否 ✅ 被注册但延迟执行
iffor 这种控制语句 ✅ 是 ✅ 立即执行(只要在顶层)

总结归纳

概念 意思
顶层代码 写在模块最外层、非函数体/类体/回调内部的代码
会自动执行吗 ✅ 会:常见如变量赋值、console.log、import 等
不会执行的 ❌ 不会自动执行函数体、类体、异步回调等
什么时候执行函数体 你自己或其他模块主动调用它时才会执行

一句话理解:当你 import 一个模块时,JS 会自动执行它的顶层代码,但不会自动调用函数或方法。你导入的只是它的接口和副作用结果

从0到1:文旅小程序开发笔记(上)

可行性调查

涵盖活动报名、景点介绍、旅行攻略以及游记分享等功能,满足用户在旅游过程中的多种需求,提升旅游体验,同时助力文旅发展。

  • 活动报名:分类呈现各类文旅活动,如文化节庆、户外探险、亲子研学、美食体验等,每项活动展示活动名称、时间、地点、活动亮点)、参与人数上限以及报名截止日期等关键信息,并配以精美的活动海报图片,方便用户快速了解活动概况;点击后进入报名表单填写页面,用户填写完成后可提交报名,系统自动生成报名订单。
  • 景点攻略:将景点按照不同类型进行分类,用户可通过分类导航快速进入相应类别页面浏览景点。
  • 我的游记:展示用户发布的精彩的游记案例,用户也可以自行发布自己的游记。

概要设计

在这里插入图片描述

数据库设计


ActivityModel.DB_STRUCTURE = {
_pid: 'string|true',
ACTIVITY_ID: 'string|true',

ACTIVITY_TITLE: 'string|true|comment=标题',
ACTIVITY_STATUS: 'int|true|default=1|comment=状态 0=未启用,1=使用中',

ACTIVITY_CATE_ID: 'string|true|default=0|comment=分类',
ACTIVITY_CATE_NAME: 'string|false|comment=分类冗余',

ACTIVITY_CANCEL_SET: 'int|true|default=1|comment=取消设置 0=不允,1=允许,2=仅截止前可取消',
ACTIVITY_CHECK_SET: 'int|true|default=0|comment=审核 0=不需要审核,1=需要审核', 
ACTIVITY_IS_MENU: 'int|true|default=1|comment=是否公开展示名单',

ACTIVITY_MAX_CNT: 'int|true|default=20|comment=人数上限 0=不限',
ACTIVITY_START: 'int|false|comment=活动开始时间',
ACTIVITY_END: 'int|false|comment=活动截止时间',
ACTIVITY_STOP: 'int|true|default=0|comment=报名截止时间 0=永不过期',

ACTIVITY_ORDER: 'int|true|default=9999',
ACTIVITY_VOUCH: 'int|true|default=0',

ACTIVITY_FORMS: 'array|true|default=[]',
ACTIVITY_OBJ: 'object|true|default={}',

ACTIVITY_JOIN_FORMS: 'array|true|default=[]',

ACTIVITY_ADDRESS: 'string|false|comment=详细地址',
ACTIVITY_ADDRESS_GEO: 'object|false|comment=详细地址坐标参数',

ACTIVITY_QR: 'string|false',
ACTIVITY_VIEW_CNT: 'int|true|default=0',
ACTIVITY_JOIN_CNT: 'int|true|default=0',
ACTIVITY_COMMENT_CNT: 'int|true|default=0',

ACTIVITY_USER_LIST: 'array|true|default=[]|comment={name,id,pic}',

ACTIVITY_ADD_TIME: 'int|true',
ACTIVITY_EDIT_TIME: 'int|true',
ACTIVITY_ADD_IP: 'string|false',
ACTIVITY_EDIT_IP: 'string|false',
};
ActivityJoinModel.DB_STRUCTURE = {
_pid: 'string|true',
ACTIVITY_JOIN_ID: 'string|true',
ACTIVITY_JOIN_ACTIVITY_ID: 'string|true|comment=报名PK',

ACTIVITY_JOIN_IS_ADMIN: 'int|true|default=0|comment=是否管理员添加 0/1',

ACTIVITY_JOIN_CODE: 'string|true|comment=核验码15',
ACTIVITY_JOIN_IS_CHECKIN: 'int|true|default=0|comment=是否签到 0/1 ',
ACTIVITY_JOIN_CHECKIN_TIME: 'int|false|default=0|签到时间',

ACTIVITY_JOIN_USER_ID: 'string|true|comment=用户ID',


ACTIVITY_JOIN_FORMS: 'array|true|default=[]|comment=表单',
ACTIVITY_JOIN_OBJ: 'object|true|default={}',

ACTIVITY_JOIN_STATUS: 'int|true|default=1|comment=状态  0=待审核 1=报名成功, 99=审核未过',
ACTIVITY_JOIN_REASON: 'string|false|comment=审核拒绝或者取消理由',

ACTIVITY_JOIN_ADD_TIME: 'int|true',
ACTIVITY_JOIN_EDIT_TIME: 'int|true',
ACTIVITY_JOIN_ADD_IP: 'string|false',
ACTIVITY_JOIN_EDIT_IP: 'string|false',
};

核心实现

class ActivityService extends BaseProjectService {

// 获取当前活动状态
getJoinStatusDesc(activity) {
let timestamp = this._timestamp;

if (activity.ACTIVITY_STATUS == 0)
return '活动停止';
else if (activity.ACTIVITY_END <= timestamp)
return '活动结束';
else if (activity.ACTIVITY_STOP <= timestamp)
return '报名结束';
else if (activity.ACTIVITY_MAX_CNT > 0
&& activity.ACTIVITY_JOIN_CNT >= activity.ACTIVITY_MAX_CNT)
return '报名已满';
else
return '报名中';
}

/** 浏览信息 */
async viewActivity(userId, id) {

let fields = '*';

let where = {
_id: id,
ACTIVITY_STATUS: ActivityModel.STATUS.COMM
}
let activity = await ActivityModel.getOne(where, fields);
if (!activity) return null;

ActivityModel.inc(id, 'ACTIVITY_VIEW_CNT', 1);

// 判断是否有报名
let whereJoin = {
ACTIVITY_JOIN_USER_ID: userId,
ACTIVITY_JOIN_ACTIVITY_ID: id,
ACTIVITY_JOIN_STATUS: ['in', [ActivityJoinModel.STATUS.WAIT, ActivityJoinModel.STATUS.SUCC]]
}
let activityJoin = await ActivityJoinModel.getOne(whereJoin);
if (activityJoin) {
activity.myActivityJoinId = activityJoin._id;
activity.myActivityJoinTag = (activityJoin.ACTIVITY_JOIN_STATUS == ActivityJoinModel.STATUS.WAIT) ? '待审核' : '已报名';
}

else {
activity.myActivityJoinId = '';
activity.myActivityJoinTag = '';
}


return activity;
}

/** 取得分页列表 */
async getActivityList({
cateId, //分类查询条件
search, // 搜索条件
sortType, // 搜索菜单
sortVal, // 搜索菜单
orderBy, // 排序 
page,
size,
isTotal = true,
oldTotal
}) {

orderBy = orderBy || {
'ACTIVITY_ORDER': 'asc',
'ACTIVITY_ADD_TIME': 'desc'
};
let fields = 'ACTIVITY_CATE_NAME,ACTIVITY_USER_LIST,ACTIVITY_STOP,ACTIVITY_JOIN_CNT,ACTIVITY_OBJ,ACTIVITY_VIEW_CNT,ACTIVITY_TITLE,ACTIVITY_MAX_CNT,ACTIVITY_START,ACTIVITY_END,ACTIVITY_ORDER,ACTIVITY_STATUS,ACTIVITY_CATE_NAME,ACTIVITY_OBJ';

let where = {};
where.and = {
_pid: this.getProjectId() //复杂的查询在此处标注PID
};
if (cateId && cateId !== '0') where.and.ACTIVITY_CATE_ID = cateId;

where.and.ACTIVITY_STATUS = ActivityModel.STATUS.COMM; // 状态  


if (util.isDefined(search) && search) {
where.or = [{
ACTIVITY_TITLE: ['like', search]
},];
} else if (sortType && util.isDefined(sortVal)) {
// 搜索菜单
switch (sortType) {
case 'cateId': {
if (sortVal) where.and.ACTIVITY_CATE_ID = String(sortVal);
break;
}
case 'sort': {
// 排序
orderBy = this.fmtOrderBySort(sortVal, 'ACTIVITY_ADD_TIME');
break;
}
case 'today': { //今天
let start = timeUtil.getDayFirstTimestamp();
let end = start + 86400 * 1000 - 1;
where.and.ACTIVITY_START = ['between', start, end];
break;
}
case 'tomorrow': { //明日
let start = timeUtil.getDayFirstTimestamp() + 86400 * 1000;
let end = start + 86400 * 1000 - 1;
where.and.ACTIVITY_START = ['between', start, end];
break;
}
case 'month': { //本月
let day = timeUtil.time('Y-M-D');
let start = timeUtil.getMonthFirstTimestamp(day);
let end = timeUtil.getMonthLastTimestamp(day);

where.and.ACTIVITY_START = ['between', start, end];
break;
}
}
}

return await ActivityModel.getList(where, fields, orderBy, page, size, isTotal, oldTotal);
}


/** 取得某一个报名分页列表 */
async getActivityJoinList(activityId, {
search, // 搜索条件
sortType, // 搜索菜单
sortVal, // 搜索菜单
orderBy, // 排序 
page,
size,
isTotal = true,
oldTotal
}) {
orderBy = orderBy || {
'ACTIVITY_JOIN_ADD_TIME': 'desc'
};
let fields = 'ACTIVITY_JOIN_OBJ,ACTIVITY_JOIN_IS_CHECKIN,ACTIVITY_JOIN_REASON,ACTIVITY_JOIN_ACTIVITY_ID,ACTIVITY_JOIN_STATUS,ACTIVITY_JOIN_ADD_TIME,user.USER_PIC,user.USER_NAME,user.USER_OBJ';

let where = {
ACTIVITY_JOIN_ACTIVITY_ID: activityId,
ACTIVITY_JOIN_STATUS: ActivityModel.STATUS.COMM
};

let joinParams = {
from: UserModel.CL,
localField: 'ACTIVITY_JOIN_USER_ID',
foreignField: 'USER_MINI_OPENID',
as: 'user',
};

let result = await ActivityJoinModel.getListJoin(joinParams, where, fields, orderBy, page, size, isTotal, oldTotal);

return result;
}


/** 取得我的报名分页列表 */
async getMyActivityJoinList(userId, {
search, // 搜索条件
sortType, // 搜索菜单
sortVal, // 搜索菜单
orderBy, // 排序 
page,
size,
isTotal = true,
oldTotal
}) {
orderBy = orderBy || {
'ACTIVITY_JOIN_ADD_TIME': 'desc'
};
let fields = 'ACTIVITY_JOIN_IS_CHECKIN,ACTIVITY_JOIN_REASON,ACTIVITY_JOIN_ACTIVITY_ID,ACTIVITY_JOIN_STATUS,ACTIVITY_JOIN_ADD_TIME,activity.ACTIVITY_END,activity.ACTIVITY_START,activity.ACTIVITY_TITLE';

let where = {
ACTIVITY_JOIN_USER_ID: userId
};

if (util.isDefined(search) && search) {
where['activity.ACTIVITY_TITLE'] = {
$regex: '.*' + search,
$options: 'i'
};
} else if (sortType) {
// 搜索菜单
switch (sortType) {
case 'timedesc': { //按时间倒序
orderBy = {
'activity.ACTIVITY_START': 'desc',
'ACTIVITY_JOIN_ADD_TIME': 'desc'
};
break;
}
case 'timeasc': { //按时间正序
orderBy = {
'activity.ACTIVITY_START': 'asc',
'ACTIVITY_JOIN_ADD_TIME': 'asc'
};
break;
}
case 'succ': {
where.ACTIVITY_JOIN_STATUS = ActivityJoinModel.STATUS.SUCC;
break;
}
case 'wait': {
where.ACTIVITY_JOIN_STATUS = ActivityJoinModel.STATUS.WAIT;
break;
}
case 'cancel': {
where.ACTIVITY_JOIN_STATUS = ActivityJoinModel.STATUS.ADMIN_CANCEL;
break;
}
}
}

let joinParams = {
from: ActivityModel.CL,
localField: 'ACTIVITY_JOIN_ACTIVITY_ID',
foreignField: '_id',
as: 'activity',
};

let result = await ActivityJoinModel.getListJoin(joinParams, where, fields, orderBy, page, size, isTotal, oldTotal);

return result;
}

/** 取得我的报名详情 */
async getMyActivityJoinDetail(userId, activityJoinId) {

let fields = '*';

let where = {
_id: activityJoinId,
ACTIVITY_JOIN_USER_ID: userId
};
let activityJoin = await ActivityJoinModel.getOne(where, fields);
if (activityJoin) {
activityJoin.activity = await ActivityModel.getOne(activityJoin.ACTIVITY_JOIN_ACTIVITY_ID, 'ACTIVITY_TITLE,ACTIVITY_START,ACTIVITY_END');
}
return activityJoin;
}
 
async statActivityJoin(id) {
// 报名数
let where = {
ACTIVITY_JOIN_ACTIVITY_ID: id,
ACTIVITY_JOIN_STATUS: ['in', [ActivityJoinModel.STATUS.WAIT, ActivityJoinModel.STATUS.SUCC]]
}
let cnt = await ActivityJoinModel.count(where);


// 用户列表
where = {
ACTIVITY_JOIN_ACTIVITY_ID: id,
ACTIVITY_JOIN_STATUS: ActivityJoinModel.STATUS.SUCC
}
let joinParams = {
from: UserModel.CL,
localField: 'ACTIVITY_JOIN_USER_ID',
foreignField: 'USER_MINI_OPENID',
as: 'user',
};
let orderBy = {
ACTIVITY_JOIN_ADD_TIME: 'desc'
}
let list = await ActivityJoinModel.getListJoin(joinParams, where, 'ACTIVITY_JOIN_ADD_TIME,user.USER_MINI_OPENID,user.USER_NAME,user.USER_PIC', orderBy, 1, 6, false, 0);
list = list.list;

for (let k = 0; k < list.length; k++) {
list[k] = list[k].user;
}

await ActivityModel.edit(id, { ACTIVITY_JOIN_CNT: cnt, ACTIVITY_USER_LIST: list });
}

/**  报名前获取关键信息 */
async detailForActivityJoin(userId, activityId) {
let fields = 'ACTIVITY_JOIN_FORMS, ACTIVITY_TITLE';

let where = {
_id: activityId,
ACTIVITY_STATUS: ActivityModel.STATUS.COMM
}
let activity = await ActivityModel.getOne(where, fields);
if (!activity)
this.AppError('该活动不存在');


// 取出本人最近一次的填写表单

let whereMy = {
ACTIVITY_JOIN_USER_ID: userId,
}
let orderByMy = {
ACTIVITY_JOIN_ADD_TIME: 'desc'
}
let joinMy = await ActivityJoinModel.getOne(whereMy, 'ACTIVITY_JOIN_FORMS', orderByMy);


let myForms = joinMy ? joinMy.ACTIVITY_JOIN_FORMS : [];
activity.myForms = myForms;

return activity;
}

/** 取消我的报名 只有成功和待审核可以取消 取消即为删除记录 */
async cancelMyActivityJoin(userId, activityJoinId) {
let where = {
ACTIVITY_JOIN_USER_ID: userId,
_id: activityJoinId,
ACTIVITY_JOIN_STATUS: ['in', [ActivityJoinModel.STATUS.WAIT, ActivityJoinModel.STATUS.SUCC]]
};
let activityJoin = await ActivityJoinModel.getOne(where);

if (!activityJoin) {
this.AppError('未找到可取消的报名记录');
}

if (activityJoin.ACTIVITY_JOIN_IS_CHECKIN == 1)
this.AppError('该活动已经签到,无法取消');

let activity = await ActivityModel.getOne(activityJoin.ACTIVITY_JOIN_ACTIVITY_ID);
if (!activity)
this.AppError('该活动不存在');

if (activity.ACTIVITY_END <= this._timestamp)
this.AppError('该活动已经结束,无法取消');

if (activity.ACTIVITY_CANCEL_SET == 0)
this.AppError('该活动不能取消');

if (activity.ACTIVITY_CANCEL_SET == 2 && activity.ACTIVITY_STOP < this._timestamp)
this.AppError('该活动已经截止报名,不能取消');

await ActivityJoinModel.del(where);

// 统计
await this.statActivityJoin(activityJoin.ACTIVITY_JOIN_ACTIVITY_ID);
}


/** 用户自助签到 */
async myJoinSelf(userId, activityId) {
let activity = await ActivityModel.getOne(activityId);
if (!activity)
this.AppError('活动不存在或者已经关闭');

let day = timeUtil.timestamp2Time(activity.ACTIVITY_START, 'Y-M-D');

let today = timeUtil.time('Y-M-D');
if (day != today)
this.AppError('仅在活动当天可以签到,当前签到码的日期是' + day);

let whereSucc = {
ACTIVITY_JOIN_USER_ID: userId,
ACTIVITY_JOIN_STATUS: ActivityJoinModel.STATUS.SUCC
}
let cntSucc = await ActivityJoinModel.count(whereSucc);

let whereCheckin = {
ACTIVITY_JOIN_USER_ID: userId,
ACTIVITY_JOIN_IS_CHECKIN: 1,
ACTIVITY_JOIN_STATUS: ActivityJoinModel.STATUS.SUCC
}
let cntCheckin = await ActivityJoinModel.count(whereCheckin);

let ret = '';
if (cntSucc == 0) {
ret = '您没有本次活动报名成功的记录,请在「个人中心 - 我的活动报名」查看详情~';
} else if (cntSucc == cntCheckin) {
// 同一活动多次报名的情况
ret = '您已签到,无须重复签到,请在「个人中心 - 我的活动报名」查看详情~';
} else {
let where = {
ACTIVITY_JOIN_USER_ID: userId,
ACTIVITY_JOIN_IS_CHECKIN: 0,
ACTIVITY_JOIN_STATUS: ActivityJoinModel.STATUS.SUCC
}
let data = {
ACTIVITY_JOIN_IS_CHECKIN: 1,
ACTIVITY_JOIN_CHECKIN_TIME: this._timestamp,
}
await ActivityJoinModel.edit(where, data);
ret = '签到成功,请在「个人中心 - 我的活动报名」查看详情~'
}
return {
ret
};
}

/** 按天获取报名项目 */
async getActivityListByDay(day) {
let start = timeUtil.time2Timestamp(day);
let end = start + 86400 * 1000 - 1;
let where = {
ACTIVITY_STATUS: ActivityModel.STATUS.COMM,
//ACTIVITY_START: ['between', start, end], //for demo
};

let orderBy = {
'ACTIVITY_ORDER': 'asc',
'ACTIVITY_ADD_TIME': 'desc'
};

let fields = 'ACTIVITY_TITLE,ACTIVITY_START,ACTIVITY_OBJ.cover';

let list = await ActivityModel.getAll(where, fields, orderBy);

let retList = [];

for (let k = 0; k < list.length; k++) {

let node = {};
node.timeDesc = timeUtil.timestamp2Time(list[k].ACTIVITY_START, 'h:m');
node.title = list[k].ACTIVITY_TITLE;
node.pic = list[k].ACTIVITY_OBJ.cover[0];
node._id = list[k]._id;
retList.push(node);

}
return retList;
}

/**
 * 获取从某天开始可报名的日期
 * @param {*} fromDay  日期 Y-M-D
 */
async getActivityHasDaysFromDay(fromDay) {
let where = {
ACTIVITY_START: ['>=', timeUtil.time2Timestamp(fromDay)],
};

let fields = 'ACTIVITY_START';
let list = await ActivityModel.getAllBig(where, fields);

let retList = [];
for (let k = 0; k < list.length; k++) {
let day = timeUtil.timestamp2Time(list[k].ACTIVITY_START, 'Y-M-D');
if (!retList.includes(day)) retList.push(day);
}

return [timeUtil.time('Y-M-D'), timeUtil.time('Y-M-D', 86400), timeUtil.time('Y-M-D', 86400 * 2), timeUtil.time('Y-M-D', 86400 * 3), timeUtil.time('Y-M-D', 86400 * 4), timeUtil.time('Y-M-D', 86400 * 5), timeUtil.time('Y-M-D', 86400 * 6)]; //for demo
return retList;
}


}

UI设计

在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述

后台管理系统设计

在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述

git代码下载

点击下载

【代码随想录刷题总结】leetcode24-两两交换链表中的节点

引言

大家好啊,我是前端拿破轮😁。

跟着卡哥学算法有一段时间了,通过代码随想录的学习,受益匪浅,首先向卡哥致敬🫡。

但是在学习过程中我也发现了一些问题,很多当时理解了并且AC的题目过一段时间就又忘记了,或者不能完美的写出来。根据费曼学习法,光有输入的知识掌握的是不够牢靠的,所以我决定按照代码随想录的顺序,输出自己的刷题总结和思考。同时,由于以前学习过程使用的是JavaScript,而在2025年的今天,TypeScript几乎成了必备项,所以本专题内容也将使用TypeScript,来巩固自己的TypeScript语言能力。

题目信息

两两交换链表中的节点

leetcode题目链接

给你一个链表,两两交换其中相邻的节点,并返回交换后链表的头节点。你必须在不修改节点内部的值的情况下完成本题(即,只能进行节点交换)。

题目分析

本题考查对链表的基本操作,两两交换链表中的节点,这里要注意的是,我们要进行的是节点的交换,而不是节点的值的交换。不能通过修改节点的值来完成,而是需要通过调整节点间指针的指向关系,让相邻的节点互换。

对于本题,同样有递归和迭代两种实现方式。两种方式的优缺点可以参考上一期内容【代码随想录刷题总结】leetcode206-反转链表

题解

迭代法

对于迭代法,由于在过程中会涉及到对头结点位置的移动,所以使用虚拟头结点来保证对节点操作的一致性。

迭代法通常需要使用指针进行遍历,需要牢记一点,在单链表中,要想操作某一个节点,当前指针必须指向其前一个节点

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

function swapPairs(head: ListNode | null): ListNode | null {
    // 剪枝
    if (!head || !head.next) return head;

    const dummy = new ListNode(0, head);

    // 当前指针
    let cur = dummy;
    while (cur && cur.next && cur.next.next) {
        const node1 = cur.next;
        const node2 = cur.next.next;

        // 改变指向
        node1.next = node2.next;
        node2.next = node1;
        cur.next = node2;

        // 移动指针
        cur = node1;
    }
    return dummy.next;
};

时间复杂度:O(n),其中n为链表的节点数量。

空间复杂度:O(1),只使用了常数个指针。

递归法

由于链表的定义具有递归性,所以本题也可以使用递归的方式来解决。

递归三部曲:

  1. 确定参数和返回值:function swapPairs(head: ListNode | null): ListNode | null
  2. 确定终止条件:当head或者head.next为null时,返回head
  3. 确定单层递归逻辑:在每一层中先将head.next.next为首的链表进行两两交换,然后再将headhead.next进行交换,并返回交换后的链表头。
/**
 * Definition for singly-linked list.
 * class ListNode {
 *     val: number
 *     next: ListNode | null
 *     constructor(val?: number, next?: ListNode | null) {
 *         this.val = (val===undefined ? 0 : val)
 *         this.next = (next===undefined ? null : next)
 *     }
 * }
 */

function swapPairs(head: ListNode | null): ListNode | null {
    // 终止条件
    if (!head || !head.next) return head;

    const newList = swapPairs(head.next.next);

    // 交换前两个
    const next = head.next;

    head.next.next = head;
    head.next = newList;
    return next;
};

时间复杂度:O(n)

空间复杂度:O(n),因为递归法有递归调用栈,深度为n

总结

两两交换链表中的节点,同样有迭代和递归两种方式。迭代的空间复杂度更优,而且更容易理解,但是代码较冗长。在使用迭代法的时候要注意使用虚拟头节点。

递归法的空间复杂度较大,且理解略显困难,但是代码简洁精炼。对于超大规模问题,还是可能会导致栈溢出。

往期推荐✨✨✨

我是前端拿破轮,关注我,和您分享前端知识,我们下期见!

面试官必问:如何 Debug 一个 Vite 插件

经常遇到打包编译一分钟的情况,这时候怎么办,到底是哪个插件慢,哪个插件报错。高级工程师经常要解决问题。本文会详细介绍如何 Debug 一个 vite 插件的思路。

当然你也可以使用这个成型的插件。开箱即用

github.com/lbb00/speed…

回到正题,Vite 打包的流程是会先解析 Plugins,按插件排列依次顺序读取配置,然后依次执行插件中的不同钩子。

image.png

可以理解 Vite 或是 Webpack 都是一边一边运行相应的钩子函数

如何定位

根据上面的流程,我们可以通过穿插一个 Debug 插件来查看到底是哪卡住了。先用 LLM 写一个钩子函数

function createDebugPlugin(name) {
  return {
    name: `vite-plugin-debug-${name}`,
    enforce: undefined, // 可以是 'pre', 'post' 或 undefined
    
    // 配置解析完成后
    configResolved(config) {
      console.log(`[${name}] configResolved - 配置已解析`);
    },
    
    // 构建开始
    buildStart(options) {
      console.log(`[${name}] buildStart - 构建开始`);
    },
    
    // 解析 ID 
    resolveId(source, importer, options) {
      // 只记录特定模块的解析
      if (source.includes('vite') || source.includes('vue')) {
        console.log(`[${name}] resolveId: ${source}`);
      }
      return null;
    },
    
    // 加载模块
    load(id) {
      // 只记录特定模

然后每个插件都插入一个,这样你就知道哪一个有问题。比如,

plugins: [
    createDebugPlugin('after-mkcert'),
    after-mkcert(),
    createDebugPlugin('vue'),
    vue()
]

汇总信息

这里有个知识点 enfore 属性,可以保证这个在最后一个执行。上 llm

// 创建一个专门用于跟踪插件完成情况的插件
function createFinalPlugin() {
  return {
    name: 'vite-plugin-final-tracker',
    enforce: 'post', // 确保最后执行
    
    configResolved(config) {
      console.log(`[最终插件] 所有配置已解析`);
      // 记录所有注册的插件名称
      const pluginNames = config.plugins.map(p => p.name).join(', ');
      console.log(`[最终插件] 已注册的插件: ${pluginNames}`);
    },
    
    buildStart() {
      console.log(`[最终插件] 所有插件已初始化,构建开始`);
    },
    
    buildEnd(error) {
      if (error) {
        console.error(`[最终插件] 构建失败,错误信息:`, error);
      } else {
        console.log(`[最终插件] 所有插件已执行完毕,构建结束`);
      }
    },
    
    closeBundle() {
      console.log(`[最终插件] 整个构建过程已完成`);
    }
  };
}

错误嵌套

这是一个常用方法,如果你会好奇 apply 函数什么时候才能用到。这就是一个很好的场景

// 包装现有插件以添加错误处理
function wrapPluginWithErrorHandler(plugin, pluginName) {
  const originalHooks = { ...plugin };
  const wrappedPlugin = {
    ...plugin,
    name: plugin.name || pluginName,
  };
  
  // 包装所有钩子添加错误处理
  for (const hookName of Object.keys(plugin)) {
    if (typeof plugin[hookName] === 'function' && hookName !== 'name') {
      wrappedPlugin[hookName] = async function(...args) {
        try {
          return await originalHooks[hookName].apply(this, args);
        } catch (error) {
          console.error(`[错误] 插件 ${pluginName}${hookName} 钩子中出错:`, error);
          throw error; // 继续抛出错误以便 Vite 处理
        }
      };
    }
  }
  
  return wrappedPlugin;
}

🧠 打造一个智能交互式文本对比编辑器 —— 基于 Monaco Editor 的完整实现

🧠 打造一个智能交互式文本对比编辑器 —— 基于 Monaco Editor 的完整实现

📌 项目简介

在代码评审、内容协同编辑、版本对比等场景中,“查看差异、快速合并、更智能地对内容进行提问或修改” 是一个常见的需求。

本项目基于 Monaco Editor 打造了一个高度交互的编辑器,具备以下能力:

  • ✅ 支持左右文本差异比对(diff)
  • 🎨 高亮新增 / 删除行
  • 🔀 差异段落可一键合并或拒绝
  • 🧠 对任意选中内容进行提问(弹出输入框)
  • 🔄 支持撤销/重做差异合并操作
  • 💡 美观、响应式、可交互性强

🧩 技术栈

技术 用途
monaco-editor 核心编辑器能力
monaco.ViewZone 自定义悬浮区域插入
model.deltaDecorations 实现行高亮样式
HTML + CSS 完善界面交互和视觉美化
JavaScript 控制交互逻辑和差异处理流程

🧱 核心功能拆解

1️⃣ Monaco 编辑器初始化

  • 使用 createEditor 创建编辑器实例
  • 监听 onDidChangeModelContent 捕捉内容变更
  • 使用 createViewZone 为选中内容添加悬浮按钮区域
editorRef.current = monaco.editor.create(...);
editorRef.current.onDidChangeModelContent(() => {
  const text = editorRef.current.getValue();
  // 更新数据状态
});

2️⃣ 文本差异分析与分组(Diff Grouping)

通过自定义 groupDiffs() 函数,将对比内容分为:

  • unchanged:未变更行
  • change:修改行(添加 + 删除)
  • added:新增内容
  • removed:删除内容
function groupDiffs() {
  const groups = [];
  for (let i = 0; i < rawDiff.length; i++) {
    const current = rawDiff[i];
    const next = rawDiff[i + 1];
    if (current.added && next?.removed) {
      groups.push({ type: 'change', added: current.value, removed: next.value });
      i++;
    } else if (current.removed) {
      groups.push({ type: 'removed', value: current.value });
    } else if (current.added) {
      groups.push({ type: 'added', value: current.value });
    } else {
      groups.push({ type: 'unchanged', value: current.value });
    }
  }
  return groups;
}

3️⃣ 差异区域渲染与样式高亮

  • 使用 editor.deltaDecorations() 对差异段落进行蓝色 / 红色渐变高亮。
  • 同时创建合并按钮悬浮区域。
decorationsRef.current = editorRef.current.deltaDecorations([], decorations);

4️⃣ 差异处理:合并 / 拒绝 + 撤销操作

为每个差异段创建按钮:

  • Accept 按钮:将改动写入 original
  • Reject 按钮:保留原始内容
  • 🔁 所有操作 push 到 historyRef 中以支持撤销
historyRef.current.push({
  type: 'merge',
  original,
  updated,
});

5️⃣ 智能提问:选中内容 → 弹出输入框

  • 监听 onDidChangeCursorSelection
  • 动态创建 ViewZone 区域
  • 在该区域中注入提问按钮与输入框,支持确认 / 取消行为

🎨 样式美化(CSS 特性)

样式文件非常丰富,仅列出部分关键亮点:

  • 差异行样式(蓝色渐变新增、红色渐变删除)
  • 合并按钮(绿色渐变)、拒绝按钮(红色渐变)
  • 选中内容按钮为圆形渐变、带光泽动效
  • 提问输入框支持玻璃拟态、聚焦高亮
  • 所有交互组件均具备 hover / active 动效
/* 差异新增高亮 */
.diff-line-added {
  background: linear-gradient(90deg,
      rgba(100, 237, 255, 0.25) 0%,
      rgba(100, 237, 255, 0.15) 50%,
      rgba(100, 237, 255, 0.1) 100%) !important;
  border-left: 2px solid rgba(100, 237, 255, 1) !important;
}

/* 删除行样式 */
.remove-line {
  background: linear-gradient(90deg,
      rgba(255, 107, 107, 0.2) 0%,
      rgba(255, 107, 107, 0.1) 50%,
      rgba(255, 107, 107, 0.05) 100%) !important;
  border-left: 2px solid #ff6b6b !important;
  color: #d32f2f !important;
}

🖼 效果预览

以下为页面示意:

  • Monaco 编辑器高亮差异内容
  • 每段差异右侧有「合并」「拒绝」按钮
  • 选中文字后浮出按钮可输入自定义问题

image.png

image.png

image.png

最后ps:小弟写不来文章全靠ai帮我写的🤔

Vite 代码降级完全指南:从build.target到自动化 Polyfill

前言

在前端开发中,浏览器兼容性是一个绕不开的话题。之前我曾探讨过如何用 Vite 解决低版本浏览器的白屏问题,但方案尚不完善。本文将以一个实际问题为切入点,深入剖析 Vite 项目中代码降级的两种核心方式——语法转译API Polyfill,并提供一套精准、自动化的终极解决方案。

一个实际问题:Object.hasOwn 引发的兼容性报错

有一次我的在开发微信H5页面应用的时候,我引入ky这个请求库,然后我就发现,即便是最新的微信开发者工具,打开还是会报错:

Uncaught TypeError: Object.hasOwn is not a function

Object.hasOwn 是 ES2022 引入的新 API,显然,目标环境的 JavaScript 引擎并不支持它。当时,由于缺乏成熟的 Vite + Babel 插件方案,我采用了一个“快速修复”的办法:手动引入 core-js 中对应的 Polyfill。

先安装core-js

pnpm add core-js

然后在入口文件main.ts中引入:

import "core-js/actual/object/has-own";

注意: 这个引入必须放在最开头

这个手动操作虽然解决了燃眉之急,但它暴露了一个更深层次的问题:在 Vite 项目中,我们应该如何系统性地处理这类兼容性需求?

vite的build.target配置

在vite的构建选项中有一个target选项,用于表示构建目标的浏览器兼容性,不同时期的vite版本,这个选项的默认值也不同。

  • vite5版本,默认值为:'modules',表示只兼容到:['es2020', 'edge88', 'firefox78', 'chrome87', 'safari14']版本的浏览器。
  • vite6版本(v7.0),默认值为:'baseline-widely-available',表示只兼容到:['chrome107', 'edge107', 'firefox104', 'safari16']版本的浏览器。
  • 除了默认值还有一个特殊值:'esnext' —— 即假设有原生动态导入支持,并只执行最低限度的转译。

由于vite在build的时候使用的是esbuild进行编译,所以它还可以设置为esbuild的target值,为此官方还提供了esbuild的文档:target

阅读文档可以发现,esbuild的target选项可以设置的有:

  1. 具体的JavaScript 语言版本/环境,比如:

    • esnext
    • es2015/es6
    • es2016
    • es2017
    • es2018
    • es2019
    • es2020
    • es2021
    • es2022
    • es2023
    • ...
  2. 具体的运行平台和特定版本,比如:

    • chrome(如 chrome58 或 chrome58.0.3029)
    • edge(如 edge16)
    • firefox(如 firefox57)
    • safari(如 safari11、ios11)
    • node(如 node12)
    • opera(如 opera45)
  3. 支持同时指定多个 target:

    esbuild.build({
      target: ['chrome58', 'firefox57', 'safari11', 'edge16'],
    })
    
  4. 默认值:

    • esnext:默认值,表示使用最新版本的 JavaScript 语法,并使用原生动态导入支持。

通过配置选项的值,我们会发现esbuild其实是没有对低于ES2015的版本做兼容的,如果我们想兼容到es5的版本,esbuild在文档中有提到:如果你使用了尚未支持转换的语法功能,它会在不支持的语法上抛出错误。

代码降级的两种方式

但是问题来了,当我将target设置为es2015的时候,build打包后,ky插件还是会报错:Object.hasOwn is not a function,这是为什么?

这里我们就需要了解下代码降级的两种方式:

  1. 代码转译(Syntax Transpilation)。
  2. API填充(Polyfilling)。

代码转译(Syntax Transpilation)

全称是Syntax Transpilation,是指将高版本的JavaScript语法转换为低版本浏览器能理解的等效语法。

例子

  • 可选链 (?.): a?.b -> a === null || a === void 0 ? void 0 : a.b
  • 箭头函数 (=>): () => {} -> function() {}
  • const/let: const a = 1; -> var a = 1;

API填充(Polyfilling)

中文经常翻译成垫片,是指在低版本浏览器中,提供供它们原生缺失的函数或对象的实现。

例子

  • Promise: IE11 没有 Promise 对象,需要提供一个 Promise 的实现。
  • Array.prototype.flat(): 旧版浏览器没有这个数组方法。
  • Object.hasOwn(): 这是一个 ES2022 的新静态方法,es2015 标准的浏览器环境里根本不存在这个函数。

esbuild 的职责范围

Vite 在构建时默认使用 esbuild 进行转译。esbuild 官方文档明确指出(target):

Note that this is only concerned with syntax features, not APIs. It does not automatically add polyfills for new APIs that are not used by these environments. You will have to explicitly import polyfills for the APIs you need (e.g. by importing core-js). Automatic polyfill injection is outside of esbuild's scope.

中文翻译:

请注意,这仅涉及语法功能,而不是 API。它不会自动添加未使用这些环境的 API 的 polyfills。您必须显式导入您需要的 API 的 polyfills(例如,通过导入 core-js )。自动 polyfill 注入超出了 esbuild 的范围。

也就是说,esbuild并不会自动为低版本浏览器添加 polyfills,需要我们手动导入 polyfills。它只做代码转译,而且只会转译ES6版本的语法,ES5语法的降级是不支持的。

为什么ky插件会报错?

我们再回过头来分析问题,为什么报错Object.hasOwn is not a function,就是因为这是浏览器API的缺失,而不是通过转换语法就能解决的。

所以esbuild只会原样输出该代码,而不会做任何降级操作,从而导致在微信开发者工具中报错。

结论:build.target 只解决了“语法看不懂”的问题,没有解决“函数不存在”的问题。

@vitejs/plugin-legacy插件

既然 esbuild 不行,那么官方推荐的 @vitejs/plugin-legacy 插件能否成为“银弹”?它的文档声称能“自动生成传统版本的 chunk 及与其相对应 ES 语言特性方面的 polyfill”。听起来很完美。

  1. 安装插件

    pnpm add @vitejs/plugin-legacy terser -D
    

    terser是插件文档要求安装的依赖,用于压缩代码。

  2. 配置插件

    import { defineConfig } from "vite";
    import legacy from "@vitejs/plugin-legacy";
    
    export default defineConfig({
      plugins: [
        legacy({
          targets: [
            "last 2 versions",
            "safari >= 11",
            "chrome >= 58",
            "firefox >= 54",
            "edge >= 16",
          ],
        }),
      ],
      build: {
        target: "es2015",
        // 为了方便查看,我们关闭代码压缩
        minify: false,
      },
    });
    

    targets我配置了一个es2015的浏览器兼容性,这样就可以测试到ky插件的报错问题。

  3. 运行build进行构建

    由于vite在server阶段,是不会使用legacybuild.target的配置,所以我们需要使用打包命令来测试效果。

    构建完成后,我们会发现原来一个的js文件现在变成了好几个:

    • index-DAY4N0oU.js
    • index-legacy-DHDPLbax.js
    • polyfills-legacy-c_wdFKuV.js

    可以看到确实有polyfills参与,但是你得看清楚,它是带legacy的。

  4. 测试

    我们通过vite自带的preview命令来预览dist文件,在微信开发者工具打开网页,可以看到还是报错:

    index-DAY4N0oU.js:288 Uncaught TypeError: Object.hasOwn is not a function
    

    报错

    html结构

    可以看到,明明html结构是正常的,有type="module"的script标签,也有nomodule的script标签,为什么还是报错呢?

    答案很简单,就是微信开发者工具是支持type="module"的script标签的,所以它不会加载nomodule的script标签,导致代码降级失败。

    从network中加载的js文件就可以发现问题:

    network

    所以@vitejs/plugin-legacy插件并不是解决问题的“银弹”,它解决的是在不支持type="module"的浏览器中,增加了兼容方案,并且它对低版本的浏览器做了两种降级处理,也就是上面所说的两种方式:代码转译和API填充。

    但是对于支持type="module"的浏览器,它只会用到esbuild的代码转译,没有API填充,所以Object.hasOwn方法还是不存在,还是会报错。

  5. 查看legacy文件

    我们打开legacy文件,可以看到它是如何做代码降级的:

    // `HasOwnProperty` abstract operation
    // https://tc39.es/ecma262/#sec-hasownproperty
    // eslint-disable-next-line es/no-object-hasown -- safe
    hasOwnProperty_1 = Object.hasOwn || function hasOwn(it, key) {
      return hasOwnProperty(toObject(it), key);
    };
    

    这里只截取了部分代码,但是可以看到,它是通过判断是否存在Object.hasOwn方法来决定是否使用polyfill,如果不存在,则使用hasOwnProperty方法来判断。而hasOwnProperty是ES3的标准,可以放心使用。

    在新版本的浏览器中,推荐使用Object.hasOwn,原因是因为hasOwnProperty是原型链上的方法,是可以被重写的,而Object.hasOwn是静态方法,不会被重写,更加安全。

  6. 问题分析

究其原因是因为@vitejs/plugin-legacy插件默认只会处理不支持type="module"的浏览器,所以它只会在legacy bundle中引入polyfill,而不会在现代 bundle中引入polyfill。

我们需要让它在现代 bundle中引入polyfill,也就是type="module"的浏览器。

临时方案:手动引入polyfill

首先我们需要安装core-js

pnpm add core-js
  1. 精确引入,我知道我的项目中只用到了Object.hasOwn,所以我们只需要在入口文件引入它的polyfill。

    import "core-js/actual/object/has-own";
    
  2. 半手动,引入一个特性集合。

    import "core-js/actual/promise"; // 这会引入 Promise, Promise.all, .race, .resolve, etc.
    
  3. 全手动,引入所有polyfill。

    import "core-js/stable"; // 引入所有 polyfill
    

手动引入 core-js 显然不是长久之计,我们无法预知所有依赖库使用了哪些新 API。幸运的是,@vitejs/plugin-legacy 提供了更精细的配置项,让我们能够优雅地解决这个问题。

使用 plugin-legacy 实现自动化 Polyfill

关键配置项:

  • modernPolyfills: true: 核心开关。当设为 true 时,插件会为现代版本的 chunk 也生成一个独立的 Polyfill 文件,并自动引入。
  • modernTargets: 指定现代浏览器的目标环境。
import { defineConfig } from "vite";
import legacy from "@vitejs/plugin-legacy";

export default defineConfig({
  plugins: [
    legacy({
      modernTargets: [
        "last 2 versions",
        "safari >= 11",
        "chrome >= 58",
        "firefox >= 54",
        "edge >= 16",
      ],
      modernPolyfills: true,
    }),
  ],
  build: {
    target: "es2015",
    // 为了方便查看,我们关闭代码压缩
    minify: false,
  },
});

这样我们build后,现代的js文件中会自动引入polyfill。

除了这样其实还有更多配置项,比如:

  • additionalLegacyPolyfills: 添加自定义的polyfill到legacy bundle中。
  • additionalModernPolyfills: 添加自定义的polyfill到现代 bundle中。
  • renderLegacyChunks:设置为 false 以禁用legacy的生成。

如果我们只需要兼容现代浏览器,也就是支持type="module"的浏览器,我们可以将renderLegacyChunks设置为false,这样可以不生成legacy bundle,减少打包体积。

import { defineConfig } from "vite";
import legacy from "@vitejs/plugin-legacy";

export default defineConfig({
  plugins: [
    legacy({
      renderLegacyChunks: false,
      modernTargets: [
        "last 2 versions",
        "safari >= 11",
        "chrome >= 58",
        "firefox >= 54",
        "edge >= 16",
      ],
      modernPolyfills: true,
    }),
  ],
  build: {
    target: "es2015",
    // 为了方便查看,我们关闭代码压缩
    minify: false,
  },
});

这个配置只会生成现代代码和对应的 Polyfill,既解决了 API 兼容性问题,又保持了代码体积的精简。

总结

为 Vite 项目实现可靠的代码降级,需要遵循以下思路:

  1. 明确降级目标:你的应用需要兼容到哪个程度?是仅支持 ES 模块的浏览器,还是需要兼容 IE11?
  2. 区分转译和 Polyfill
    • 使用 build.target 来处理 JavaScript 语法的向后兼容。
    • 使用 Polyfill 来解决 JavaScript API 的缺失问题。
  3. 善用 @vitejs/plugin-legacy
    • 不要停留在它的默认功能,那只为最古老的浏览器服务。
    • 开启 modernPolyfills: true,为所有支持 ES 模块的浏览器提供按需 Polyfill,这是解决像 Object.hasOwn 这类问题的最佳实践
    • 如果不需要支持古老浏览器,设置 renderLegacyChunks: false 来优化构建产物。

从url输入到页面渲染(二):解析和渲染过程详解

上回书说到,彼时的页面,上有解析连天网,下有TCP断连诀,浏览器接收HTML,欲给页面画上朗朗乾坤,传说,浏览器在解析之时,曾言道...

注:本文为上一篇《从url输入到页面渲染:深挖浏览器渲染的完整过程》中,“浏览器解析与渲染页面”内容部分的详细解读,上一篇文章对本文内容无影响,可放心食用。

我获取内容,并以七步完成渲染:

第一步:解析HTML(HTML Parser),构建HTML树。

第二步:解析CSS(CSS Parser),构建CSSOM树。

第三步:合成渲染树(Render Tree)

第四步:布局(Layout)

第五步:分层(Layering)

第六步:绘制(Painting)

第七步:合成显示(Display)

过程详解如图所示:

ae7aa55c42430c2139c5db346e112a57.png

接下来,是对于每一个步骤的详细分析:

第一步:解析HTML,构建DOM树

过程详解

请先看图:

屏幕截图 2025-06-26 203838.png

当用户输入URL后,浏览器通过网络请求获取HTML文件(相关内容详解文章,点我打开链接)。

此时,HTML文件本质上是一段字节流,浏览器会根据HTTP头中的Content-Type(如text/html)和编码格式(如UTF-8)将其解码为可读的HTML字符串。

HTML解析器(HTML Parser)开始工作,将字符串解析为DOM树。解析过程中,浏览器会按以下步骤处理:

  1. 令牌化(Tokenization) :通过正则匹配标签名、属性、注释等内容,将HTML字符串拆分为一个个“令牌”(Token)。

    例如: <div id="box"></div> 会被拆分为 <div> 、id="box" 、</div> 三个令牌。
    
  2. 节点构建(Tree Construction) :根据令牌生成对应的DOM节点对象。

    每个节点包含类型(如div)、属性(如id: "box")和子节点,最终不断递归,形成一个树状结构,称为DOM树

  3. 阻塞行为:解析HTML时,若遇到<script>标签,浏览器会暂停解析,优先执行脚本(除非脚本添加了asyncdefer属性)。

关键点与优化

  • DOM树的高效性:DOM树以树形结构存储数据,支持快速查找和操作(如querySelector)。

  • 性能优化:减少HTML文件大小(压缩空白符、合并资源)、避免深层嵌套结构,可加速解析。

DOM节点内存结构

以HTML片段<div id="box"><p>Hello</p></div>为例,DOM树的存储形式如下:

{
  nodeType: 1,
  nodeName: "DIV",
  nodeValue: null,
  attributes: { id: "box" },
  childNodes: [
        { 
          nodeType: 1,
          nodeName: "P",
          nodeValue: null,
          attributes: {},
          childNodes: [
                { 
                  nodeType: 3,
                  nodeName: "#text",
                  nodeValue: "Hello"
                }
          ],
          parentNode: [指向div节点]
        }
  ],
  parentNode: null                        
}

第二步:解析CSS,构建CSSOM树

过程详解

请先看图

屏幕截图 2025-06-26 205203.png

浏览器在解析HTML时,会同时下载CSS文件(通过<link>标签)。CSS文件同样需要经过解析,生成CSSOM树(CSS Object Model Tree)。

解析过程如下:

  1. 下载与解码:浏览器根据Content-Type(如text/css)和编码格式(如UTF-8)解码CSS文本。

  2. 词法分析(Lexical Analysis) :将CSS文本拆分为令牌(如选择器、属性值)。

  3. 规则生成:将令牌转换为CSS规则对象(如div { color: red })。

  4. 构建CSSOM树:将规则按选择器优先级(如!important、ID选择器、类选择器)和继承关系组合成树状结构。

关键点与优化

  • CSSOM树的特性:样式规则存在继承和层叠(如color: red继承至子元素),最终通过优先级计算确定每个元素的样式。

  • 阻塞渲染:CSS解析会阻塞HTML解析和渲染(因为浏览器需要知道所有样式才能确定布局)。

  • 优化建议

    • 将关键CSS内联到HTML中,减少请求。
    • 使用media属性延迟加载非关键CSS(如<link rel="stylesheet" media="print">)。

CSSOM节点内存结构

以CSS片段div.box { color: red }为例,其内存存储如下:

// CSS规则对象
{
  selector: "div.box", // 选择器
  specificity: [0, 1, 1], // 优先级计算(ID:0, 类:1, 元素:1)
  declarations: {
    color: "red", // 样式属性
    ... // 其他属性
  },
  next: null // 链表指针,指向下一个规则
}

第三步:合成渲染树(Render Tree)

过程详解

同样先看图再说:

a496de9fb92aae11b689a4194199328f.png

DOM树和CSSOM树构建完成后,浏览器会将两者合并生成渲染树(Render Tree)。渲染树仅包含需要渲染的节点(如<div><span>),忽略不可见元素(如display: none的节点)。

合成过程如下:

  1. 匹配规则:将DOM节点与CSSOM规则匹配,确定每个节点的最终样式。

  2. 生成渲染节点:每个可见节点生成对应的渲染节点(Render Object),包含几何信息(如位置、大小)和样式属性。

  3. 层级关系:渲染树的结构与DOM树类似,但会根据z-indexposition等属性调整节点的层级。

关键点与优化

  • 渲染树的轻量化:渲染树仅包含可见元素,减少计算开销。
  • 性能影响:频繁修改DOM或CSSOM会导致渲染树频繁重建,触发重排(Reflow)。

第四步:布局(Layout)

过程详解

布局阶段(也称重排)的目标是计算每个元素在屏幕上的精确位置和大小。

浏览器会遍历渲染树,应用盒模型规则(如marginpaddingborder),并结合文档流(Normal Flow)和定位(如floatabsolute)确定元素的位置。

关键步骤:

  1. 根节点计算:从根节点(<html>)开始,计算视口大小。

  2. 递归布局:依次计算子元素的位置和大小。例如:

    div {
      width: 100px;
      height: 100px;
      margin: 10px;
    }
    

    浏览器会计算div的实际宽度为100px + 10px*2 = 120px

  3. BFC与IFC:通过块级格式化上下文(BFC)和行内格式化上下文(IFC)管理元素的排列规则。

关键点与优化

  • 重排的代价:布局是性能开销最大的阶段之一,频繁触发会导致页面卡顿。

  • 优化建议

    • 避免频繁读取布局属性(如offsetWidth),因为这会强制触发同步重排。
    • 使用transformopacity进行动画,避免修改布局属性(如leftwidth)。

第五步:分层(Layering)

过程详解

分层阶段的目的是将页面划分为多个独立的图层(Layer),每个图层可以独立渲染,提升性能。浏览器会根据以下条件创建新图层:

  1. 透明元素(如opacity < 1)。
  2. 3D变换(如transform: translateZ(0))。
  3. 固定/绝对定位(如position: fixed)。
  4. z-index非0的堆叠上下文

每个图层会生成一个独立的合成树(Composited Layer Tree),包含图层的层级关系和绘制顺序。

以下面浏览器的主页为例,在你刚打开浏览器的时候,你看到的页面可能和我类似,是这个样子:

屏幕截图 2025-06-27 160123.png

然而,如果你打开浏览器的控制台页面(快捷键f12 或 鼠标右键点击检查),使用图层(layers)工具:

image.png

随后,你会看到,你看到的页面并不是一个平面图,而是由一个个小区快,通过布局一层层叠在页面上,最后才组成你所看到的页面:

屏幕截图 2025-06-27 160142.png

关键点与优化

  • GPU加速:独立图层由GPU加速绘制,减少CPU负担。
  • 性能平衡:过多图层会增加内存消耗,需合理使用will-changetransform属性。

第六步:绘制(Painting)

过程详解

绘制阶段将每个图层的内容转换为像素点,生成位图(Bitmap)。浏览器会遍历图层,按以下步骤绘制:

  1. 绘制顺序:从后往前绘制(遵循z-index和堆叠上下文规则)。
  2. 像素操作:将样式属性(如background-colorborder-radius)转换为像素点。
  3. 光栅化:将矢量图形(如SVG)转换为位图,供GPU使用。

关键点与优化

  • 重绘的代价:绘制操作涉及大量像素计算,频繁触发会导致性能下降。

  • 优化建议

    • 使用硬件加速(如transform)减少重绘。
    • 避免使用复杂的CSS效果(如大量渐变或阴影)。

第七步:合成显示(Display)

过程详解

合成显示是最终的渲染阶段,浏览器将所有图层的位图合并,生成最终的屏幕图像。过程如下:

  1. 图层合并:浏览器的合成线程(Compositor Thread)将图层按z-index顺序合并。

  2. GPU渲染:合并后的位图通过GPU发送到显示器,按帧率(如60Hz)刷新屏幕。

  3. 防闪烁处理:通过双缓冲(Double Buffering) 技术,避免绘制过程中的画面撕裂。

关键点与优化

  • 帧率控制:确保每帧绘制时间不超过16ms(60Hz屏幕)。

  • 动画优化:使用requestAnimationFrame确保动画与屏幕刷新率同步。

隐藏关卡:回流(Reflow)与重绘(Repaint)

过程详解

  1. 回流/重排(Reflow)
  • DOM树或CSSOM树发生变化,导致元素的几何属性(如位置、大小)改变时,浏览器需重新计算布局(Layout),这一过程称为回流

    触发回流后,浏览器会重新进行布局分层绘制合成显示 等步骤。

  • 触发条件

    • 修改元素的几何属性(如widthheightmarginpadding)。
    • 添加/删除可见的DOM节点。
    • 调整窗口大小(resize)或滚动页面(scroll)。
    • 读取某些布局属性(如offsetWidthgetBoundingClientRect),强制触发同步回流。
  1. 重绘(Repaint)
  • 元素的样式属性(如颜色、背景、透明度)改变但不影响布局时,浏览器需重新绘制像素,这一过程称为重绘

    触发重绘后,则仅需重新执行绘制合成显示 步骤,但仍需依赖布局和分层的结果。

  • 触发条件

    • 修改非几何属性(如colorbackground-colorvisibility)。
    • 激活伪类(如:hover)。

本文为作者的理解,如果内容有误,欢迎各位读者在评论区指正。

如果觉得这篇文章对你有所帮助,不妨动动小手,点赞 + 收藏 一波!🌟

99db98bccf5c6a316b4efb1f323da9a0.gif

新手必看,AI编程路上不可避免的Node管理

大家好,我是李想。

AI编程真的很火,但经常有朋友在问我Node是什么,经常看见运行这个项目需要Node环境,运行这个MCP又需要Node环境,问我怎么下载,下载哪个版本。

就比如新出的Gemini-cli,前置条件就是必须安装Node.js18的版本。

wechat_2025-06-27_151047_685.png

为了让小白更好的上手AI编程,今天我就给大家写一篇Node基础文章,并推荐一个Node的版本管理工具。

1.Node简介

那Node到底是什么?

wechat_2025-06-27_165300_332.png

官方介绍:Node.js 是一个利用高性能 V8 引擎在服务器端运行 JavaScript 的平台,其独特的事件驱动和非阻塞 I/O 设计使它成为构建高性能、可扩展网络应用的理想选择,并拥有极其丰富的 npm 生态系统支持。

其实不重要,你不需要掌握Node.js这门语言,只是因为有Node.js有着庞大的开源库生态系统 npm-它是世界上最大的软件注册中心,提供数百万个可重用的代码包(模块)

所以我们需要通过Node.js的 npm 去下载相关的依赖包(工具),这样我们才能更好的去接触AI编程,接触Github众多的项目。

2.下载安装

网址:nodejs.org/zh-cn

来到我们的官网,点击Install下载

图片.pngwechat_2025-06-27_165344_284.png

在这里你可以选择Node版本,你的系统,然后点击.msi开始下载

图片.pngwechat_2025-06-27_153704_231.png

下载完毕后运行msi文件,一直next。

图片.pngwechat_2025-06-27_153907_065.png

这里可以把Node安装到C盘以外的地方,然后一直next

wechat_2025-06-27_153957_706.png

这里点击Install就可以把Node安装到本地了。

图片.png

wechat_2025-06-27_154040_153.png

安装完毕后Win+R开启命令列,输入cmd打开终端。

图片.png

wechat_2025-06-27_154339_921.png

然后输入node -v和npm -v。

图片.pngwechat_2025-06-27_154520_986.png

成功显示版本信息就说明我们的Node安装成功了!

3.Node版本管理工具

给大家介绍了Node,实在有必要给大家说说Node的版本管理工具-nvm

网址:github.com/coreybutler…

wechat_2025-06-27_161112_751.png

为什么需要对Node版本进行管理呢?大家通过Node去下载项目依赖后会经常遇见项目启动报错,启动不起来。

很多时候就是因为Node版本的问题,因为有些项目他需要的Node版本可能是18。

有点项目又可能是22或者其他的,但是我们系统下载Node的版本只有一个,总不能跑一个相对就重新下一个Node版本吧。

这时候,Node版本管理工具nvm就很重要了,它可以下载多种node版本,在不同的项目中切换不同的Node版本,这样在下载项目的依赖就不会出错了!

网址:github.com/coreybutler…

来到我们的下载页面

图片.pngwechat_2025-06-27_161212_789.png

选择红框中的exe版本下载。在安装之前大家记得把之前下载的Node给卸载了,没安装过就不用管。

一直下一步,这里的路径记得不用使用中文。

图片.pngwechat_2025-06-27_161326_808.png

安装完毕后启动win+r,cmd启动命令输入nvm -v。

图片.png

wechat_2025-06-27_161600_008.png

成功显示就说明成功。

之后我们可以通过nvm install 来下载指定的Node版本,比如nvm install 18.20.7。

wechat_2025-06-27_162212_991.png

通过nvm list查看我们安装了哪些版本,比如我这里就显示了我下载了两个版本,*代表现在使用的是18.20.7版本。

图片.png

wechat_2025-06-27_162212_991.png

切换的时候同nvm use 23.0.0 就可以切换成功了。

图片.png

wechat_2025-06-27_162328_542.png

最后再附上命令一览表

命令 说明
nvm install <version> 安装指定版本的 Node.js
nvm install lts 安装最新的 LTS(长期支持)版本
nvm use <version> 切换使用指定的 Node.js 版本
nvm list 查看所有已安装的 Node.js 版本
nvm ls-remote 列出所有远程可用的 Node.js 版本
nvm uninstall <version> 卸载指定的 Node.js 版本
nvm alias default <version> 设置默认使用的 Node.js 版本
nvm current 显示当前正在使用的 Node.js 版本
nvm on 启用 nvm 版本管理功能
nvm off 禁用 nvm 版本管理功能
nvm version 查看当前安装的 nvm 版本
nvm proxy [url] 设置或查看下载代理服务器
nvm node_mirror [url] 设置或查看 Node.js 镜像源
nvm npm_mirror [url] 设置或查看 npm 镜像源
nvm reinstall-packages <ver> 将当前 npm 包重新安装到另一个 Node 版本
nvm list available 显示可供安装的所有版本(Windows 专用)
nvm root [path] 设置或查看 nvm 的安装路径
nvm cache dir 显示 nvm 的缓存目录路径
nvm cache clear 清空 nvm 的缓存

4.结语

今天的文章就到这里了,恭喜我们又掌握了编程路上的一个小知识。

图片.png

Nuxt.js:现代Web应用开发的终极选择

探索Nuxt.js构建现代Web应用的强大能力,了解为何它应该成为您下一个项目的首选框架

作为深耕Vue.js生态多年的开发者,我可以说见证Nuxt的演进是现代Web开发中最激动人心的旅程之一。初次接触Nuxt 2时,它已令人惊艳,而团队在Nuxt 3和即将发布的Nuxt 4上取得的成就,堪称革命性突破。

本文将分享为何我认为Nuxt.js已成为2025年Vue开发者的不二之选,更重要的是,它将如何彻底改变您的Web应用构建方式。

从优秀到卓越的进化之路

我仍清晰记得Nuxt 3之前构建Vue应用的痛点。Vue本身固然出色,但配置SSR、构建工具和路由管理等繁琐工作令人头疼。直到Nuxt 3带来全面架构革新,一切为之改变。

拥有超过55,000个GitHub星标,并被路易威登、NASA喷气推进实验室和GitLab等企业采用,Nuxt已证明它不仅是业余项目的选择,更是企业级解决方案。但最令我兴奋的不是这些知名用户,而是它如何让日常开发变得愉悦高效。

Nitro引擎:颠覆认知的突破

Nitro服务器引擎彻底改变了我们对全栈开发的认知。从传统VPS到边缘网络,Nitro应用的部署体验始终流畅如一。

令人惊叹的是5毫秒的冷启动时间——某些无服务器函数的启动时间甚至比整个Nuxt应用还长。配置简单得令人难以置信:

// nuxt.config.ts - 边缘部署只需这些配置
export default defineNuxtConfig({
  nitro: {
    preset: 'cloudflare-pages', // 一行代码实现边缘部署
    routeRules: {
      '/api/**': { cors: true, cache: { maxAge: 60 } },
      '/admin/**': { ssr: false }
    }
  }
})

创建API路由同样简单:

// server/api/users/[id].ts
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')
  
  if (!id) {
    throw createError({
      statusCode: 400,
      statusMessage: 'ID参数必填'
    })
  }
  
  return { user: await getUserById(id) }
})

过去需要数小时配置Express服务器和中间件的工作,现在只需在server/api目录创建文件即可完成。TypeScript类型推断自动完成,错误处理优雅简洁,部署轻松自如。

文件路由系统:约定优于配置的典范

Nuxt的路由设计最令人称道之处在于它完美契合开发者的思维模式。项目规划时的页面结构草图可直接转化为目录结构:

pages/
├── index.vue                   # 对应/
├── about.vue                   # 对应/about
├── blog/
│   ├── index.vue              # 对应/blog
│   ├── [slug].vue             # 对应/blog/:slug
│   └── [...comments].vue      # 对应/blog/*/comments/*
├── user-[id]/
│   └── profile.vue            # 对应/user-:id/profile
└── [[optional]].vue           # 对应/或/:optional

更强大的是可针对不同路由混合使用渲染策略:

export default defineNuxtConfig({
  routeRules: {
    '/': { prerender: true },           // 静态首页提升速度
    '/blog/**': { swr: 3600 },          // 博客使用智能缓存
    '/admin/**': { ssr: false },        // 管理后台采用SPA
    '/api/**': { cors: true }           // API配置
  }
})

传统方案需要多个应用或复杂配置才能实现的混合渲染策略,在Nuxt中只需几行配置。

自动导入:真正可用的"魔法"

初次听说自动导入功能时,我曾怀疑它会导致生产环境问题。但实际使用后,它确实在保证可靠性的前提下大幅提升了开发效率。

<script setup lang="ts">
// 无需手动导入!
const count = ref(0) // Vue响应式ref
const { data } = await useFetch('/api/data') // Nuxt组合式函数
const config = useRuntimeConfig() // 另一个Nuxt组合式函数

// 甚至components目录中的自定义组件也自动导入
</script>

<template>
  <div>
    <MyButton @click="increment">{{ count }}</MyButton>
    <FormsInputField v-model="email" name="email" />
  </div>
</template>

最棒的是完整的TypeScript支持。自动生成的类型定义让IDE无需手动维护导入语句就能识别所有内容,就像有位助手处理了所有繁琐工作,让开发者专注于功能实现。

智能渲染策略:告别单一模式

多年来最令人沮丧的是必须在SSR、SSG或SPA中做出全站统一选择。为何从不变化的营销页面要与高度动态的用户面板采用相同渲染方式?

Nuxt 3的混合渲染提供了务实解决方案:

export default defineNuxtConfig({
  routeRules: {
    // 营销页面:预渲染以获得最佳速度和SEO
    '/': { prerender: true },
    '/pricing': { prerender: true },
    
    // 博客:带降级的新鲜内容(SWR模式)
    '/blog/**': { swr: 3600 },
    
    // 用户面板:纯客户端实现丰富交互
    '/dashboard/**': { ssr: false },
    
    // API:正确的缓存和CORS配置
    '/api/**': { 
      cors: true,
      cache: { maxAge: 3600 }
    }
  }
})

每个路由都能获得最适合的渲染策略,如同拥有多个应用,却只需维护单一代码库。

零配置TypeScript体验

过去设置新项目的TypeScript总是令人畏惧——配置tsconfig.json、设置路径、确保与构建系统兼容等耗时工作。Nuxt彻底改变了这一状况。

零配置TypeScript意味着开箱即用的类型支持:

// server/api/users.get.ts - 自动类型推断
export default defineEventHandler(async (event) => {
  return { users: [{ id: 1, name: 'John' }] }
})

// 组件中数据自动类型为{ users: User[] }
const { data } = await $fetch('/api/users')

全栈类型推断令人印象深刻——API响应、路由参数甚至自动导入的组合函数都保持完整类型签名。这种开发体验让人难以回归其他框架。

无需专业知识的性能优化

曾经花费无数时间优化打包体积、实现代码分割和延迟加载。Nuxt自动处理了大部分工作,而需要精细控制时,工具同样直观易用:

<template>
  <!-- 按需加载 -->
  <LazyHeavyComponent v-if="showComponent" />
  
  <!-- 现代格式的优化图片 -->
  <NuxtImg
    src="/hero.jpg"
    alt="Hero image"
    width="800"
    height="600"
    format="webp"
    loading="lazy"
  />
  
  <!-- 自动生成srcset的响应式图片 -->
  <NuxtPicture
    src="/hero.jpg"
    format="avif,webp"
    sizes="sm:100vw md:50vw lg:400px"
  />
</template>

@nuxt/image模块就节省了大量配置时间,自动处理格式选择、响应式图片和CDN集成,无需手动生成srcset或调试WebP兼容性问题。

模块生态:站在巨人肩上

200多个可用模块意味着几乎所有需求都有现成解决方案。需要CMS?@nuxt/content可将Markdown转化为完整内容管理系统:

<!-- pages/blog/[...slug].vue -->
<template>
  <ContentDoc />
</template>

<script setup lang="ts">
// 如此简单就能将Markdown转化为功能完整的博客文章
// 包含语法高亮、组件嵌入等功能
</script>

认证需求?@sidebase/nuxt-auth提供完整方案:

// server/api/auth/[...].ts
export default NuxtAuthHandler({
  providers: [
    GithubProvider({
      clientId: process.env.GITHUB_CLIENT_ID,
      clientSecret: process.env.GITHUB_CLIENT_SECRET,
    })
  ]
})

这些模块使用起来如同框架原生功能,而非生硬的第三方扩展。

部署:分钟级上线体验

从Vercel、Netlify到Cloudflare Workers和传统VPS,Nuxt应用的部署体验始终出色:

# 边缘部署?一行命令
NITRO_PRESET=vercel-edge nuxt build

# 使用Cloudflare?同样一行
NITRO_PRESET=cloudflare-pages nuxt build

# 传统服务器?依然简单
NITRO_PRESET=node-server nuxt build

通用部署能力意味着可以从简单托管开始,无缝扩展到边缘网络,无需修改应用代码。

Nuxt vs Next.js:务实比较

这是常被问及的比较。基于两者构建生产应用的经验,我的客观评价如下:

|方面|Nuxt.js|Next.js| |学习曲线|更平缓直观|更陡峭但更灵活| |配置|基于约定,最少配置|更多选项,更复杂| |自动导入|组件、组合函数、工具全支持|有限自动导入| |TypeScript|零配置自动生成|需手动设置| |打包体积|通常更小|视实现而定|

选择Nuxt当您追求开发速度、偏好约定优于配置,且团队熟悉Vue时。选择Next.js当需要最大灵活性、具备React专长或需要深度第三方集成时。

对大多数Vue团队而言,Nuxt是显而易见的选择。

真实世界成功案例

从电商平台、SaaS应用到内容网站,Nuxt的应用模式始终如一:

  • 得益于约定和自动导入,开发周期更短
  • 通过智能渲染策略获得更好SEO表现
  • 框架自动处理大量工作,维护更轻松
  • 开发者能专注于功能而非配置,满意度更高

GitLab用其构建文档系统,BackMarket等电商平台用于店面展示,优化得当的项目可获得Lighthouse满分100分。

令人沉醉的开发体验

Nuxt DevTools彻底改变了游戏规则——实时可视化应用结构、审查组件和理解数据流使调试变得异常简单。结合Vite的热模块替换,反馈循环极其迅速。

真正让Nuxt脱颖而出的是它消除摩擦的方式——基于文件的路由、自动导入、零配置TypeScript、自动代码分割等不仅是功能,更是生产力倍增器。一旦习惯这种体验,手动配置就显得过时。

未来展望

Nuxt 4将于2025年第二季度发布,改进包括更好的数据获取、性能优化和组件命名一致性。团队"稳定性优先"的理念确保这些更新是渐进式而非颠覆性的——这正是生产框架应有的品质。

路线图清晰显示对开发者体验和性能的关注,Nuxt 5承诺带来更重大的基础设施改进。

为何我全力投入Nuxt

多年Web应用开发经验让我深刻认识到:优秀的框架应该让开发者专注于解决业务问题而非技术细节。Nuxt完美做到了这一点。

不仅是技术能力令人印象深刻,更是其深思熟虑的默认配置、平缓的学习曲线,以及化繁为简却不牺牲能力的方式。无论是简单营销站点还是复杂SaaS应用,Nuxt都提供了所需工具,而无需繁琐配置。

对2025年的Vue开发者而言,Nuxt不仅是好选择,更是必然选择。卓越的开发者体验、出色性能和灵活的部署选项,使其成为现代Web开发的完美之选。

如果您仍在观望,建议在下个项目中小试牛刀——从简单博客或作品集网站开始。一旦体验其工作流程,您就会理解为何众多开发者(包括我自己)将Nuxt视为Vue应用的首选框架。

Vue开发的未来已来,它由Nuxt构建。

原文链接:juststeveking.com/articles/nu…
作者:Steve McDougall

[Python3/Java/C++/Go/TypeScript] 一题一解:BFS(清晰题解)

方法一:BFS

我们可以先统计字符串中每个字符出现的次数,然后将出现次数大于等于 $k$ 的字符按从小到大的顺序存入一个列表 $\textit{cs}$ 中。接下来,我们可以使用 BFS 来枚举所有可能的子序列。

我们定义一个队列 $\textit{q}$,初始时将空字符串放入队列中。然后,我们从队列中取出一个字符串 $\textit{cur}$,并尝试将每个字符 $c \in \textit{cs}$ 添加到 $\textit{cur}$ 的末尾,形成一个新的字符串 $\textit{nxt}$。如果 $\textit{nxt}$ 是一个重复 $k$ 次的子序列,我们就将其加入到答案中,并将 $\textit{nxt}$ 放入队列中继续处理。

我们需要一个辅助函数 $\textit{check(t, k)}$ 来判断字符串 $\textit{t}$ 是否是字符串 $s$ 的一个重复 $k$ 次的子序列。具体地,我们可以使用两个指针来遍历字符串 $s$ 和 $\textit{t}$,如果在遍历过程中能够找到 $\textit{t}$ 的所有字符,并且能够重复 $k$ 次,那么就返回 $\textit{true}$,否则返回 $\textit{false}$。

###python

class Solution:
    def longestSubsequenceRepeatedK(self, s: str, k: int) -> str:
        def check(t: str, k: int) -> bool:
            i = 0
            for c in s:
                if c == t[i]:
                    i += 1
                    if i == len(t):
                        k -= 1
                        if k == 0:
                            return True
                        i = 0
            return False

        cnt = Counter(s)
        cs = [c for c in ascii_lowercase if cnt[c] >= k]
        q = deque([""])
        ans = ""
        while q:
            cur = q.popleft()
            for c in cs:
                nxt = cur + c
                if check(nxt, k):
                    ans = nxt
                    q.append(nxt)
        return ans

###java

class Solution {
    private char[] s;

    public String longestSubsequenceRepeatedK(String s, int k) {
        this.s = s.toCharArray();
        int[] cnt = new int[26];
        for (char c : this.s) {
            cnt[c - 'a']++;
        }

        List<Character> cs = new ArrayList<>();
        for (char c = 'a'; c <= 'z'; ++c) {
            if (cnt[c - 'a'] >= k) {
                cs.add(c);
            }
        }
        Deque<String> q = new ArrayDeque<>();
        q.offer("");
        String ans = "";
        while (!q.isEmpty()) {
            String cur = q.poll();
            for (char c : cs) {
                String nxt = cur + c;
                if (check(nxt, k)) {
                    ans = nxt;
                    q.offer(nxt);
                }
            }
        }
        return ans;
    }

    private boolean check(String t, int k) {
        int i = 0;
        for (char c : s) {
            if (c == t.charAt(i)) {
                i++;
                if (i == t.length()) {
                    if (--k == 0) {
                        return true;
                    }
                    i = 0;
                }
            }
        }
        return false;
    }
}

###cpp

class Solution {
public:
    string longestSubsequenceRepeatedK(string s, int k) {
        auto check = [&](const string& t, int k) -> bool {
            int i = 0;
            for (char c : s) {
                if (c == t[i]) {
                    i++;
                    if (i == t.size()) {
                        if (--k == 0) {
                            return true;
                        }
                        i = 0;
                    }
                }
            }
            return false;
        };
        int cnt[26] = {};
        for (char c : s) {
            cnt[c - 'a']++;
        }

        vector<char> cs;
        for (char c = 'a'; c <= 'z'; ++c) {
            if (cnt[c - 'a'] >= k) {
                cs.push_back(c);
            }
        }

        queue<string> q;
        q.push("");
        string ans;
        while (!q.empty()) {
            string cur = q.front();
            q.pop();
            for (char c : cs) {
                string nxt = cur + c;
                if (check(nxt, k)) {
                    ans = nxt;
                    q.push(nxt);
                }
            }
        }
        return ans;
    }
};

###go

func longestSubsequenceRepeatedK(s string, k int) string {
check := func(t string, k int) bool {
i := 0
for _, c := range s {
if byte(c) == t[i] {
i++
if i == len(t) {
k--
if k == 0 {
return true
}
i = 0
}
}
}
return false
}

cnt := [26]int{}
for i := 0; i < len(s); i++ {
cnt[s[i]-'a']++
}

cs := []byte{}
for c := byte('a'); c <= 'z'; c++ {
if cnt[c-'a'] >= k {
cs = append(cs, c)
}
}

q := []string{""}
ans := ""
for len(q) > 0 {
cur := q[0]
q = q[1:]
for _, c := range cs {
nxt := cur + string(c)
if check(nxt, k) {
ans = nxt
q = append(q, nxt)
}
}
}
return ans
}

###ts

function longestSubsequenceRepeatedK(s: string, k: number): string {
    const check = (t: string, k: number): boolean => {
        let i = 0;
        for (const c of s) {
            if (c === t[i]) {
                i++;
                if (i === t.length) {
                    k--;
                    if (k === 0) {
                        return true;
                    }
                    i = 0;
                }
            }
        }
        return false;
    };

    const cnt = new Array(26).fill(0);
    for (const c of s) {
        cnt[c.charCodeAt(0) - 97]++;
    }

    const cs: string[] = [];
    for (let i = 0; i < 26; ++i) {
        if (cnt[i] >= k) {
            cs.push(String.fromCharCode(97 + i));
        }
    }

    const q: string[] = [''];
    let ans = '';
    while (q.length > 0) {
        const cur = q.shift()!;
        for (const c of cs) {
            const nxt = cur + c;
            if (check(nxt, k)) {
                ans = nxt;
                q.push(nxt);
            }
        }
    }

    return ans;
}

有任何问题,欢迎评论区交流,欢迎评论区提供其它解题思路(代码),也可以点个赞支持一下作者哈😄~

React HOC(高阶组件-补充篇)

HOC(Higher Order Component) 高阶组件

本章可以选择性观看

  1. 在使用hooks写法的时候,HOC的场景会缩小
  2. 为什么出这一章节,面试的时候还是会问,所以还是得了解下
  3. 了解相关的规范,不至于在实际项目开发中不懂理论

什么是高阶组件?

高阶组件就是一个组件,它接受另一个组件作为参数,并返回一个新的组件,(如果你学过Vue的话,跟Vue中的二次封装组件有点类似)新的组件可以复用旧组件的逻辑,并可以添加新的功能。常用于类组件中,虽然目前都是hooks写法会缩小HOC的使用场景,但还是有部分场景会用到(因为人是死的,代码是活的,要灵活变通)🤡

入门级用法

注意点

  • HOC不会修改传入的组件,而是使用组合的方式,通过将原组件包裹在一个容器组件中来实现功能扩展
  • 注意避免多层嵌套,一般HOC的嵌套层级不要超过3层
  • HOC的命名规范:with开头,如withLoadingwithAuth

代码示例

我们以一个权限判断的例子来入门HOC,并且可以灵活的复用这个逻辑。

enum Role {
  ADMIN = 'admin',
  USER = 'user',
}
const withAuthorization = (role: Role) => (Component: React.FC) => {
  // 判断是否具有权限的函数
  const isAuthorized = (role: Role) => {
    return role === Role.ADMIN;
  }
  return (props: any) => {
    // 判断是否具有权限
    if (isAuthorized(role)) {
      //把props透传给组件
      return <Component {...props} />
    } else {
      // 没有权限则返回一个提示
      return <div>抱歉,您没有权限访问该页面</div>
    }
  }
}

const AdminPage = withAuthorization(Role.ADMIN)(() => {
  return <div>管理员页面</div> //有权限输出
})

const UserPage = withAuthorization(Role.USER)(() => {
  return <div>用户页面</div> //没有权限不输出
})

进阶用法

封装一个通用的HOC,实现埋点统计,比如点击事件,页面挂载,页面卸载等。

封装一个埋点服务可以根据自己的业务自行扩展

  1. trackType表示发送埋点的组件类型
  2. data表示发送的数据
  3. eventData表示需要统计的用户行为数据
  4. navigator.sendBeacon是浏览器提供的一种安全可靠的异步数据传输方式,适合发送少量数据,比如埋点数据,并且浏览器关闭时,数据也会发送,不会阻塞页面加载
const trackService = {
  sendEvent: <T,>(trackType: string, data: T = null as T) => {
    const eventData = {
      timestamp: Date.now(), // 时间戳
      trackType, // 事件类型
      data, // 事件数据
      userAgent: navigator.userAgent, // 用户代理
      url: window.location.href, // 当前URL
    }
    //发送数据
    navigator.sendBeacon(
      'http://localhost:5173',
      JSON.stringify(eventData)
    )
  }
}

实现HOC高阶组件,通过useEffect统计组件挂载和卸载,并且封装一个trackEvent方法,传递给子组件,子组件可以自行调用,统计用户行为。

const withTrack = (Component: React.ComponentType<any>, trackType: string) => {
  return (props: any) => {
    useEffect(() => {
      //发送数据 组件挂载
      trackService.sendEvent(`${trackType}-MOUNT`)
      return () => {
        //发送数据 组件卸载
        trackService.sendEvent(`${trackType}-UNMOUNT`)
      }
    }, [])

    //处理事件
    const trackEvent = (eventType: string, data: any) => {
      trackService.sendEvent(`${trackType}-${eventType}`, data)
    }


    return <Component {...props} trackEvent={trackEvent} />
  }
}

使用HOC高阶组件,注册了一个button按钮,并传递了trackEvent方法,子组件可以自行调用,统计用户行为。

const Button = ({ trackEvent }) => {
  // 点击事件
  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    trackEvent(e.type, {
      name: e.type,
      type: e.type,
      clientX: e.clientX,
      clientY: e.clientY,
    })
  }

  return <button   onClick={handleClick}>我是按钮</button>
}
// 使用HOC高阶组件
const TrackButton = withTrack(Button, 'button')
// 使用组件
const App = () => {
  return <div>
    <TrackButton />
  </div>
}

export default App

上报的数据格式预览(可以根据自己的业务自行扩展或者修改)

image.png

入门状态机解析器

大家好,我是风骨,最近在项目中涉及到 「状态机解析器」 的应用,今天便以这个为话题同大家进行分享,相信可以帮助一些小伙伴解决项目难题。

接下来让我们一起从前端角度来理解状态机,掌握的它的设计思想和应用场景。


附 - 毛遂自荐:笔者当前离职在看工作机会,各位小伙伴如果有合适的内推机会,期待您的引荐 🤝

个人简介:男,27岁,专科 · 计算机专业,6 年前端工作经历,从事过 AIGC、低代码、动态表单、前端基建 等方向工作,base 北京 - 高级前端岗,vx 联系方式:iamcegz

1、认识状态机

状态机全称是有限状态机(Finite State Machine, FSM) ,是一种程序设计思想:分析需求,先定义好会出现的 case 状态,然后从初始状态开始,执行对应的状态函数完成该状态的工作,同时推导出下一个状态。继续重复上述操作,直到结束。

在内容解析场景下,状态机的核心概念:

  1. 状态(State): 定义需要处理的状态,一个状态机至少要包含两个状态。以 <button> 代码标签为例,状态可以定义为:< 左括号button 标签名> 右括号 三个状态;
  2. 状态处理函数(State handle) :每个状态都会对应一个处理函数,完成该状态要做的事情。如在标签名状态下,收集得到标签名称 button
  3. 转换(Transition): 定义从一个状态到另一个状态的变化规则,逻辑可以编写在状态处理函数中,推导出下一个状态。如 < 左括号 的下一个状态是解析标签名

采用状态机的优势在于:

  1. 封装了每一种状态的转换规则,清晰且便于维护,解决 if else 逻辑分支嵌套严重问题;
  2. 可扩展性强,新增状态不需要改动核心逻辑,新增一个状态转换函数即可;

在前端,状态机思想常常被应用在 代码解析器 上,如:

  1. 词法分析器,类似 Babel 工作原理,将一段字符串 code 解析成 token 组成的数组 tokens
  2. 流式内容提取,AIGC 生成的流式内容的解析。

2、应用于词法分析器

2.1、需求描述

Babel 编译过程首要阶段是 Parsing 解析,分为「词法分析」和「语法分析」 两个子阶段。我们的需求是采用状态机思想实现「词法分析」

以下面 JSX 代码为例:

<h1 id="title"><span>hello</span>world</h1>

经过词法分析,把它们分割成由一个个 token 组成的数组 tokens。输出结果如下:

PS:token 是指代码在词法分析阶段,将原始代码分割成一个个代码碎片,可以是 标签符合、名称、属性 等。

[
  { type: 'LeftParentheses', value: '<' },
  { type: 'JSXIdentifier', value: 'h1' },
  { type: 'AttributeKey', value: 'id' },
  { type: 'AttributeValue', value: '"title"' },
  { type: 'RightParentheses', value: '>' },
  { type: 'LeftParentheses', value: '<' },
  { type: 'JSXIdentifier', value: 'span' },
  { type: 'RightParentheses', value: '>' },
  { type: 'JSXText', value: 'hello' },
  { type: 'LeftParentheses', value: '<' },
  { type: 'BackSlash', value: '/' },
  { type: 'JSXIdentifier', value: 'span' },
  { type: 'RightParentheses', value: '>' },
  { type: 'JSXText', value: 'world' },
  { type: 'LeftParentheses', value: '<' },
  { type: 'BackSlash', value: '/' },
  { type: 'JSXIdentifier', value: 'h1' },
  { type: 'RightParentheses', value: '>' }
]

2.2、思路分析

首先,根据输入的 JSX,分析出我们需要定义哪些状态,初始状态定义为 Start,其他状态基本和 token 类型一一对应:一个 token 类型代表一类状态。

enum ParserState {
  Start = "Start", // 开始
  LeftParentheses = "LeftParentheses", // <
  RightParentheses = "RightParentheses", // >
  JSXIdentifier = "JSXIdentifier", // 标识符(标签名称)
  AttributeKey = "AttributeKey", // 属性的 key
  AttributeValue = "AttributeValue", // 属性的值
  JSXText = "JSXText", // 文本内容
  BackSlash = "BackSlash", // 反斜杠 /
}

接着,我们思考如何组织处理这些状态:遍历每一个字符,让当前状态对应的处理函数去执行工作。因此 Parser 的框架结构可以这样搭建:

type Token = { type: ParserState | ""; value: string }

class Parser {
  private tokens: Token[] = []
  private currentToken: Token = { type: "", value: "" }
  // 当前状态
  private state: ParserState = ParserState.Start
  // 状态处理函数集合
  private handlers: Record<ParserState, (char: string) => void> = {
    [ParserState.Start]: this.handleStart,
    ... 其他的状态处理函数
  }

  constructor() {}

  private handleStart(char: string) {...}

  public parse(input: string) {
    // 遍历每一个字符,交给 state 对应的状态函数处理
    for (let char of input) {
      const handler = this.handlers[this.state]
      if (handler) {
        handler.call(this, char)
      } else {
        throw new Error(`Unknown state: ${this.state}`)
      }
    }
    return this.tokens;
  }
}

其中:

  • state 定义了当前所处的状态;
  • handlers 定义了所有状态对应的处理函数集合;

最后,我们要明确每个状态函数的工作内容:完成两件事情

  1. 完成当前状态下要做的工作:创建 token
  2. 根据 chat 字符类型,推算出下一步该如何走,即推算出下一个状态。

比如,最初的 state = "Start",它的状态函数要做的事情是:匹配 < 字符。

  • 1)创建 < 类型的 token
  • 2)推算出下一个状态为 state = "LeftParentheses"
private handleStart(char: string) {
  if (char === "<") {
    // 状态函数要做的工作:创建存储 token 的容器
    this.emit({ type: ParserState.LeftParentheses, value: "<" })
    // 推算出下一个状态
    this.state = ParserState.LeftParentheses
  } else {
    // 第一个字符不是 < 抛出错误
    throw new Error(`第一个字符必须是 <,得到的却是 ${char}`)
  }
}

再往下走,state = "LeftParentheses" 的状态函数要做的事情是:匹配是否是普通字符(字母、数字),若是字符说明是标签名称如 h1

  • 1)记录当前字符
  • 2)推算出下一个状态为 state = "JSXIdentifier"
const LETTERS = /[A-Za-z0-9]/
private handleLeftParentheses(char: string) {
  // 是否字母,如果是,进入标签名称状态(标识符状态收集)
  if (LETTERS.test(char)) {
    this.currentToken.type = ParserState.JSXIdentifier
    this.currentToken.value += char
    this.state = ParserState.JSXIdentifier
  }
}

再往下走,state = "JSXIdentifier" 的状态函数要做的事情是:

  • 1)如果 chat 是普通字符(字母、数字),记录当前字符即可,不用更改状态,下一个字符还是交给它处理;

  • 2)如果 chat 是空格,这说明标签名称解析完成了,

    • 1)创建标签名称对应的 token;
    • 2)推算出下一个状态为 state = "AttributeKey"
private handleJSXIdentifier(char: string) {
  if (LETTERS.test(char)) {
    this.currentToken.value += char // 继续收集标识符,且不用更新状态
  } else if (char === " ") {
    // 收集标识符过程中遇到了空格,进入标签结束状态
    this.emit(this.currentToken)
    this.state = ParserState.AttributeKey
  }
}

到这里,目标代码中的 <h1 对应 tokens 已解析完成,以此类推。总结一下就是不断推测下一个状态要做什么事情< 后面是标签名称,标签名称后面可能是属性名,属性名后面可能是 = = 后面可能是属性值 ...

2.3、具体实现

// 定义解析状态,同时一些枚举值也会作为 token 类型(有一些一一对应)
enum ParserState {
  Start = "Start", // 开始
  LeftParentheses = "LeftParentheses", // <
  RightParentheses = "RightParentheses", // >
  JSXIdentifier = "JSXIdentifier", // 标识符(标签名称)
  AttributeKey = "AttributeKey", // 属性的 key
  AttributeValue = "AttributeValue", // 属性的值
  TryLeaveAttribute = "TryLeaveAttribute", // 试图离开属性,若后面没有属性则会离开
  JSXText = "JSXText", // 文本内容
  BackSlash = "BackSlash", // 反斜杠 /
}

// 正则匹配字符
const LETTERS = /[A-Za-z0-9]/

type Token = { type: ParserState | ""; value: string }

class Parser {
  private tokens: Token[] = []
  private currentToken: Token = { type: "", value: "" }
  // 当前状态
  private state: ParserState = ParserState.Start
  // 状态处理函数集合
  private handlers: Record<ParserState, (char: string) => void> = {
    [ParserState.Start]: this.handleStart,
    [ParserState.LeftParentheses]: this.handleLeftParentheses,
    [ParserState.RightParentheses]: this.handleRightParentheses,
    [ParserState.JSXIdentifier]: this.handleJSXIdentifier,
    [ParserState.AttributeKey]: this.handleAttributeKey,
    [ParserState.AttributeValue]: this.handleAttributeValue,
    [ParserState.TryLeaveAttribute]: this.handleTryLeaveAttribute,
    [ParserState.JSXText]: this.handleJSXText,
    [ParserState.BackSlash]: this.handleBackSlash,
  }

  constructor() {}

  private emit(token: Token) {
    this.tokens.push({ ...token }) // 添加到 tokens 中
    this.currentToken.type = this.currentToken.value = ""
  }

  private handleStart(char: string) {
    if (char === "<") {
      // 状态函数要做的工作:创建存储 token 的容器
      this.emit({ type: ParserState.LeftParentheses, value: "<" })
      // 推算出下一个状态
      this.state = ParserState.LeftParentheses
    } else {
      // 第一个字符不是 < 抛出错误
      throw new Error(`第一个字符必须是 <,得到的却是 ${char}`)
    }
  }

  private handleLeftParentheses(char: string) {
    // 是否字母,如果是,进入标签名称状态(标识符状态收集)
    if (LETTERS.test(char)) {
      this.currentToken.type = ParserState.JSXIdentifier
      this.currentToken.value += char
      this.state = ParserState.JSXIdentifier
    } else if (char === "/") {
      // 闭合标签,如:</h1>
      this.emit({ type: ParserState.BackSlash, value: "/" })
      this.state = ParserState.BackSlash
    }
  }

  private handleJSXIdentifier(char: string) {
    if (LETTERS.test(char)) {
      this.currentToken.value += char // 继续收集标识符,且不用更新状态
    } else if (char === " ") {
      // 收集标识符过程中遇到了空格,进入标签结束状态
      this.emit(this.currentToken)
      this.state = ParserState.AttributeKey
    } else if (char === ">") {
      // 说明此标签已没有要处理的属性
      this.emit(this.currentToken)
      this.emit({ type: ParserState.RightParentheses, value: ">" })
      this.state = ParserState.RightParentheses
    }
  }

  private handleAttributeKey(char: string) {
    if (LETTERS.test(char)) {
      this.currentToken.type = ParserState.AttributeKey
      this.currentToken.value += char // 继续收集标识符,且不用更新状态
    } else if (char === "=") {
      this.emit(this.currentToken)
      this.state = ParserState.AttributeValue
    }
  }

  private handleAttributeValue(char: string) {
    if (!this.currentToken.value && char === '"') {
      this.currentToken.type = ParserState.AttributeValue
      this.currentToken.value = '"'
    } else if (LETTERS.test(char)) {
      this.currentToken.value += char
    } else if (char === '"') {
      // 说明属性值结束了,存储 token
      this.currentToken.value += '"'
      this.emit(this.currentToken)
      this.state = ParserState.TryLeaveAttribute // 试图离开属性,若后面没有属性则会离开
    }
  }

  private handleTryLeaveAttribute(char: string) {
    if (char === " ") {
      // 如果 char 是空格,说明后面有新的属性,进入属性状态
      this.state = ParserState.AttributeKey
    } else if (char === ">") {
      // 说明开始标签结束了
      this.emit({ type: ParserState.RightParentheses, value: ">" })
      this.state = ParserState.RightParentheses
    }
  }

  private handleRightParentheses(char: string) {
    // 如果是 <,进入标签开始状态
    if (char === "<") {
      this.emit({ type: ParserState.LeftParentheses, value: "<" })
      this.state = ParserState.LeftParentheses
    } else {
      // 认为是纯文本,如 world
      this.currentToken.type = ParserState.JSXText
      this.currentToken.value += char
      this.state = ParserState.JSXText
    }
  }

  private handleJSXText(char: string) {
    if (LETTERS.test(char)) {
      this.currentToken.value += char
    } else if (char === "<") {
      // 遇到了和文本同级的兄弟标签
      this.emit(this.currentToken) // { type: JSXText, value: 'world' }
      this.emit({ type: ParserState.LeftParentheses, value: "<" })
      this.state = ParserState.LeftParentheses
    }
  }

  private handleBackSlash(char: string) {
    if (LETTERS.test(char)) {
      this.currentToken.type = ParserState.JSXIdentifier
      this.currentToken.value += char
      this.state = ParserState.JSXIdentifier
    }
  }

  public parse(input: string) {
    // 遍历每一个字符,交给 state 对应的状态函数处理
    for (let char of input) {
      const handler = this.handlers[this.state]
      if (handler) {
        handler.call(this, char)
      } else {
        throw new Error(`Unknown state: ${this.state}`)
      }
    }
    return this.tokens
  }
}

让我们来测试一下,相信和预期的结果一致。

const sourceCode = `<h1 id="title"><span>hello</span>world</h1>`
const parser = new Parser()
console.log(parser.parse(sourceCode))

3、应用于流式内容提取

3.1、需求描述

当下 AIGC 应用已经非常普遍,AI 问答交互普遍采用流式的形式输出内容。

假设我们有一个 AI 生成组件代码平台,需要从流式内容中提取到代码块内容,实时呈现到页面 CodeIDE 中,该如何实现?

PS:注意,如果是一段完整的内容,可以使用 正则 匹配实现,但这里是流式逐字输出的内容该如何实现?

比如 AI 流式输出内容为:

The generated components code is as follows:

<ComponentFile fileName="App.tsx" isEntryFile="true">
  import Button from "./Button";
  export const App = () => {
    return (
      <Button>按钮</Button>
    )
  }
</ComponentFile>
<ComponentFile fileName="Button.tsx">
  export const Button = ({ children }) => {
    return (
      <button>{children}</button>
    )
  }
</ComponentFile>

The content contains two components: App and Button

现在期望在流式输出过程中匹配到以下内容时,通过事件回调暴露给外部:

  • 匹配到 <ComponentFile fileName="App.tsx" isEntryFile="true"> 时,触发 onOpenTag 事件,并将 tagNamefileNameisEntryFile 等属性通过事件暴露出去;
  • 匹配到 <ComponentFile> 标签内的代码时,触发 onConent 事件,将解析到的代码 code 内容暴露出去;
  • 匹配到 </ComponentFile> 时,触发 onCloseTag 事件;

对应到 调用解析器 的代码示例如下:

let currentFile: {
  name: string
  isEntryFile: boolean
  content: string
} | null = null

const parser = new StreamParser();

parser.onOpenTag = function ({ name, attrs }) {
  if (name === "ComponentFile") {
    const fileName = attrs.fileName as string
    const isEntryFile = attrs.isEntryFile === "true"
    // 定义组件结构
    currentFile = {
      name: fileName,
      isEntryFile,
      content: "",
    }
    console.log("onOpenTag", name, attrs)
  }
}

parser.onContent = function ({ name }, text) {
  if (name === "ComponentFile" && currentFile) {
    // 收集文件内容
    currentFile.content += text
    // TODO... 自定义处理文件逻辑,如将 code content 渲染到 CodeIDE 中
  }
}

parser.onCloseTag = function ({ name }) {
  if (name === "ComponentFile" && currentFile) {
    console.log("onCloseTag", name, currentFile)
    // TODO... 自定义处理文件逻辑
    currentFile = null
  }
}

打印输出示例:

onOpenTag ComponentFile { fileName: 'App.tsx', isEntryFile: 'true' }
onCloseTag ComponentFile {
  name: 'App.tsx',
  isEntryFile: true,
  content: '\n' +
    '  import Button from "./Button";\n' +
    '  export const App = () => {\n' +
    '    return (\n' +
    '      <Button>按钮</Button>\n' +
    '    )\n' +
    '  }\n'
}
onOpenTag ComponentFile { fileName: 'Button.tsx' }
onCloseTag ComponentFile {
  name: 'Button.tsx',
  isEntryFile: false,
  content: '\n' +
    '  export const Button = ({ children }) => {\n' +
    '    return (\n' +
    '      <button>{children}</button>\n' +
    '    )\n' +
    '  }\n'
}

3.2、思路分析

首先,根据输入的流式内容,分析出我们大致需要定义哪些状态。在该场景下,初始状态定义为 TEXT 处理普通文本(如开头和结尾的文本),其他状态的定义用于匹配 <ComponentFile> 标签代码,比如 开闭标签符号 <>、标签名称、属性、属性值、代码内容 等。

// 定义解析器状态枚举
enum ParserState {
  TEXT, // 1)初始状态,标签外的普通文本(如开头和结尾的文本)
  TAG_OPEN, // 2)开始标签,刚遇到 <
  TAG_NAME, // 3)解析标签名,如 ComponentFile
  ATTR_NAME_START, // 4)准备解析属性名
  ATTR_NAME, // 5)解析属性名,如 fileName
  ATTR_VALUE_START, // 6)属性值开始,等待 " 或者 '
  ATTR_VALUE, // 7)解析属性值,如 App.tsx
  CONTENT, // 8)要解析的代码内容,如 import Button from "./Button";
  CONTENT_POTENTIAL_END, // 9)在代码内容中遇到可能的结束标签
  CLOSING_TAG_OPEN, // 10)结束标签,遇到 </
  CLOSING_TAG_NAME, // 11)解析结束标签名
  SELF_CLOSING_START, // 12)自闭合标签开始,遇到 /
}

接着,我们思考如何对流式内容进行解析工作:将流式内容实时写入到 buffer 缓存区中,搭配 position 指针来处理缓冲区中的字符

class StreamParser {
  private buffer: string = "" // 缓冲区,用于存储当前需要解析的内容
  private position: number = 0 // 当前解析到的位置
  ...
  
  // 当前状态
  private state: ParserState = ParserState.TEXT
  // 状态处理函数集合
  private handlers: Record<ParserState, (char: string) => void> = {
    [ParserState.TEXT]: this.handleTextState,
    ...
  }
  
  public write(chunk: string) {
    this.buffer += chunk // 添加到缓冲区
    this.parseBuffer()
  }

  private parseBuffer() {
    while (this.position < this.buffer.length) {
      const char = this.buffer[this.position]
      const handler = this.handlers[this.state]
      if (handler) {
        handler.call(this, char)
      } else {
        throw new Error(`Unknown state: ${this.state}`)
      }
      // 移动到下一个字符
      this.position++
    }
    
    // 若存在解析到的 content,通知 onContent 回调
    this.sendPendingContent()
    ...
  }
}

最后,实现状态函数,完成状态函数要处理的工作并推算出下一个状态。

比如,最初的 state = "TEXT",它的状态函数要做的事情是:匹配 < 字符,推算出下一个状态为 state = "TAG_OPEN" 解析 <ComponentFile> 标签:

private handleTextState(char: string) {
  if (char === "<") {
    // 开始一个新标签
    this.state = ParserState.TAG_OPEN
  }
  // 文本状态下,其他非代码块字符忽略处理
}

后面的流程大致和「词法分析器」的解析相似,依次匹配 标签名、属性名、 属性值 等。额外增加的逻辑是:在匹配完成 Open 标签(如 <ComponentFile>)后触发 onOpenTag 事件,匹配完成 Close 标签(如 </ComponentFile>)后触发 onCloseTag 事件。

最后重点说一下 onContent 事件和 content 内容匹配逻辑:

解析器的目的主要是解析出 <ComponentFile>component code</ComponentFile> 中间的 component code。当匹配到 tagName = ComponentFile 时,便开始进入 ParserState.CONTENT 状态收集 content

private parseAsContentTags: string[] = ["ComponentFile"]

handleOpenTag() {
  const tagName = this.currentTagName
  ...
  // 检查是否是 <ComponentFile> 标签
  if (this.parseAsContentTags.includes(tagName)) {
    this.state = ParserState.CONTENT // 开始收集 content 内容
  } else {
    this.state = ParserState.TEXT
  }
}

同时,在收集过程中遇到嵌套标签(比如 <Button>),将不会进入 标签解析 流程,仅作为 content 内容拼接,只有当匹配到 </ComponentFile> 标签时,ParserState.CONTENT 状态才会结束,这时便收集到了 <ComponentFile> 标签中的完整代码。

private handleContentState(char: string) {
  if (char === "<") {
    // 进入潜在闭合标签状态
    this.sendPendingContent()
    this.pendingClosingTag = "<" // 开始收集可能的结束标签
    this.potentialEndTagMatchPos = 1 // 已匹配到"<",下一个应该是"/"
    this.state = ParserState.CONTENT_POTENTIAL_END // 切换状态
  } else {
    // 继续累积内容
    this.pendingContent += char
  }
}

private handleContentPotentialEndState(char: string) {
  // 伪代码表示
  if ("匹配的闭合标签名称" === "</ComponentFile">) {
    this.onCloseTag?.(curTagData)
    this.state = ParserState.TEXT // 更新状态为文本,恢复为最初状态
  }
}

从上面代码可以看出,触发 onContent 事件的时机可以在:1)当前流式 buffer 缓冲区解析完成,2)解析 content 时遇到了闭合标签就执行一次。

3.3、具体实现

// 定义解析器状态枚举
enum ParserState {
  TEXT, // 1)初始状态,标签外的普通文本(如开头和结尾的文本)
  TAG_OPEN, // 2)开始标签,刚遇到 <
  TAG_NAME, // 3)解析标签名,如 ComponentFile
  ATTR_NAME_START, // 4)准备解析属性名
  ATTR_NAME, // 5)解析属性名,如 fileName
  ATTR_VALUE_START, // 6)属性值开始,等待 " 或者 '
  ATTR_VALUE, // 7)解析属性值,如 App.tsx
  CONTENT, // 8)要解析的代码内容,如 import Button from "./Button";
  CONTENT_POTENTIAL_END, // 9)在代码内容中遇到可能的结束标签
  CLOSING_TAG_OPEN, // 10)结束标签,遇到 </
  CLOSING_TAG_NAME, // 11)解析结束标签名
  SELF_CLOSING_START, // 12)自闭合标签开始,遇到 /
}

interface TagData {
  name: string
  attrs: Record<string, string>
}

class StreamParser {
  private buffer: string = "" // 缓冲区,用于存储当前需要解析的内容
  private position: number = 0 // 当前解析到的位置
  private tagStack: TagData[] = [] // 标签栈,用于存储当前解析到的标签
  private currentTagName: string = "" // 当前解析到的标签名
  private currentAttrs: TagData["attrs"] = {}
  private currentAttrName: string = "" // 当前解析到的属性名
  private currentAttrValue: string = "" // 当前解析到的属性值
  private attrQuoteChar: string = "" // 当前解析到的属性值的引号字符,用于匹配属性值的结束

  // 定义内容标签集合,仅解析此集合中的标签的内容,作为要解析的原始内容使用 onContent 事件暴露
  private parseAsContentTags: string[] = ["ComponentFile"]
  // 保存潜在的未完成的闭合标签
  private pendingClosingTag: string = ""
  // 保存未发送的原始内容
  private pendingContent: string = ""
  // 当前潜在闭合标签匹配位置
  private potentialEndTagMatchPos: number = 0

  // Event handlers
  public onOpenTag: ((tagData: TagData) => void) | null = null
  public onCloseTag: ((tagData: TagData) => void) | null = null
  public onContent: ((tagData: TagData, content: string) => void) | null = null

  // 当前状态
  private state: ParserState = ParserState.TEXT
  // 状态处理函数集合
  private handlers: Record<ParserState, (char: string) => void> = {
    [ParserState.TEXT]: this.handleTextState,
    [ParserState.TAG_OPEN]: this.handleTagOpenState,
    [ParserState.TAG_NAME]: this.handleTagNameState,
    [ParserState.CLOSING_TAG_OPEN]: this.handleClosingTagOpenState,
    [ParserState.CLOSING_TAG_NAME]: this.handleClosingTagNameState,
    [ParserState.ATTR_NAME_START]: this.handleAttrNameStartState,
    [ParserState.ATTR_NAME]: this.handleAttrNameState,
    [ParserState.ATTR_VALUE_START]: this.handleAttrValueStartState,
    [ParserState.ATTR_VALUE]: this.handleAttrValueState,
    [ParserState.SELF_CLOSING_START]: this.handleSelfClosingStartState,
    [ParserState.CONTENT]: this.handleContentState,
    [ParserState.CONTENT_POTENTIAL_END]: this.handleContentPotentialEndState,
  }

  public write(chunk: string) {
    this.buffer += chunk // 添加到缓冲区
    this.parseBuffer()
  }

  private parseBuffer() {
    while (this.position < this.buffer.length) {
      const char = this.buffer[this.position]
      const handler = this.handlers[this.state]
      if (handler) {
        handler.call(this, char)
      } else {
        throw new Error(`Unknown state: ${this.state}`)
      }
      // 移动到下一个字符
      this.position++
    }

    // 若存在解析到的 content,通知 onContent 回调
    this.sendPendingContent()

    // 处理完成字符,重置缓冲区
    if (this.position >= this.buffer.length) {
      this.buffer = ""
      this.position = 0
    }
  }

  private getCurrentHandlingTagData(): TagData {
    return this.tagStack[this.tagStack.length - 1]
  }

  // 辅助方法:判断是否是空白字符
  private isWhitespace(char: string): boolean {
    return char === " " || char === "\t" || char === "\n" || char === "\r"
  }

  private isValidNameChar(char: string): boolean {
    return /[A-Za-z0-9]/.test(char)
  }

  private sendPendingContent() {
    if (this.state !== ParserState.CONTENT || !this.pendingContent) return
    this.onContent?.(this.getCurrentHandlingTagData(), this.pendingContent)
    this.pendingContent = ""
  }

  private resetCurrentTagData(): void {
    this.currentTagName = ""
    this.currentAttrs = {}
    this.currentAttrName = ""
    this.currentAttrValue = ""
    this.attrQuoteChar = ""
  }

  private handleTextState(char: string) {
    if (char === "<") {
      // 开始一个新标签
      this.state = ParserState.TAG_OPEN
    }
    // 文本状态下,其他非代码块字符忽略处理
  }

  private handleTagOpenState(char: string) {
    if (char === "/") {
      // 这是一个结束标签 </tag>
      this.state = ParserState.CLOSING_TAG_OPEN
    } else if (this.isValidNameChar(char)) {
      // 开始收集标签名
      this.currentTagName = char
      this.state = ParserState.TAG_NAME
    } else {
      // 标签开始后应该是 标签名 或 /,否则是错误的语法
    }
  }

  private handleTagNameState(char: string) {
    if (this.isWhitespace(char)) {
      // 标签名后面有空白,准备解析属性
      this.state = ParserState.ATTR_NAME_START
    } else if (char === ">") {
      // 标签结束,没有属性
      this.handleOpenTag()
    } else if (char === "/") {
      // 可能是自闭合标签
      this.state = ParserState.SELF_CLOSING_START
    } else {
      // 继续收集标签名
      this.currentTagName += char
    }
  }

  private handleAttrNameStartState(char: string) {
    if (this.isValidNameChar(char)) {
      // 开始收集属性名
      this.currentAttrName = char
      this.state = ParserState.ATTR_NAME
    } else if (char === ">") {
      // 没有更多属性,标签结束
      this.handleOpenTag()
    } else if (char === "/") {
      // 自闭合标签
      this.state = ParserState.SELF_CLOSING_START
    }
    // 忽略多余的空白
  }

  private handleAttrNameState(char: string) {
    if (char === "=") {
      // 直接遇到=,属性名结束
      this.state = ParserState.ATTR_VALUE_START
    } else if (char === ">") {
      // 布尔属性,没有值
      this.currentAttrs[this.currentAttrName] = "true"
      this.handleOpenTag()
    } else if (char === "/") {
      // 自闭合标签前的布尔属性
      this.currentAttrs[this.currentAttrName] = ""
      this.state = ParserState.SELF_CLOSING_START
    } else {
      // 继续收集属性名
      this.currentAttrName += char
    }
  }

  private handleAttrValueStartState(char: string) {
    if (char === '"' || char === "'") {
      // 属性值开始
      this.attrQuoteChar = char
      this.currentAttrValue = ""
      this.state = ParserState.ATTR_VALUE
    }
    // 忽略=和引号之间的空白
  }

  private handleAttrValueState(char: string) {
    if (this.attrQuoteChar && char === this.attrQuoteChar) {
      // 引号闭合,属性值结束
      this.currentAttrs[this.currentAttrName] = this.currentAttrValue
      this.currentAttrName = ""
      this.currentAttrValue = ""
      this.state = ParserState.ATTR_NAME_START
    } else {
      // 继续收集属性值
      this.currentAttrValue += char
    }
  }

  private handleClosingTagOpenState(char: string) {
    if (this.isValidNameChar(char)) {
      // 开始收集结束标签名
      this.currentTagName = char
      this.state = ParserState.CLOSING_TAG_NAME
    }
  }

  private handleClosingTagNameState(char: string) {
    if (char === ">") {
      // 结束标签结束
      this.handleCloseTag()
      this.currentTagName = ""
    } else if (!this.isWhitespace(char)) {
      // 继续收集标签名
      this.currentTagName += char
    }
    // 忽略结束标签名和>之间的空白
  }

  private handleSelfClosingStartState(char: string): void {
    if (char === ">") {
      // 处理自闭合标签
      const tagData: TagData = {
        name: this.currentTagName,
        attrs: this.currentAttrs,
      }
      // 触发开始和结束标签回调
      this.onOpenTag?.(tagData)
      this.onCloseTag?.(tagData)
      this.resetCurrentTagData()
      this.state = ParserState.TEXT
    }
  }

  private handleContentState(char: string) {
    if (char === "<") {
      // 进入潜在闭合标签状态
      this.sendPendingContent()
      this.pendingClosingTag = "<" // 开始收集可能的结束标签
      this.potentialEndTagMatchPos = 1 // 已匹配到"<",下一个应该是"/"
      this.state = ParserState.CONTENT_POTENTIAL_END // 切换状态
    } else {
      // 继续累积内容
      this.pendingContent += char
    }
  }

  private handleContentPotentialEndState(char: string) {
    const curTagData = this.getCurrentHandlingTagData()

    // 基于字符逐个匹配潜在的闭合标签
    const expectedEndTag = `</${curTagData.name}>` // 期望的结束标签

    // 检查当前字符是否匹配期望的字符
    if (char === expectedEndTag[this.potentialEndTagMatchPos]) {
      // 字符匹配,更新匹配位置
      this.pendingClosingTag += char
      this.potentialEndTagMatchPos++

      // 检查是否完全匹配了闭合标签
      if (this.potentialEndTagMatchPos === expectedEndTag.length) {
        // 完全匹配,重置状态并触发关闭标签
        this.onCloseTag?.(curTagData)
        this.resetCurrentTagData()

        // 从标签栈中移除
        if (
          this.tagStack.length > 0 &&
          this.tagStack[this.tagStack.length - 1].name === curTagData.name
        ) {
          this.tagStack.pop()
        }

        // 检查父标签是否是原始内容标签
        if (this.tagStack.length > 0) {
          const parentTag = this.tagStack[this.tagStack.length - 1]
          if (this.parseAsContentTags.includes(parentTag.name)) {
            this.state = ParserState.CONTENT
          } else {
            this.state = ParserState.TEXT
          }
        } else {
          this.state = ParserState.TEXT
        }

        // 重置匹配状态
        this.pendingClosingTag = ""
        this.potentialEndTagMatchPos = 0
      }
    } else {
      // 不匹配,回到 CONTENT 状态
      // 将已收集的 pendingClosingTag,以及当前字符 char 作为内容
      this.pendingContent += this.pendingClosingTag + char
      // 重置状态
      this.pendingClosingTag = ""
      this.potentialEndTagMatchPos = 0
      this.state = ParserState.CONTENT
    }
  }

  handleOpenTag() {
    const tagName = this.currentTagName
    const tagData: TagData = { name: tagName, attrs: this.currentAttrs }
    // 触发开始标签回调
    this.onOpenTag?.(tagData)
    // 添加到标签栈
    this.tagStack.push(tagData)
    // 重置当前标签相关数据
    this.currentTagName = ""
    this.currentAttrs = {}
    // 检查是否是原始内容标签
    if (this.parseAsContentTags.includes(tagName)) {
      this.state = ParserState.CONTENT // 开始收集 content 内容
    } else {
      this.state = ParserState.TEXT
    }
  }

  handleCloseTag() {
    const tagName = this.currentTagName
    // 触发结束标签回调
    this.onCloseTag?.({ name: tagName, attrs: this.currentAttrs })
    this.resetCurrentTagData()
    // 从标签栈中移除
    if (
      this.tagStack.length > 0 &&
      this.tagStack[this.tagStack.length - 1].name === tagName
    ) {
      this.tagStack.pop()
    }
    // 检查父标签是否是原始内容标签
    if (this.tagStack.length > 0) {
      const parentTag = this.tagStack[this.tagStack.length - 1]
      if (this.parseAsContentTags.includes(parentTag.name)) {
        this.state = ParserState.CONTENT
      }
    }
    if (this.state !== ParserState.CONTENT) {
      this.state = ParserState.TEXT
    }
  }
}

让我们使用计时器模拟 AI 生成流式内容,相信和预期的结果一致。

let currentFile: {
  name: string
  isEntryFile: boolean
  content: string
} | null = null

const parser = new StreamParser()

parser.onOpenTag = function ({ name, attrs }) {
  if (name === "ComponentFile") {
    const fileName = attrs.fileName as string
    const isEntryFile = attrs.isEntryFile === "true"
    // 定义组件结构
    currentFile = {
      name: fileName,
      isEntryFile,
      content: "",
    }
    console.log("onOpenTag", name, attrs)
  }
}

parser.onContent = function ({ name }, text) {
  if (name === "ComponentFile" && currentFile) {
    // 收集文件内容
    currentFile.content += text
    // console.log("onContent", name, text)
  }
}

parser.onCloseTag = function ({ name }) {
  if (name === "ComponentFile" && currentFile) {
    console.log("onCloseTag", name, currentFile)
    // TODO... 自定义处理文件逻辑
    currentFile = null
  }
}

const content = `
The generated components code is as follows:

<ComponentFile fileName="App.tsx" isEntryFile>
  import Button from "./Button";
  export const App = () => {
    return (
      <Button>按钮</Button>
    )
  }
</ComponentFile>
<ComponentFile fileName="Button.tsx">
  export const Button = ({ children }) => {
    return (
      <button>{children}</button>
    )
  }
</ComponentFile>

The content contains two components: App and Button
`

let index = 0
function typeWriter() {
  if (index < content.length) {
    const text = content.slice(index, index + 10)
    index += 10
    parser.write(text)
    setTimeout(typeWriter, 100)
  }
}
typeWriter()

文末

感谢阅读!文章内容你觉得有用,欢迎点赞支持一下~

命令行的进化:CMD、PowerShell 和 Bash

引子

在图形界面横行的今天,命令行看起来就像上个世纪的老古董。但真相是——每一行命令背后,藏着整个操作系统的灵魂。

无论是 Windows 上的 CMD,Linux/macOS 上的 Bash,还是微软后来力推的 PowerShell,它们不仅是开发者的得力助手,更是技术演进的缩影。每个命令、每个符号,都是一代工程师试图解决某个痛点的答案。

本篇,就让我们沿着命令行的时间轴,看看它如何从上世纪的字符终端,一步步走到云计算和 AI 驱动的今天。

🎯 演进时间线一图流

1981 ──────── 1989 ──────── 2006 ──────── 2016 ──────── 2025  
                                                      
├─ CMD (MS-DOS)                                         
             ├─ Bash 0.99               ├─ WSL 1.0      
                          ├─ PS 1.0     ├─ PS 6.0 (跨平台)  
                                       ├─ Bash on Windows  
                          AI + CLI 融合(Copilot时代)

一、诞生的背后:它们到底在解决什么问题?

CMD:MS-DOS 留下的“历史债务”

CMD 诞生的使命其实很朴素——兼容老软件,别让几十年前的脚本白写了。

1981 年,MS-DOS 面世,command.com 成为当时的默认命令行。后来进入 Windows 时代,cmd.exe 接过了接力棒。虽然功能有限,但它承担了“兼容老脚本”“帮 Windows 用户跑批处理”的重要角色。

一句话总结:CMD 不是为了创新,而是为了不让旧时代崩溃。


PowerShell:Windows 自动化的自我救赎

到了 2000 年代,Windows 运维界一片混乱:

  • CMD,功能太弱。
  • VBScript,写的人抓狂,看的人更崩溃。
  • 图形界面点点点,根本没法自动化。

于是,微软的 Jeffrey Snover 提出了一个惊世骇俗的想法:为什么命令行不能直接操作对象?

这就是后来 PowerShell 的核心思想——对象管道。命令不再传递一堆字符串,而是传递带属性、带方法的对象。你可以直接对文件、进程、服务下手,无需靠什么 awkgrep 拼命切字符串。

比如你想查看 nginx 进程的内存占用,只能先拿到一堆文本,再自己想办法提取出有用的字段。

ps aux | grep nginx | awk '{print $4}'

这段命令做了什么?

  • ps aux → 列出所有进程(输出一大坨文本)
  • grep nginx → 从文本中过滤出包含 nginx 的行
  • awk '{print $4}' → 拿到第四列(通常是内存占用)

但这里有个致命问题:一旦输出格式变了,这个命令就废了。

比如不同的 Linux 发行版、不同版本的 ps 命令,字段位置可能变,空格数量可能变,甚至列标题变长都会导致 awk '{print $4}' 报错或者结果错位。

而 PowerShell 传递的不是文本,而是 .NET 对象。每个对象都有属性,结构化、标准化,不再依赖格式推测。

对应的 PowerShell 写法:

Get-Process nginx | Select-Object -ExpandProperty CPU

解释一下:

  • Get-Process nginx → 返回的是一个 进程对象列表,每个对象都包含标准字段,比如 CPU、Memory、ProcessName 等。

  • Select-Object -ExpandProperty CPU → 直接提取这个对象的 CPU 使用率。

  • 没有文本解析,没有位置依赖、没有空格错位的问题

  • 哪怕操作的是一台 Windows、本地 Linux,还是云端容器,对象结构是统一的

  • 可以链式继续操作:

    Get-Process nginx | Where-Object { $_.CPU -gt 80 } | Stop-Process
    

Bash:开源世界的“共同语言”

Bash 的出现其实更像一场“自由之战”。

1988 年,GNU 项目需要一个开源的 Shell,来取代当时专有的 Bourne Shell。于是,Brian Fox 写下了 Bash(Bourne-Again Shell)。

它不仅保留了 Unix 世界的命令行传统,还不断加入诸如命令补全、作业管理、数组、函数等高级功能。Bash 逐渐成了 Linux 开发者的母语,也成了开源生态的命脉之一。


二、技术进化:谁在原地踏步,谁在狂飙?

CMD:稳如老狗,也老得发霉

  • 功能?能跑就行。
  • 创新?别想了。
  • 它的存在感,更多是“我不主动退役,你也没法彻底抛弃”。

PowerShell:从 Windows 到全世界

版本 年份 大事件
1.0 2006 初代发布,终于告别 VBScript
3.0 2012 支持模块自动加载,舒服多了
5.0 2015 加入类定义,开始支持 OOP
6.0+ 2018 跨平台,跑到 Linux 和 macOS 上

当微软宣布 PowerShell 开源、支持 Linux/macOS 时,很多人都知道,这已经不仅仅是 Windows 的工具,而是云时代的自动化中枢


Bash:从服务器跑进了 Windows

Bash 的强大来自社区,30 多年来,它不断进化:

  • 支持函数、数组、正则。
  • 有 Zsh、Fish 这样的兄弟竞品,但它依然是脚本领域的事实标准
  • 更牛的是,2016 年,微软通过 WSL(Windows Subsystem for Linux)让 Bash 正式进驻 Windows。开发者终于不用在 Windows 和 Linux 之间反复横跳了。

三、核心哲学:文本 vs 对象

管道的本质区别

  • Bash/CMD:传递的是“文本” 。所以你经常看到这样:

    ps aux | grep nginx | awk '{print $4}'
    

    ——输出是字符串,后续处理全靠你自己解析。

  • PowerShell:传递的是“对象” 。直接操作属性,省事又优雅:

    Get-Process nginx | Select-Object -ExpandProperty CPU
    

脚本能力对比

CMD Bash PowerShell
易用性 简单但功能弱 易上手,符号多稍绕 上手陡,功能极强
安全性 基本没 靠文件权限 有执行策略保护
扩展性 没有 社区脚本库丰富 模块化 + 面向对象
跨平台 Windows 独有 Linux/macOS/WSL Windows + Linux + Mac

四、今天的命令行,早就不是单打独斗

  • CMD:老系统的守门人,跑跑批处理,救急。
  • Bash:开发者的万能胶,从服务器到 CI/CD,无所不在。
  • PowerShell:企业级 DevOps 自动化的主力军,尤其在 Azure 和多云管理场景下一骑绝尘。

五、未来趋势:命令行不会死,反而更猛

  • Bash:依旧坚挺,特别是在云原生、容器编排(Kubernetes)里,它就是 glue code(胶水)。

  • PowerShell:越来越像 DevOps 世界的瑞士军刀,支持混合云、本地集群,甚至 AI Copilot 正在学会帮你写 PowerShell 脚本。

  • AI + CLI:未来的命令行,可能不仅是命令,更是自然语言驱动的半自动脚本。比如——

    “帮我查一下 A 集群 CPU 用量,把高于 80% 的节点拉出来重启。”
    ——Copilot 自动转成 PowerShell 或 Bash 脚本。


结语:命令行,从未过时

  • CMD,活在兼容性里
  • Bash,活在开源生态里
  • PowerShell,活在云端自动化里

命令行不是老掉牙的工具,而是这个数字世界里最简洁、最高效、最具创造力的接口之一。

技术的演化,从不是消灭谁,而是融合谁。


🚀 关于 Dev Odyssey

「Dev Odyssey」是一张为开发者绘制的成长航海图。
从计算机底层,到前沿技术;从系统思维,到智能时代。路线图、时间轴、技能矩阵,都是你穿越迷雾的指南针。

🌐 在线体验:dev-odyssey.pages.dev
💻 项目开源:github.com/tuaran/dev-…

愿每一个热爱技术的灵魂,都能在属于自己的奥赛德之旅中,驶向星辰大海。

Vue懒加载实战:让你的应用飞起来!

大家好,我是小杨,一个在前端圈摸爬滚打了6年的老司机。今天想和大家聊聊Vue项目性能优化的必杀技——懒加载。这玩意儿用好了,页面加载速度能快一倍不止,用户再也不抱怨"这网页怎么这么卡"了!

一、懒加载是啥?为啥要用?

想象一下你去餐厅吃饭,服务员一次性把所有菜都端上来,桌子都放不下,你看着也头疼。懒加载就像聪明的服务员,先上你马上要吃的,其他的等你要了再上。

在Vue里,懒加载主要用在两个地方:

  1. 组件懒加载:路由切换时才加载对应组件
  2. 图片懒加载:图片进入可视区域才加载

二、组件懒加载实战

以前我们是这样引入组件的:

import Home from './views/Home.vue'
import About from './views/About.vue'

这就像一口气把整本菜单背下来,多累啊!现在试试懒加载:

const Home = () => import('./views/Home.vue')
const About = () => import('./views/About.vue')

配合vue-router使用更香:

const router = new VueRouter({
  routes: [
    {
      path: '/',
      component: () => import('./views/Home.vue')
    },
    {
      path: '/about',
      component: () => import('./views/About.vue')
    }
  ]
})

效果:首次加载只下载当前页面的代码,其他页面等用户点了再加载。

三、图片懒加载实战

图片懒加载我推荐用vue-lazyload这个插件,用起来贼简单:

  1. 先安装:
npm install vue-lazyload
  1. 在main.js配置:
import VueLazyload from 'vue-lazyload'

Vue.use(VueLazyload, {
  preLoad: 1.3, // 预加载高度比例
  error: require('./assets/error.png'), // 加载失败显示的图片
  loading: require('./assets/loading.gif'), // 加载中显示的图片
  attempt: 3 // 尝试加载次数
})
  1. 把img标签的src换成v-lazy:
<!-- 以前 -->
<img src="我.jpg">

<!-- 现在 -->
<img v-lazy="我.jpg">

效果:页面滚动到图片位置时才加载,首屏加载速度直接起飞!

四、高级玩法:自定义懒加载指令

觉得插件太重?自己写个简单的图片懒加载也不难:

// main.js
Vue.directive('lazy', {
  inserted(el, binding) {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          el.src = binding.value
          observer.unobserve(el)
        }
      })
    })
    observer.observe(el)
  }
})

用的时候:

<img v-lazy="我.jpg">

五、性能对比实测

上周我做了个对比测试:

  • 不用懒加载:首屏加载2.8MB,完全加载4.2MB
  • 用了懒加载:首屏只要1.2MB,其他按需加载

效果立竿见影!

六、避坑指南

  1. 别懒加载首屏内容:首屏该显示的东西要直接加载
  2. 合理设置预加载:vue-lazyload的preLoad别设太大
  3. 注意SEO影响:懒加载内容可能不被搜索引擎抓取
  4. 提供加载状态:记得加loading动画,别让用户对着空白发呆

七、最后说两句

懒加载就像精明的管家,帮你把资源安排得明明白白。但记住两点:

  1. 不是所有东西都适合懒加载
  2. 懒加载不是性能优化的唯一手段

我见过有人把所有组件都懒加载,结果切换路由时疯狂转圈圈,这就本末倒置了。

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

❌