普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月14日技术

2025 年终总结 - Agent 元年

作者 EricLee
2026年1月14日 01:12

前言 - 变革的到来

回想 2024 年的时候,公司内部大多人建设思路,还停留在传统产品开发设计上。其实在 2023 年 Q2 的时候,我就开始涉及 LLM 相关的功能开发,但那个时候并没有想象到,AI 能在 2023 年之后的 2 年间,发展到现在这种全民级 Agent 级应用的情况,这场变革对互联网局内人实属是一场革命了,对传统编程与产品模式都造成巨大冲击。

如何跟上 AI 这趟车

互联网从业者

现在 AI 普及之后,对于程序员等的职能要重新定义了。从前对于国内互联网环境,我们会把岗位拆的很细(为了提升开发效率)。现在即便是自己不擅长的领域,有了 AI 能力之后,也多少能够进行一些不擅长技术栈的开发,也能学习前端、后端、数开等其他领域知识

未来的软件开发工程师,会更加注重对 AI 工具的运用,一个是对于未知知识的学习能力,另一个是运用 AI 工具开发不熟悉领域的能力。随着 AI 模型的进步,生成时长与采纳率提升,在 DevOps 场景下,一站式自动完成 PRD、前后端、数据运营等能力。

产品功能

从前我们的产品更关注于用户体验,如某些成熟的产品,很多产品由于基本功能迭代完毕,都在做用户体验优化(如 UI 交互升级等)。

在新的 AI 时代,更关注数据的获取速度,提炼的是否足够精准。用户希望产品作为信息入口,能直接把需要的最终结果,加工吐给用户,加快信息的流转速度。

AI 提效的领域

需要思考的是,AI 需要什么?

我们在平常做 prompt 开发的时候,会注入很多 context,那么核心还是在 context 上,以此为视角,尝试去找到需要的领域。

上下文是什么,本质还是数据,所以只要自身所在的业务,掌握数据,就有 Agent 应用的方向。

产品领域

举个例子,抖音 App,一般我们只在平常去刷抖音。但尝试去思考其中的信息,每个 UGC 创作者的视频内,有大量的有效信息。那我们是不是就可以收集视频中的文案,尝试让 AI 汇总一些关键内容,在搜索场景下做 RAG。对于这点当前抖音、小红书等 App 已经在金刚位放置了 AI 总结能力,对于我日常检索来说,也会经常用到。

在这个激烈竞争的移动互联网时代,作为用户我是更关注在更短的时间完成所需信息的收集,目前看这类能力还是有很大空间的,各个 App 接下来的 1 年还会持续不断的把 AI 融合到各产品中。

技术领域

  • 可以做代码生成
    • 基于低代码 DSL 的生成
    • hive、sql 语句的生成,这里要解决的问题是,转移范式,用户只需要描述场景,屏蔽相关具体库表的细节
    • Proocde 生成
  • 问题处理:自动归因,智能修复
  • 上下文提炼:智能对话助手、Oncall 排查助手

创业方向寻找

全球目前经过 2 年的厮杀之后,AI 这场游戏,上桌的国家目前基本只剩下中美两国。相比于美国来说,国内互联网公司对于 Agent 应用竞争力更强。实际表现于产品的表达升级,很多 Agent 能力快速融入了各类亿级 DAU App 中,对于非大厂的创业者来说,创业简直就是炼狱难度了。

如果有创业的念头,建议还是考虑国外人均消费高的发展中国家,如阿根廷、巴西等南美国家,或亚洲日本、泰国等国家。

我们可以做的方向有以下等:

  • 数据总结:帮助用户更快的理解数据,加工数据,如果做的好的话,用户会对这个效果买单
  • Agent 应用:如 AI 客服等系统,这种对 b 端用户来说,可以大幅释放人力,只要把精准率做上去,竞争力就很强,这种的典型是 Manus 这类产品了,当然年末也刚刚被 Meta 收购了,在应用这块是中美差距的重点,也是 Meta 和 Google 等大厂之间的核心差距

关于自己 - 旅行记录

今年一共去了 9 个国家 2 个地区,遍及欧洲非洲东南亚,简单记录一下自己的感受吧

非洲

🇪🇬 埃及

埃及对于一个人自由行还是很友好的,一路预约不同司机的包车,飞驰在戈壁的高速上,真的就很有自由的感觉。有黄沙漫天,还有单手打电话飙车的司机师傅,还有看到亚洲面孔就冲上来拍合照的埃及本地人,这就是埃及,他拥有独特的特色还有历史底蕴,值得去探索。

  • 景点管理:政府对景区还是投入了资源的,比如统一了门票管理,另外在景区周围可以看到有很多持枪军警管理秩序。不过,比如埃博里的展品,还有卢克索这些地方的,很多都没有做好防护,还是有文物损毁风险的
  • 价钱低廉:打车尤其是包车真的很便宜,indrive 的杀价简直疯狂,尤其是当时 app 的昵称叫穆罕默德,很多司机就按当地人价格来开价,吃饭还有住宿的价钱也是非常便宜了,红海的全包式住宿基本在东南亚都很少有,还有 90 块钱的出海包餐一日游,极为划算
  • 秩序混乱:进入民众的日常生活,还是有一丝丝混乱的,比如开罗闯红绿灯的汽车,还有单手打电话的司机师傅,还有一路上要 1 dollar 的老人和小孩,也是经济问题导致的。
  • 国家全景:一路从阿斯旺往北到开罗,可以感受到越往南越穷,越干旱缺水,往北才可以看到一些高楼与绿洲

🇰🇪 肯尼亚:

  • 整体体验:全程是联系的非洲地接,走的全包 safari 团,2300 刀,所以其实并没有深入当地民众的生活。各式各样的国家公园,对于初次看到真实的动物世界的我来说的确很震撼。从干燥扬沙的安博塞利一路经过纳瓦沙、纳库鲁到马赛马拉,见证乞力马扎罗下的象群,纳瓦沙的新月岛,马赛马拉的狮王。还享受了 2 次在马赛马拉露营午餐,下面的这次的行程

暂时无法在飞书文档外展示此内容

  • 酒店:全程住的 5 星酒店,比预期中的好太多了,每天都有自助餐,可以在酒店里看河马、斑马、鸵鸟,在酒店就可以看到马赛马拉草原的动物,即便是帐篷酒店,也是热水、洗澡这些应有尽有,在帐篷外就可以仰望星空,但不得不提的是缺点是全程 wifi 信号很差,基本上没有。可能也是为了让旅客尽可能放松,享受 safari 的旅程吧
  • 交通:从内罗毕去马赛马拉的路上,大货车很多,会压着吉普的速度,司机会不得不超车,其实是很危险,很多人会因为躲对向大货车而翻车,所以东非这些国家,如果考虑安全性的话,还是少去,一次性尽量的都玩到
  • 路途:司机会带着在公园内寻找动物,本地人之间说话会用肯尼亚语,但基本上每个人都会说英语。去安博塞利的路非常难走

欧洲

🇦🇹 奥地利

  • 整体印象:相比与维也纳,湖区是很让人流连忘返,可以算是瑞士平替。如果想远离尘嚣,就躺平在巴德伊舍这种地方,一条河把小镇隔开,每周都会开一次市集,也是最热闹的时候
  • 交通:
    • 公交车:宁静的湖区中转站巴德伊舍,可以坐公交车,去任何附近的地方,比如圣沃尔夫冈、哈尔施塔特、戈绍等,公交车发车就在火车站旁边,很方便。
    • 铁路:奥地利的 OBB 火车系统也很发达,软件上清晰的展示去任何地方方式,但想吐槽的是换乘时间真的是太短了,基本都在 3 - 8 分钟之间,如果上一趟有延误,对赶下一趟车就很赶了

🇨🇿 捷克

  • 捷克只去了 CK 小镇,从奥地利湖区巴德伊舍出发,坐奥铁到 Linz,林茨市内有公交车能到 Flixbus 的上车点,再坐 flixbus 可以直接到 CK 小镇,整体单程耗费大约 4 小时时间,当天出发的时候天气还挺阴的,但到 CK 小镇之后,放晴了!一切都是值得的,flixbus 会提供往返大巴,大概能在 ck 小镇玩 3 个小时
  • 到小镇城堡的顶部,可以一眼望到远处的高山草甸,与 ck 小镇整体融合成绝美的画卷。再顺着城堡往上走,还有一个小花园,很不错

🇩🇪 德国

  • 印象:总的来说相比于西班牙人、意大利人,德国人还是比较的高冷,整个国家有点像北京的感觉,不知是不是 10 月的原因,整体在德国期间的天气不是特别好,一直是比较阴天的状态,不过也和慕尼黑整体的色调有点搭。
  • 在慕尼黑住 2 天,其中一天坐 Flixbus 去新天鹅堡,这也是此行的主要目的,大巴会把大家拉到城堡底下的小镇,接下来顺着人流一路爬升,可以到新天鹅堡的大门,可惜没有提前买票,也就没有入内了,城堡主要是从外部看比较震撼。
  • 那如何才能抵达下图拍照的地点呢,其实是在玛利亚桥,但这个桥其实只是一个晃晃悠悠的小桥,真的站上去的时候还挺让人紧张的,旁边也会有牌子显示当前的人数,是会做限流的,站在桥上往下俯瞰,德国乡村的平原尽收眼底,虽然天气不是很好,但也能想象出太阳出来时候下面绿油油的感觉了,德皇修在这里的选址还是很不错的

🇫🇷 法国

  • 已经是第二次去法国了,这次主要是去圣米歇尔山,山上的修道院很有中世纪的感觉,但山下的小羊才是旅途中的惊喜。唯一缺点是从巴黎往返要 8 个小时,待不了太久,有点小遗憾。
  • 牧场进来的人不多,在吊桥出来之后,有一扇小门,只需要把门栓打开就可以进去(记得随手关上),如果是小羊肖恩爱好者强烈建议在这里多停留一些时间,非常治愈。

东南亚

🇲🇻 马尔代夫

目前去过的最顶级的海岛,从迪拜转机,飞的时候坐在左侧,视角会很好,可以欣赏到美丽的岛景 下午 4 点落地,出了机场之后就是码头,有很多船开往不同的居民岛,这次去的是马富士,快艇开了将近 1 个小时到达,码头旁边有很多家出海项目的店,可以砍价5🔪左右一般,其中 kanni icom arena 家比较火,如果不想要太多人,可以考虑 ocean 等小店

  • day1 行程:shark bay 早上 8 点 30,下午3点返回,晚上 5 点开始夜钓,钓了 5 条,包晚饭

    • shark 55 刀,包 video
    • 夜钓 25 刀,大概有 2 个小时左右时间调
  • day2 行程:sun siyam 双鱼岛一日游,115刀 salt beach hotel 家,包午饭,早上 8 点集合,下午 5 点返程

    岛由几个小个岛,旧岛和梦岛,新岛构成,套餐不包含新岛,岛上的摆渡车可以任意乘坐,可以参观水屋的栈道,有 3 个浮潜点可以浮潜。但岛上的项目会比较贵,200 多刀起步

  • day3 行程:Ocean vista hotel 浮潜花园 25刀

    第一个潜点看不到鱼 第二个潜点不错,在某个度假岛旁边,可以看到一些鱼,但水下的能见度不太好 第三个点拉到无人沙洲,和 shark bay 不一样的是,不会直接停靠在沙洲旁,需要蹚水到沙洲上,会比较扎脚,但风景绝美,值得

    马富士参加了 3 天出海项目,分别是护士鲨+无人沙洲,在双鱼岛

出海项目是在马富士玩的,kanni 主要是出片占的时间会比较多,如果对出片不感兴趣,可以报别人家的

🇸🇬 新加坡

  • 新加坡的体验从出了机场的地铁开始,从地铁上明确的罚款提示,可以看出社会秩序的约束,地铁是在高架上的,所以一路上可以看到两侧各种彩色的房子,南洋的感觉扑面而来。
  • 华人主导的国家就是基本说中文就可以完成日常生活的沟通,另外每个人的英文说的都非常溜。

🇲🇾 马来西亚

  • 飞到吉隆坡的时候是晚上了,从上空俯瞰夜景其实就很震惊了,灯光看起来有点像东京那种特大城市的感觉。airbnb 上伊顿公寓的民宿很多,很多都可以在顶层的无边泳池在夜晚还有白天看双子塔。
  • 10 月的吉隆坡还是很闷热,只要不在购物中心里就一直在流汗,对北方人这气候简直是受不了
  • 吉隆坡整体建设还是很繁华的,中国文化入侵很严重,地铁里打中国手游的一大片,支付宝也是很多商户都有接入

中国

🇭🇰 香港

  • 和日本一样的窄街道,一切都是那么原汁原味,饭其实没有网上说的贵,一倍的价钱但能吃到饱,有机会再去一趟香港的海岛,因为城市实在是太臃肿了,大城市的吸引力没有那么大

🇲🇴 澳门

  • 住在官也街,楼下的小街道很有葡式特色,坐着轻轨从氹仔岛去老城,衣湾斜巷,相比香港更喜欢澳门多一些,原因还是人少,节奏慢,生活的气息浓厚

今年解锁了很多新的国家,看到了很多新的风景,看到许多历史课上老师曾经讲到的实实在在发生过的痕迹,也享受了 Safari 过程的洒脱,再次见证了阿尔卑斯山另一侧的绝美湖区风景。

希望自己一直能这么自由,享受走在路上的过程,感谢这美好的 27 岁,感受到世界每一个角落的发生的一切。

每日一题-分割正方形 II🔴

2026年1月14日 00:00

给你一个二维整数数组 squares ,其中 squares[i] = [xi, yi, li] 表示一个与 x 轴平行的正方形的左下角坐标和正方形的边长。

找到一个最小的 y 坐标,它对应一条水平线,该线需要满足它以上正方形的总面积 等于 该线以下正方形的总面积。

答案如果与实际答案的误差在 10-5 以内,将视为正确答案。

注意:正方形 可能会 重叠。重叠区域只 统计一次 

 

示例 1:

输入: squares = [[0,0,1],[2,2,1]]

输出: 1.00000

解释:

任何在 y = 1y = 2 之间的水平线都会有 1 平方单位的面积在其上方,1 平方单位的面积在其下方。最小的 y 坐标是 1。

示例 2:

输入: squares = [[0,0,2],[1,1,1]]

输出: 1.00000

解释:

由于蓝色正方形和红色正方形有重叠区域且重叠区域只统计一次。所以直线 y = 1 将正方形分割成两部分且面积相等。

 

提示:

  • 1 <= squares.length <= 5 * 104
  • squares[i] = [xi, yi, li]
  • squares[i].length == 3
  • 0 <= xi, yi <= 109
  • 1 <= li <= 109
  • 所有正方形的总面积不超过 1015

Lazy 线段树 + 扫描线(Python/Java/C++/Go)

作者 endlesscheng
2025年2月17日 17:23

前置题目850. 矩形面积 II我的题解

首先用 850 题的扫描线方法,求出所有正方形的面积并 $\textit{totArea}$。

然后再次扫描,设扫描线下方的面积和为 $\textit{area}$,那么扫描线上方的面积和为 $\textit{totArea} - \textit{area}$。

题目要求

$$
\textit{area} = \textit{totArea} - \textit{area}
$$

$$
\textit{area}\cdot 2 = \textit{totArea}
$$

设当前扫描线的纵坐标为 $y$,下一个需要经过的正方形上/下边界的纵坐标为 $y'$,被至少一个正方形覆盖的底边长之和为 $\textit{sumLen}$,那么新的面积和为

$$
\textit{area} + \textit{sumLen} \cdot (y'-y)
$$

如果发现

$$
(\textit{area} + \textit{sumLen} \cdot (y'-y))\cdot 2 \ge \textit{totArea}
$$

取等号,解得

$$
y' = y + \dfrac{\textit{totalArea}/2 - \textit{area}}{\textit{sumL}} = y + \dfrac{\textit{totalArea} - \textit{area}\cdot 2}{\textit{sumL}\cdot 2}
$$

即为答案。

编程技巧:把第一次扫描过程中的关键数据 $\textit{area}$ 和 $\textit{sumLen}$ 记录到一个数组中,然后遍历数组(或者二分),这样可以避免跑两遍线段树(空间换时间)。

###py

class Node:
    __slots__ = 'l', 'r', 'min_cover_len', 'min_cover', 'todo'

    def __init__(self):
        self.l = 0
        self.r = 0
        self.min_cover_len = 0  # 区间内被矩形覆盖次数最少的底边长之和
        self.min_cover = 0      # 区间内被矩形覆盖的最小次数
        self.todo = 0           # 子树内的所有节点的 min_cover 需要增加的量,注意这可以是负数


class SegmentTree:
    def __init__(self, xs: List[int]):
        n = len(xs) - 1  # xs.size() 个横坐标有 xs.size()-1 个差值
        self.seg = [Node() for _ in range(2 << (n - 1).bit_length())]
        self.build(xs, 1, 0, n - 1)

    def get_uncovered_length(self) -> int:
        return 0 if self.seg[1].min_cover else self.seg[1].min_cover_len

    # 根据左右儿子的信息,更新当前节点的信息
    def maintain(self, o: int) -> None:
        lo = self.seg[o * 2]
        ro = self.seg[o * 2 + 1]
        mn = min(lo.min_cover, ro.min_cover)
        self.seg[o].min_cover = mn
        # 只统计等于 min_cover 的底边长之和
        self.seg[o].min_cover_len = (lo.min_cover_len if lo.min_cover == mn else 0) + \
                                    (ro.min_cover_len if ro.min_cover == mn else 0)

    # 仅更新节点信息,不下传懒标记 todo
    def do(self, o: int, v: int) -> None:
        self.seg[o].min_cover += v
        self.seg[o].todo += v

    # 下传懒标记 todo
    def spread(self, o: int) -> None:
        v = self.seg[o].todo
        if v:
            self.do(o * 2, v)
            self.do(o * 2 + 1, v)
            self.seg[o].todo = 0

    # 建树
    def build(self, xs: List[int], o: int, l: int, r: int) -> None:
        self.seg[o].l = l
        self.seg[o].r = r
        if l == r:
            self.seg[o].min_cover_len = xs[l + 1] - xs[l]
            return
        m = (l + r) // 2
        self.build(xs, o * 2, l, m)
        self.build(xs, o * 2 + 1, m + 1, r)
        self.maintain(o)

    # 区间更新
    def update(self, o: int, l: int, r: int, v: int) -> None:
        if l <= self.seg[o].l and self.seg[o].r <= r:
            self.do(o, v)
            return
        self.spread(o)
        m = (self.seg[o].l + self.seg[o].r) // 2
        if l <= m:
            self.update(o * 2, l, r, v)
        if m < r:
            self.update(o * 2 + 1, l, r, v)
        self.maintain(o)


# 代码逻辑同 850 题,增加一个 records 数组记录关键数据
class Solution:
    def separateSquares(self, squares: List[List[int]]) -> float:
        xs = []
        events = []
        for lx, y, l in squares:
            rx = lx + l
            xs.append(lx)
            xs.append(rx)
            events.append((y, lx, rx, 1))
            events.append((y + l, lx, rx, -1))

        # 排序,方便离散化
        xs = sorted(set(xs))

        # 初始化线段树
        t = SegmentTree(xs)

        # 模拟扫描线从下往上移动
        events.sort(key=lambda e: e[0])
        records = []
        tot_area = 0
        for (y, lx, rx, delta), e2 in pairwise(events):
            l = bisect_left(xs, lx)  # 离散化
            r = bisect_left(xs, rx) - 1  # r 对应着 xs[r] 与 xs[r+1]=rx 的差值
            t.update(1, l, r, delta)  # 更新被 [lx, rx] 覆盖的次数
            sum_len = xs[-1] - xs[0] - t.get_uncovered_length()  # 减去没被矩形覆盖的长度
            records.append((tot_area, sum_len))  # 记录关键数据
            tot_area += sum_len * (e2[0] - y)  # 新增面积 = 被至少一个矩形覆盖的底边长之和 * 矩形高度

        # 二分找最后一个 < tot_area / 2 的面积
        i = bisect_left(records, tot_area, key=lambda r: r[0] * 2) - 1
        area, sum_len = records[i]
        return events[i][0] + (tot_area - area * 2) / (sum_len * 2)

###java

class SegmentTree {
    private final int n;
    private final int[] minCoverLen; // 区间内被矩形覆盖次数最少的底边长之和
    private final int[] minCover;    // 区间内被矩形覆盖的最小次数
    private final int[] todo;        // 子树内的所有节点的 minCover 需要增加的量,注意这可以是负数

    public SegmentTree(int[] xs) {
        n = xs.length - 1; // xs.length 个横坐标有 xs.length-1 个差值
        int size = 2 << (32 - Integer.numberOfLeadingZeros(n - 1));
        minCoverLen = new int[size];
        minCover = new int[size];
        todo = new int[size];
        build(xs, 1, 0, n - 1);
    }

    public void update(int l, int r, int v) {
        update(1, 0, n - 1, l, r, v);
    }

    public int getUncoveredLength() {
        return minCover[1] == 0 ? minCoverLen[1] : 0;
    }

    // 根据左右儿子的信息,更新当前节点的信息
    private void maintain(int o) {
        int mn = Math.min(minCover[o * 2], minCover[o * 2 + 1]);
        minCover[o] = mn;
        // 只统计等于 mn 的底边长之和
        minCoverLen[o] = (minCover[o * 2] == mn ? minCoverLen[o * 2] : 0) +
                (minCover[o * 2 + 1] == mn ? minCoverLen[o * 2 + 1] : 0);
    }

    // 仅更新节点信息,不下传懒标记 todo
    private void do_(int o, int v) {
        minCover[o] += v;
        todo[o] += v;
    }

    // 下传懒标记 todo
    private void spread(int o) {
        if (todo[o] != 0) {
            do_(o * 2, todo[o]);
            do_(o * 2 + 1, todo[o]);
            todo[o] = 0;
        }
    }

    // 建树
    private void build(int[] xs, int o, int l, int r) {
        if (l == r) {
            minCoverLen[o] = xs[l + 1] - xs[l];
            return;
        }
        int m = (l + r) / 2;
        build(xs, o * 2, l, m);
        build(xs, o * 2 + 1, m + 1, r);
        maintain(o);
    }

    // 区间更新
    private void update(int o, int l, int r, int ql, int qr, int v) {
        if (ql <= l && r <= qr) {
            do_(o, v);
            return;
        }
        spread(o);
        int m = (l + r) / 2;
        if (ql <= m) {
            update(o * 2, l, m, ql, qr, v);
        }
        if (m < qr) {
            update(o * 2 + 1, m + 1, r, ql, qr, v);
        }
        maintain(o);
    }
}

// 代码逻辑同 850 题,增加一个 records 数组记录关键数据
class Solution {
    private record Event(int y, int lx, int rx, int delta) {
    }

    private record Record(long area, int sumLen) {
    }

    public double separateSquares(int[][] squares) {
        int n = squares.length * 2;
        int[] xs = new int[n];
        Event[] events = new Event[n];
        n = 0;
        for (int[] sq : squares) {
            int lx = sq[0];
            int y = sq[1];
            int l = sq[2];
            int rx = lx + l;
            xs[n] = lx;
            xs[n + 1] = rx;
            events[n++] = new Event(y, lx, rx, 1);
            events[n++] = new Event(y + l, lx, rx, -1);
        }

        // 排序,方便离散化
        Arrays.sort(xs);

        // 初始化线段树
        SegmentTree t = new SegmentTree(xs);

        // 模拟扫描线从下往上移动
        Arrays.sort(events, (a, b) -> a.y - b.y);
        Record records[] = new Record[n - 1];
        long totArea = 0;
        for (int i = 0; i < n - 1; i++) {
            Event e = events[i];
            int l = Arrays.binarySearch(xs, e.lx); // 离散化
            int r = Arrays.binarySearch(xs, e.rx) - 1; // r 对应着 xs[r] 与 xs[r+1]=rx 的差值
            t.update(l, r, e.delta); // 更新被 [lx, rx] 覆盖的次数
            int sumLen = xs[n - 1] - xs[0] - t.getUncoveredLength(); // 减去没被矩形覆盖的长度
            records[i] = new Record(totArea, sumLen);
            totArea += (long) sumLen * (events[i + 1].y - e.y); // 新增面积 = 被至少一个矩形覆盖的底边长之和 * 矩形高度
        }

        // 找最后一个 < totArea / 2 的面积
        int i = 0;
        while (i < n - 1 && records[i].area * 2 < totArea) {
            i++;
        }
        i--;
        return events[i].y + (totArea - records[i].area * 2) / (records[i].sumLen * 2.0);
    }
}

###cpp

class SegmentTree {
public:
    SegmentTree(vector<int>& xs) {
        unsigned n = xs.size() - 1; // xs.size() 个横坐标有 xs.size()-1 个差值
        seg.resize(2 << bit_width(n - 1));
        build(xs, 1, 0, n - 1);
    }

    void update(int l, int r, int v) {
        update(1, l, r, v);
    }

    int get_uncovered_length() {
        return seg[1].min_cover ? 0 : seg[1].min_cover_len;
    }

private:
    struct Node {
        int l, r;
        int min_cover_len = 0; // 区间内被矩形覆盖次数最少的底边长之和
        int min_cover = 0;     // 区间内被矩形覆盖的最小次数
        int todo = 0;          // 子树内的所有节点的 min_cover 需要增加的量,注意这可以是负数
    };

    vector<Node> seg;

    // 根据左右儿子的信息,更新当前节点的信息
    void maintain(int o) {
        Node& lo = seg[o * 2];
        Node& ro = seg[o * 2 + 1];
        int mn = min(lo.min_cover, ro.min_cover);
        seg[o].min_cover = mn;
        // 只统计等于 min_cover 的底边长之和
        seg[o].min_cover_len = (lo.min_cover == mn ? lo.min_cover_len : 0) +
                               (ro.min_cover == mn ? ro.min_cover_len : 0);
    }

    // 仅更新节点信息,不下传懒标记 todo
    void do_(int o, int v) {
        seg[o].min_cover += v;
        seg[o].todo += v;
    }

    // 下传懒标记 todo
    void spread(int o) {
        int& v = seg[o].todo;
        if (v != 0) {
            do_(o * 2, v);
            do_(o * 2 + 1, v);
            v = 0;
        }
    }

    // 建树
    void build(vector<int>& xs, int o, int l, int r) {
        seg[o].l = l;
        seg[o].r = r;
        if (l == r) {
            seg[o].min_cover_len = xs[l + 1] - xs[l];
            return;
        }
        int m = (l + r) / 2;
        build(xs, o * 2, l, m);
        build(xs, o * 2 + 1, m + 1, r);
        maintain(o);
    }

    // 区间更新
    void update(int o, int l, int r, int v) {
        if (l <= seg[o].l && seg[o].r <= r) {
            do_(o, v);
            return;
        }
        spread(o);
        int m = (seg[o].l + seg[o].r) / 2;
        if (l <= m) {
            update(o * 2, l, r, v);
        }
        if (m < r) {
            update(o * 2 + 1, l, r, v);
        }
        maintain(o);
    }
};

// 代码逻辑同 850 题,增加一个 records 数组记录关键数据
class Solution {
public:
    double separateSquares(vector<vector<int>>& squares) {
        vector<int> xs;
        struct Event { int y, lx, rx, delta; };
        vector<Event> events;
        for (auto& sq : squares) {
            int lx = sq[0], y = sq[1], l = sq[2];
            int rx = lx + l;
            xs.push_back(lx);
            xs.push_back(rx);
            events.emplace_back(y, lx, rx, 1);
            events.emplace_back(y + l, lx, rx, -1);
        }

        // 排序去重,方便离散化
        ranges::sort(xs);
        xs.erase(ranges::unique(xs).begin(), xs.end());

        // 初始化线段树
        SegmentTree t(xs);

        // 模拟扫描线从下往上移动
        ranges::sort(events, {}, &Event::y);
        vector<pair<long long, int>> records(events.size() - 1);
        long long tot_area = 0;
        for (int i = 0; i + 1 < events.size(); i++) {
            auto& [y, lx, rx, delta] = events[i];
            int l = ranges::lower_bound(xs, lx) - xs.begin(); // 离散化
            int r = ranges::lower_bound(xs, rx) - xs.begin() - 1; // r 对应着 xs[r] 与 xs[r+1]=rx 的差值
            t.update(l, r, delta); // 更新被 [lx, rx] 覆盖的次数
            int sum_len = xs.back() - xs[0] - t.get_uncovered_length(); // 减去没被矩形覆盖的长度
            records[i] = {tot_area, sum_len};
            tot_area += 1LL * sum_len * (events[i + 1].y - y); // 新增面积 = 被至少一个矩形覆盖的底边长之和 * 矩形高度
        }

        // 二分找最后一个 < tot_area / 2 的面积
        int i = ranges::lower_bound(records, tot_area, {}, [](auto& p) { return p.first * 2; }) - records.begin() - 1;
        auto [area, sum_len] = records[i];
        return events[i].y + (tot_area - area * 2) / (sum_len * 2.0);
    }
};

###go

// 线段树每个节点维护一段横坐标区间 [lx, rx]
type seg []struct {
    l, r        int
    minCoverLen int // 区间内被矩形覆盖次数最少的底边长之和
    minCover    int // 区间内被矩形覆盖的最小次数
    todo        int // 子树内的所有节点的 minCover 需要增加的量,注意这可以是负数
}

// 根据左右儿子的信息,更新当前节点的信息
func (t seg) maintain(o int) {
    lo, ro := &t[o<<1], &t[o<<1|1]
    mn := min(lo.minCover, ro.minCover)
    t[o].minCover = mn
    t[o].minCoverLen = 0
    if lo.minCover == mn { // 只统计等于 minCover 的底边长之和
        t[o].minCoverLen = lo.minCoverLen
    }
    if ro.minCover == mn {
        t[o].minCoverLen += ro.minCoverLen
    }
}

// 仅更新节点信息,不下传懒标记 todo
func (t seg) do(o, v int) {
    t[o].minCover += v
    t[o].todo += v
}

// 下传懒标记 todo
func (t seg) spread(o int) {
    v := t[o].todo
    if v != 0 {
        t.do(o<<1, v)
        t.do(o<<1|1, v)
        t[o].todo = 0
    }
}

// 建树
func (t seg) build(xs []int, o, l, r int) {
    t[o].l, t[o].r = l, r
    t[o].todo = 0
    if l == r {
        t[o].minCover = 0
        t[o].minCoverLen = xs[l+1] - xs[l]
        return
    }
    m := (l + r) >> 1
    t.build(xs, o<<1, l, m)
    t.build(xs, o<<1|1, m+1, r)
    t.maintain(o)
}

// 区间更新
func (t seg) update(o, l, r, v int) {
    if l <= t[o].l && t[o].r <= r {
        t.do(o, v)
        return
    }
    t.spread(o)
    m := (t[o].l + t[o].r) >> 1
    if l <= m {
        t.update(o<<1, l, r, v)
    }
    if m < r {
        t.update(o<<1|1, l, r, v)
    }
    t.maintain(o)
}

// 代码逻辑同 850 题,增加一个 records 数组记录关键数据
func separateSquares(squares [][]int) float64 {
    m := len(squares) * 2
    xs := make([]int, 0, m)
    type event struct{ y, lx, rx, delta int }
    events := make([]event, 0, m)
    for _, sq := range squares {
        lx, y, l := sq[0], sq[1], sq[2]
        rx := lx + l
        xs = append(xs, lx, rx)
        events = append(events, event{y, lx, rx, 1}, event{y + l, lx, rx, -1})
    }

    // 排序去重,方便离散化
    slices.Sort(xs)
    xs = slices.Compact(xs)

    // 初始化线段树
    n := len(xs) - 1 // len(xs) 个横坐标有 len(xs)-1 个差值
    t := make(seg, 2<<bits.Len(uint(n-1)))
    t.build(xs, 1, 0, n-1)

    // 模拟扫描线从下往上移动
    slices.SortFunc(events, func(a, b event) int { return a.y - b.y })
    type pair struct{ area, sumLen int }
    records := make([]pair, m-1)
    totArea := 0
    for i, e := range events[:m-1] {
        l := sort.SearchInts(xs, e.lx)
        r := sort.SearchInts(xs, e.rx) - 1 // 注意 r 对应着 xs[r] 与 xs[r+1]=e.rx 的差值
        t.update(1, l, r, e.delta)         // 更新被 [e.lx, e.rx] 覆盖的次数
        sumLen := xs[len(xs)-1] - xs[0]    // 总的底边长度
        if t[1].minCover == 0 {            // 需要去掉没被矩形覆盖的长度
            sumLen -= t[1].minCoverLen
        }
        records[i] = pair{totArea, sumLen} // 记录关键数据
        totArea += sumLen * (events[i+1].y - e.y) // 新增面积 = 被至少一个矩形覆盖的底边长之和 * 矩形高度
    }

    // 二分找最后一个 < totArea / 2 的面积
    i := sort.Search(m-1, func(i int) bool { return records[i].area*2 >= totArea }) - 1
    return float64(events[i].y) + float64(totArea-records[i].area*2)/float64(records[i].sumLen*2)
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log n)$,其中 $n$ 是 $\textit{squares}$ 的长度。
  • 空间复杂度:$\mathcal{O}(n)$。

更多相似题目,见下面数据结构题单中的「§8.4 Lazy 线段树」。

分类题单

如何科学刷题?

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

我的题解精选(已分类)

扫描线 & 线段树,详解矩形面积并

作者 tsreaper
2025年2月16日 14:01

解法:扫描线 & 线段树

我们先把所有正方形并集的面积求出来。这是非常经典的扫描线模板题:矩形面积并。

graph.png

我们以上图的三个矩形为例,讲述如何求它们面积的并。我们对每个矩形的上边界和下边界都画一条水平线,可以看出,相邻水平线之间,矩形的横截长度都是一样的。因此,相邻水平线之间矩形面积的并,就等于横截长度乘以水平线的高度差。问题转为如何求矩形的横截长度。

我们来看相邻的水平线之间,矩形的横截长度发生了什么变化。

  • 在 $y = 1$ 时,横截长度只是区间 $[1, 6]$ 的长度,即 $5$。所以 $y = 1$ 到 $y = 3$ 之间的面积是 $(3 - 1) \times 5 = 10$。
  • 到了 $y = 3$ 时,由于新矩形的加入,区间数量增加了一个,变成 $[1, 6] \cup [9, 14]$ 的长度,即 $10$。所以 $y = 3$ 到 $y = 4$ 之间的面积是 $(4 - 3) \times 10 = 10$。
  • 到了 $y = 4$ 时,由于新矩形的加入,区间数量又增加了一个,变成 $[1, 6] \cup [4, 11] \cup [9, 14] = [1, 14]$ 的长度,即 $13$。所以 $y = 4$ 到 $y = 7$ 之间的面积是 $(7 - 4) \times 13 = 39$。
  • 到了 $y = 7$ 时,由于一个矩形的退出,区间的数量减少了一个,变成 $[1, 6] \cup [9, 14]$ 的长度,即 $10$。所以 $y = 7$ 到 $y = 8$ 之间的面积是 $(8 - 7) \times 10 = 10$。
  • 到了 $y = 8$ 时,由于一个矩形的退出,区间的数量又减少了一个,变成 $[1, 6]$ 的长度,即 $5$。所以 $y = 8$ 到 $y = 10$ 之间的面积是 $(10 - 8) \times 5 = 10$。

所以矩形面积并为 $10 + 10 + 39 + 10 + 10 = 79$。

从上面的例子可以看出,横截长度的变化,其实就是维护一个区间的集合。每次我要加入或删除一个区间,然后求区间并集的长度。

$n$ 个区间的端点只有 $2n$ 种,比如上面的例子中,$3$ 个区间的端点只有 $x = 1, 4, 6, 9, 11, 14$ 这 $6$ 种。因此我们可以把相邻端点看成一个小区间,这样任何的区间就只会由若干个不相交(不计端点相交,因为对长度不影响)的小区间构成。比如上面的例子中,我们有 $5$ 个小区间 $a_1 = [1, 4], a_2 = [4, 6], a_3 = [6, 9], a_4 = [9, 11], a_5 = [11, 14]$。那么 $[1, 6] = a_1 \cup a_2$,$[4, 11] = a_2 \cup a_3 \cup a_4$,$[9, 14] = a_4 \cup a_5$。

因为我们只对区间的长度有兴趣,所以我们直接把每个区间转为长度,例如 $a_1 = 3, a_2 = 2, \cdots$。这样我们就成功把区间问题转为了序列问题:给定 $(2n - 1)$ 个数 $a_1, a_2, \cdots, a_{2n - 1}$,其中第 $i$ 个数被覆盖了 $b_i$ 次,一开始 $b_i = 0$。每次操作会选择一个区间 $[l, r]$,让 $l \le i \le r$ 的 $b_i$ 都增加或减少 $1$。每次操作后,求 $\sum\limits_{b_i \ne 0} a_i$。

区间修改 + 区间查询,大家可能会很快想到用线段树来维护。可是对于一个节点,满足 $b_i \ne 0$ 的 $a_i$ 之和很难维护。比如一个节点代表了下标 $[1, 4]$,目前有 $b_1 = b_2 = b_4 = 1$,$b_3 = 2$。如果我对该节点进行了 $-1$ 操作,我怎么知道只有 $b_1$,$b_2$ 和 $b_4$ 变成了 $0$,而 $b_3$ 还不是 $0$ 呢?

我们发现,直接维护的困难在于:一次操作对于同一节点内的每个元素可能有不同的影响。所以我们要换一种维护方式,只维护能将所有元素一视同仁的值。

对于线段树的一个节点,我们维护:$b_i$ 的最小值 $x$,以及满足 $b_i = x$ 的 $a_i$ 之和 $s$。为什么这样维护可以呢?因为对节点进行 $+v$ 操作,只会让 $x$ 增加 $v$,而不影响 $s$ 的值。那 $\sum\limits_{b_i \ne 0} a_i$ 怎么求呢?设节点对应的元素总和为 $t$,那么当 $b_i = 0$ 时,答案就是 $t - s$,否则答案就是 $t$。

因为操作涉及到了区间修改 + 区间查询,我们可以用懒标记下推线段树完成操作的维护。当然,这种线段树的维护方式不是最高效的,事实上还可以利用每次添加区间的操作一定会被撤回的性质,写出一种不需要懒标记下推,非常特殊的线段树,见 leetcode 850. 矩形面积 II 的官方题解。这里只介绍懒标记下推线段树的维护方法,是因为懒标记下推是最为常用,最为“标准”的线段树,比较好理解。

这样我们就成功维护了横截长度。最后的问题是找到最下面的水平线,使得水平线下方的面积并减去水平线上方的面积并大等于 $0$。

假设我们枚举到水平线 $y = t_1$ 和 $y = t_2$ 之间时,横截长度为 $l$,且到了 $y = t_2$ 之后,这个差值 $d \ge 0$。此时如果水平线往下移 $\Delta t$,这个差值会减少 $2l\Delta t$,因此答案就是 $t - \frac{d}{2l}$。

复杂度 $\mathcal{O}(n\log n)$。

参考代码(c++)

class Solution {
public:
    double separateSquares(vector<vector<int>>& squares) {
        int n = squares.size(), m = 0;
        map<int, int> mp;
        for (auto &sq : squares) mp[sq[0]] = mp[sq[0] + sq[2]] = 1;
        for (auto &p : mp) p.second = m++;
        int A[m];
        for (auto &p : mp) A[p.second] = p.first;

        struct Node {
            // mn:当前节点的最小覆盖数
            // len:满足覆盖数 = 最小覆盖数的 A[i] 之和
            // lazy:加法的懒标记
            int mn, len, lazy;

            // 对节点的覆盖数整个增加 qv,只影响 mn,不影响 len
            void add(int qv) {
                mn += qv;
                lazy += qv;
            }
        } tree[m * 4 + 5];

        // 线段树两个子节点合并
        auto merge = [&](Node &nl, Node &nr) {
            int mn = min(nl.mn, nr.mn);
            return Node {
                mn,
                (nl.mn == mn ? nl.len : 0) + (nr.mn == mn ? nr.len : 0),
                0
            };
        };

        // 线段树模板开始

        // 建树
        auto build = [&](this auto &&self, int id, int l, int r) -> void {
            if (l == r) tree[id] = { 0, A[r] - A[r - 1], 0 };
            else {
                int nxt = id << 1, mid = (l + r) >> 1;
                self(nxt, l, mid); self(nxt | 1, mid + 1, r);
                tree[id] = merge(tree[nxt], tree[nxt | 1]);
            }
        };

        // 懒标记下推
        auto down = [&](int id) {
            if (tree[id].lazy == 0) return;
            int nxt = id << 1;
            tree[nxt].add(tree[id].lazy);
            tree[nxt | 1].add(tree[id].lazy);
            tree[id].lazy = 0;
        };

        // 区间加减覆盖次数
        auto modify = [&](this auto &&self, int id, int l, int r, int ql, int qr, int qv) -> void {
            if (ql <= l && r <= qr) tree[id].add(qv);
            else {
                down(id);
                int nxt = id << 1, mid = (l + r) >> 1;
                if (ql <= mid) self(nxt, l, mid, ql, qr, qv);
                if (qr > mid) self(nxt | 1, mid + 1, r, ql, qr, qv);
                tree[id] = merge(tree[nxt], tree[nxt | 1]);
            }
        };

        // 线段树模板结束

        // 把正方形的上下边界取出来
        vector<array<int, 4>> vec;
        for (auto &sq : squares) {
            vec.push_back({sq[1], mp[sq[0]] + 1, mp[sq[0] + sq[2]], 1});
            vec.push_back({sq[1] + sq[2], mp[sq[0]] + 1, mp[sq[0] + sq[2]], -1});
        }
        sort(vec.begin(), vec.end());

        // 求总的面积并
        long long tot = 0;
        build(1, 1, m - 1);
        for (int i = 0; i + 1 < vec.size(); i++) {
            // 考虑水平线 y = vec[i][0] 和 y = vec[i + 1][0] 之间的情况
            modify(1, 1, m - 1, vec[i][1], vec[i][2], vec[i][3]);
            // 求横截长度
            int len = A[m - 1] - A[0];
            // 如果最小覆盖数是 0,那么扣掉相应的长度
            if (tree[1].mn == 0) len -= tree[1].len;
            // 面积 = 横截长度 * 高度差
            tot += 1LL * len * (vec[i + 1][0] - vec[i][0]);
        }

        long long now = 0;
        build(1, 1, m - 1);
        for (int i = 0; i + 1 < vec.size(); i++) {
            modify(1, 1, m - 1, vec[i][1], vec[i][2], vec[i][3]);
            int len = A[m - 1] - A[0];
            if (tree[1].mn == 0) len -= tree[1].len;
            now += 1LL * len * (vec[i + 1][0] - vec[i][0]);
            // delta 非负了,套公式
            long long det = now - (tot - now);
            if (det >= 0) return vec[i + 1][0] - 0.5 * det / len;
        };
        return -1;
    }
};

两次扫描线解法(跑两次850题)

作者 vclip
2025年2月16日 13:08

这里属于会 850. 矩形面积 II 题就能秒。

如果不动 850 题的代码,直接调用 850 题的话,可以采用二分 $y$ 坐标,然后截断正方形,去除在当前 $y$ 值以上的部分,再用 850 题的方法求面积判断是否达到总面积一半解决。

但实际上也可以两次扫描线解决:先用 850 题的方法求出总面积 $S$,然后用垂直 $y$ 轴的扫描线扫,扫到在 $y_1$ 以下的正方形面积 $S_1$ 小于一半,在 $y_2$ 以下的正方形面积 $S_2$ 大于等于一半时,答案即为 $y_1+\dfrac{S/2-S_1}{w}$,其中 $w$ 为在 $y_1$ 和 $y_2$ 之间的正方形截面长度(即 850 题中线段树维护的长度)。

代码

###C++

class Solution {
public:
    struct Edge {
        int y;
        bool state : 1;
        int x0 : 31, x1;
        auto operator<=>(Edge other) const {
            return y <=> other.y;
        }
    };

    struct Node {
        int cnt, len, sum;
    };

    double separateSquares(const vector<vector<int>>& squares) {
        const int n = squares.size();
        vector<int> ord;
        vector<Edge> edges;
        for (const auto& e : squares) {
            ord.push_back(e[0]);
            ord.push_back(e[0] + e[2]);
            edges.push_back({e[1], true, e[0], e[0] + e[2]});
            edges.push_back({e[1] + e[2], false, e[0], e[0] + e[2]});
        }
        ranges::sort(ord);
        ord.erase(unique(ord.begin(), ord.end()), ord.end());
        const int c = ord.size() - 1;
        for (auto& e : edges) {
            e.x0 = ranges::lower_bound(ord, e.x0) - ord.begin();
            e.x1 = ranges::lower_bound(ord, e.x1) - ord.begin();
        }
        sort(edges.begin(), edges.end());
        const int h = c > 1 ? 32 - __builtin_clz(c - 1) : 0;
        const int m = 1 << h, M = m + c;
        vector<Node> tree(M);
        for (int i = 0;i < c;++i)
            tree[m + i] = {0, ord[i + 1] - ord[i], 0};
        for (int i = 1;i <= h;++i)
            for (int j = 0;j < (c >> i);++j)
                tree[(m >> i) + j] = {0, ord[(j + 1) << i] - ord[j << i], 0};
        const auto update_leaf = [&] (int p, int d) {
            tree[p].sum = (tree[p].cnt += d) > 0 ? tree[p].len : 0;
        };
        const auto update_branch = [&] (int p, int d) {
            tree[p].sum = (tree[p].cnt += d) > 0 ? tree[p].len : tree[2 * p].sum + tree[2 * p + 1].sum;
        };
        const auto update = [&] (int l, int r, int d) {
            const int L = l += m, R = r += m;
            if (l & 1) update_leaf(l++, d);
            if (r & 1) update_leaf(--r, d);
            for (l >>= 1, r >>= 1;l < r;l >>= 1, r >>= 1) {
                if (l & 1) update_branch(l++, d);
                if (r & 1) update_branch(--r, d);
            }
            for (int i = L >> 1, j = M >> 1;i < j;i >>= 1, j >>= 1)
                update_branch(i, 0);
            for (int i = R >> 1, j = M >> 1;i < j;i >>= 1, j >>= 1)
                update_branch(i, 0);
        };
        const auto query = [&] {
            int ans = 0;
            for (int i = m, j = M;i < j;i >>= 1, j >>= 1) {
                if (i & 1) ans += tree[i++].sum;
                if (j & 1) ans += tree[--j].sum;
            }
            return ans;
        };
        long long total = 0;
        {
            int pre = 0;
            for (const auto [x, state, y0, y1] : edges) {
                total += 1ll * query() * (x - pre);
                update(y0, y1, 2 * state - 1);
                pre = x;
            }
        }
        int pre = 0;
        long long sum = 0;
        for (const auto [x, state, y0, y1] : edges) {
            const int w = query();
            const long long nsum = sum + 1ll * w * (x - pre);
            if (2 * nsum >= total) return pre + (total - 2 * sum) / (2.0 * w);
            update(y0, y1, 2 * state - 1);
            sum = nsum;
            pre = x;
        }
        return -1;
    }
};
昨天 — 2026年1月13日技术

Vue 路由信息获取全攻略:8 种方法深度解析

作者 北辰alk
2026年1月13日 22:33

Vue 路由信息获取全攻略:8 种方法深度解析

在 Vue 应用中,获取当前路由信息是开发中的常见需求。本文将全面解析从基础到高级的各种获取方法,并帮助你选择最佳实践。

一、路由信息全景图

在深入具体方法前,先了解 Vue Router 提供的完整路由信息结构:

// 路由信息对象结构
{
  path: '/user/123/profile?tab=info',    // 完整路径
  fullPath: '/user/123/profile?tab=info&token=abc',
  name: 'user-profile',                   // 命名路由名称
  params: {                               // 动态路径参数
    id: '123'
  },
  query: {                                // 查询参数
    tab: 'info',
    token: 'abc'
  },
  hash: '#section-2',                     // 哈希片段
  meta: {                                 // 路由元信息
    requiresAuth: true,
    title: '用户资料'
  },
  matched: [                              // 匹配的路由记录数组
    { path: '/user', component: UserLayout, meta: {...} },
    { path: '/user/:id', component: UserContainer, meta: {...} },
    { path: '/user/:id/profile', component: UserProfile, meta: {...} }
  ]
}

二、8 种获取路由信息的方法

方法 1:$route 对象(最常用)

<template>
  <div>
    <h1>用户详情页</h1>
    <p>用户ID: {{ $route.params.id }}</p>
    <p>当前标签: {{ $route.query.tab || 'default' }}</p>
    <p>需要认证: {{ $route.meta.requiresAuth ? '是' : '否' }}</p>
  </div>
</template>

<script>
export default {
  created() {
    // 访问路由信息
    console.log('路径:', this.$route.path)
    console.log('参数:', this.$route.params)
    console.log('查询:', this.$route.query)
    console.log('哈希:', this.$route.hash)
    console.log('元信息:', this.$route.meta)
    
    // 获取完整的匹配记录
    const matchedRoutes = this.$route.matched
    matchedRoutes.forEach(route => {
      console.log('匹配的路由:', route.path, route.meta)
    })
  }
}
</script>

特点:

  • ✅ 简单直接,无需导入
  • ✅ 响应式变化(路由变化时自动更新)
  • ✅ 在模板和脚本中都能使用

方法 2:useRoute Hook(Vue 3 Composition API)

<script setup>
import { useRoute } from 'vue-router'
import { watch, computed } from 'vue'

// 获取路由实例
const route = useRoute()

// 直接使用
console.log('当前路由路径:', route.path)
console.log('路由参数:', route.params)

// 计算属性基于路由
const userId = computed(() => route.params.id)
const isEditMode = computed(() => route.query.mode === 'edit')

// 监听路由变化
watch(
  () => route.params.id,
  (newId, oldId) => {
    console.log(`用户ID从 ${oldId} 变为 ${newId}`)
    loadUserData(newId)
  }
)

// 监听多个路由属性
watch(
  () => ({
    id: route.params.id,
    tab: route.query.tab
  }),
  ({ id, tab }) => {
    console.log(`ID: ${id}, Tab: ${tab}`)
  },
  { deep: true }
)
</script>

<template>
  <div>
    <h1>用户 {{ userId }} 的资料</h1>
    <nav>
      <router-link :to="{ query: { tab: 'info' } }" 
                   :class="{ active: route.query.tab === 'info' }">
        基本信息
      </router-link>
      <router-link :to="{ query: { tab: 'posts' } }"
                   :class="{ active: route.query.tab === 'posts' }">
        动态
      </router-link>
    </nav>
  </div>
</template>

方法 3:路由守卫中获取

// 全局守卫
router.beforeEach((to, from, next) => {
  // to: 即将进入的路由
  // from: 当前导航正要离开的路由
  
  console.log('前往:', to.path)
  console.log('来自:', from.path)
  console.log('需要认证:', to.meta.requiresAuth)
  
  // 权限检查
  if (to.meta.requiresAuth && !isAuthenticated()) {
    next({
      path: '/login',
      query: { redirect: to.fullPath } // 保存目标路径
    })
  } else {
    next()
  }
})

// 组件内守卫
export default {
  beforeRouteEnter(to, from, next) {
    // 不能访问 this,因为组件实例还没创建
    console.log('进入前:', to.params.id)
    
    // 可以通过 next 回调访问实例
    next(vm => {
      vm.initialize(to.params.id)
    })
  },
  
  beforeRouteUpdate(to, from, next) {
    // 可以访问 this
    console.log('路由更新:', to.params.id)
    this.loadData(to.params.id)
    next()
  },
  
  beforeRouteLeave(to, from, next) {
    // 离开前的确认
    if (this.hasUnsavedChanges) {
      const answer = window.confirm('有未保存的更改,确定离开吗?')
      if (!answer) {
        next(false) // 取消导航
        return
      }
    }
    next()
  }
}

方法 4:$router 对象获取当前路由

export default {
  methods: {
    getCurrentRouteInfo() {
      // 获取当前路由信息(非响应式)
      const currentRoute = this.$router.currentRoute
      
      // Vue Router 4 中的变化
      // const currentRoute = this.$router.currentRoute.value
      
      console.log('当前路由对象:', currentRoute)
      
      // 编程式导航时获取
      this.$router.push({
        path: '/user/456',
        query: { from: currentRoute.fullPath } // 携带来源信息
      })
    },
    
    // 检查是否在特定路由
    isActiveRoute(routeName) {
      return this.$route.name === routeName
    },
    
    // 检查路径匹配
    isPathMatch(pattern) {
      return this.$route.path.startsWith(pattern)
    }
  },
  
  computed: {
    // 基于当前路由的复杂计算
    breadcrumbs() {
      return this.$route.matched.map(route => ({
        name: route.meta?.breadcrumb || route.name,
        path: route.path
      }))
    },
    
    // 获取嵌套路由参数
    nestedParams() {
      const params = {}
      this.$route.matched.forEach(route => {
        Object.assign(params, route.params)
      })
      return params
    }
  }
}

方法 5:通过 Props 传递路由参数(推荐)

// 路由配置
const routes = [
  {
    path: '/user/:id',
    component: UserDetail,
    props: true // 将 params 作为 props 传递
  },
  {
    path: '/search',
    component: SearchResults,
    props: route => ({ // 自定义 props 函数
      query: route.query.q,
      page: parseInt(route.query.page) || 1,
      sort: route.query.sort || 'relevance'
    })
  }
]

// 组件中使用
export default {
  props: {
    // 从路由 params 自动注入
    id: {
      type: [String, Number],
      required: true
    },
    // 从自定义 props 函数注入
    query: String,
    page: Number,
    sort: String
  },
  
  watch: {
    // props 变化时响应
    id(newId) {
      this.loadUser(newId)
    },
    query(newQuery) {
      this.performSearch(newQuery)
    }
  },
  
  created() {
    // 直接使用 props,无需访问 $route
    console.log('用户ID:', this.id)
    console.log('搜索词:', this.query)
  }
}

方法 6:使用 Vuex/Pinia 管理路由状态

// store/modules/route.js (Vuex)
const state = {
  currentRoute: null,
  previousRoute: null
}

const mutations = {
  SET_CURRENT_ROUTE(state, route) {
    state.previousRoute = state.currentRoute
    state.currentRoute = {
      path: route.path,
      name: route.name,
      params: { ...route.params },
      query: { ...route.query },
      meta: { ...route.meta }
    }
  }
}

// 在全局守卫中同步
router.afterEach((to, from) => {
  store.commit('SET_CURRENT_ROUTE', to)
})

// 组件中使用
export default {
  computed: {
    ...mapState({
      currentRoute: state => state.route.currentRoute,
      previousRoute: state => state.route.previousRoute
    }),
    
    // 基于路由状态的衍生数据
    pageTitle() {
      const route = this.currentRoute
      return route?.meta?.title || '默认标题'
    }
  }
}
// Pinia 版本(Vue 3)
import { defineStore } from 'pinia'

export const useRouteStore = defineStore('route', {
  state: () => ({
    current: null,
    history: []
  }),
  
  actions: {
    updateRoute(route) {
      this.history.push({
        ...this.current,
        timestamp: new Date().toISOString()
      })
      
      // 只保留最近10条记录
      if (this.history.length > 10) {
        this.history = this.history.slice(-10)
      }
      
      this.current = {
        path: route.path,
        fullPath: route.fullPath,
        name: route.name,
        params: { ...route.params },
        query: { ...route.query },
        meta: { ...route.meta }
      }
    }
  },
  
  getters: {
    // 获取路由参数
    routeParam: (state) => (key) => {
      return state.current?.params?.[key]
    },
    
    // 获取查询参数
    routeQuery: (state) => (key) => {
      return state.current?.query?.[key]
    },
    
    // 检查是否在特定路由
    isRoute: (state) => (routeName) => {
      return state.current?.name === routeName
    }
  }
})

方法 7:自定义路由混合/组合函数

// 自定义混合(Vue 2)
export const routeMixin = {
  computed: {
    // 便捷访问器
    $routeParams() {
      return this.$route.params || {}
    },
    
    $routeQuery() {
      return this.$route.query || {}
    },
    
    $routeMeta() {
      return this.$route.meta || {}
    },
    
    // 常用路由检查
    $isHomePage() {
      return this.$route.path === '/'
    },
    
    $hasRouteParam(param) {
      return param in this.$route.params
    },
    
    $getRouteParam(param, defaultValue = null) {
      return this.$route.params[param] || defaultValue
    }
  },
  
  methods: {
    // 路由操作辅助方法
    $updateQuery(newQuery) {
      this.$router.push({
        ...this.$route,
        query: {
          ...this.$route.query,
          ...newQuery
        }
      })
    },
    
    $removeQueryParam(key) {
      const query = { ...this.$route.query }
      delete query[key]
      this.$router.push({ query })
    }
  }
}

// 在组件中使用
export default {
  mixins: [routeMixin],
  
  created() {
    console.log('用户ID:', this.$getRouteParam('id', 'default'))
    console.log('是否首页:', this.$isHomePage)
    
    // 更新查询参数
    this.$updateQuery({ page: 2, sort: 'name' })
  }
}
// Vue 3 Composition API 版本
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'

export function useRouteHelpers() {
  const route = useRoute()
  const router = useRouter()
  
  const routeParams = computed(() => route.params || {})
  const routeQuery = computed(() => route.query || {})
  const routeMeta = computed(() => route.meta || {})
  
  const isHomePage = computed(() => route.path === '/')
  
  function getRouteParam(param, defaultValue = null) {
    return route.params[param] || defaultValue
  }
  
  function updateQuery(newQuery) {
    router.push({
      ...route,
      query: {
        ...route.query,
        ...newQuery
      }
    })
  }
  
  function removeQueryParam(key) {
    const query = { ...route.query }
    delete query[key]
    router.push({ query })
  }
  
  return {
    routeParams,
    routeQuery,
    routeMeta,
    isHomePage,
    getRouteParam,
    updateQuery,
    removeQueryParam
  }
}

// 在组件中使用
<script setup>
const {
  routeParams,
  routeQuery,
  getRouteParam,
  updateQuery
} = useRouteHelpers()

const userId = getRouteParam('id')
const currentTab = computed(() => routeQuery.tab || 'info')

function changeTab(tab) {
  updateQuery({ tab })
}
</script>

方法 8:访问 Router 实例的匹配器

export default {
  methods: {
    // 获取所有路由配置
    getAllRoutes() {
      return this.$router.options.routes
    },
    
    // 通过名称查找路由
    findRouteByName(name) {
      return this.$router.options.routes.find(route => route.name === name)
    },
    
    // 检查路径是否匹配路由
    matchRoute(path) {
      // Vue Router 3
      const matched = this.$router.match(path)
      return matched.matched.length > 0
      
      // Vue Router 4
      // const matched = this.$router.resolve(path)
      // return matched.matched.length > 0
    },
    
    // 生成路径
    generatePath(routeName, params = {}) {
      const route = this.findRouteByName(routeName)
      if (!route) return null
      
      // 简单的路径生成(实际项目建议使用 path-to-regexp)
      let path = route.path
      Object.keys(params).forEach(key => {
        path = path.replace(`:${key}`, params[key])
      })
      return path
    }
  }
}

三、不同场景的推荐方案

场景决策表

场景 推荐方案 理由
简单组件中获取参数 $route.params.id 最简单直接
Vue 3 Composition API useRoute() Hook 响应式、类型安全
组件复用/测试友好 Props 传递 解耦路由依赖
复杂应用状态管理 Vuex/Pinia 存储 全局访问、历史记录
多个组件共享逻辑 自定义混合/组合函数 代码复用
路由守卫/拦截器 守卫参数 (to, from) 官方标准方式
需要路由配置信息 $router.options.routes 访问完整配置

性能优化建议

// ❌ 避免在模板中频繁访问深层属性
<template>
  <div>
    <!-- 每次渲染都会计算 -->
    {{ $route.params.user.details.profile.name }}
  </div>
</template>

// ✅ 使用计算属性缓存
<template>
  <div>{{ userName }}</div>
</template>

<script>
export default {
  computed: {
    userName() {
      return this.$route.params.user?.details?.profile?.name || '未知'
    },
    
    // 批量提取路由信息
    routeInfo() {
      const { params, query, meta } = this.$route
      return {
        userId: params.id,
        tab: query.tab,
        requiresAuth: meta.requiresAuth
      }
    }
  }
}
</script>

响应式监听最佳实践

export default {
  watch: {
    // 监听特定参数变化
    '$route.params.id': {
      handler(newId, oldId) {
        if (newId !== oldId) {
          this.loadUserData(newId)
        }
      },
      immediate: true
    },
    
    // 监听查询参数变化
    '$route.query': {
      handler(newQuery) {
        this.applyFilters(newQuery)
      },
      deep: true // 深度监听对象变化
    }
  },
  
  // 或者使用 beforeRouteUpdate 守卫
  beforeRouteUpdate(to, from, next) {
    // 只处理需要的变化
    if (to.params.id !== from.params.id) {
      this.loadUserData(to.params.id)
    }
    next()
  }
}

四、实战案例:用户管理系统

<template>
  <div class="user-management">
    <!-- 面包屑导航 -->
    <nav class="breadcrumbs">
      <router-link v-for="item in breadcrumbs" 
                   :key="item.path"
                   :to="item.path">
        {{ item.title }}
      </router-link>
    </nav>
    
    <!-- 用户详情 -->
    <div v-if="$route.name === 'user-detail'">
      <h2>用户详情 - {{ userName }}</h2>
      <UserTabs :active-tab="activeTab" @change-tab="changeTab" />
      <router-view />
    </div>
    
    <!-- 用户列表 -->
    <div v-else-if="$route.name === 'user-list'">
      <UserList :filters="routeFilters" />
    </div>
  </div>
</template>

<script>
import { mapState } from 'vuex'

export default {
  computed: {
    ...mapState(['currentUser']),
    
    // 从路由获取信息
    userId() {
      return this.$route.params.userId
    },
    
    activeTab() {
      return this.$route.query.tab || 'profile'
    },
    
    routeFilters() {
      return {
        department: this.$route.query.dept,
        role: this.$route.query.role,
        status: this.$route.query.status || 'active'
      }
    },
    
    // 面包屑导航
    breadcrumbs() {
      const crumbs = []
      const { matched } = this.$route
      
      matched.forEach((route, index) => {
        const { meta, path } = route
        
        // 生成面包屑项
        if (meta?.breadcrumb) {
          crumbs.push({
            title: meta.breadcrumb,
            path: this.generateBreadcrumbPath(matched.slice(0, index + 1))
          })
        }
      })
      
      return crumbs
    },
    
    // 用户名(需要根据ID查找)
    userName() {
      const user = this.$store.getters.getUserById(this.userId)
      return user ? user.name : '加载中...'
    }
  },
  
  watch: {
    // 监听用户ID变化
    userId(newId) {
      if (newId) {
        this.$store.dispatch('fetchUser', newId)
      }
    },
    
    // 监听标签页变化
    activeTab(newTab) {
      this.updateDocumentTitle(newTab)
    }
  },
  
  created() {
    // 初始化加载
    if (this.userId) {
      this.$store.dispatch('fetchUser', this.userId)
    }
    
    // 设置页面标题
    this.updateDocumentTitle()
    
    // 记录页面访问
    this.logPageView()
  },
  
  methods: {
    changeTab(tab) {
      // 更新查询参数
      this.$router.push({
        ...this.$route,
        query: { ...this.$route.query, tab }
      })
    },
    
    generateBreadcrumbPath(routes) {
      // 生成完整路径
      return routes.map(r => r.path).join('')
    },
    
    updateDocumentTitle(tab = null) {
      const tabName = tab || this.activeTab
      const title = this.$route.meta.title || '用户管理'
      document.title = `${title} - ${this.getTabDisplayName(tabName)}`
    },
    
    logPageView() {
      // 发送分析数据
      analytics.track('page_view', {
        path: this.$route.path,
        name: this.$route.name,
        params: this.$route.params
      })
    }
  }
}
</script>

五、常见问题与解决方案

问题1:路由信息延迟获取

// ❌ 可能在 created 中获取不到完整的 $route
created() {
  console.log(this.$route.params.id) // 可能为 undefined
}

// ✅ 使用 nextTick 确保 DOM 和路由都就绪
created() {
  this.$nextTick(() => {
    console.log('路由信息:', this.$route)
    this.loadData(this.$route.params.id)
  })
}

// ✅ 或者使用 watch + immediate
watch: {
  '$route.params.id': {
    handler(id) {
      if (id) this.loadData(id)
    },
    immediate: true
  }
}

问题2:路由变化时组件不更新

// 对于复用组件,需要监听路由变化
export default {
  // 使用 beforeRouteUpdate 守卫
  beforeRouteUpdate(to, from, next) {
    this.userId = to.params.id
    this.loadUserData()
    next()
  },
  
  // 或者使用 watch
  watch: {
    '$route.params.id'(newId) {
      this.userId = newId
      this.loadUserData()
    }
  }
}

问题3:TypeScript 类型支持

// Vue 3 + TypeScript
import { RouteLocationNormalized } from 'vue-router'

// 定义路由参数类型
interface UserRouteParams {
  id: string
}

interface UserRouteQuery {
  tab?: 'info' | 'posts' | 'settings'
  edit?: string
}

export default defineComponent({
  setup() {
    const route = useRoute()
    
    // 类型安全的参数访问
    const userId = computed(() => {
      const params = route.params as UserRouteParams
      return params.id
    })
    
    const currentTab = computed(() => {
      const query = route.query as UserRouteQuery
      return query.tab || 'info'
    })
    
    // 类型安全的路由跳转
    const router = useRouter()
    function goToEdit() {
      router.push({
        name: 'user-edit',
        params: { id: userId.value },
        query: { from: route.fullPath }
      })
    }
    
    return { userId, currentTab, goToEdit }
  }
})

六、总结:最佳实践指南

  1. 优先使用 Props 传递 - 提高组件可测试性和复用性
  2. 复杂逻辑使用组合函数 - Vue 3 推荐方式,逻辑更清晰
  3. 适当使用状态管理 - 需要跨组件共享路由状态时
  4. 性能优化 - 避免频繁访问深层属性,使用计算属性缓存
  5. 类型安全 - TypeScript 项目一定要定义路由类型

快速选择流程图:

graph TD
    A[需要获取路由信息] --> B{使用场景}
    
    B -->|简单访问参数| C[使用 $route.params]
    B -->|Vue 3 项目| D[使用 useRoute Hook]
    B -->|组件需要复用/测试| E[使用 Props 传递]
    B -->|多个组件共享状态| F[使用 Pinia/Vuex 存储]
    B -->|通用工具函数| G[自定义组合函数]
    
    C --> H[完成]
    D --> H
    E --> H
    F --> H
    G --> H

记住黄金法则:优先考虑组件独立性,只在必要时直接访问路由对象。


思考题:在你的 Vue 项目中,最常使用哪种方式获取路由信息?遇到过哪些有趣的问题?欢迎分享你的实战经验!

Vue Watch 立即执行:5 种初始化调用方案全解析

作者 北辰alk
2026年1月13日 22:29

Vue Watch 立即执行:5 种初始化调用方案全解析

你是否遇到过在组件初始化时就需要立即执行 watch 逻辑的场景?本文将深入探讨 Vue 中 watch 的立即执行机制,并提供 5 种实用方案。

一、问题背景:为什么需要立即执行 watch?

在 Vue 开发中,我们经常遇到这样的需求:

export default {
  data() {
    return {
      userId: null,
      userData: null,
      filters: {
        status: 'active',
        sortBy: 'name'
      },
      filteredUsers: []
    }
  },
  
  watch: {
    // 需要组件初始化时就执行一次
    'filters.status'() {
      this.loadUsers()
    },
    
    'filters.sortBy'() {
      this.sortUsers()
    }
  },
  
  created() {
    // 我们期望:初始化时自动应用 filters 的默认值
    // 但默认的 watch 不会立即执行
  }
}

二、解决方案对比表

方案 适用场景 优点 缺点 Vue 版本
1. immediate 选项 简单监听 原生支持,最简洁 无法复用逻辑 2+
2. 提取为方法 复杂逻辑复用 逻辑可复用,清晰 需要手动调用 2+
3. 计算属性 派生数据 响应式,自动更新 不适合副作用 2+
4. 自定义 Hook 复杂业务逻辑 高度复用,可组合 需要额外封装 2+ (Vue 3 最佳)
5. 侦听器工厂 多个相似监听 减少重复代码 有一定复杂度 2+

三、5 种解决方案详解

方案 1:使用 immediate: true(最常用)

export default {
  data() {
    return {
      searchQuery: '',
      searchResults: [],
      loading: false
    }
  },
  
  watch: {
    // 基础用法:立即执行 + 深度监听
    searchQuery: {
      handler(newVal, oldVal) {
        this.performSearch(newVal)
      },
      immediate: true,    // ✅ 组件创建时立即执行
      deep: false         // 默认值,可根据需要开启
    },
    
    // 监听对象属性
    'filters.status': {
      handler(newStatus) {
        this.applyFilter(newStatus)
      },
      immediate: true
    },
    
    // 监听多个源(Vue 2.6+)
    '$route.query': {
      handler(query) {
        // 路由变化时初始化数据
        this.initFromQuery(query)
      },
      immediate: true
    }
  },
  
  methods: {
    async performSearch(query) {
      this.loading = true
      try {
        this.searchResults = await api.search(query)
      } catch (error) {
        console.error('搜索失败:', error)
      } finally {
        this.loading = false
      }
    },
    
    initFromQuery(query) {
      // 从 URL 参数初始化状态
      if (query.search) {
        this.searchQuery = query.search
      }
    }
  }
}

进阶技巧:动态 immediate

export default {
  data() {
    return {
      shouldWatchImmediately: true,
      value: ''
    }
  },
  
  watch: {
    value: {
      handler(newVal) {
        this.handleValueChange(newVal)
      },
      // 动态决定是否立即执行
      immediate() {
        return this.shouldWatchImmediately
      }
    }
  }
}

方案 2:提取为方法并手动调用(最灵活)

export default {
  data() {
    return {
      pagination: {
        page: 1,
        pageSize: 20,
        total: 0
      },
      items: []
    }
  },
  
  created() {
    // ✅ 立即调用一次
    this.handlePaginationChange(this.pagination)
    
    // 同时设置 watch
    this.$watch(
      () => ({ ...this.pagination }),
      this.handlePaginationChange,
      { deep: true }
    )
  },
  
  methods: {
    async handlePaginationChange(newPagination, oldPagination) {
      // 避免初始化时重复调用(如果 created 中已调用)
      if (oldPagination === undefined) {
        // 这是初始化调用
        console.log('初始化加载数据')
      }
      
      // 防抖处理
      if (this.loadDebounce) {
        clearTimeout(this.loadDebounce)
      }
      
      this.loadDebounce = setTimeout(async () => {
        this.loading = true
        try {
          const response = await api.getItems({
            page: newPagination.page,
            pageSize: newPagination.pageSize
          })
          this.items = response.data
          this.pagination.total = response.total
        } catch (error) {
          console.error('加载失败:', error)
        } finally {
          this.loading = false
        }
      }, 300)
    }
  }
}

优势对比:

// ❌ 重复逻辑
watch: {
  pagination: {
    handler() { this.loadData() },
    immediate: true,
    deep: true
  },
  filters: {
    handler() { this.loadData() },  // 重复的 loadData 调用
    immediate: true,
    deep: true
  }
}

// ✅ 提取方法,复用逻辑
created() {
  this.loadData()  // 初始化调用
  
  // 多个监听复用同一方法
  this.$watch(() => this.pagination, this.loadData, { deep: true })
  this.$watch(() => this.filters, this.loadData, { deep: true })
}

方案 3:计算属性替代(适合派生数据)

export default {
  data() {
    return {
      basePrice: 100,
      taxRate: 0.08,
      discount: 10
    }
  },
  
  computed: {
    // 计算属性自动响应依赖变化
    finalPrice() {
      const priceWithTax = this.basePrice * (1 + this.taxRate)
      return Math.max(0, priceWithTax - this.discount)
    },
    
    // 复杂计算场景
    formattedReport() {
      // 这里会立即执行,并自动响应 basePrice、taxRate、discount 的变化
      return {
        base: this.basePrice,
        tax: this.basePrice * this.taxRate,
        discount: this.discount,
        total: this.finalPrice,
        timestamp: new Date().toISOString()
      }
    }
  },
  
  created() {
    // 计算属性在 created 中已可用
    console.log('初始价格:', this.finalPrice)
    console.log('初始报告:', this.formattedReport)
    
    // 如果需要执行副作用(如 API 调用),仍需要 watch
    this.$watch(
      () => this.finalPrice,
      (newPrice) => {
        this.logPriceChange(newPrice)
      },
      { immediate: true }
    )
  }
}

方案 4:自定义 Hook/Composable(Vue 3 最佳实践)

// composables/useWatcher.js
import { watch, ref, onMounted } from 'vue'

export function useImmediateWatcher(source, callback, options = {}) {
  const { immediate = true, ...watchOptions } = options
  
  // 立即执行一次
  if (immediate) {
    callback(source.value, undefined)
  }
  
  // 设置监听
  watch(source, callback, watchOptions)
  
  // 返回清理函数
  return () => {
    // 如果需要,可以返回清理逻辑
  }
}

// 在组件中使用
import { ref } from 'vue'
import { useImmediateWatcher } from '@/composables/useWatcher'

export default {
  setup() {
    const searchQuery = ref('')
    const filters = ref({ status: 'active' })
    
    // 使用自定义 Hook
    useImmediateWatcher(
      searchQuery,
      async (newQuery) => {
        await performSearch(newQuery)
      },
      { debounce: 300 }
    )
    
    useImmediateWatcher(
      filters,
      (newFilters) => {
        applyFilters(newFilters)
      },
      { deep: true, immediate: true }
    )
    
    return {
      searchQuery,
      filters
    }
  }
}

Vue 2 版本的 Mixin 实现:

// mixins/immediateWatcher.js
export const immediateWatcherMixin = {
  created() {
    this._immediateWatchers = []
  },
  
  methods: {
    $watchImmediate(expOrFn, callback, options = {}) {
      // 立即执行一次
      const unwatch = this.$watch(
        expOrFn,
        (...args) => {
          callback(...args)
        },
        { ...options, immediate: true }
      )
      
      this._immediateWatchers.push(unwatch)
      return unwatch
    }
  },
  
  beforeDestroy() {
    // 清理所有监听器
    this._immediateWatchers.forEach(unwatch => unwatch())
    this._immediateWatchers = []
  }
}

// 使用
export default {
  mixins: [immediateWatcherMixin],
  
  created() {
    this.$watchImmediate(
      () => this.userId,
      (newId) => {
        this.loadUserData(newId)
      }
    )
  }
}

方案 5:侦听器工厂函数(高级封装)

// utils/watchFactory.js
export function createImmediateWatcher(vm, configs) {
  const unwatchers = []
  
  configs.forEach(config => {
    const {
      source,
      handler,
      immediate = true,
      deep = false,
      flush = 'pre'
    } = config
    
    // 处理 source 可以是函数或字符串
    const getter = typeof source === 'function' 
      ? source 
      : () => vm[source]
    
    // 立即执行
    if (immediate) {
      const initialValue = getter()
      handler.call(vm, initialValue, undefined)
    }
    
    // 创建侦听器
    const unwatch = vm.$watch(
      getter,
      handler.bind(vm),
      { deep, immediate: false, flush }
    )
    
    unwatchers.push(unwatch)
  })
  
  // 返回清理函数
  return function cleanup() {
    unwatchers.forEach(unwatch => unwatch())
  }
}

// 组件中使用
export default {
  data() {
    return {
      filters: { category: 'all', sort: 'newest' },
      pagination: { page: 1, size: 20 }
    }
  },
  
  created() {
    // 批量创建立即执行的侦听器
    this._cleanupWatchers = createImmediateWatcher(this, [
      {
        source: 'filters',
        handler(newFilters) {
          this.applyFilters(newFilters)
        },
        deep: true
      },
      {
        source: () => this.pagination.page,
        handler(newPage) {
          this.loadPage(newPage)
        }
      }
    ])
  },
  
  beforeDestroy() {
    // 清理
    if (this._cleanupWatchers) {
      this._cleanupWatchers()
    }
  }
}

四、实战场景:表单初始化与验证

<template>
  <form @submit.prevent="handleSubmit">
    <input v-model="form.email" @blur="validateEmail" />
    <input v-model="form.password" type="password" />
    
    <div v-if="errors.email">{{ errors.email }}</div>
    <button :disabled="!isFormValid">提交</button>
  </form>
</template>

<script>
export default {
  data() {
    return {
      form: {
        email: '',
        password: ''
      },
      errors: {
        email: '',
        password: ''
      },
      isInitialValidationDone: false
    }
  },
  
  computed: {
    isFormValid() {
      return !this.errors.email && !this.errors.password
    }
  },
  
  watch: {
    'form.email': {
      handler(newEmail) {
        // 只在初始化验证后,或者用户修改时验证
        if (this.isInitialValidationDone || newEmail) {
          this.validateEmail()
        }
      },
      immediate: true  // ✅ 初始化时触发验证
    },
    
    'form.password': {
      handler(newPassword) {
        this.validatePassword(newPassword)
      },
      immediate: true  // ✅ 初始化时触发验证
    }
  },
  
  created() {
    // 标记初始化验证完成
    this.$nextTick(() => {
      this.isInitialValidationDone = true
    })
  },
  
  methods: {
    validateEmail() {
      const email = this.form.email
      if (!email) {
        this.errors.email = '邮箱不能为空'
      } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
        this.errors.email = '邮箱格式不正确'
      } else {
        this.errors.email = ''
      }
    },
    
    validatePassword(password) {
      if (!password) {
        this.errors.password = '密码不能为空'
      } else if (password.length < 6) {
        this.errors.password = '密码至少6位'
      } else {
        this.errors.password = ''
      }
    }
  }
}
</script>

五、性能优化与注意事项

1. 避免无限循环

export default {
  data() {
    return {
      count: 0,
      doubled: 0
    }
  },
  
  watch: {
    count: {
      handler(newVal) {
        // ❌ 危险:可能导致无限循环
        this.doubled = newVal * 2
        
        // 在某些条件下修改自身依赖
        if (newVal > 10) {
          this.count = 10  // 这会导致循环
        }
      },
      immediate: true
    }
  }
}

2. 合理使用 deep 监听

export default {
  data() {
    return {
      config: {
        theme: 'dark',
        notifications: {
          email: true,
          push: false
        }
      }
    }
  },
  
  watch: {
    // ❌ 过度使用 deep
    config: {
      handler() {
        this.saveConfig()
      },
      deep: true,  // 整个对象深度监听,性能开销大
      immediate: true
    },
    
    // ✅ 精确监听
    'config.theme': {
      handler(newTheme) {
        this.applyTheme(newTheme)
      },
      immediate: true
    },
    
    // ✅ 监听特定嵌套属性
    'config.notifications.email': {
      handler(newValue) {
        this.updateNotificationPref('email', newValue)
      },
      immediate: true
    }
  }
}

3. 异步操作的防抖与取消

export default {
  data() {
    return {
      searchInput: '',
      searchRequest: null
    }
  },
  
  watch: {
    searchInput: {
      async handler(newVal) {
        // 取消之前的请求
        if (this.searchRequest) {
          this.searchRequest.cancel('取消旧请求')
        }
        
        // 创建新的可取消请求
        this.searchRequest = this.$axios.CancelToken.source()
        
        try {
          const response = await api.search(newVal, {
            cancelToken: this.searchRequest.token
          })
          this.searchResults = response.data
        } catch (error) {
          if (!this.$axios.isCancel(error)) {
            console.error('搜索错误:', error)
          }
        }
      },
      immediate: true,
      debounce: 300  // 需要配合 debounce 插件
    }
  }
}

六、Vue 3 Composition API 特别指南

<script setup>
import { ref, watch, watchEffect } from 'vue'

const userId = ref(null)
const userData = ref(null)
const loading = ref(false)

// 方案1: watch + immediate
watch(
  userId,
  async (newId) => {
    loading.value = true
    try {
      userData.value = await fetchUser(newId)
    } finally {
      loading.value = false
    }
  },
  { immediate: true }  // ✅ 立即执行
)

// 方案2: watchEffect(自动追踪依赖)
const searchQuery = ref('')
const searchResults = ref([])

watchEffect(async () => {
  // 自动追踪 searchQuery 依赖
  if (searchQuery.value.trim()) {
    const results = await searchApi(searchQuery.value)
    searchResults.value = results
  } else {
    searchResults.value = []
  }
})  // ✅ watchEffect 会立即执行一次

// 方案3: 自定义立即执行的 composable
function useImmediateWatch(source, callback, options = {}) {
  const { immediate = true, ...watchOptions } = options
  
  // 立即执行
  if (immediate && source.value !== undefined) {
    callback(source.value, undefined)
  }
  
  return watch(source, callback, watchOptions)
}

// 使用
const filters = ref({ category: 'all' })
useImmediateWatch(
  filters,
  (newFilters) => {
    applyFilters(newFilters)
  },
  { deep: true }
)
</script>

七、决策流程图

graph TD
    A[需要初始化执行watch] --> B{场景分析}
    
    B -->|简单监听,逻辑不复杂| C[方案1: immediate:true]
    B -->|复杂逻辑,需要复用| D[方案2: 提取方法]
    B -->|派生数据,无副作用| E[方案3: 计算属性]
    B -->|Vue3,需要组合复用| F[方案4: 自定义Hook]
    B -->|多个相似监听器| G[方案5: 工厂函数]
    
    C --> H[完成]
    D --> H
    E --> H
    F --> H
    G --> H
    
    style C fill:#e1f5e1
    style D fill:#e1f5e1

八、总结与最佳实践

核心原则:

  1. 优先使用 immediate: true - 对于简单的监听需求
  2. 复杂逻辑提取方法 - 提高可测试性和复用性
  3. 避免副作用在计算属性中 - 保持计算属性的纯函数特性
  4. Vue 3 优先使用 Composition API - 更好的逻辑组织和复用

代码规范建议:

// ✅ 良好实践
export default {
  watch: {
    // 明确注释为什么需要立即执行
    userId: {
      handler: 'loadUserData', // 使用方法名,更清晰
      immediate: true // 初始化时需要加载用户数据
    }
  },
  
  created() {
    // 复杂初始化逻辑放在 created
    this.initializeComponent()
  },
  
  methods: {
    loadUserData(userId) {
      // 可复用的方法
    },
    
    initializeComponent() {
      // 集中处理初始化逻辑
    }
  }
}

常见陷阱提醒:

  1. 不要immediate 回调中修改依赖数据(可能导致循环)
  2. 谨慎使用 deep: true,特别是对于大型对象
  3. 记得清理手动创建的侦听器(避免内存泄漏)
  4. 考虑 SSR 场景下 immediate 的执行时机

Vue 三剑客:组件、插件、插槽的深度辨析

作者 北辰alk
2026年1月13日 22:19

Vue 三剑客:组件、插件、插槽的深度辨析

组件、插件、插槽是 Vue 生态中的三个核心概念,理解它们的差异是掌握 Vue 架构设计的关键。让我们通过一个完整的对比体系来彻底搞懂它们。

一、核心概念全景图

graph TB
    A[Vue 核心概念] --> B[Component 组件]
    A --> C[Plugin 插件]
    A --> D[Slot 插槽]
    
    B --> B1[UI 复用单元]
    B --> B2[局部作用域]
    B --> B3[父子通信]
    
    C --> C1[全局功能扩展]
    C --> C2[一次配置]
    C --> C3[多组件共享]
    
    D --> D1[内容分发]
    D --> D2[灵活占位]
    D --> D3[模板组合]
    
    B --> E[使用插件]
    B --> F[包含插槽]
    C --> G[增强组件]
    D --> H[扩展组件]

二、三者的本质区别:一句话概括

概念 本质 类比
组件 可复用的 UI 单元 乐高积木块
插件 全局功能扩展包 乐高工具箱
插槽 组件的内容占位符 乐高积木上的接口

三、组件 (Component) - Vue 的基石

定义与核心特征

组件是 Vue 应用的构建块,每个组件都是自包含的、可复用的 Vue 实例。

<!-- UserCard.vue - 组件示例 -->
<template>
  <div class="user-card">
    <img :src="avatar" alt="用户头像" />
    <h3>{{ name }}</h3>
    <p>{{ bio }}</p>
    <!-- 使用插槽提供扩展点 -->
    <slot name="actions"></slot>
  </div>
</template>

<script>
export default {
  // 组件定义
  name: 'UserCard',
  props: {
    name: String,
    avatar: String,
    bio: String
  },
  // 局部状态
  data() {
    return {
      isActive: false
    }
  },
  // 生命周期
  mounted() {
    console.log('组件已挂载')
  }
}
</script>

<style scoped>
.user-card {
  border: 1px solid #ccc;
  padding: 20px;
}
</style>

组件的核心能力:

// 1. 组件注册
// 全局注册
Vue.component('global-component', {
  template: '<div>全局组件</div>'
})

// 局部注册
const LocalComponent = {
  template: '<div>局部组件</div>'
}

new Vue({
  components: {
    'local-component': LocalComponent
  }
})

// 2. 组件通信体系
const ParentComponent = {
  template: `
    <child-component 
      :title="parentTitle" 
      @child-event="handleChildEvent"
    />
  `,
  methods: {
    handleChildEvent(payload) {
      // 处理子组件事件
    }
  }
}

四、插件 (Plugin) - Vue 的扩展系统

定义与核心特征

插件是对 Vue 的全局增强,用于添加全局级的功能。

// my-plugin.js - 自定义插件
const MyPlugin = {
  install(Vue, options) {
    // 1. 添加全局方法或属性
    Vue.myGlobalMethod = function() {
      console.log('全局方法')
    }
    
    // 2. 添加全局资源(指令/过滤器/组件)
    Vue.directive('my-directive', {
      bind(el, binding) {
        // 指令逻辑
      }
    })
    
    // 3. 注入组件选项
    Vue.mixin({
      created() {
        console.log('所有组件都会执行')
      }
    })
    
    // 4. 添加实例方法
    Vue.prototype.$myMethod = function() {
      console.log('实例方法')
    }
  }
}

// 使用插件
Vue.use(MyPlugin, { someOption: true })

常见插件类型:

// 1. UI 组件库插件
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
Vue.use(ElementUI)

// 2. 功能增强插件
import VueRouter from 'vue-router'
Vue.use(VueRouter)

import Vuex from 'vuex'
Vue.use(Vuex)

// 3. 工具类插件
import VueLazyload from 'vue-lazyload'
Vue.use(VueLazyload, {
  loading: '/loading.gif'
})

插件 vs 组件的关键差异:

对比维度 组件 插件
作用范围 局部(需要显式引入) 全局(一次配置,处处可用)
主要目的 构建 UI 界面 增强 Vue 本身的能力
使用频率 高频率、多次使用 一次性配置
典型示例 Button、Modal、Form Router、Vuex、i18n

五、插槽 (Slot) - 组件的灵活扩展点

定义与核心特征

插槽是组件的内容分发出口,让父组件可以向子组件传递模板内容。

<!-- BaseLayout.vue - 包含插槽的组件 -->
<template>
  <div class="container">
    <header>
      <!-- 具名插槽 -->
      <slot name="header"></slot>
    </header>
    
    <main>
      <!-- 默认插槽 -->
      <slot>
        <!-- 后备内容(当父组件不提供内容时显示) -->
        <p>默认内容</p>
      </slot>
    </main>
    
    <footer>
      <slot name="footer"></slot>
    </footer>
  </div>
</template>

插槽的三种类型:

<!-- 父组件使用 -->
<template>
  <BaseLayout>
    <!-- 具名插槽 -->
    <template v-slot:header>
      <h1>页面标题</h1>
    </template>
    
    <!-- 默认插槽(简写) -->
    <p>主要内容区域</p>
    <p>这是默认插槽的内容</p>
    
    <!-- 作用域插槽 -->
    <template v-slot:footer="slotProps">
      <p>页脚: {{ slotProps.year }} 年</p>
    </template>
    
    <!-- 动态插槽名 -->
    <template v-slot:[dynamicSlotName]>
      动态内容
    </template>
  </BaseLayout>
</template>

作用域插槽(高级模式):

<!-- TodoList.vue -->
<template>
  <ul>
    <li v-for="todo in todos" :key="todo.id">
      <!-- 作用域插槽:向父组件暴露数据 -->
      <slot :todo="todo" :index="index">
        <!-- 默认显示 -->
        {{ todo.text }}
      </slot>
    </li>
  </ul>
</template>

<!-- 父组件接收数据 -->
<template>
  <TodoList :todos="todos">
    <template v-slot:default="slotProps">
      <span :class="{ completed: slotProps.todo.done }">
        {{ slotProps.index + 1 }}. {{ slotProps.todo.text }}
      </span>
    </template>
  </TodoList>
</template>

六、三者协同工作的完整示例

让我们通过一个实战项目理解三者如何协同:

// 1. 首先安装路由插件
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)  // 🔴 插件:全局启用路由功能

// 2. 定义可复用的布局组件
const AppLayout = {
  template: `
    <div class="app-layout">
      <slot name="navbar"></slot>
      <div class="content">
        <!-- 默认插槽用于显示页面内容 -->
        <slot></slot>
      </div>
      <slot name="footer"></slot>
    </div>
  `
}

// 3. 创建页面组件
const HomePage = {
  template: `
    <AppLayout>
      <template v-slot:navbar>
        <!-- 向布局组件传递自定义导航栏 -->
        <NavBar title="首页" />
      </template>
      
      <!-- 默认插槽内容 -->
      <h1>欢迎访问</h1>
      <ProductList>
        <!-- 作用域插槽自定义产品显示 -->
        <template v-slot:product="props">
          <ProductCard :product="props.product" />
        </template>
      </ProductList>
      
      <template v-slot:footer>
        <AppFooter />
      </template>
    </AppLayout>
  `,
  components: {
    AppLayout,      // 🔵 组件:布局组件
    NavBar,         // 🔵 组件:导航栏组件
    ProductList,    // 🔵 组件:产品列表
    ProductCard,    // 🔵 组件:产品卡片
    AppFooter       // 🔵 组件:页脚组件
  }
}

// 4. 配置路由(使用插件提供的功能)
const router = new VueRouter({
  routes: [
    {
      path: '/',
      component: HomePage  // 使用组件作为路由页面
    }
  ]
})

// 5. 创建Vue实例
new Vue({
  router,  // 🔴 插件提供的路由实例
  template: '<router-view></router-view>'  // 🟡 插槽:路由视图占位
}).$mount('#app')

七、设计模式与最佳实践

何时使用什么?

graph LR
    A[需求分析] --> B{需要什么?}
    
    B -->|构建UI界面| C[使用组件]
    B -->|全局功能扩展| D[使用插件]
    B -->|自定义组件内容| E[使用插槽]
    
    C --> F{组件需要灵活性?}
    F -->|是| G[在组件中添加插槽]
    F -->|否| H[创建完整组件]
    
    D --> I{功能需要复用?}
    I -->|是| J[开发为插件]
    I -->|否| K[使用局部混入]

组件设计原则:

<!-- 好的组件设计示例 -->
<template>
  <!-- 提供清晰的插槽接口 -->
  <div class="card">
    <div class="card-header" v-if="$slots.header">
      <slot name="header"></slot>
    </div>
    
    <div class="card-body">
      <slot>
        <!-- 合理的默认内容 -->
        <p>暂无内容</p>
      </slot>
    </div>
    
    <!-- 作用域插槽提供数据 -->
    <div class="card-footer" v-if="$slots.footer">
      <slot name="footer" :data="footerData"></slot>
    </div>
  </div>
</template>

插件开发规范:

// 良好的插件结构
const WellDesignedPlugin = {
  install(Vue, options = {}) {
    // 1. 参数验证
    if (!options.requiredConfig) {
      console.warn('插件需要配置 requiredConfig')
    }
    
    // 2. 安全的全局扩展
    const version = Number(Vue.version.split('.')[0])
    if (version >= 2) {
      Vue.prototype.$safeMethod = function() {
        // 兼容性处理
      }
    }
    
    // 3. 提供卸载方法
    const originalDestroy = Vue.prototype.$destroy
    Vue.prototype.$destroy = function() {
      // 清理逻辑
      originalDestroy.call(this)
    }
  }
}

八、常见误区与澄清

误区1:插件可以替代组件

// ❌ 错误:用插件实现UI组件
Vue.use({
  install(Vue) {
    Vue.prototype.$showModal = function(content) {
      // 这应该是组件,不是插件
    }
  }
})

// ✅ 正确:组件实现UI,插件封装工具
// Modal.vue - 作为组件
// modal-plugin.js - 如果需要全局调用,可以包装为插件

误区2:插槽就是子组件

<!-- ❌ 误解:插槽是子组件 -->
<Parent>
  <Child />  <!-- 这是组件,不是插槽内容 -->
</Parent>

<!-- ✅ 正确理解 -->
<Parent>
  <!-- 这是插槽内容,会被分发到Parent的<slot>位置 -->
  <template v-slot:default>
    <Child />
  </template>
</Parent>

误区3:过度使用混入(Mixin)

// ❌ 过度使用:应该用插槽或组合式API代替
Vue.mixin({
  data() {
    return {
      globalData: '应该避免'
    }
  }
})

// ✅ 更好的方式:组合式函数(Vue 3)
// 或使用作用域插槽传递数据

九、Vue 3 中的演进

组合式 API 的影响:

<!-- Vue 3 中三者关系更加清晰 -->
<script setup>
// 1. 组件 - 更简洁的定义
import { defineComponent } from 'vue'

// 2. 插件 - 通过 provide/inject 更好地集成
import { provide } from 'vue'
provide('pluginData', data)

// 3. 插槽 - 更灵活的用法
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>

<template>
  <!-- 插槽作用域解构 -->
  <slot name="item" v-bind="{ id, name }"></slot>
</template>

十、总结:三位一体的 Vue 架构

概念 角色 关键特征 最佳实践
组件 构建者 局部作用域、props/events接口、可复用 单一职责、合理拆分、明确接口
插件 增强者 全局作用域、一次配置、功能扩展 轻量封装、提供选项、良好文档
插槽 连接者 内容分发、模板组合、作用域暴露 明确命名、提供后备、作用域数据

记住这个核心公式:

应用 = 插件增强的Vue实例 + 组件构建的UI树 + 插槽连接的组件关系

最终决策指南:

  1. 当你需要...

    • 复用UI片段 → 创建组件
    • 添加全局功能 → 开发插件
    • 自定义组件内部结构 → 使用插槽
  2. 在架构中...

    • 插件在最外层配置全局能力
    • 组件在中间层构建功能模块
    • 插槽在最内层实现灵活定制
  3. 进化方向...

    • Vue 2:Options API + 三者分明
    • Vue 3:Composition API + 更灵活的组合

Vue 组件模板的 7 种定义方式:从基础到高级的完整指南

作者 北辰alk
2026年1月13日 22:15

Vue 组件模板的 7 种定义方式:从基础到高级的完整指南

模板是 Vue 组件的核心视图层,但你可能不知道它竟有如此多灵活的定义方式。掌握这些技巧,让你的组件开发更加得心应手。

一、模板定义全景图

在深入细节之前,先了解 Vue 组件模板的完整知识体系:

graph TD
    A[Vue 组件模板] --> B[单文件组件 SFC]
    A --> C[内联模板]
    A --> D[字符串模板]
    A --> E[渲染函数]
    A --> F[JSX]
    A --> G[动态组件]
    A --> H[函数式组件]
    
    B --> B1[&lttemplate&gt标签]
    B --> B2[作用域 slot]
    
    D --> D1[template 选项]
    D --> D2[内联模板字符串]
    
    E --> E1[createElement]
    E --> E2[h 函数]
    
    G --> G1[component:is]
    G --> G2[异步组件]

下面我们来详细探讨每种方式的特点和适用场景。

二、7 种模板定义方式详解

1. 单文件组件(SFC)模板 - 现代 Vue 开发的标准

<!-- UserProfile.vue -->
<template>
  <!-- 最常用、最推荐的方式 -->
  <div class="user-profile">
    <h2>{{ user.name }}</h2>
    <img :src="user.avatar" alt="Avatar" />
    <slot name="actions"></slot>
  </div>
</template>

<script>
export default {
  props: ['user']
}
</script>

<style scoped>
.user-profile {
  padding: 20px;
}
</style>

特点:

  • ✅ 语法高亮和提示
  • ✅ CSS 作用域支持
  • ✅ 良好的可维护性
  • ✅ 构建工具优化(如 Vue Loader)

最佳实践:

<template>
  <!-- 始终使用单个根元素(Vue 2) -->
  <div class="container">
    <!-- 使用 PascalCase 的组件名 -->
    <UserProfile :user="currentUser" />
    
    <!-- 复杂逻辑使用计算属性 -->
    <p v-if="shouldShowMessage">{{ formattedMessage }}</p>
  </div>
</template>

2. 字符串模板 - 简单场景的轻量选择

// 方式1:template 选项
new Vue({
  el: '#app',
  template: `
    <div class="app">
      <h1>{{ title }}</h1>
      <button @click="handleClick">点击</button>
    </div>
  `,
  data() {
    return {
      title: '字符串模板示例'
    }
  },
  methods: {
    handleClick() {
      alert('按钮被点击')
    }
  }
})

// 方式2:内联模板字符串
const InlineComponent = {
  template: '<div>{{ message }}</div>',
  data() {
    return { message: 'Hello' }
  }
}

适用场景:

  • 简单的 UI 组件
  • 快速原型开发
  • 小型项目或演示代码

注意事项:

// ⚠️ 模板字符串中的换行和缩进
const BadTemplate = `
<div>
  <p>第一行
  </p>
</div>  // 缩进可能被包含

// ✅ 使用模板字面量保持整洁
const GoodTemplate = `<div>
  <p>第一行</p>
</div>`

3. 内联模板 - 快速但不推荐

<!-- 父组件 -->
<div id="parent">
  <child-component inline-template>
    <!-- 直接在 HTML 中写模板 -->
    <div>
      <p>来自子组件: {{ childData }}</p>
      <p>来自父组件: {{ parentMessage }}</p>
    </div>
  </child-component>
</div>

<script>
new Vue({
  el: '#parent',
  data: {
    parentMessage: '父组件数据'
  },
  components: {
    'child-component': {
      data() {
        return { childData: '子组件数据' }
      }
    }
  }
})
</script>

⚠️ 警告:

  • ❌ 作用域难以理解
  • ❌ 破坏组件封装性
  • ❌ 不利于维护
  • ✅ 唯一优势:快速原型

4. X-Templates - 分离但老式

<!-- 在 HTML 中定义模板 -->
<script type="text/x-template" id="user-template">
  <div class="user">
    <h3>{{ name }}</h3>
    <p>{{ email }}</p>
  </div>
</script>

<script>
// 在 JavaScript 中引用
Vue.component('user-component', {
  template: '#user-template',
  props: ['name', 'email']
})
</script>

特点:

  • 🟡 模板与逻辑分离
  • 🟡 无需构建工具
  • ❌ 全局命名空间污染
  • ❌ 无法使用构建工具优化

5. 渲染函数 - 完全的 JavaScript 控制力

// 基本渲染函数
export default {
  props: ['items'],
  render(h) {
    return h('ul', 
      this.items.map(item => 
        h('li', { key: item.id }, item.name)
      )
    )
  }
}

// 带条件渲染和事件
export default {
  data() {
    return { count: 0 }
  },
  render(h) {
    return h('div', [
      h('h1', `计数: ${this.count}`),
      h('button', {
        on: {
          click: () => this.count++
        }
      }, '增加')
    ])
  }
}

高级模式 - 动态组件工厂:

// 组件工厂函数
const ComponentFactory = {
  functional: true,
  props: ['type', 'data'],
  render(h, { props }) {
    const components = {
      text: TextComponent,
      image: ImageComponent,
      video: VideoComponent
    }
    
    const Component = components[props.type]
    return h(Component, {
      props: { data: props.data }
    })
  }
}

// 动态 slot 内容
const LayoutComponent = {
  render(h) {
    // 获取具名 slot
    const header = this.$slots.header
    const defaultSlot = this.$slots.default
    const footer = this.$slots.footer
    
    return h('div', { class: 'layout' }, [
      header && h('header', header),
      h('main', defaultSlot),
      footer && h('footer', footer)
    ])
  }
}

6. JSX - React 开发者的福音

// .vue 文件中使用 JSX
<script>
export default {
  data() {
    return {
      items: ['Vue', 'React', 'Angular']
    }
  },
  render() {
    return (
      <div class="jsx-demo">
        <h1>JSX 在 Vue 中</h1>
        <ul>
          {this.items.map((item, index) => (
            <li key={index}>{item}</li>
          ))}
        </ul>
        {/* 使用指令 */}
        <input vModel={this.inputValue} />
        {/* 事件监听 */}
        <button onClick={this.handleClick}>点击</button>
      </div>
    )
  }
}
</script>

配置方法:

// babel.config.js
module.exports = {
  presets: ['@vue/cli-plugin-babel/preset'],
  plugins: [
    '@vue/babel-plugin-jsx' // 启用 Vue JSX 支持
  ]
}

JSX vs 模板:

// JSX 的优势:动态性更强
const DynamicList = {
  props: ['config'],
  render() {
    const { tag: Tag, items, itemComponent: Item } = this.config
    
    return (
      <Tag class="dynamic-list">
        {items.map(item => (
          <Item item={item} />
        ))}
      </Tag>
    )
  }
}

7. 动态组件 - 运行时模板决策

<template>
  <!-- component:is 动态组件 -->
  <component 
    :is="currentComponent"
    v-bind="currentProps"
    @custom-event="handleEvent"
  />
</template>

<script>
import TextEditor from './TextEditor.vue'
import ImageUploader from './ImageUploader.vue'
import VideoPlayer from './VideoPlayer.vue'

export default {
  data() {
    return {
      componentType: 'text',
      content: ''
    }
  },
  computed: {
    currentComponent() {
      const components = {
        text: TextEditor,
        image: ImageUploader,
        video: VideoPlayer
      }
      return components[this.componentType]
    },
    currentProps() {
      // 根据组件类型传递不同的 props
      const baseProps = { content: this.content }
      
      if (this.componentType === 'image') {
        return { ...baseProps, maxSize: '5MB' }
      }
      
      return baseProps
    }
  }
}
</script>

三、进阶技巧:混合模式与优化

1. 模板与渲染函数结合

<template>
  <!-- 使用模板定义主体结构 -->
  <div class="data-table">
    <table-header :columns="columns" />
    <table-body :render-row="renderTableRow" />
  </div>
</template>

<script>
export default {
  methods: {
    // 使用渲染函数处理复杂行渲染
    renderTableRow(h, row) {
      return h('tr', 
        this.columns.map(column => 
          h('td', {
            class: column.className,
            style: column.style
          }, column.formatter ? column.formatter(row) : row[column.key])
        )
      )
    }
  }
}
</script>

2. 高阶组件模式

// 高阶组件:增强模板功能
function withLoading(WrappedComponent) {
  return {
    render(h) {
      const directives = [
        {
          name: 'loading',
          value: this.isLoading,
          expression: 'isLoading'
        }
      ]
      
      return h('div', { directives }, [
        h(WrappedComponent, {
          props: this.$attrs,
          on: this.$listeners
        }),
        this.isLoading && h(LoadingSpinner)
      ])
    },
    data() {
      return { isLoading: false }
    },
    mounted() {
      // 加载逻辑
    }
  }
}

3. SSR 优化策略

// 服务端渲染友好的模板
export default {
  // 客户端激活所需
  mounted() {
    // 仅客户端的 DOM 操作
    if (process.client) {
      this.initializeThirdPartyLibrary()
    }
  },
  
  // 服务端渲染优化
  serverPrefetch() {
    // 预取数据
    return this.fetchData()
  },
  
  // 避免客户端 hydration 不匹配
  template: `
    <div>
      <!-- 避免使用随机值 -->
      <p>服务器时间: {{ serverTime }}</p>
      
      <!-- 避免使用 Date.now() 等 -->
      <!-- 服务端和客户端要一致 -->
    </div>
  `
}

四、选择指南:如何决定使用哪种方式?

场景 推荐方式 理由
生产级应用 单文件组件(SFC) 最佳开发体验、工具链支持、可维护性
UI 组件库 SFC + 渲染函数 SFC 提供开发体验,渲染函数处理动态性
高度动态 UI 渲染函数/JSX 完全的 JavaScript 控制力
React 团队迁移 JSX 降低学习成本
原型/演示 字符串模板 快速、简单
遗留项目 X-Templates 渐进式迁移
服务端渲染 SFC(注意 hydration) 良好的 SSR 支持

决策流程图:

graph TD
    A[开始选择模板方式] --> B{需要构建工具?}
    B -->|是| C{组件动态性强?}
    B -->|否| D[使用字符串模板或X-Templates]
    
    C -->|是| E{团队熟悉JSX?}
    C -->|否| F[使用单文件组件SFC]
    
    E -->|是| G[使用JSX]
    E -->|否| H[使用渲染函数]
    
    D --> I[完成选择]
    F --> I
    G --> I
    H --> I

五、性能与最佳实践

1. 编译时 vs 运行时模板

// Vue CLI 默认配置优化了 SFC
module.exports = {
  productionSourceMap: false, // 生产环境不生成 source map
  runtimeCompiler: false, // 不使用运行时编译器,减小包体积
}

2. 模板预编译

// 手动预编译模板
const { compile } = require('vue-template-compiler')

const template = `<div>{{ message }}</div>`
const compiled = compile(template)

console.log(compiled.render)
// 输出渲染函数,可直接在组件中使用

3. 避免的常见反模式

<!-- ❌ 避免在模板中使用复杂表达式 -->
<template>
  <div>
    <!-- 反模式:复杂逻辑在模板中 -->
    <p>{{ user.firstName + ' ' + user.lastName + ' (' + user.age + ')' }}</p>
    
    <!-- 正确:使用计算属性 -->
    <p>{{ fullNameWithAge }}</p>
  </div>
</template>

<script>
export default {
  computed: {
    fullNameWithAge() {
      return `${this.user.firstName} ${this.user.lastName} (${this.user.age})`
    }
  }
}
</script>

六、Vue 3 的新变化

<!-- Vue 3 组合式 API + SFC -->
<template>
  <!-- 支持多个根节点(Fragment) -->
  <header>{{ title }}</header>
  <main>{{ content }}</main>
  <footer>{{ footerText }}</footer>
</template>

<script setup>
// 更简洁的语法
import { ref, computed } from 'vue'

const title = ref('Vue 3 组件')
const content = ref('新特性介绍')

const footerText = computed(() => `© ${new Date().getFullYear()}`)
</script>

总结

Vue 提供了从声明式到命令式的完整模板方案光谱:

  1. 声明式端:SFC 模板 → 易读易写,适合大多数业务组件
  2. 命令式端:渲染函数/JSX → 完全控制,适合高阶组件和库
  3. 灵活选择:根据项目需求和团队偏好选择合适的方式

记住这些关键原则:

  • 默认使用 SFC,除非有特殊需求
  • 保持一致性,一个项目中不要混用太多模式
  • 性能考量:生产环境避免运行时编译
  • 团队协作:选择团队最熟悉的方式

深入理解 Vue 生命周期:created 与 mounted 的核心差异与实战指南

作者 北辰alk
2026年1月13日 22:12

深入理解 Vue 生命周期:created 与 mounted 的核心差异与实战指南

掌握生命周期钩子,是 Vue 开发从入门到精通的关键一步。今天我们来深度剖析两个最容易混淆的钩子:createdmounted

一、生命周期全景图:先看森林,再见树木

在深入细节之前,让我们先回顾 Vue 实例的完整生命周期:

graph TD
    A[new Vue()] --> B[Init Events & Lifecycle]
    B --> C[beforeCreate]
    C --> D[Init Injections & Reactivity]
    D --> E[created]
    E --> F[Compile Template]
    F --> G[beforeMount]
    G --> H[Create vm.$el]
    H --> I[mounted]
    I --> J[Data Changes]
    J --> K[beforeUpdate]
    K --> L[Virtual DOM Re-render]
    L --> M[updated]
    M --> N[beforeDestroy]
    N --> O[Teardown]
    O --> P[destroyed]

理解这张图,你就掌握了 Vue 组件从出生到消亡的完整轨迹。而今天的主角——createdmounted,正是这个旅程中两个关键的里程碑。

二、核心对比:created vs mounted

让我们通过一个表格直观对比:

特性 created mounted
执行时机 数据观测/方法/计算属性初始化后,模板编译前 模板编译完成,DOM 挂载到页面后
DOM 可访问性 ❌ 无法访问 DOM ✅ 可以访问 DOM
$el 状态 undefined 已挂载的 DOM 元素
主要用途 数据初始化、API 调用、事件监听 DOM 操作、第三方库初始化
SSR 支持 ✅ 在服务端和客户端都会执行 ❌ 仅在客户端执行

三、实战代码解析:从理论到实践

场景 1:API 数据获取的正确姿势

export default {
  data() {
    return {
      userData: null,
      loading: true
    }
  },
  
  async created() {
    // ✅ 最佳实践:在 created 中发起数据请求
    // 此时数据观测已就绪,可以设置响应式数据
    try {
      this.userData = await fetchUserData()
    } catch (error) {
      console.error('数据获取失败:', error)
    } finally {
      this.loading = false
    }
    
    // ❌ 这里访问 DOM 会失败
    // console.log(this.$el) // undefined
  },
  
  mounted() {
    // ✅ DOM 已就绪,可以执行依赖 DOM 的操作
    const userCard = document.getElementById('user-card')
    if (userCard) {
      // 使用第三方图表库渲染数据
      this.renderChart(userCard, this.userData)
    }
    
    // ✅ 初始化需要 DOM 的第三方插件
    this.initCarousel('.carousel-container')
  }
}

关键洞察:数据获取应尽早开始(created),DOM 相关操作必须等待 mounted。

场景 2:计算属性与 DOM 的微妙关系

<template>
  <div ref="container">
    <p>容器宽度: {{ containerWidth }}px</p>
    <div class="content">
      <!-- 动态内容 -->
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      items: []
    }
  },
  
  computed: {
    // ❌ 错误示例:在 created 阶段访问 $refs
    containerWidth() {
      // created 阶段:this.$refs.container 是 undefined
      // mounted 阶段:可以正常访问
      return this.$refs.container?.offsetWidth || 0
    }
  },
  
  created() {
    // ✅ 安全操作:初始化数据
    this.items = this.generateItems()
    
    // ⚠️ 注意:computed 属性在此阶段可能基于错误的前提计算
    console.log('created 阶段宽度:', this.containerWidth) // 0
  },
  
  mounted() {
    console.log('mounted 阶段宽度:', this.containerWidth) // 实际宽度
    
    // ✅ 正确的 DOM 相关初始化
    this.observeResize()
  },
  
  methods: {
    observeResize() {
      // 使用 ResizeObserver 监听容器大小变化
      const observer = new ResizeObserver(entries => {
        this.handleResize(entries[0].contentRect.width)
      })
      observer.observe(this.$refs.container)
    }
  }
}
</script>

四、性能优化:理解渲染流程避免常见陷阱

1. 避免在 created 中执行阻塞操作

export default {
  created() {
    // ⚠️ 潜在的渲染阻塞
    this.processLargeData(this.rawData) // 如果处理时间过长,会延迟首次渲染
    
    // ✅ 优化方案:使用 Web Worker 或分块处理
    this.asyncProcessData()
  },
  
  async asyncProcessData() {
    // 使用 requestIdleCallback 避免阻塞主线程
    if ('requestIdleCallback' in window) {
      requestIdleCallback(() => {
        this.processInBackground()
      })
    } else {
      // 回退方案:setTimeout 让出主线程
      setTimeout(() => this.processInBackground(), 0)
    }
  }
}

2. 理解异步更新队列

export default {
  mounted() {
    // 情景 1:直接修改数据
    this.someData = 'new value'
    console.log(this.$el.textContent) // ❌ 可能还是旧值
    
    // 情景 2:使用 $nextTick
    this.someData = 'new value'
    this.$nextTick(() => {
      console.log(this.$el.textContent) // ✅ 更新后的值
    })
    
    // 情景 3:多个数据变更
    this.data1 = 'new1'
    this.data2 = 'new2'
    this.data3 = 'new3'
    
    // Vue 会批量处理,只触发一次更新
    this.$nextTick(() => {
      // 所有变更都已反映到 DOM
    })
  }
}

五、高级应用:SSR 场景下的特殊考量

export default {
  // created 在服务端和客户端都会执行
  async created() {
    // 服务端渲染时,无法访问 window、document 等浏览器 API
    if (process.client) {
      // 客户端特定逻辑
      this.screenWidth = window.innerWidth
    }
    
    // 数据预取(Universal)
    await this.fetchUniversalData()
  },
  
  // mounted 只在客户端执行
  mounted() {
    // 安全的浏览器 API 使用
    this.initializeBrowserOnlyLibrary()
    
    // 处理客户端 hydration
    this.handleHydrationEffects()
  },
  
  // 兼容 SSR 的数据获取模式
  async fetchUniversalData() {
    // 避免重复获取数据
    if (this.$ssrContext && this.$ssrContext.data) {
      // 服务端已获取数据
      Object.assign(this, this.$ssrContext.data)
    } else {
      // 客户端获取数据
      const data = await this.$axios.get('/api/data')
      Object.assign(this, data)
    }
  }
}

六、实战技巧:常见问题与解决方案

Q1:应该在哪个钩子初始化第三方库?

export default {
  mounted() {
    // ✅ 大多数 UI 库需要 DOM 存在
    this.$nextTick(() => {
      // 确保 DOM 完全渲染
      this.initSelect2('#my-select')
      this.initDatepicker('.date-input')
    })
  },
  
  beforeDestroy() {
    // 记得清理,防止内存泄漏
    this.destroySelect2()
    this.destroyDatepicker()
  }
}

Q2:如何处理动态组件?

<template>
  <component :is="currentComponent" ref="dynamicComponent" />
</template>

<script>
export default {
  data() {
    return {
      currentComponent: 'ComponentA'
    }
  },
  
  watch: {
    currentComponent(newVal, oldVal) {
      // 组件切换时,新的 mounted 会在下次更新后执行
      this.$nextTick(() => {
        console.log('新组件已挂载:', this.$refs.dynamicComponent)
      })
    }
  },
  
  mounted() {
    // 初次挂载
    this.initializeCurrentComponent()
  }
}
</script>

七、最佳实践总结

  1. 数据初始化 → 优先选择 created
  2. DOM 操作 → 必须使用 mounted(配合 $nextTick 确保渲染完成)
  3. 第三方库初始化mounted + beforeDestroy 清理
  4. 性能敏感操作 → 考虑使用 requestIdleCallback 或 Web Worker
  5. SSR 应用 → 注意浏览器 API 的兼容性检查

写在最后

理解 createdmounted 的区别,本质上是理解 Vue 的渲染流程。记住这个核心原则:

created 是关于数据的准备,mounted 是关于视图的准备。

随着 Vue 3 Composition API 的普及,生命周期有了新的使用方式,但底层原理依然相通。掌握这些基础知识,能帮助你在各种场景下做出更合适的架构决策。

Vuex日渐式微?状态管理的三大痛点与新时代方案

作者 北辰alk
2026年1月13日 22:07

作为Vue生态曾经的“官方标配”,Vuex在无数项目中立下汗马功劳。但近年来,随着Vue 3和Composition API的崛起,越来越多的开发者开始重新审视这个老牌状态管理库。

Vuex的设计初衷:解决组件通信难题

回想Vue 2时代,当我们的应用从简单的单页面逐渐演变成复杂的中大型应用时,组件间的数据共享成为了一大痛点。

// 经典的Vuex store结构
const store = new Vuex.Store({
  state: {
    count0,
    usernull
  },
  mutations: {
    increment(state) {
      state.count++
    }
  },
  actions: {
    async fetchUser({ commit }) {
      const user = await api.getUser()
      commit('SET_USER', user)
    }
  },
  getters: {
    doubleCountstate => state.count * 2
  }
})

这种集中式的状态管理模式,确实在当时解决了:

  • • 多个组件共享同一状态的问题
  • • 状态变更的可追溯性
  • • 开发工具的时间旅行调试

痛点浮现:Vuex的三大“时代局限”

1. 样板代码过多,开发体验繁琐

这是Vuex最常被诟病的问题。一个简单的状态更新,需要经过actionmutationstate的完整流程:

// 定义部分
const actions = {
  updateUser({ commit }, user) {
    commit('SET_USER', user)
  }
}

const mutations = {
  SET_USER(state, user) {
    state.user = user
  }
}

// 使用部分
this.$store.dispatch('updateUser', newUser)

相比之下,直接的状态赋值只需要一行代码。在中小型项目中,这种复杂度常常显得“杀鸡用牛刀”。

2. TypeScript支持不友好

虽然Vuex 4改进了TS支持,但其基于字符串的dispatchcommit调用方式,始终难以获得完美的类型推断:

// 类型安全较弱
store.commit('SET_USER', user) // 'SET_USER'字符串无类型检查

// 需要额外定义类型
interface User {
  idnumber
  namestring
}

// 但定义和使用仍是分离的

3. 模块系统复杂,代码组织困难

随着项目增大,Vuex的模块系统(namespaced modules)带来了新的复杂度:

// 访问模块中的状态需要命名空间前缀
computed: {
  ...mapState({
    userstate => state.moduleA.user
  })
}

// 派发action也需要前缀
this.$store.dispatch('moduleA/fetchData')

动态注册模块、模块间的依赖关系处理等问题,让代码维护成本逐渐升高。

新时代的解决方案:更轻量、更灵活的选择

方案一:Composition API + Provide/Inject

Vue 3的Composition API为状态管理提供了全新思路:

// 使用Composition API创建响应式store
export function useUserStore() {
  const user = ref<User | null>(null)
  
  const setUser = (newUser: User) => {
    user.value = newUser
  }
  
  return {
    user: readonly(user),
    setUser
  }
}

// 在组件中使用
const { user, setUser } = useUserStore()

优点

  • • 零依赖、零学习成本
  • • 完美的TypeScript支持
  • • 按需导入,Tree-shaking友好

方案二:Pinia——Vuex的现代继承者

Pinia被看作是“下一代Vuex”,解决了Vuex的许多痛点:

// 定义store
export const useUserStore = defineStore('user', {
  state() => ({
    usernull as User | null,
  }),
  actions: {
    async fetchUser() {
      this.user = await api.getUser()
    },
  },
})

// 使用store
const userStore = useUserStore()
userStore.fetchUser()

Pinia的进步

  • • 移除mutations,actions可直接修改状态
  • • 完整的TypeScript支持
  • • 更简洁的API设计
  • • 支持Composition API和Options API

实战建议:如何选择?

根据我的项目经验,建议如下:

继续使用Vuex的情况

  • • 维护已有的Vue 2大型项目
  • • 团队已深度熟悉Vuex,且项目运行稳定
  • • 需要利用Vuex DevTools的特定功能

考虑迁移/使用新方案的情况

  • • 新项目:优先考虑Pinia
  • • Vue 3项目:中小型可用Composition API,大型推荐Pinia
  • • 对TypeScript要求高:直接选择Pinia

迁移策略:平稳过渡

如果你决定从Vuex迁移到Pinia,可以采取渐进式策略:

  1. 1. 并行运行:新旧store系统共存
  2. 2. 模块逐个迁移:按业务模块逐步迁移
  3. 3. 工具辅助:利用官方迁移指南和工具
// 迁移示例:将Vuex模块转为Pinia store
// Vuex版本
const userModule = {
  state: { name'' },
  mutations: { SET_NAME(state, name) { state.name = name } }
}

// Pinia版本
const useUserStore = defineStore('user', {
  state() => ({ name'' }),
  actions: {
    setName(name: string) {
      this.name = name
    }
  }
})

写在最后

技术总是在不断演进。Vuex作为特定历史阶段的优秀解决方案,完成了它的使命。而今天,我们有更多、更好的选择。

核心不是追求最新技术,而是为项目选择最合适的工具。

对于大多数新项目,Pinia无疑是更现代、更优雅的选择。但对于已有的Vuex项目,除非有明确的痛点需要解决,否则“稳定压倒一切”。

从“死了么”到“活着记”:用Gmeek在数字世界留下思想印记

作者 修己xj
2026年1月13日 20:47

本文从近期热议的“死了么”App入手,探讨现代人对数字安全与思想存续的双重需求,详细介绍基于GitHub的极简博客框架Gmeek,阐述在数字时代通过博客记录思想、对抗遗忘的重要意义,鼓励读者建立个人数字思想家园。

github_gmeek.png

github_gmeek.png

引言

一款名为“死了么”的App近期引发广泛讨论。它以直白甚至略显生硬的名字,精准地切中了当代社会一个真实且规模庞大的群体需求。

可以说,“死了么”的火爆,是产品创意、社会情绪与网络传播共同作用的结果。它如同一面棱镜,折射出当代独居生活的潜在隐忧。

如今,独居者人数众多。他们往往独自奋斗,习惯“一个人扛下所有”,最担心的莫过于在突发疾病或意外时无人知晓。这款App之所以能够走红,恰恰在于它敏锐地捕捉到了现代人一种微妙而普遍的心理——“不愿日常打扰他人,却渴望在异常状况下被关注”。用户无需复杂的社交,仅通过简单的每日打卡,就能为自己构筑一道最低成本的“安全防线”。其付费下载量的快速增长,也印证了这一需求真实而强烈。

然而,人终有一死。那么,我们该如何留下生活过的痕迹?如何在日常中安顿内心、与自己对话?活着,不仅仅意味着没有死去,而是要有思想、有记录、有回响地活着。博客,作为一种数字化的表达方式,正成为越来越多人记录自我、分享见解、沉淀思想的平台。

最近读到一篇文章,颇受启发。People Die, but Long Live GitHub

people-die-but-long-live-github.png

people-die-but-long-live-github.png

最近,我还在 GitHub 上发现了一个开源项目——Gmeek。它是一个超轻量级的个人博客框架,完全基于 GitHub Pages、GitHub Issues 与 GitHub Actions 构建,可谓“All in GitHub”。无需本地部署,从搭建到写作,整个过程只需三步:前两步用 18 秒完成博客搭建,第三步即可开始书写。今天我们就来介绍下如何使用这款开源项目构建个人github博客。

Gmeek:三步构建你的数字思想家园

一个博客框架,超轻量级个人博客模板。完全基于Github PagesGithub IssuesGithub Actions。不需要本地部署,从搭建到写作,只需要18秒,2步搭建好博客,第3步就是写作。

github地址:github.com/Meekdai/Gme…

文档博客:blog.meekdai.com/tag.html#Gm…

gmeek_star.png

gmeek_star.png

gmeek_doc_blog.png

gmeek_doc_blog.png

快速开始

  1. 【创建仓库】点击通过模板创建仓库,建议仓库名称为XXX.github.io,其中XXX为你的github用户名。

xiuji_github_blog.png

xiuji_github_blog.png

  1. 【启用Pages】在仓库的SettingsPages->Build and deployment->Source下面选择Github Actions
  2. 【开始写作】打开一篇issue,开始写作,并且必须添加一个标签Label(至少添加一个),再保存issue后会自动创建博客内容,片刻后可通过XXX.github.io 访问(可进入Actions页面查看构建进度)。
  3. 【手动全局生成】这个步骤只有在修改config.json文件或者出现奇怪问题的时候,需要执行。
通过Actions->build Gmeek->Run workflow->里面的按钮全局重新生成一次

[!NOTE] issue必须添加一个标签Label(至少添加一个)

到此,提交完issue之后Actions页面构建完成之后就可以看到我们的博客了。

博主的github博客地址:xiuji008.github.io/

配置及使用

config.json 文件就是配置文件,在创建的仓库内可以找到,对应修改为自己的即可。

配置可参考Gmeek作者博文:blog.meekdai.com/post/Gmeek-…

static文件夹使用

  1. 在自己的仓库根目录下新建一个文件夹,名称必须是static。
  2. 然后在static文件内上传一些自己的文件,比如博客图片、插件js等。
  3. 通过手动全局生成一次成功后,你就可以通过 xxx.github.io/your.png 访问了

插件功能的使用

为了使得Gmeek的功能更加的丰富,Gmeek作者添加了插件的功能,目前已经有几个插件可以使用。大家可以直接复制文章中的配置代码使用,也可以把对应的插件文件拷贝到自己的static文件夹下使用。

计数工具 Vercount

  1. 全站添加计数工具Vercount,只需要在config.json文件内添加配置
"allHead":"<script src='https://blog.meekdai.com/Gmeek/plugins/GmeekVercount.js'></script>",

2. 单个文章页添加Vercount,只需要在文章最后一行添加如下

<!-- ##{"script":"<script src='https://blog.meekdai.com/Gmeek/plugins/GmeekVercount.js'></script>"}## -->

gmeek_plugin_vercount.png

gmeek_plugin_vercount.png

TOC目录

  1. 所有文章页添加TOC目录,只需要在config.json文件内添加配置
"script":"<script src='https://blog.meekdai.com/Gmeek/plugins/GmeekTOC.js'></script>",

2. 单个文章页添加TOC目录,只需要在文章最后一行添加如下

<!-- ##{"script":"<script src='https://blog.meekdai.com/Gmeek/plugins/GmeekTOC.js'></script>"}## -->

gmeek_plugins_toc.png

gmeek_plugins_toc.png

灯箱插件

[!TIP] 此插件由Tiengming编写,可以放大浏览文章中的图片,适合一些图片较多的文章。

  1. 所有文章页添加lightbox,只需要在config.json文件内添加配置
"script":"<script src='https://blog.meekdai.com/Gmeek/plugins/lightbox.js'></script>",

2. 单个文章页添加lightbox,只需要在文章最后一行添加如下

<!-- ##{"script":"<script src='https://blog.meekdai.com/Gmeek/plugins/lightbox.js'></script>"}## -->

看板娘(花里胡哨)

[!TIP] 此插件从github开源项目live2d-widget引入,纯属页面展示

  1. 所有文章页添加lightbox,只需要在config.json文件内添加配置
"script":"<script src='https://fastly.jsdelivr.net/npm/live2d-widgets@1.0.0/dist/autoload.js'></script>",

gmeek_plugins_live2d-widget.png

gmeek_plugins_live2d-widget.png

对看板娘项目感兴趣的伙伴也可以研究下

看板娘项目github地址:github.com/stevenjoezh…

github_live2d_widget.png

github_live2d_widget.png

多插件使用

同时在所有文章页使用TOC目录、灯箱插件及其它插件,需要这样添加配置文件:

    "allHead":"<script src='https://xiuji008.github.io/plugins/gmeekVercount.js'></script><script src='https://xiuji008.github.io/plugins/lightbox.js'></script><script src='https://xiuji008.github.io/plugins/gmeekTOC.js'></script><script src='https://fastly.jsdelivr.net/npm/live2d-widgets@1.0.0/dist/autoload.js'></script>",

其它使用说明

issue添加中文标签

  1. 点击 issue页签, 点击右侧 Labels 后边的设置按钮,点击Edit labels

issues_labels_setting.png

issues_labels_setting.png

  1. Labels 页面则可以新增或修改标签

issues_labels_edit.png

issues_labels_edit.png

置顶博客文章

只需要Pin issue后,手动全局生成一次即可。

issues_pin.png

issues_pin.png

评论 utteranc报错

如果在评论里面登录后评论报错,可直接按照提示安装utteranc app即可

Error: utterances is not installed on xxx/xxx.github.io. If you own this repo, install the app. Read more about this change in the PR.

删除文章

只需要Close issue或者Delete issue后,再手动全局生成一次即可。

结语:在数字时代留下有温度的痕迹

“死了么”关注的是物理存在的安全,而Gmeek这样的工具关注的是思想存在的延续。两者看似无关,实则都回应了现代人对存在感的深层渴望。

在这个算法主导、注意力碎片化的时代,拥有一个属于自己的数字角落,定期记录、整理、输出,不仅是对抗遗忘的方式,更是一种积极的生活态度——主动塑造自己的数字身份,而非被动地被平台定义。

从担心“无人知晓的离去”到主动“留下有思想的痕迹”,或许正是数字时代给予我们的一种平衡:既通过工具获得安全感,也通过表达实现自我确认。

你的思想值得被记录,你的声音值得被听见。现在,只需18秒,就可以开始在GitHub上建造你的数字思想家园。

HarmonyOS 多模块项目中的公共库治理与最佳实践

作者 90后晨仔
2026年1月13日 20:43

鸿蒙(HarmonyOS)多模块项目 中,如果你希望 避免在每个模块(Module)中重复集成同一个三方库或公共库,可以将该库 提升到项目级别(Project-level)进行统一管理。以下是标准做法,适用于 Stage 模型 + ArkTS + DevEco Studio 的工程结构。


✅ 目标

将公共库(如 @ohos/utils、自研工具库、第三方 npm 包等)只声明一次,供多个模块(entry、feature、service 等)共享使用


📁 鸿蒙项目结构回顾

MyHarmonyProject/
├── build-profile.json5        ← 项目级构建配置
├── oh-package.json5           ← 项目级依赖(关键!)
├── modules/
│   ├── entry/                 ← 主模块
│   ├── feature_news/          ← 功能模块1
│   └── feature_ebook/         ← 功能模块2
└── libs/                      ← (可选)本地 aar/har 公共库

✅ 正确做法:在 项目根目录的 oh-package.json5 中声明依赖

步骤 1:在项目根目录的 oh-package.json5 中添加依赖

{
  "devDependencies": {
    // 开发依赖(如 types)
  },
  "dependencies": {
    // 👇 把公共库放在这里(项目级)
    "@ohos/utils": "1.0.0",
    "some-third-party-lib": "^2.3.0"
  }
}

✅ 这样,所有子模块都可以继承使用这些依赖,无需在每个 module/xxx/oh-package.json5 中重复声明。


步骤 2:删除各子模块中的重复依赖

确保 modules/entry/oh-package.json5modules/feature_news/oh-package.json5不再包含 已提升到项目级的依赖。

例如,不要entry/oh-package.json5 中再写:

{
  "dependencies": {
    "@ohos/utils": "1.0.0"  // ❌ 删除这行!
  }
}

步骤 3:在子模块代码中正常 import 使用

// 在 entry 或 feature_news 模块中
import { ZGJYBAppearanceColorUtil } from '@ohos/utils';

// ✅ 可以正常使用,因为依赖已由项目级提供

⚠️ 注意事项

1. 仅适用于 npm 类型的包(通过 ohpm 安装)

  • 如果你是通过 ohpm install @ohos/utils 安装的库,它会被记录在 oh-package.json5
  • 这种方式支持 依赖提升(hoisting) ,类似 npm/yarn 的 workspace。

2. 本地 .har.hap 库不能这样共享

  • 如果你的“库”是一个 本地开发的 .har(HarmonyOS Archive)模块,则需要:

    • 将其放在 libs/ 目录下;
    • 每个需要使用的模块module.json5 中声明 deps 引用;
    • 或者将其发布为私有 ohpm 包,再通过 oh-package.json5 引入。

示例:引用本地 har(仍需逐模块配置)

// modules/entry/module.json5
{
  "deps": [
    "../libs/my-common-utils.har"
  ]
}

❌ 这种情况无法完全避免重复声明,但你可以通过脚本或模板减少工作量。


3. 确保 DevEco Studio 同步了依赖

  • 修改 oh-package.json5 后,点击 “Sync Now” 或运行:

    ohpm install
    

    在项目根目录执行,会安装所有模块共享的依赖。


✅ 最佳实践总结

场景 推荐方案
公共 npm/ohpm 库(如 @ohos/utils ✅ 在 项目根目录 oh-package.json5 中声明一次
自研公共逻辑(TS 工具函数) ✅ 创建一个 shared 模块,发布为 ohpm 私有包,再在项目级引入
本地 .har ⚠️ 需在每个模块的 module.json5 中引用,但可统一放在 libs/ 目录管理
避免重复代码 ✅ 抽象公共组件/工具到独立模块,通过依赖注入使用

🔧 附加建议:创建 shared 模块(高级)

  1. 新建模块:File > New > Module > Static Library (HAR)

    • 命名为 shared
  2. 在其中放置公共工具类、常量、网络封装等

  3. shared/oh-package.json5 中定义包名:

    { "name": "@myorg/shared", "version": "1.0.0" }
    
  4. 在项目根目录运行:

    ohpm install ./modules/shared --save
    
  5. 然后在 oh-package.json5 中就会出现:

    "dependencies": {
      "@myorg/shared": "file:./modules/shared"
    }
    
  6. 所有模块即可通过 import { xxx } from '@myorg/shared' 使用。

✅ 这是最接近“项目级公共库”的鸿蒙官方推荐方案。


✅ 结论

把公共库写在项目根目录的 oh-package.json5dependencies 中,即可实现“一次集成,多模块共享”

只要你的库是通过 ohpm 管理的(包括本地 file: 引用),就支持这种共享机制。这是 HarmonyOS 多模块项目的标准依赖管理方式。

Vue插槽

作者 yyt_
2026年1月13日 20:15

一、先明确核心概念

  1. 具名插槽:给 <slot> 标签添加 name 属性,用于区分不同位置的插槽,让父组件可以精准地将内容插入到子组件的指定位置,解决「默认插槽只能插入一处内容」的问题。
  2. 默认插槽:没有 name 属性的 <slot>,是具名插槽的特殊形式(默认名称为 default),父组件中未指定插槽名称的内容,会默认插入到这里。
  3. 插槽默认内容:在子组件的 <slot> 标签内部写入内容,当父组件未给该插槽传递任何内容时,会显示这份默认内容;若父组件传递了内容,会覆盖默认内容,提升组件的复用性和容错性。
  4. 作用域插槽:子组件通过「属性绑定」的方式给 <slot> 传递内部私有数据,父组件在使用插槽时可以接收这些数据并自定义渲染,解决「父组件无法访问子组件内部数据」的问题,实现「子组件供数、父组件定制渲染」。

二、分步实例演示

第一步:实现最基础的「具名插槽 + 默认插槽」

核心需求:创建一个通用的「页面容器组件」,包含「页头」「页面内容」「页脚」三个部分,其中「页面内容」用默认插槽,「页头」「页脚」用具名插槽。

1. 子组件:定义插槽(文件名:PageContainer.vue

<template>
  <!-- 通用页面容器样式(简单美化,方便查看效果) -->
  <div class="page-container" style="border: 1px solid #e0e0e0; border-radius: 8px; padding: 20px; margin: 20px 0;">
    <!-- 具名插槽:页头(name="header") -->
    <div class="page-header" style="border-bottom: 1px dashed #e0e0e0; padding-bottom: 10px; margin-bottom: 10px;">
      <slot name="header" />
    </div>

    <!-- 默认插槽:页面核心内容(无name属性,对应default) -->
    <div class="page-content" style="margin: 20px 0; min-height: 100px;">
      <slot />
    </div>

    <!-- 具名插槽:页脚(name="footer") -->
    <div class="page-footer" style="border-top: 1px dashed #e0e0e0; padding-top: 10px; margin-top: 10px; text-align: right;">
      <slot name="footer" />
    </div>
  </div>
</template>

<script setup>
// 子组件无需额外逻辑,仅定义插槽结构即可
</script>

2. 父组件:使用插槽(传递内容,文件名:App.vue

父组件通过 v-slot:插槽名(简写:#插槽名)指定内容要插入的具名插槽,未指定的内容默认插入到默认插槽。

<template>
  <h2>基础具名插槽 + 默认插槽演示</h2>

  <!-- 使用子组件 PageContainer -->
  <PageContainer>
    <!-- 给具名插槽 header 传递内容(简写 #header,完整写法 v-slot:header) -->
    <template #header>
      <h3>这是文章详情页的页头</h3>
      <nav>首页 > 文章 > Vue 插槽教程</nav>
    </template>

    <!-- 未指定插槽名,默认插入到子组件的默认插槽 -->
    <div>
      <p>1. 具名插槽可以让父组件精准控制内容插入位置。</p>
      <p>2. 默认插槽用于承载组件的核心内容,使用更简洁。</p>
      <p>3. 这部分内容会显示在页头和页脚之间。</p>
    </div>

    <!-- 给具名插槽 footer 传递内容(简写 #footer) -->
    <template #footer>
      <span>发布时间:2026-01-13</span>
      <button style="margin-left: 20px; padding: 4px 12px;">收藏文章</button>
    </template>
  </PageContainer>
</template>

<script setup>
// 导入子组件
import PageContainer from './PageContainer.vue';
</script>

3. 运行效果与说明

  • 页头区域显示「文章详情页标题 + 面包屑导航」(对应 #header 插槽内容)。
  • 中间区域显示核心正文(对应默认插槽内容)。
  • 页脚区域显示「发布时间 + 收藏按钮」(对应 #footer 插槽内容)。
  • 关键:父组件的 <template> 标签包裹插槽内容,通过 #插槽名 绑定子组件的具名插槽,结构清晰,互不干扰。

第二步:实现「带默认内容的插槽」

核心需求:优化上面的 PageContainer.vue,给「页脚插槽」添加默认内容(默认显示「返回顶部」按钮),当父组件未给 footer 插槽传递内容时,显示默认按钮;若传递了内容,覆盖默认内容。

1. 修改子组件:给插槽添加默认内容(PageContainer.vue

仅修改 footer 插槽部分,在 <slot name="footer"> 内部写入默认内容:

<template>
  <div class="page-container" style="border: 1px solid #e0e0e0; border-radius: 8px; padding: 20px; margin: 20px 0;">
    <!-- 具名插槽:页头 -->
    <div class="page-header" style="border-bottom: 1px dashed #e0e0e0; padding-bottom: 10px; margin-bottom: 10px;">
      <slot name="header" />
    </div>

    <!-- 默认插槽:页面核心内容 -->
    <div class="page-content" style="margin: 20px 0; min-height: 100px;">
      <slot />
    </div>

    <!-- 具名插槽:页脚(带默认内容) -->
    <div class="page-footer" style="border-top: 1px dashed #e0e0e0; padding-top: 10px; margin-top: 10px; text-align: right;">
      <slot name="footer">
        <!-- 插槽默认内容:父组件未传递footer内容时,显示该按钮 -->
        <button style="padding: 4px 12px;" @click="backToTop">返回顶部</button>
      </slot>
    </div>
  </div>
</template>

<script setup>
// 定义默认内容的点击事件(返回顶部)
const backToTop = () => {
  window.scrollTo({
    top: 0,
    behavior: 'smooth' // 平滑滚动
  });
};
</script>

2. 父组件演示两种场景(App.vue

分别演示「不传递 footer 内容」和「传递 footer 内容」的效果:

<template>
  <h2>带默认内容的插槽演示</h2>

  <!-- 场景1:父组件不传递 footer 插槽内容,显示子组件的默认「返回顶部」按钮 -->
  <h4>场景1:未传递页脚内容(显示默认按钮)</h4>
  <PageContainer>
    <template #header>
      <h3>这是未传递页脚的页面</h3>
    </template>
    <p>该页面父组件没有给 footer 插槽传递内容,所以页脚会显示子组件默认的「返回顶部」按钮。</p>
  </PageContainer>

  <!-- 场景2:父组件传递 footer 插槽内容,覆盖默认按钮 -->
  <h4 style="margin-top: 40px;">场景2:传递页脚内容(覆盖默认按钮)</h4>
  <PageContainer>
    <template #header>
      <h3>这是传递了页脚的页面</h3>
    </template>
    <p>该页面父组件给 footer 插槽传递了自定义内容,会覆盖子组件的默认「返回顶部」按钮。</p>
    <template #footer>
      <span>作者:Vue 小白教程</span>
      <button style="margin-left: 20px; padding: 4px 12px;">点赞</button>
      <button style="margin-left: 10px; padding: 4px 12px;">评论</button>
    </template>
  </PageContainer>
</template>

<script setup>
import PageContainer from './PageContainer.vue';
</script>

3. 运行效果与说明

  • 场景1:页脚显示「返回顶部」按钮,点击可实现平滑滚动到页面顶部(默认内容生效)。
  • 场景2:页脚显示「作者 + 点赞 + 评论」,默认的「返回顶部」按钮被覆盖(自定义内容生效)。
  • 核心价值:插槽默认内容让组件更「健壮」,无需父组件每次都传递所有插槽内容,减少冗余代码,提升组件复用性。

第三步:实际业务场景综合应用(卡片组件)

核心需求:创建一个通用的「商品卡片组件」,使用具名插槽实现「商品图片」「商品标题」「商品价格」「操作按钮」的自定义配置,其中「操作按钮」插槽带默认内容(默认「加入购物车」按钮)。

1. 子组件:商品卡片(GoodsCard.vue

<template>
  <div class="goods-card" style="width: 280px; border: 1px solid #f0f0f0; border-radius: 12px; padding: 16px; margin: 16px; float: left; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
    <!-- 具名插槽:商品图片 -->
    <div class="goods-img" style="width: 100%; height: 180px; margin-bottom: 12px; text-align: center;">
      <slot name="image" />
    </div>

    <!-- 具名插槽:商品标题 -->
    <div class="goods-title" style="font-size: 16px; font-weight: 500; margin-bottom: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
      <slot name="title" />
    </div>

    <!-- 具名插槽:商品价格 -->
    <div class="goods-price" style="font-size: 18px; color: #ff4400; margin-bottom: 16px;">
      <slot name="price" />
    </div>

    <!-- 具名插槽:操作按钮(带默认内容) -->
    <div class="goods-actions" style="text-align: center;">
      <slot name="action">
        <!-- 默认内容:加入购物车按钮 -->
        <button style="width: 100%; padding: 8px 0; background: #ff4400; color: #fff; border: none; border-radius: 8px; cursor: pointer;">
          加入购物车
        </button>
      </slot>
    </div>
  </div>
</template>

<script setup>
// 无需额外逻辑,仅提供插槽结构和默认内容
</script>

2. 父组件:使用商品卡片组件(App.vue

自定义不同商品的内容,演示插槽的灵活性:

<template>
  <h2>实际业务场景:商品卡片组件</h2>
  <div style="overflow: hidden; clear: both;">
    <!-- 商品1:使用默认操作按钮(加入购物车) -->
    <GoodsCard>
      <template #image>
        <img src="https://picsum.photos/240/180?random=1" alt="商品图片" style="width: 240px; height: 180px; object-fit: cover; border-radius: 8px;">
      </template>
      <template #title>
        小米手机 14 旗舰智能手机
      </template>
      <template #price>
        ¥ 4999
      </template>
      <!-- 未传递 #action 插槽,显示默认「加入购物车」按钮 -->
    </GoodsCard>

    <!-- 商品2:自定义操作按钮(立即购买 + 收藏) -->
    <GoodsCard>
      <template #image>
        <img src="https://picsum.photos/240/180?random=2" alt="商品图片" style="width: 240px; height: 180px; object-fit: cover; border-radius: 8px;">
      </template>
      <template #title>
        苹果 iPad Pro 平板电脑
      </template>
      <template #price>
        ¥ 7999
      </template>
      <!-- 自定义 #action 插槽内容,覆盖默认按钮 -->
      <template #action>
        <button style="width: 48%; padding: 8px 0; background: #0071e3; color: #fff; border: none; border-radius: 8px; cursor: pointer; margin-right: 4%;">
          立即购买
        </button>
        <button style="width: 48%; padding: 8px 0; background: #f0f0f0; color: #333; border: none; border-radius: 8px; cursor: pointer;">
          收藏
        </button>
      </template>
    </GoodsCard>
  </div>
</template>

<script setup>
import GoodsCard from './GoodsCard.vue';
</script>

3. 运行效果与说明

  • 商品1:操作按钮显示默认的「加入购物车」,快速实现基础功能。
  • 商品2:操作按钮显示「立即购买 + 收藏」,满足自定义需求。
  • 业务价值:通过具名插槽,打造了「通用可复用」的商品卡片组件,父组件可以根据不同商品场景,灵活配置各个区域的内容,既减少了重复代码,又保证了灵活性。

第四步:实现「作用域插槽」

核心需求:基于现有商品卡片组件优化,让子组件持有私有商品数据,通过作用域插槽传递给父组件,父组件自定义渲染格式(如给高价商品加「高端」标识、显示商品优惠信息)。

1. 修改子组件:定义作用域插槽,传递内部数据(GoodsCard.vue

子组件新增内部私有数据,通过「属性绑定」给插槽传递数据(:数据名="子组件内部数据"):

vue

<template>
  <div class="goods-card" style="width: 280px; border: 1px solid #f0f0f0; border-radius: 12px; padding: 16px; margin: 16px; float: left; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
    <!-- 作用域插槽:商品图片(暴露商品id和图片地址) -->
    <div class="goods-img" style="width: 100%; height: 180px; margin-bottom: 12px; text-align: center;">
      <slot name="image" :goodsId="goods.id" :imgUrl="goods.imgUrl" />
    </div>

    <!-- 作用域插槽:商品标题(暴露商品名称和价格) -->
    <div class="goods-title" style="font-size: 16px; font-weight: 500; margin-bottom: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
      <slot name="title" :goodsName="goods.name" :goodsPrice="goods.price" />
    </div>

    <!-- 作用域插槽:商品价格(暴露价格和优惠信息) -->
    <div class="goods-price" style="font-size: 18px; color: #ff4400; margin-bottom: 16px;">
      <slot name="price" :price="goods.price" :discount="goods.discount" />
    </div>

    <!-- 具名插槽:操作按钮(带默认内容) -->
    <div class="goods-actions" style="text-align: center;">
      <slot name="action">
        <!-- 默认内容:加入购物车按钮 -->
        <button style="width: 100%; padding: 8px 0; background: #ff4400; color: #fff; border: none; border-radius: 8px; cursor: pointer;">
          加入购物车
        </button>
      </slot>
    </div>
  </div>
</template>

<script setup>
// 子组件内部私有数据(模拟接口返回,父组件无法直接访问)
const goods = {
  id: 1001,
  name: "小米手机 14 旗舰智能手机",
  price: 4999,
  imgUrl: "https://picsum.photos/240/180?random=1",
  discount: "立减200元,支持分期免息"
};
</script>

2. 父组件:接收并使用作用域插槽数据(App.vue

父组件通过 template #插槽名="插槽数据对象" 接收子组件暴露的数据,支持解构赋值简化代码,自定义渲染逻辑:

vue

<template>
  <h2>进阶:作用域插槽演示(子组件供数,父组件定制渲染)</h2>
  <div style="overflow: hidden; clear: both; margin-top: 40px;">
    <GoodsCard>
      <!-- 接收图片插槽的作用域数据:slotProps(自定义名称,包含goodsId、imgUrl) -->
      <template #image="slotProps">
        <img :src="slotProps.imgUrl" :alt="'商品' + slotProps.goodsId" style="width: 240px; height: 180px; object-fit: cover; border-radius: 8px;">
        <!-- 利用子组件传递的goodsId,添加自定义标识 -->
        <span style="position: absolute; top: 8px; left: 8px; background: red; color: #fff; padding: 2px 8px; border-radius: 4px; z-index: 10;">
          编号:{{ slotProps.goodsId }}
        </span>
      </template>

      <!-- 接收标题插槽的作用域数据:解构赋值(更简洁,推荐) -->
      <template #title="{ goodsName, goodsPrice }">
        {{ goodsName }}
        <!-- 父组件自定义逻辑:价格高于4000加「高端」标识 -->
        <span v-if="goodsPrice > 4000" style="color: #ff4400; font-size: 12px; margin-left: 8px;">
          高端
        </span>
      </template>

      <!-- 接收价格插槽的作用域数据:结合优惠信息渲染 -->
      <template #price="{ price, discount }">
        <span>¥ {{ price }}</span>
        <!-- 渲染子组件传递的优惠信息,自定义样式 -->
        <p style="font-size: 12px; color: #999; margin-top: 4px; text-align: left;">
          {{ discount }}
        </p>
      </template>
    </GoodsCard>
  </div>
</template>

<script setup>
import GoodsCard from './components/GoodsCard.vue';
</script>

3. 运行效果与说明

  • 父组件成功获取子组件私有数据(goodsIddiscount 等),并实现自定义渲染(商品编号、高端标识、优惠信息);
  • 核心语法:子组件「属性绑定传数据」,父组件「插槽数据对象接收」,支持解构赋值简化代码;
  • 核心价值:通用组件(列表、卡片、表格)既保留内部数据逻辑,又开放渲染格式定制权,极大提升组件灵活性和复用性;
  • 注意:作用域插槽本质仍是具名 / 默认插槽,只是增加了「子向父」的数据传递能力。

三、总结(核心知识点回顾,加深记忆)

  1. 使用步骤
  • 子组件:用 <slot name="xxx"> 定义具名插槽(内部可写默认内容),用 :数据名="内部数据" 给插槽传递数据(作用域插槽);
  • 父组件:用 <template #xxx> 给指定具名插槽传内容,用 <template #xxx="slotProps"> 接收作用域插槽数据,未指定插槽名的内容默认插入到 <slot>(默认插槽)。
  1. 核心语法
  • v-slot:插槽名 可简写为 #插槽名,仅能用于 <template> 标签或组件标签上;
  • 作用域插槽数据支持解构赋值,可设置默认值(如 #title="{ goodsName = '默认商品', goodsPrice = 0 }")避免报错。
  1. 插槽体系
  • 基础层:默认插槽(单一区域)、具名插槽(多区域精准定制);
  • 增强层:插槽默认内容(提升健壮性)、作用域插槽(子供数 + 父定制,进阶核心)。

分割正方形 I

2026年1月8日 12:04

方法一:二分查找

思路与算法

设数组 $\textit{squares}$ 的长度为 $n$,数组中的每个元素为 $(x_i,y_i,l_i)$,此时所有正方形的面积之和则为:

$$
\textit{totalArea} = \sum_{i=0}^{n-1} l_i^2
$$

根据题目要求,我们需要找到一个分割线 $y$,使得所有在 $y$ 以上的正方形的面积之和等于所有在 $y$ 以下的正方形的面积之和。设 $y$ 以下的正方形面积为 $\textit{area}_y$,此时需要满足:

$$
\textit{area}_y \cdot 2= \textit{totalArea}
$$

随着分割线 $y$ 的增大,$\textit{area}_y$ 单调不减,因此可以使用二分查找。具体地,我们可以二分查找找到最小的 $y$ 的值,使得在 $y$ 以下的正方形的面积满足:

$$
\textit{area}_y \cdot 2 \ge \textit{totalArea}
$$

假设给定的 $y$ 的值,如果给定正方形 $(x_i,y_i,l_i)$,满足 $y_i < y$,那么这个正方形在 $y$ 以下,否则在 $y$ 以上。此时该正方形在 $y$ 以下的面积则为:

$$
\textit{area} = l_i \cdot \min(y - y_i, l_i)
$$

我们可以根据这个性质来计算在 $y$ 以下的所有正方形的面积之和:

$$
\textit{area}y = \sum{i=0}^{n-1} l_i \cdot \max(0,\min(y - y_i, l_i))
$$

由于计算面积时存在精度问题,题目要求与实际答案的误差在 $10^{-5}$ 以内。我们需要在二分查找时使用 $10^{-5}$ 作为精度,即可以使用上限与下限的差距不超过 $\text{10}^{-5}$ 作为二分查找的终止条件:

$$
\textit{hi} - \textit{lo} \le 10^{-5}
$$

我们通过二分查找找到最小的 $y$ 值即为答案。

细节

我们可以分析二分查找的次数上限,设初始二分区间长度为 $L$,每二分一次,二分区间长度减半。要至少减半到 $10^{-5}$ 才能满足题目的误差要求。设循环次数为 $k$,我们有:

$$
\dfrac{L}{2^k} \le 10^{-5}
$$

解得:

$$
k \ge \log_2 (L \cdot 10^5)
$$

在本题的数据范围下,$0 \le L \le 10^9$,此时 $k \ge \log_2 (L \cdot 10^5) \ge \log_2 (10^{14}) = 14 \log_2 (10) \approx 46.506993328423076$。二分查找的次数上限为 $47$ 次。

代码

###C++

class Solution {
public:
    double separateSquares(vector<vector<int>>& squares) {
        double max_y = 0, total_area = 0;
        for (auto& sq : squares) {
            int y = sq[1], l = sq[2];
            total_area += (double)l * l;
            max_y = max(max_y, (double)(y + l));
        }
        
        auto check = [&](double limit_y) -> bool {
            double area = 0;
            for (auto& sq : squares) {
                int y = sq[1], l = sq[2];
                if (y < limit_y) {
                    area += l * min(limit_y - y, (double)l);
                }
            }
            return area >= total_area / 2;
        };
        
        double lo = 0, hi = max_y;
        double eps = 1e-5;
        while (abs(hi - lo) > eps) {
            double mid = (hi + lo) / 2;
            if (check(mid)) {
                hi = mid;
            } else {
                lo = mid;
            }
        }
        return hi;
    }
};

###Java

class Solution {
    public double separateSquares(int[][] squares) {
        double max_y = 0, total_area = 0;
        for (int[] sq : squares) {
            int y = sq[1], l = sq[2];
            total_area += (double)l * l;
            max_y = Math.max(max_y, (double)(y + l));
        }
        
        double lo = 0, hi = max_y;
        double eps = 1e-5;
        while (Math.abs(hi - lo) > eps) {
            double mid = (hi + lo) / 2;
            if (check(mid, squares, total_area)) {
                hi = mid;
            } else {
                lo = mid;
            }
        }

        return hi;
    }

    private Boolean check(double limit_y, int[][] squares, double total_area) {
        double area = 0;
        for (int[] sq : squares) {
            int y = sq[1], l = sq[2];
            if (y < limit_y) {
                area += (double)l * Math.min(limit_y - y, (double)l);
            }
        }
        return area >= total_area / 2;
    }
}

###C#

public class Solution {
    public double SeparateSquares(int[][] squares) {
        double max_y = 0, total_area = 0;
        foreach (int[] sq in squares) {
            int y = sq[1], l = sq[2];
            total_area += (double)l * l;
            max_y = Math.Max(max_y, (double)(y + l));
        }
        
        double lo = 0, hi = max_y;
        double eps = 1e-5;
        while (Math.Abs(hi - lo) > eps) {
            double mid = (hi + lo) / 2;
            if (Check(mid, squares, total_area)) {
                hi = mid;
            } else {
                lo = mid;
            }
        }

        return hi;
    }

    private bool Check(double limit_y, int[][] squares, double total_area) {
        double area = 0;
        foreach (int[] sq in squares) {
            int y = sq[1], l = sq[2];
            if (y < limit_y) {
                area += (double)l * Math.Min(limit_y - y, (double)l);
            }
        }
        return area >= total_area / 2;
    }
}

###Go

func separateSquares(squares [][]int) float64 {
    max_y, total_area := 0.0, 0.0
    for _, sq := range squares {
        y, l := sq[1], sq[2]
        total_area += float64(l * l)
        if float64(y + l) > max_y {
            max_y = float64(y + l)
        }
    }
    
    check := func(limit_y float64) bool {
        area := 0.0
        for _, sq := range squares {
            y, l := sq[1], sq[2]
            if float64(y) < limit_y {
                overlap := math.Min(limit_y-float64(y), float64(l))
                area += float64(l) * overlap
            }
        }
        
        return area >= total_area / 2.0
    }
    
    lo, hi := 0.0, max_y
    eps := 1e-5
    for math.Abs(hi-lo) > eps {
        mid := (hi + lo) / 2.0
        if check(mid) {
            hi = mid
        } else {
            lo = mid
        }
    }
    return hi
}

###Python

class Solution:
    def separateSquares(self, squares: List[List[int]]) -> float:
        max_y, total_area = 0, 0
        for x, y, l in squares:
            total_area += l ** 2
            max_y = max(max_y, y + l)
        
        def check(limit_y):
            area = 0
            for x, y, l in squares:
                if y < limit_y:
                    area += l * min(limit_y - y, l)
            return area >= total_area / 2
        
        lo, hi = 0, max_y
        eps = 1e-5
        while abs(hi - lo) > eps:
            mid = (hi + lo) / 2
            if check(mid):
                hi = mid
            else:
                lo = mid

        return hi

###C

bool check(double limit_y, int** squares, int squaresSize, double total_area) {
    double area = 0.0;

    for (int i = 0; i < squaresSize; i++) {
        int y = squares[i][1];
        int l = squares[i][2];
        if (y < limit_y) {
            area += (double)l * fmin(l, limit_y - y);
        }
    }

    return area >= total_area / 2.0;
}

double separateSquares(int** squares, int squaresSize, int* squaresColSize) {
    double max_y = 0.0, total_area = 0.0;
    for (int i = 0; i < squaresSize; i++) {
        int y = squares[i][1];
        int l = squares[i][2];
        total_area += (double)l * l;
        if (y + l > max_y) {
            max_y = y + l;
        }
    }
    
    double lo = 0.0, hi = max_y;
    double eps = 1e-5;
    while (fabs(hi - lo) > eps) {
        double mid = (hi + lo) / 2.0;
        if (check(mid, squares, squaresSize, total_area)) {
            hi = mid;
        } else {
            lo = mid;
        }
    }
    return hi;
}

###JavaScript

var separateSquares = function(squares) {
    let max_y = 0, total_area = 0;
    for (const [x, y, l] of squares) {
        total_area += l * l;
        max_y = Math.max(max_y, y + l);
    }
    
    const check = (limit_y) => {
        let area = 0;
        for (const [x, y, l] of squares) {
            if (y < limit_y) {
                area += l * Math.min(limit_y - y, l);
            }
        }
        return area >= total_area / 2;
    };
    
    let lo = 0, hi = max_y;
    const eps = 1e-5;
    while (Math.abs(hi - lo) > eps) {
        const mid = (hi + lo) / 2;
        if (check(mid)) {
            hi = mid;
        } else {
            lo = mid;
        }
    }
    return hi;
};

###TypeScript

function separateSquares(squares: number[][]): number {
    let max_y = 0, total_area = 0;
    for (const [x, y, l] of squares) {
        total_area += l * l;
        max_y = Math.max(max_y, y + l);
    }
    
    const check = (limit_y: number): boolean => {
        let area = 0;
        for (const [x, y, l] of squares) {
            if (y < limit_y) {
                area += l * Math.min(limit_y - y, l);
            }
        }
        return area >= total_area / 2;
    };
    
    let lo = 0, hi = max_y;
    const eps = 1e-5;
    while (Math.abs(hi - lo) > eps) {
        const mid = (hi + lo) / 2;
        if (check(mid)) {
            hi = mid;
        } else {
            lo = mid;
        }
    }
    return hi;
}

###Rust

impl Solution {
    pub fn separate_squares(squares: Vec<Vec<i32>>) -> f64 {
        let mut max_y: f64 = 0.0;
        let mut total_area: f64 = 0.0;
        
        for sq in &squares {
            let l = sq[2] as f64;
            total_area += l * l;
            max_y = max_y.max((sq[1] + sq[2]) as f64);
        }
        
        let mut lo = 0.0;
        let mut hi = max_y;
        let eps = 1e-5;
        while (hi - lo).abs() > eps {
            let mid = (hi + lo) / 2.0;
            if Self::check(mid, &squares, total_area) {
                hi = mid;
            } else {
                lo = mid;
            }
        }
        
        hi
    }
    
    fn check(limit_y: f64, squares: &Vec<Vec<i32>>, total_area: f64) -> bool {
        let mut area = 0.0;
        
        for sq in squares {
            let y = sq[1] as f64;
            let l = sq[2] as f64;
            if y < limit_y {
                let overlap = (limit_y - y).min(l);
                area += l * overlap;
            }
        }
        
        area >= total_area / 2.0
    }
}

复杂度分析

  • 时间复杂度:$O(n \log (LU))$,其中 $n$ 是数组 $\textit{squares}$ 的长度,设数组中的每个元素为 $(x_i,y_i,l_i)$,此时 $U = \max(y_i + l_i)$,$L = 10^5$。二分查找每次校验的时间复杂度度为 $O(n)$,二分查找的次数为 $O(\log (LU))$,因此总时间复杂度为 $O(n \log (LU))$。

  • 空间复杂度:$O(1)$。

方法二:扫描线

思路与算法

我们可以参考「扫描线」的解法。首先可以计算出所有正方形的总面积 $\textit{totalArea}$,接着我们从下往上进行扫描,设扫描线 $y = y^{'}$ 下方的覆盖的面积和为 $\textit{area}$,那么扫描线上方的面积和为 $\textit{totalArea}−\textit{area}$。

题目要求 $y = y^{'}$ 下面的面积与上方的面积相等,即:

$$
\textit{area} = \textit{totalArea}− \textit{area}
$$

即:

$$
\textit{area} = \dfrac{\textit{totalArea}}{2}
$$

设当前经过正方形上/下边界的扫描线为 $y = y^{'}$,此时扫面线以下的覆盖面积为 $\textit{area}$;向上移动时下一个需要经过的正方形上/下边界的扫描线为 $y = y^{''}$,此时被正方形覆盖的底边长之和为 $\textit{width}$,则此时在扫面线 $y = y^{''}$ 以下覆盖的面积之和为:

$$
\textit{area} + \textit{width} \cdot (y^{''} - y^{'})
$$

此时当满足:

$$
\textit{area} < \dfrac{\textit{totalArea}}{2} \
\textit{area} + \textit{width} \cdot (y^{''} - y^{'}) \ge \dfrac{\textit{totalArea}}{2}
$$

时,则可以知道目标值 $y$ 一定处于区间 $[y^{'},y^{''}]$。
由于两个扫面线之间的被覆盖区域中所有的矩形的高度相同,扫面线在区间 $[y^{'},y^{''}]$ 移动长度为 $\Delta$ 时,此时被覆盖区域的面积变化即为 $\Delta \cdot \textit{width}$,此时被覆盖的面积只需增加 $\dfrac{\textit{totalArea}}{2} - \textit{area}$,即可满足上下面积相等,此时我们可以直接求出目标值 $y$ 即为:

$$
y = y^{'} + \dfrac{\dfrac{\textit{totalArea}}{2} - \textit{area}}{\textit{width}} = y^{'} + \dfrac{\textit{totalArea} - 2\cdot \textit{area}}{2\cdot\textit{width}}
$$

我们依次遍历每个正方形上/下边界的扫面线,找到目标值返回即可。

代码

###C++

class Solution {
public:
    double separateSquares(vector<vector<int>>& squares) {
        long long total_area = 0;
        vector<tuple<int, int, int>> events; 
        for (const auto& sq : squares) {
            int y = sq[1], l = sq[2];
            total_area += (long long)l * l;
            events.emplace_back(y, l, 1);
            events.emplace_back(y + l, l, -1);
        }
        // 按照 y 坐标进行排序
        sort(events.begin(), events.end(), [](const auto& a, const auto& b) {
            return get<0>(a) < get<0>(b);
        });
        
        double covered_width = 0;  // 当前扫描线下所有底边之和
        double curr_area = 0;       // 当前累计面积
        double prev_height = 0;     // 前一个扫描线的高度
        for (const auto &[y, l, delta] : events) {
            int diff = y - prev_height;
            // 两条扫面线之间新增的面积
            double area = covered_width * diff;
            // 如果加上这部分面积超过总面积的一半
            if (2LL * (curr_area + area) >= total_area) {
                return prev_height + (total_area - 2.0 * curr_area) / (2.0 * covered_width);
            }
            // 更新宽度:开始事件加宽度,结束事件减宽度
            covered_width += delta * l;
            curr_area += area;
            prev_height = y;
        }
        
        return 0.0;
    }
};

###Java

class Solution {
    public double separateSquares(int[][] squares) {
        long totalArea = 0;
        List<int[]> events = new ArrayList<>();
        
        for (int[] sq : squares) {
            int y = sq[1], l = sq[2];
            totalArea += (long) l * l;
            events.add(new int[]{y, l, 1});
            events.add(new int[]{y + l, l, -1});
        }
        
        // 按y坐标排序
        events.sort((a, b) -> Integer.compare(a[0], b[0]));
        double coveredWidth = 0;  // 当前扫描线下所有底边之和
        double currArea = 0;      // 当前累计面积
        double prevHeight = 0;    // 前一个扫描线的高度
        
        for (int[] event : events) {
            int y = event[0];
            int l = event[1];
            int delta = event[2];
            
            int diff = y - (int) prevHeight;
            // 两条扫描线之间新增的面积
            double area = coveredWidth * diff;
            // 如果加上这部分面积超过总面积的一半
            if (2L * (currArea + area) >= totalArea) {
                return prevHeight + (totalArea - 2.0 * currArea) / (2.0 * coveredWidth);
            }
            // 更新宽度:开始事件加宽度,结束事件减宽度
            coveredWidth += delta * l;
            currArea += area;
            prevHeight = y;
        }
        
        return 0.0;
    }
}

###C#

public class Solution {
    public double SeparateSquares(int[][] squares) {
        long totalArea = 0;
        List<int[]> events = new List<int[]>();
        
        foreach (var sq in squares) {
            int y = sq[1], l = sq[2];
            totalArea += (long)l * l;
            events.Add(new int[] { y, l, 1 });
            events.Add(new int[] { y + l, l, -1 });
        }
        
        // 按y坐标排序
        events.Sort((a, b) => a[0].CompareTo(b[0]));
        
        double coveredWidth = 0;  // 当前扫描线下所有底边之和
        double currArea = 0;      // 当前累计面积
        double prevHeight = 0;    // 前一个扫描线的高度
        
        foreach (var eventItem in events) {
            int y = eventItem[0];
            int l = eventItem[1];
            int delta = eventItem[2];
            
            int diff = y - (int)prevHeight;
            // 两条扫描线之间新增的面积
            double area = coveredWidth * diff;
            // 如果加上这部分面积超过总面积的一半
            if (2L * (currArea + area) >= totalArea) {
                return prevHeight + (totalArea - 2.0 * currArea) / (2.0 * coveredWidth);
            }
            // 更新宽度:开始事件加宽度,结束事件减宽度
            coveredWidth += delta * l;
            currArea += area;
            prevHeight = y;
        }
        
        return 0.0;
    }
}

###Go

func separateSquares(squares [][]int) float64 {
    var totalArea int64 = 0
    type Event struct {
        y     int
        l     int
        delta int
    }
    events := make([]Event, 0, len(squares)*2)
    
    for _, sq := range squares {
        y, l := sq[1], sq[2]
        totalArea += int64(l) * int64(l)
        events = append(events, Event{y, l, 1})
        events = append(events, Event{y + l, l, -1})
    }
    
    // 按y坐标排序
    sort.Slice(events, func(i, j int) bool {
        return events[i].y < events[j].y
    })
    
    coveredWidth := 0.0  // 当前扫描线下所有底边之和
    currArea := 0.0      // 当前累计面积
    prevHeight := 0.0    // 前一个扫描线的高度
    
    for _, event := range events {
        y, l, delta := event.y, event.l, event.delta
        diff := float64(y) - prevHeight
        // 两条扫描线之间新增的面积
        area := coveredWidth * diff
        // 如果加上这部分面积超过总面积的一半
        if 2.0*(currArea+area) >= float64(totalArea) {
            return prevHeight + (float64(totalArea) - 2.0*currArea) / (2.0 * coveredWidth)
        }
        // 更新宽度:开始事件加宽度,结束事件减宽度
        coveredWidth += float64(delta * l)
        currArea += area
        prevHeight = float64(y)
    }
    
    return 0.0
}

###Python

class Solution:
    def separateSquares(self, squares: List[List[int]]) -> float:
        total_area = 0
        events = []
        
        for sq in squares:
            y, l = sq[1], sq[2]
            total_area += l * l
            events.append((y, l, 1))
            events.append((y + l, l, -1))
        
        # 按y坐标排序
        events.sort(key=lambda x: x[0])
        
        covered_width = 0.0  # 当前扫描线下所有底边之和
        curr_area = 0.0      # 当前累计面积
        prev_height = 0.0    # 前一个扫描线的高度
        
        for y, l, delta in events:
            diff = y - prev_height
            # 两条扫描线之间新增的面积
            area = covered_width * diff
            # 如果加上这部分面积超过总面积的一半
            if 2 * (curr_area + area) >= total_area:
                return prev_height + (total_area - 2 * curr_area) / (2 * covered_width)
            # 更新宽度:开始事件加宽度,结束事件减宽度
            covered_width += delta * l
            curr_area += area
            prev_height = y
        
        return 0.0

###C

typedef struct {
    int y;
    int l;
    int delta;
} Event;

int compareEvents(const void* a, const void* b) {
    Event* e1 = (Event*)a;
    Event* e2 = (Event*)b;
    return e1->y - e2->y;
}

double separateSquares(int** squares, int squaresSize, int* squaresColSize) {
    long long totalArea = 0;
    Event* events = malloc(2 * squaresSize * sizeof(Event));
    int eventCount = 0;
    
    for (int i = 0; i < squaresSize; i++) {
        int y = squares[i][1];
        int l = squares[i][2];
        totalArea += (long long)l * l;
        events[eventCount++] = (Event){y, l, 1};
        events[eventCount++] = (Event){y + l, l, -1};
    }
    
    // 按y坐标排序
    qsort(events, eventCount, sizeof(Event), compareEvents);
    
    double coveredWidth = 0.0;  // 当前扫描线下所有底边之和
    double currArea = 0.0;      // 当前累计面积
    double prevHeight = 0.0;    // 前一个扫描线的高度
    
    for (int i = 0; i < eventCount; i++) {
        int y = events[i].y;
        int l = events[i].l;
        int delta = events[i].delta;
        
        int diff = y - (int)prevHeight;
        // 两条扫描线之间新增的面积
        double area = coveredWidth * diff;
        // 如果加上这部分面积超过总面积的一半
        if (2LL * (currArea + area) >= totalArea) {
            double result = prevHeight + (totalArea - 2.0 * currArea) / (2.0 * coveredWidth);
            free(events);
            return result;
        }
        // 更新宽度:开始事件加宽度,结束事件减宽度
        coveredWidth += delta * l;
        currArea += area;
        prevHeight = y;
    }
    
    free(events);
    return 0.0;
}

###JavaScript

var separateSquares = function(squares) {
    let totalArea = 0n;
    const events = [];
    
    for (const sq of squares) {
        const y = sq[1], l = sq[2];
        totalArea += BigInt(l) * BigInt(l);
        events.push([y, l, 1]);
        events.push([y + l, l, -1]);
    }
    
    // 按y坐标排序
    events.sort((a, b) => a[0] - b[0]);
    
    let coveredWidth = 0;  // 当前扫描线下所有底边之和
    let currArea = 0;      // 当前累计面积
    let prevHeight = 0;    // 前一个扫描线的高度
    
    for (const [y, l, delta] of events) {
        const diff = y - prevHeight;
        // 两条扫描线之间新增的面积
        const area = coveredWidth * diff;
        // 如果加上这部分面积超过总面积的一半
        if (2n * BigInt(Math.ceil(currArea + area)) >= totalArea) {
            return prevHeight + (Number(totalArea) - 2.0 * currArea) / (2.0 * coveredWidth);
        }
        // 更新宽度:开始事件加宽度,结束事件减宽度
        coveredWidth += delta * l;
        currArea += area;
        prevHeight = y;
    }
    
    return 0.0;
};

###TypeScript

function separateSquares(squares: number[][]): number {
    let totalArea: bigint = 0n;
    const events: [number, number, number][] = [];
    
    for (const sq of squares) {
        const y = sq[1], l = sq[2];
        totalArea += BigInt(l) * BigInt(l);
        events.push([y, l, 1]);
        events.push([y + l, l, -1]);
    }
    
    // 按y坐标排序
    events.sort((a, b) => a[0] - b[0]);
    
    let coveredWidth: number = 0;  // 当前扫描线下所有底边之和
    let currArea: number = 0;      // 当前累计面积
    let prevHeight: number = 0;    // 前一个扫描线的高度
    
    for (const [y, l, delta] of events) {
        const diff: number = y - prevHeight;
        // 两条扫描线之间新增的面积
        const area: number = coveredWidth * diff;
        // 如果加上这部分面积超过总面积的一半
        if (2n * BigInt(Math.ceil(currArea + area)) >= totalArea) {
            return prevHeight + (Number(totalArea) - 2.0 * currArea) / (2.0 * coveredWidth);
        }
        // 更新宽度:开始事件加宽度,结束事件减宽度
        coveredWidth += delta * l;
        currArea += area;
        prevHeight = y;
    }
    
    return 0.0;
}

###Rust

impl Solution {
    pub fn separate_squares(squares: Vec<Vec<i32>>) -> f64 {
        let mut total_area: i64 = 0;
        let mut events: Vec<(i32, i32, i32)> = Vec::new();
        
        for sq in &squares {
            let y = sq[1];
            let l = sq[2];
            total_area += l as i64 * l as i64;
            events.push((y, l, 1));
            events.push((y + l, l, -1));
        }
        
        // 按y坐标排序
        events.sort_by_key(|&(y, _, _)| y);
        
        let mut covered_width: f64 = 0.0;  // 当前扫描线下所有底边之和
        let mut curr_area: f64 = 0.0;      // 当前累计面积
        let mut prev_height: f64 = 0.0;    // 前一个扫描线的高度
        
        for (y, l, delta) in events {
            let diff = y as f64 - prev_height;
            // 两条扫描线之间新增的面积
            let area = covered_width * diff;
            // 如果加上这部分面积超过总面积的一半
            if 2.0 * (curr_area + area) >= total_area as f64 {
                return prev_height + (total_area as f64 - 2.0 * curr_area) / (2.0 * covered_width);
            }
            // 更新宽度:开始事件加宽度,结束事件减宽度
            covered_width += (delta * l) as f64;
            curr_area += area;
            prev_height = y as f64;
        }
        
        0.0
    }
}

复杂度分析

  • 时间复杂度:$O(n \log n)$,其中 $n$ 是数组 $\textit{squares}$ 的长度。排序需要的时间复杂度为 $O(n \log n)$。

  • 空间复杂度:$O(n)$,其中 $n$ 是数组 $\textit{squares}$ 的长度。存储扫面线高度,需要的空间为 $O(n)$。

从零实现 React Native(2): 跨平台支持

作者 zerosrat
2026年1月13日 19:21

上一回:从零实现 React Native(1): 桥通信的原理与实现

平台支持的取舍

在上一篇《从零实现 React Native(1): 桥通信的原理与实现》中,基于 macos 平台实现了 JS 与 Native 的双向桥通信,在本篇中将对其他平台进行支持,实现「write once,run anywhere」这个理念。

接下来便来进行 iOS 和 Android 平台的支持工作。

iOS 进展顺利

在支持了 macos 端后,支持 iOS 是很容易的,可以马上着手来搞这个事情。得益于 Apple 生态带来的:macOS 和 iOS 都内置了 JavaScriptCore.framework,这意味着无需额外的引擎移植工作;且编程 API 很相似,这意味着差异化实现较少,大多可复用或类比实现。

事实上,我只花了半天时间就完成了 iOS 端的支持工作,其中主要的时间花在了构建配置的修改、测试示例的新增和调整,少部分时间花在了差异化的 DeviceInfo 模块实现。

得益于 Apple 生态,iOS 的支持工作中大部分代码都是复用的,复用率 90%。因为 macos 和 iOS 的 JSC API 一致,以及 C++ 语言的优势,可以用于跨端复用。复用的内容包含:

  • JSCExector
  • Bridge 通信逻辑
  • 模块注册逻辑

Android 滑铁卢

在顺利支持了 iOS 后,预想是 Android 的支持也不会太难,但实际做起来发现没这么简单。

记得是周末的午后的轻松下午,我先把 Android 的相关环境搭建好(包括 Android Studio、Java SDK 及其环境变量、NDK 等),然后进入 JSC 的移植工作。Why JSC 移植?因为不同于 Apple 生态,Android 系统是没有内置 JSC 引擎的。而正是这一步让我陷入泥潭。

我首先尝试了三方编译的版本,但是要么遇到了 libjsc.so(JSC 编译后的二进制文件,可供 Android 平台运行,可类比理解为前端的 wasm) 不支持 arm64(由于是 MBP 机器,安卓模拟器必须用 arm64 而非 x86 架构的),要么是遇到了 libjsc.so 和 NDK 版本不兼容。然后尝试了从社区提供的 jsc-android-buildscripts 自行编译,也遇到了各种问题编译失败,考虑到每次编译时间:2-3 小时,这也是一个艰难的旅程。

就算解决了 JavaScriptCore,还有 JNI 在等着我。Java 和 C++ 之间的桥梁不是简单的函数调用。我要处理诸如:类型转换、线程同步等问题。前方有很多新的坑在等着我。

舍与得

Maybe it's not the right time. 先理解核心,再扩展边界。先放下 Android 的支持,或许未来的某一天再回头来看这件事。

这个决定让我想起了 MVP(最小可行产品)的原则:先让核心功能跑通,再逐步完善。在学习项目中,这个原则同样适用——先掌握本质,再扩展边界。

既然决定专注于 iOS 和 macOS 双平台,那么接下来就需要一套优雅的构建系统来支撑跨平台开发。一个好的构建系统不仅能让开发者轻松切换平台,更重要的是,它能为后续的代码复用奠定基础。

构建系统的演进

在上一篇博客中,受制于篇幅的限制,跳过了对构建系统的讲解。而在跨平台支持中,天然需要迭代构建系统,也正是对其展开讲讲的一个好时机。

Make 是什么

Make 是一个诞生于 1976 年的构建工具,它的工作原理很简单:描述文件之间的依赖关系,然后只重新编译"变化过的"文件。

Make 适合于 需要多步骤构建流程 的项目,本项目的构建流程较为复杂:JS 产物打包 -> CMake 配置 -> C++ 产物编译 -> 运行 test 代码,很适合引入 Make 进行任务流程的编排。

Make 工具的配套 Makefile 文件是一个文本配置文件,它定义了构建规则、依赖关系和执行命令,可以将其理解为 npm 和 package.json 的关系。

以下是基于 macos 编译和测试的 Makefile 文件摘要代码,核心步骤包含了 js-build, configure, test

# ============================================
# 变量定义 (Makefile 第 10-13 行)
# ============================================
BUILD_DIR = build
CMAKE_BUILD_TYPE ?= Debug
CORES = $(shell sysctl -n hw.ncpu)  # 动态检测 CPU 核心数

# ============================================
# 主要构建目标 - 依赖链设计
# ============================================

# 默认目标:make 等价于 make build
.PHONY: all
all: build

# 核心构建流程:js-build → configure → 实际编译
.PHONY: build
build: js-build configure
    @echo "🔨 Building Mini React Native..."
    @cd $(BUILD_DIR) && make -j$(CORES)
    @echo "✅ Build complete"

# ============================================
# 步骤 1:JavaScript 构建 (第 29-33 行)
# ============================================
.PHONY: js-build
js-build:
    @echo "📦 Building JavaScript bundle..."
    @npm run build    # 执行 rollup -c,生成 dist/bundle.js
    @echo "✅ JavaScript bundle built"

# ============================================
# 步骤 2:CMake 配置 (第 22-26 行)
# ============================================
.PHONY: configure
configure:
    @echo "🔧 Configuring build system..."
    @mkdir -p $(BUILD_DIR)
    @cd $(BUILD_DIR) && cmake -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) -DCMAKE_EXPORT_COMPILE_COMMANDS=ON ..
    @echo "✅ Configuration complete"


# ============================================
# 步骤 4:分层测试流程 (第 91-130 行)
# ============================================

# 完整测试:build → 4 个测试依次执行
.PHONY: test
test: build
    @echo "🧪 Running all tests..."
    @echo "\n📝 Test 1: Basic functionality test"
    @./$(BUILD_DIR)/mini_rn_test
    @echo "\n📝 Test 2: Module framework test"
    @./$(BUILD_DIR)/test_module_framework
    @echo "\n📝 Test 3: Integration test"
    @./$(BUILD_DIR)/test_integration
    @echo "\n📝 Test 4: Performance test"
    @./$(BUILD_DIR)/test_performance
    @echo "\n✅ All tests complete"

# 单独的测试目标 - 允许细粒度测试
.PHONY: test-basic
test-basic: build
    @echo "🧪 Running basic functionality test..."
    @./$(BUILD_DIR)/mini_rn_test

.PHONY: test-performance
test-performance: build
    @echo "🧪 Running performance test..."
    @./$(BUILD_DIR)/test_performance

在引入了 make 后,可以很方便的进行复杂流程的编排,例如我们想要运行测试代码时,实际的发生的事情如下所示。

用户命令: make test
    ↓
test: build
    ↓
build: js-build configure
        ↓             ↓
    js-build          configure
        ↓                   ↓
    npm run build       cmake ..
        ↓                   ↓
    dist/bundle.js      build/Makefile
                            ↓
                        make -j8 (CMake 管理的依赖)
                            ↓
                        libmini_react_native.a
                            ↓
                        mini_rn_test (等 4 个可执行文件)

Before 引入 Make:想象一下,如果没有 Make,每次修改代码后你需要手动执行

# 步骤1:构建 JavaScript
npm run build

# 步骤2:配置 CMake
mkdir -p build
cd build && cmake ..

# 步骤3:编译 C++
cd build && make -j8

# 步骤4:运行测试
./build/mini_rn_test
./build/test_module_framework
./build/test_integration
./build/test_performance

每次都要记住这么多命令,还要确保执行顺序正确。更糟糕的是,如果某个步骤失败了,你需要手动判断从哪里重新开始。

After 引入 Makemake test 一条命令搞定所有事情

CMake 是什么

在把 C++ 代码编译成二进制文件这一步之前,其实构建系统提前引入了 CMake 进行管理。CMake 不是“构建工具”,而是“构建系统的构建系统”,在这个场景中 CMake 实际上生成了编译代码的工具 Makefile 文件。CMake 会读取 CMakeLists.txt,然后生成原生的构建文件。

Why CMake?因为 mini-rn 项目开始之初就是要考虑多平台支持的,为了实现这个 feature,便会遇到 多平台构建的复杂性 这个问题。

问题 1:平台特定源文件管理

不同平台需要不同的实现:

  • macOS:使用 IOKit 获取硬件信息
  • iOS:使用 UIDevice 获取设备信息

没有 CMake 需要维护两套构建脚本,引入 CMake 后可通过条件编译一套配置搞定。

问题 2:系统框架动态链接

不同平台需要链接不同框架:macOS 需要 IOKit,iOS 需要 UIKit

引入 CMake 后可自动检测并链接正确的框架。

解决效果

引入 CMake 前:需要维护多套构建脚本,手动管理复杂配置,容易出错。

引入 CMake 后:一套 CMakeLists.txt 支持所有平台,自动处理平台差异,大幅降低维护成本。

CMake 关键语法解释

  • CMAKE_SYSTEM_NAME:CMake 内置变量,表示目标系统名称(iOS、Darwin等)
  • find_library():在系统中查找指定的库文件
  • target_link_libraries():将库文件链接到目标可执行文件
  • set():设置变量的值
  • if(MATCHES):条件判断,支持正则表达式匹配

改动一:Makefile 新增 iOS 构建目标

在 macOS 的可扩展构建系统配置就绪后,接下来看看如何改动以支持 iOS。

改动一实现了什么?

核心目标:在现有 Makefile 基础上,新增 iOS 平台的完整构建流程,实现"一套 Makefile,双平台支持"。

具体实现

  1. 新增 4 个 iOS 专用目标ios-configureios-buildios-testios-test-deviceinfo
  2. 建立 iOS 构建流程:js-build → ios-configure → ios-build → ios-test
  3. 实现平台隔离:iOS 使用独立的 build_ios/ 目录,与 macOS 的 build/ 目录完全分离
  4. 自动化 Xcode 环境配置:自动检测 SDK 路径、设置开发者目录、配置模拟器架构

新增的 4 个 iOS 目标

原本基于 macOS 的构建路径是:js-build → configure → build → test,现在为 iOS 新增了对应的平行路径:js-build → ios-configure → ios-build → ios-test。

# iOS 构建配置(模拟器)
.PHONY: ios-configure
ios-configure:
    @mkdir -p $(BUILD_DIR)_ios
    @cd $(BUILD_DIR)_ios && DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer cmake \
        -DCMAKE_SYSTEM_NAME=iOS \
        -DCMAKE_OSX_ARCHITECTURES=$$(uname -m) \
        -DCMAKE_OSX_SYSROOT=$$(DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcrun --sdk iphonesimulator --show-sdk-path) \
        -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) \
        -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
        ..

# 构建 iOS 版本(模拟器)
.PHONY: ios-build
ios-build: js-build ios-configure
    @cd $(BUILD_DIR)_ios && make -j$(CORES)

# iOS 测试目标
.PHONY: ios-test
ios-test: ios-build
    @./test_ios.sh all

# iOS DeviceInfo 测试
.PHONY: ios-test-deviceinfo
ios-test-deviceinfo: ios-build
    @./test_ios.sh deviceinfo

关键设计决策

1. 独立构建目录

macOS 用 build/,iOS 用 build_ios/,互不干扰:

@mkdir -p $(BUILD_DIR)_ios   # iOS 构建目录

2. 仅支持 iOS 模拟器

为什么不支持真机?因为:

  • 真机需要开发者证书和配置文件
  • 模拟器足够验证 Bridge 通信机制
  • 降低环境配置复杂度
-DCMAKE_OSX_SYSROOT=$$(xcrun --sdk iphonesimulator --show-sdk-path)

3. 语义化命令

make ios-configure 比写一长串 CMake 命令简洁太多。这就是 Makefile 作为用户接口的价值。

改动二:CMake 平台条件编译

改动二实现了什么?

核心目标:让 CMake 能够智能识别目标平台,并自动选择正确的源文件和系统框架,实现"一套 CMakeLists.txt,智能适配双平台"。

具体实现

  1. 平台检测机制:通过 CMAKE_SYSTEM_NAME 变量自动识别是 macOS 还是 iOS
  2. 源文件智能选择:根据平台自动选择对应的 .mm 实现文件
  3. 框架动态链接:iOS 链接 UIKit,macOS 链接 IOKit,共享 JavaScriptCore 和 Foundation
  4. 编译标志自动设置:为 Objective-C++ 文件自动设置 ARC 标志
  5. 部署目标配置:iOS 设为 12.0+,macOS 设为 10.15+

设计精髓:编译时确定,运行时无开销。最终的 iOS 二进制文件中完全没有 macOS 代码,反之亦然。

原来的代码(仅 macOS)

# 原始版本 - 仅支持 macOS
if(APPLE)
    set(PLATFORM_SOURCES
        src/macos/modules/deviceinfo/DeviceInfoModule.mm
    )
    find_library(IOKIT_FRAMEWORK IOKit)
endif()

target_link_libraries(mini_react_native
    ${JAVASCRIPTCORE_FRAMEWORK}
    ${IOKIT_FRAMEWORK}
)

演进后的代码(macOS + iOS)

# 演进版本 - 支持 macOS + iOS
if(APPLE)
    # 根据具体平台选择源文件
    if(${CMAKE_SYSTEM_NAME} MATCHES "iOS")
        set(PLATFORM_SOURCES
            src/ios/modules/deviceinfo/DeviceInfoModule.mm
        )
    else()
        # macOS
        set(PLATFORM_SOURCES
            src/macos/modules/deviceinfo/DeviceInfoModule.mm
        )
    endif()

    # 平台特定框架
    if(${CMAKE_SYSTEM_NAME} MATCHES "iOS")
        find_library(UIKIT_FRAMEWORK UIKit)
        set(PLATFORM_FRAMEWORKS ${UIKIT_FRAMEWORK})
    else()
        find_library(IOKIT_FRAMEWORK IOKit)
        set(PLATFORM_FRAMEWORKS ${IOKIT_FRAMEWORK})
    endif()

    # 统一链接
    target_link_libraries(mini_react_native
        ${JAVASCRIPTCORE_FRAMEWORK}
        ${FOUNDATION_FRAMEWORK}
        ${PLATFORM_FRAMEWORKS}
    )
endif()

三个关键变化

1. 源文件分离

src/
├── macos/modules/deviceinfo/DeviceInfoModule.mm
└── ios/modules/deviceinfo/DeviceInfoModule.mm

两个文件虽然文件名相同,但实现不同:

  • macOS 版本:用 IOKit 获取硬件信息
  • iOS 版本:用 UIDevice 获取设备信息

2. 框架动态链接

平台 共享框架 平台特定框架
macOS JavaScriptCore, Foundation IOKit
iOS JavaScriptCore, Foundation UIKit

3. 部署目标设置

if(${CMAKE_SYSTEM_NAME} MATCHES "iOS")
    set(CMAKE_OSX_DEPLOYMENT_TARGET "12.0")
elseif(${CMAKE_SYSTEM_NAME} MATCHES "Darwin")
    set(CMAKE_OSX_DEPLOYMENT_TARGET "10.15")
endif()

两个改动的协同作用

改动一 + 改动二 = 完美的跨平台构建系统

这两个改动巧妙地分工合作:

  • Makefile(改动一):作为用户接口层,提供简单统一的命令,隐藏平台配置的复杂性
  • CMake(改动二):作为构建逻辑层,智能处理平台差异,自动选择正确的源文件和框架

协同效果

  1. 开发者体验make build vs make ios-build,命令接口一致
  2. 构建隔离:两个平台使用独立目录,可以并行构建,切换无需清理
  3. 智能适配:CMake 根据 Makefile 传入的平台信息,自动配置所有细节
  4. 零运行时开销:编译时就确定了平台,最终二进制文件纯净无冗余

这种设计让跨平台支持变得既强大又优雅:开发者只需要记住两个命令,背后的所有复杂性都被自动化处理了。

DeviceInfo - 变与不变

在构建系统演进完成后,我们来深入分析 DeviceInfo 模块的双平台实现。这个模块展示了跨平台架构设计的智慧:如何在保持接口统一的同时,让每个平台发挥自身优势。

90% 复用率是怎么做到的?

关键洞察:大部分逻辑其实是平台无关的

仔细分析 DeviceInfo 模块,你会发现一个惊人的事实:

// 这些逻辑在任何平台都一样
std::string DeviceInfoModule::getName() const {
    return "DeviceInfo";
}

std::vector<std::string> DeviceInfoModule::getMethods() const {
    return {
        "getUniqueId",       // methodId = 0
        "getSystemVersion",  // methodId = 1
        "getDeviceId"        // methodId = 2
    };
}

void DeviceInfoModule::invoke(const std::string& methodName,
                             const std::string& args, int callId) {
    try {
        if (methodName == "getUniqueId") {
            std::string uniqueId = getUniqueIdImpl();  // 只是调用,不关心具体实现
            sendSuccessCallback(callId, uniqueId);
        } else {
            sendErrorCallback(callId, "Unknown method: " + methodName);
        }
    } catch (const std::exception& e) {
        sendErrorCallback(callId, "Method invocation failed: " + std::string(e.what()));
    }
}

**Bridge 通信协议、方法注册机制、消息分发逻辑,完全都是可以复用的!**真正不同的,只是那几个 xxxImpl() 方法的底层实现。

复用的边界

但这里有个更深层的问题:为什么有些代码能 100% 复用,有些却完全不能?

让我们看看实际的复用率统计:

代码类型 复用率 为什么?
Bridge 通信逻辑 100% 协议标准化
模块注册机制 100% 框架层抽象
错误处理机制 100% 异常处理逻辑相同
设备唯一标识 0% 平台理念完全不同
系统版本获取 95% 只有注释不同
设备型号获取 85% 都用 sysctlbyname,iOS多了模拟器判断

100% 复用:协议的力量

为什么 Bridge 通信能 100% 复用?

因为这是协议层,不管底层平台怎么变,JavaScript 和 Native 之间的通信协议是固定的。方法名、参数、回调 ID、错误处理这些都是标准化的。就像 HTTP 协议,不管服务器是 Linux 还是 Windows,浏览器都用同样的方式发请求。

0% 复用:平台的鸿沟

为什么设备唯一标识完全不能复用?

macOS 追求真正的硬件级别唯一性,有复杂的降级机制;iOS 在 MVP 阶段采用了简化策略,每次启动生成新ID。这不是技术问题,而是:

  1. 平台哲学的差异:桌面 vs 移动的隐私理念
  2. 开发策略的差异:完整实现 vs MVP验证

复用边界的哲学

通过 DeviceInfo 模块,我们发现了跨平台复用的三个层次:

  1. 协议层:100% 复用,因为标准统一
  2. API 层:看运气,苹果生态有优势
  3. 实现层:看平台差异,移动端更复杂

这揭示了一个残酷的真相:跨平台的成本永远存在,只是被转移了。

可以用抽象基类隐藏差异,但差异本身不会消失。关键是找到合适的边界,让复用最大化,让差异最小化。

头文件的魔法

解决方案其实就是基于 面向对象 的:

// common/modules/DeviceInfoModule.h
class DeviceInfoModule : public NativeModule {
public:
    DeviceInfoModule();
    ~DeviceInfoModule() override = default;

    // NativeModule 接口实现 - 所有平台共享
    std::string getName() const override;
    std::vector<std::string> getMethods() const override;
    void invoke(const std::string& methodName, const std::string& args,
                int callId) override;

    // 平台特定的实现接口 - 让各平台去填这些"洞"
    std::string getUniqueIdImpl() const;
    std::string getSystemVersionImpl() const;
    std::string getDeviceIdImpl() const;

private:
    // 工具方法
    std::string createSuccessResponse(const std::string& data) const;
    std::string createErrorResponse(const std::string& error) const;
};

注意这里没有用虚函数,因为已经引入了 CMake 在编译时确定了对应平台的文件,不需要运行时多态,结果是 同一个头文件,不同的实现文件。每个平台都有自己的 .mm 文件来实现这些方法,编译时自动选择对应的实现。

基类定义了 what(做什么),各平台实现 how (怎么做)。Bridge 通信、方法注册、消息分发等这些复杂的逻辑只写一遍,所有平台自动继承。

分平台实现

// macOS 实现 - src/macos/modules/deviceinfo/DeviceInfoModule.mm
std::string DeviceInfoModule::getUniqueIdImpl() const {
    @autoreleasepool {
        // 尝试获取硬件 UUID
        io_registry_entry_t ioRegistryRoot =
            IORegistryEntryFromPath(kIOMasterPortDefault, "IOService:/");
        CFStringRef uuidCf = (CFStringRef)IORegistryEntryCreateCFProperty(
            ioRegistryRoot, CFSTR(kIOPlatformUUIDKey), kCFAllocatorDefault, 0);

        if (uuidCf) {
            NSString* uuid = (__bridge NSString*)uuidCf;
            std::string result = [uuid UTF8String];
            CFRelease(uuidCf);
            return result;
        }
        // 多层降级机制...
        return "macOS-" + getDeviceIdImpl() + "-" + getSystemVersionImpl();
    }
}

// iOS 实现 - src/ios/modules/deviceinfo/DeviceInfoModule.mm
std::string DeviceInfoModule::getUniqueIdImpl() const {
    @autoreleasepool {
        // iOS 简化实现:使用 NSUUID 生成唯一标识
        // 注意:这个实现每次启动都会生成新的ID,适用于MVP测试
        NSUUID* uuid = [NSUUID UUID];
        NSString* uuidString = [uuid UUIDString];
        return [uuidString UTF8String];
    }
}

Objective-C++ 关键字解释

  • @autoreleasepool:自动释放池,管理 Objective-C 对象的内存,确保及时释放
  • __bridge:ARC(自动引用计数)中的桥接转换,在 C/C++ 指针和 Objective-C 对象间转换
  • [object method]:Objective-C 的方法调用语法
  • .mm 文件扩展名:表示 Objective-C++ 文件,可以混合使用 C++、C 和 Objective-C 代码

两个平台的实现文件自动拥有了完整的 Bridge 通信能力,现在只需要实现平台差异部分即可~

应自动化尽自动化

DeviceInfo 模块的自动化实现揭示了一个重要原则:

好的跨平台架构不是让代码在所有平台都能跑,而是让正确的代码在正确的平台上跑。

通过这个项目的三层自动化体系:

  1. Makefile 自动化:统一的命令接口,隐藏平台配置复杂性
  2. CMake 自动化:智能的源文件选择和框架链接
  3. 编译器自动化:平台特定的二进制生成

这样的架构让开发者专注于业务逻辑,而把平台适配的复杂性交给了工具链。

真正的自动化不是写一份代码到处跑,而是:

  • 开发体验统一make build vs make ios-build,命令接口一致
  • 实现策略分离:每个平台有最适合的实现方式
  • 构建过程透明:开发者不需要关心 Xcode SDK 路径、编译标志等细节

这种设计在面对更复杂的系统时依然有效:只要保持接口统一、实现分离、构建自动化,就能优雅地扩展到视图渲染、事件处理等更复杂的场景。

彩蛋

项目地址: github.com/zerosrat/mi…

当前项目中包含了本篇文章中的全部内容:

  • ✅ iOS 构建系统适配
  • ✅ iOS 跨平台的差异化实现(DeviceInfo)

完成本阶段后,项目已经具备了进入第三阶段的基础:视图渲染系统


📝 本文首发于个人博客: zerosrat.dev/n/2025/mini…

useEffect 空依赖 + 定时器 = 闭包陷阱?count 永远停在 1 的坑我踩透了

2026年1月13日 18:55

写 React 时,你有没有遇到过「定时器里的 state 永远不更新」的诡异情况?比如明明写了setCount(count + 1),页面上的count却永远停在 1—— 这其实是 ** 闭包陷阱(Stale Closure)** 在搞鬼。

今天用一个极简示例,拆解这个坑的本质,再给你 2 个一劳永逸的解决方案。

一、先看复现:count 为什么永远停在 1?

先看这段 “看似没问题” 的代码:

carbon.png

运行结果:页面上的count从 0 变成 1 后,就再也不涨了。

二、核心原因:闭包 “定格” 了初始 state

问题出在 2 个关键点的叠加:

  1. useEffect 的空依赖[] :空依赖意味着useEffect只在组件挂载时执行 1 次,后续组件更新不会重新运行这个 effect。
  2. 闭包捕获了 “快照” 值useEffect执行时,内部的setInterval函数形成了闭包 —— 它 “抓住” 了当时的count(值为 0)。后续count虽然被更新,但因为useEffect没重新执行,这个闭包永远拿着初始值 0,所以setCount(count + 1)永远是0 + 1 = 1

三、2 个解决方案:从根源避开闭包陷阱

针对这个场景,推荐 2 种既简单又安全的写法:

方案 1:函数式更新(推荐)

setState函数式写法,直接获取最新的 state 值,绕开闭包的旧值:

carbon (1).png

原理setCount(c => c + 1)会从 React 内部获取当前最新的count值,不管闭包抓的是旧值,都能拿到最新数据。

方案 2:补全依赖数组

count加入useEffect的依赖数组,让useEffectcount变化时重新执行,生成新的闭包:

carbon (2).png

注意:这个方案会频繁创建 / 清理定时器(每次count变化都重新执行 effect),性能不如方案 1,仅推荐在 “必须依赖外部变量” 的场景使用。

四、避坑总结:useEffect + 定时器的正确姿势

  1. 优先用函数式更新setState(prev => prev + 1)是避开闭包陷阱的 “万能钥匙”;
  2. 空依赖要谨慎:空依赖的useEffect里,尽量避免直接引用 state/props,改用函数式更新;
  3. 依赖数组要写全:如果必须依赖外部变量,一定要把变量加入依赖数组(配合 ESLint 的react-hooks/exhaustive-deps规则)。

React + Ts eslint配置

作者 SsunmdayKT
2026年1月13日 18:32

一、核心依赖包(分基础和 React/TS 适配)

React + TS 项目的 ESLint 依赖同样分为基础核心包适配 React/TS 的插件包,以下是完整列表及作用:

包名 作用
eslint ESLint 核心库,提供代码检查基础能力
@typescript-eslint/eslint-plugin TypeScript 专属 ESLint 规则插件
@typescript-eslint/parser ESLint 解析 TS 代码的解析器
eslint-plugin-react React 专属 ESLint 插件(支持 React 18+)
eslint-plugin-react-hooks 检查 React Hooks 使用规范(如依赖项、规则 Hooks 调用)
eslint-plugin-react-refresh(可选) 检查 React 组件热更新相关规范(Vite 项目推荐)
eslint-config-prettier(可选) 禁用 ESLint 中与 Prettier 冲突的规则
eslint-plugin-prettier(可选) 将 Prettier 规则集成到 ESLint 中

二、安装命令

1. 基础安装(仅 ESLint + React + TS)

bash

运行

# npm 安装
npm install eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-react eslint-plugin-react-hooks --save-dev

# yarn 安装
yarn add eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-react eslint-plugin-react-hooks -D

# pnpm 安装(推荐)
pnpm add eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-react eslint-plugin-react-hooks -D

2. 包含 Prettier + 热更新检查(推荐)

如果需要 Prettier 格式化 + React 热更新检查,补充安装:

bash

运行

pnpm add prettier eslint-config-prettier eslint-plugin-prettier eslint-plugin-react-refresh --save-dev

三、核心配置(.eslintrc.js)

在项目根目录创建 .eslintrc.js 文件,这是 React + TS 最常用的配置模板:

javascript

运行

module.exports = {
  // 指定代码运行环境,启用对应全局变量
  env: {
    browser: true, // 浏览器环境(React 运行环境)
    es2021: true,  // 支持 ES2021 语法
    node: true     // Node.js 环境(如配置文件、脚本)
  },
  // 继承已有规则集,减少重复配置
  extends: [
    'eslint:recommended', // ESLint 官方推荐规则
    'plugin:@typescript-eslint/recommended', // TS 推荐规则
    'plugin:react/recommended', // React 推荐规则
    'plugin:react/jsx-runtime', // 适配 React 17+ 的 JSX 自动导入(无需手动 import React)
    'plugin:react-hooks/recommended', // React Hooks 强制规则
    'eslint-config-prettier' // 禁用与 Prettier 冲突的规则(装了 Prettier 才加)
    // 'plugin:prettier/recommended' // 开启 Prettier 作为 ESLint 规则(装了 eslint-plugin-prettier 才加)
  ],
  // 指定解析器(TS 解析器)
  parser: '@typescript-eslint/parser',
  // 解析器选项
  parserOptions: {
    ecmaVersion: 'latest', // 支持最新 ES 版本
    sourceType: 'module',  // 模块化代码(ES Module)
    ecmaFeatures: {
      jsx: true // 支持 JSX 语法(React 核心)
    }
  },
  // 启用的插件
  plugins: [
    '@typescript-eslint',
    'react',
    'react-hooks',
    'react-refresh' // 可选,热更新检查
  ],
  // 自定义规则(按需调整)
  rules: {
    // 关闭 TS any 类型禁止规则(新手项目可临时关闭)
    '@typescript-eslint/no-explicit-any': 'off',
    // React Hooks 必选规则(强制检查依赖项)
    'react-hooks/rules-of-hooks': 'error',
    'react-hooks/exhaustive-deps': 'warn',
    // 禁用 React 组件文件名必须 PascalCase 的检查(可选)
    'react/filename-rules': 'off',
    // 关闭 React 必须声明 props 类型的检查(TS 已做类型检查,无需重复)
    'react/prop-types': 'off',
    // 热更新检查:禁止默认导出(React 组件推荐命名导出)
    'react-refresh/only-export-components': [
      'warn',
      { allowConstantExport: true }
    ]
  },
  // 针对 React 版本的配置(自动检测)
  settings: {
    react: {
      version: 'detect'
    }
  }
}

四、补充配置(可选但推荐)

  1. 忽略文件(.eslintignore) :指定 ESLint 不检查的文件 / 目录

plaintext

node_modules/
dist/
build/
*.d.ts
.vscode/
  1. package.json 脚本:添加检查 / 修复命令

json

{
  "scripts": {
    "lint": "eslint . --ext .js,.jsx,.ts,.tsx", // 检查所有 React/TS 文件
    "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix" // 自动修复可修复问题
  }
}

五、特殊说明(针对 Create React App 项目)

如果你的 React 项目是通过 create-react-app 创建的(内置了 ESLint),无需手动安装核心包,只需:

  1. 安装缺失的适配包:

bash

运行

pnpm add eslint-plugin-react-hooks @typescript-eslint/eslint-plugin @typescript-eslint/parser --save-dev
  1. 在项目根目录创建 .eslintrc.js 覆盖默认配置即可。

总结

  1. 核心必装包eslint + @typescript-eslint/*(解析器 + 插件) + eslint-plugin-react + eslint-plugin-react-hooks
  2. 可选扩展eslint-plugin-react-refresh(热更新)、eslint-config-prettier/eslint-plugin-prettier(兼容 Prettier);
  3. 关键配置.eslintrc.js 中需启用 jsx: true 支持 React 语法,通过 settings 自动检测 React 版本,同时开启 React Hooks 核心规则。

安装完成后运行 npm run lint 即可检查代码,npm run lint:fix 可自动修复缩进、空格等格式问题。

❌
❌