阅读视图

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

纯电卡宴 2.5 秒破百!最强保时捷,却最不像保时捷

保时捷刚刚发布了纯电版的 Cayenne(卡宴)Electric 和 Cayenne Electric Turbo。

CEO Oliver Blume(奥利弗 布鲁默)和设计主管 Michael Mauer(迈克尔 摩尔)仅用了 15 分钟,就将这台在保时捷历史上意义非凡的 SUV 带入了新时代。

「不像保时捷」

先来看看争议最大的外观设计。

我们想知道,在第一眼看到这辆车时,你的感受如何?

纯电卡宴比燃油版车型长 55 毫米,整车长宽高分别为 4985 / 1980 / 1674 毫米,轴距也增加至了 3023 毫米。

新车前脸部分最显著的变化是采用了更低矮的发动机罩和更纤细的 Matrix LED 大灯。新大灯组将远近光、日行灯等功能集成在一个模块内,并通过横向延展的造型强化车头宽度感。

车身侧面则保留了保时捷标志性的「飞线」(flyline)轮廓,从 B 柱开始平缓下滑的车顶线条,配合轮廓突出的翼子板,维持了品牌一贯的比例语言。

细节上,新车采用无框车门,车门钣金带有一道贯穿前后门的高腰线;轮拱处则增加了专属塑料饰件,强调其越野属性。

车尾部分采用贯穿式灯带,支持动态点亮效果,并配有发光的「PORSCHE」字样。Turbo 车型进一步通过名为「Turbonite」的深灰色装饰件(包括轮毂盖、侧窗饰条、车标等)实现视觉区隔。

纯电卡宴上还能看到不少空气动力学组件,包括前脸可调导流 flap、自适应尾翼、以及全覆盖底盘、空气帘和专用轮毂等,Turbo 版本还有专属的「气动刀片」(aero blades),整车的风阻系数最低可达 0.25cd。

过去二十年,卡宴之所以能成为豪华 SUV 市场的常青树,很大程度上得益于其高度统一且极具辨识度的家族设计语言:隆起的翼子板、圆润的四点式大灯、标志性的溜背轮廓,以及那张低调却存在感极强的进气格栅——这些元素共同构成了消费者心中「保时捷 SUV」的标准答案。

▲ 燃油版卡宴

然而,在电动化浪潮下,设计团队似乎有意让纯电卡宴扮演一次「反叛者」。

正如保时捷造型设计负责人 Michael Mauer 在发布会上所言:

新款 Cayenne 无疑是保时捷,也是 Cayenne。我们在经典设计元素的基础上进行进化,保留了这款 SUV 的独特灵魂,最终呈现出一种面向未来的现代设计理念。

但市场对这套新设计的认可度似乎很有限。

编辑部的几位同事看到实车之后,纷纷认为纯电卡宴「像埃安霸王龙」、「像蔚来 EC6」 等等。

油管上的海外用户们的评论也颇为一致,「像途观」、「像 Vauxhall Grandland」、「幸亏保留了保时捷的标志,否则真看不出来」、「看起来不错,只是不像保时捷」。

▲ 评论由英文机翻而来

这种质疑背后,其实是一种更高的期待:我们希望保时捷能在纯电卡宴上复刻 Taycan 的成功——即在拥抱电动化的同时,依然输出一套鲜明、自洽且不可替代的设计语言。

但目前看来,纯电卡宴的设计更像是在「未来感」与「传承感」之间走钢丝:前脸试图拥抱新能源时代的简洁与科技感,而侧面和尾部却保留了太多燃油时代的比例与细节,整体缺乏 Taycan 那种从内到外为电动架构重新思考的完整性。

这种割裂感,削弱了设计的一致性,也让本该成为品牌电动转型标杆的卡宴,显得犹豫而保守。

2.5 秒破百的性能猛兽

抛开设计上的争议,纯电卡宴在机械层面延续了保时捷一贯的高性能。

纯电卡宴的两个版本均标配四驱系统,并配备保时捷电子牵引力管理系统(ePTM)。

Turbo 车型的后轴电机采用源自赛车运动的直喷油冷技术,可以确保电机以持续高功率输出并维持高效能表现。车辆的 0-100 公里/小时加速仅需 2.5 秒,0-200 公里/小时加速时间为 7.4 秒,最高时速达 260 公里/小时。在启用 Launch Control(弹射起步)模式时,车辆可输出 850 千瓦(约 1156 马力)和 1500 牛·米扭矩。

在常规驾驶模式下,Turbo 车型的最大功率可达 630 千瓦(857 马力),在按下「Push-to-Pass」按钮后,还可以额外获得 130 千瓦(176 马力)的动力,持续 10 秒。

入门级的标准版在常规模式下可输出 300 千瓦(408 马力),启用 Launch Control 时可提升至 325 千瓦(442 马力),峰值扭矩达 835 牛·米,0-100 公里/小时加速时间为 4.8 秒,最高时速 230 公里/小时。

接近「天花板」的性能水平并非是靠牺牲续航水平而来。

纯电卡宴搭载一块 113 千瓦时的高压电池,采用双面液冷技术,WLTP 工况下标准版续航最高可达 642 公里,Turbo 版为 623 公里。

得益于 800V 架构,新车直流快充峰值功率可达 400 千瓦,从 10% 充至 80% 仅需不到 16 分钟,10 分钟即可补充约 320 公里续航。

此外,保时捷在能量回收上也下了功夫。官方称其回收功率最高达 600 千瓦,日常 97% 的制动操作可完全由电机完成,机械刹车几乎无需介入。这不仅延长了续航,也减少了刹车片磨损。对于追求极致性能的 Turbo 用户,还可选装陶瓷复合刹车系统(PCCB)作为冗余保障。

值得一提的是,纯电卡宴还是保时捷首款支持无线充电的车型,最大功率 11 千瓦。用户只需将车停在地面上的充电板上方,系统便会自动启动充电。

即使在电动车型上,保时捷依然强调了驾驶和操控的重要性。

两款车型均标配自适应空气悬架与 PASM 主动悬挂管理系统,Turbo 版更进一步,配备了后桥限滑差速器(PTV Plus)和最大转角 5 度的后轮转向系统,以提升弯道灵活性与高速稳定性。

「Porsche Active Ride」主动悬挂系统也被下放给了电动卡宴,该技术此前仅用于 Panamera 等轿车,通过高频调节减震器阻尼,几乎完全抑制车身俯仰与侧倾,在颠簸路面也能保持平稳。官方称其「重新定义了舒适与动态的边界」。

为数字原住民而生

来到车内,纯电卡宴的座舱设计依旧秉承了「反叛者」和「创造者」的设计哲学。

主驾前方是一块 14.25 英寸 OLED 数字仪表,中控区域则有一块巨大的曲面屏「流淌」出来,延伸到了副驾,三屏联动构成保时捷史上最大显示面积。

如果是高配车型,保时捷还提供了一块等效 87 英寸的 AR-HUD。

董车会在稍早前,保时捷刚刚公布内饰官图时曾评论过:

纯电卡宴瞄准的是一群新的用户。他们可能不是传统的 911 车迷,但他们成长于数字时代,对科技体验有很高的要求。同时,作为豪华品牌的消费者,他们也不满足于一块简单的屏幕,而是期待与之匹配的设计感。纯电卡宴的这套内饰,就是为这种需求做的定义。

那么你是否认为保时捷的这套新内饰比特斯拉式的悬浮中控屏更有设计感呢?

不过设计是一方面,好不好用则是另外一方面。

纯电卡宴的空调温度、音量等高频功能仍保留物理按键,设计师还开发了一个一体化手托,让驾驶员在激烈驾驶时也能盲操。

在车机内部,保时捷这次支持了自定义小组件、主题皮肤,并可通过 App Center 集成第三方应用。新的基于 AI 的语音助手可理解上下文对话,支持导航设定、在线查询等复杂指令,同时手机和手表可作为数字钥匙,最多分享给 7 位用户。

同时,全新引入的「氛围模式」可以同时改变车内的座椅位置、氛围灯光、空调设定、声音风格及显示屏界面,带来更为沉浸的乘坐体验。

价格方面,纯电卡宴标准版在英国的售价为 83200 英镑(约合人民币 77.5 万元),Turbo 车型售价为 130900 英镑(约合人民币 122 万元)。

考虑到纯电 Macan 引进国内的价格比英国市场稍低,纯电卡宴标准版在国内的起售价可能会在 70 万元左右。

不过,保时捷的选配向来是不可不品的一环。

纯电卡宴的个性化选项空前丰富,有 13 种标准车漆、9 款 20-22 英寸轮毂、12 种内饰组合,外加多个装饰包,通过专属的定制部门,你甚至可以可指定任意颜色或打造独一无二的「one-off」车型。

保时捷甚至还推出了一款可以匹配车辆配色的定制腕表。

战略回撤

在发布会的最后,保时捷明确表示,纯电卡宴将与现有燃油版、插电混动版卡宴在全球市场并行销售,至少持续到 2030 年代初期,消费者将在同一展厅将面对三种动力形式的选择。

在电动化转型进行了十年后,保时捷终究还是放慢了脚步。

原因也不难理解。

今年,保时捷纯电新车销售占比达到 23%,在一众豪华品牌中名列前茅,今年前三季度 Macan EV 在全球卖出 3.6 万辆,销量超过了 Macan 燃油版。

但是在高销量的油车销量占比下滑后,保时捷单车平均收入反而跌了 3000 欧元,毛利率也是直线下行。

在 50 万元以上的价格段,纯电、豪华、爆款依然是个不可能三角。

中国市场累计销量破万的纯电车型也仅有小米 SU7 Ultra 一款,而从小米前不久发布的财报看,小米 SU7 Ultra 的销量似乎已经见顶,连带着小米汽车的毛利率也有所下跌。

在此背景下诞生的纯电卡宴,理所当然的被赋予了更多探索的使命。

最后,还有一个颇具象征意义的故事值得提及。

保时捷现任 CEO 奥利弗·布鲁默(Oliver Blume)将于今年底卸任,纯电卡宴或许将会使他发布的最后一辆保时捷。

他从大众集团的一名普通工程师起步,最终执掌保时捷,并在其任内推动了品牌向电动化的关键转型,早在 2015 年,保时捷就已启动电动平台研发,并于 2019 年推出首款纯电车型 Taycan。与此同时,他也大力拓展中国市场,使其成为保时捷全球最重要的单一市场之一。

在 2001 年,他刚刚来到上海,成为同济大学汽车工程学院招收的第一位德国籍博士生。

而他的导师在一年前刚刚成为电动汽车重大专项的首席科学家,主导了中国新能源汽车战略的顶层设计,深刻改变了中国汽车工业的发展轨迹。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


22.98 万起!从攀岩涉水到冰箱电磁炉,福特烈马变了,但这才是它的销量密码

如果把时间往回拨六七年,福特 Bronco 烈马与 Jeep 牧马人是越野车爱好者心中的毫无争议的图腾级车型。

自 1966 年诞生以来,这款方正硬朗的硬派 SUV 见证了无数越野传奇。非承载式车身、可拆卸车顶、三把差速锁,这些配置一度成为专业玩家心中的「标准答案」。

Bronco 曾伴随探险者穿越北美荒野,登上多部好莱坞大片银幕,甚至在 1970 年代石油危机时期,成为美国自由精神的象征。

▲ 1966 年的初代烈马

在北美市场,2021 年重启的第六代 Bronco 首年订单突破 10 万辆,到今年更是突破了 150 万辆,印证了这一 IP 的持久号召力。

然而,当这股「Bronco 热」试图进入中国市场时,却遭遇了现实的冷遇。

▲2024 年成都车展上的烈马

2024 年 4 月国产燃油版福特烈马上市时,福特官方曾表示过:

在市场这么卷的情况下,我们的底气还是蛮足的,中国市场一定会成为烈马、游骑侠全球 Top3 的市场,甚至会是最大的单一市场。

但燃油烈马的销量,其实并不尽如人意。

2024 年,该车上市来累计取得销量 8814 辆,即月均销量在千辆规模,进入 2025 年后,燃油烈马销量进一步下滑,上半年累计销量 4041 辆,即月均销量下滑至 660 辆左右。

▲ 燃油版烈马配备的三段式氮气减震器

除了售价较高、真正硬核定位的越野车受众过窄等因素外,国内自主品牌的越野车的崛起也是导致福特一败再败的重要因素。

比如坦克 300,凭借 20 万元级定价、城市 SUV 的舒适性与基础越野能力,即使销量已经比巅峰时下滑了不少,在近半年依然售出了 27761 辆车。

长城汽车曾经做过一次调研,数据显示,坦克 300 用户中 76% 为首次购买硬派 SUV,日常通勤占比达 65%。也就是说,大部分的越野车车主日常面临最多的越野场景可能是「上个马路牙子」,实际消费过程中,买家更关注也是「露营拓展能力」和「城市驾驶舒适性」,而非攀岩、涉水等极限性能。

但在被国产越野降维打击了一年之后,福特也回过味来了,「打不过就加入」,于是就有了这款中国特供的——智趣烈马。

11 月 18 日,福特正式开启智趣烈马预售,这款基于「福域」智能新能源架构打造的中型 SUV,提供增程版(22.98 万-27.28 万元)和纯电版(25.98 万-28.28 万元)双动力选择。

其定价策略明显低于燃油版烈马,直接切入了方程豹豹 5、坦克 300 新能源等国产竞品的核心价格区间。

智趣烈马延续了 Bronco 家族方正硬朗的外观轮廓,但细节上做了电动化适配,前脸采用半封闭设计,嵌入发光「BRONCO」标识;隐藏式门把手替代了传统拉手;侧开式尾门和外挂备胎得以保留,但车身结构转为承载式,从硬派越野变为了城市 SUV。

这一变化意味着它并非传统硬派越野取向,而是更侧重城市通勤与轻度越野的平衡。新车接近角 30°、离去角 32°、最小离地间隙 220mm,参数上介于城市 SUV 与专业越野车之间。

智趣烈马全系标配四驱系统,高配长滩版提供前后电控差速锁,支持行进中上锁功能,配合 8 种地形模式应对复杂路况。

空间设计是智趣烈马的核心差异点。5025mm 的车身长度配合 2950mm 轴距,创造了同级较大的内部空间,再加上 5 座布局,整体的乘坐和储物空间都很宽裕。

新车的内饰风格比较硬朗。前排配备了一块 15.6 英寸的 2.5K 中控屏以及 70 英寸 AR-HUD,车机搭载高通骁龙 8255 芯片以及 7.5L 冷暖大冰箱和 21 扬声器音响系统。

此外、新车车顶配备电动举升机构,升起后可扩展露营空间,官方称经过了 7000 次耐久测试;160L 前备箱也针对户外场景进行了优化,采用防水可冲洗设计;尾门更是集成轻量化桌板与电磁炉接口,结合 6.6kW 对外放电能力,可满足基础烹饪需求。

这些设计反映了福特对户外场景的务实理解——不追求极限越野,而是提升日常露营场景下的便利性。

在辅助驾驶能力上,智趣烈马高配车型配备激光雷达及 32 个传感器,由双 Orin-X 芯片提供 508 TOPS 算力,支持高快速路与城市 NOA 功能。

此外烈马的特色功能「旅行路书」整合了 45 条预设路线,可以结合卫星地图提供实时景点推荐,并联动车身摄像头抓拍分享。

动力系统分两条路径:增程版采用 1.5T 增程器(110kW)与双电机组合,系统综合功率 310kW,配 43.7kWh 电池,CLTC 纯电续航 220km,综合续航 1220km;纯电版双电机总功率 332kW,搭载 105.4kWh 电池,CLTC 续航 650km。

新车的悬架系统则是前双叉臂、后五连杆,均为独立悬架。不过远湾版和墨钻版没有差速锁,长滩版配备了前桥和后桥差速锁,理论上的越野性能不输坦克 500 Hi4-Z 或比亚迪方程豹的豹 8。

安全层面,新车车身采用 25% 热成型钢,一体式后地板强度 1500MPa,全系标配 10 气囊,包含二排侧气囊及远端气囊。

福特转型意图在这台智趣烈马的身上展现的十分清晰——从强调「征服」的硬派越野,转向「可城可野」的生活场景解决方案。

智趣烈马的露营配件、外放电功能、灵活空间,都是对这一需求的精准回应。

目前市面上的竞品中,与智趣烈马最接近的应该是方程豹钛 7,两者的四驱版本配置大差不差,智趣烈马的纯电续航多了 40 公里,但价格贵了 2 万元。

如果正式发售后的价格再降一降,配合上福特烈马留存的品牌力,应该会卖得不错。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


13.29万起售,深蓝 L06 订单1小时破2万!麋鹿测试成绩超越保时捷 911

 

我们给深蓝 L06 配置了磁流变悬架、把底盘做得也很扎实,绝对不仅仅是让大家开得快,我们更想要实现的是,大家在快的同时更加安全。

预售时就号称悬架比肩法拉利的深蓝 L06 于 11 月 18 日正式上市了,正式售价来到了 13.29 万元到 15.49 万元的区间。

深蓝官方对这台的定位很明确——以「驾控之王、座舱新贵、智驾黑马、全系宁德时代、续航领先」五大全维领先的配置,为年轻人打造一台「Dream Car」。

在这种思路下,深蓝 L06 各个层面都堪称 15 万元轿车的「卷王」。

年轻人的「Dream Car」首先当然要好看。

新车长宽高分别为 4830mm、1905mm、1480mm,轴距 2900mm,迈入了 B 级车的门槛,整体车身姿态相当运动。

深蓝 L06 采用了家族的最新设计语言,前脸造型较为简洁,视觉重点则通过两条隆起的筋线被引导至了由 3 组 LED 大灯构成的「花瓣式」大灯组上。

车身侧面的运动风格更是一览无遗,超窄无边框后视镜、车四门无框车门、随速无极可调电动尾翼、双灯带悬浮尾灯、花瓣状运动轮毂进一步加强了这辆车的运动气质。

走进车内,新车座舱为经典的 T 字形布局,提供烟晶雾灰、月光石白、舒俱来紫三种配色,副驾配备了零重力座椅,支持 8 点式按摩,并搭载了 18 扬声器音响、256 色氛围灯等配置。

在智能化配置上,深蓝 L06 配备了 50 英寸 AR-HUD 和类似新款小鹏 P7 的 15 度双向旋转向日葵屏。此外新车还搭载了球首发的 3nm 车规级座舱芯片以及四图融合实景车道级导航,通过基础导航 + 车道级导航 + SR 渲染 + 环境重构四图叠加来使信息呈现更全面。

而在 13.29 万元的起步价中,深蓝 L06 的辅助驾驶系统堪称全面。

新车全系标配了激光雷达以及 3 颗毫米波雷达、2 颗前视摄像头、4 颗侧视摄像头、4 颗环视摄像头、1 颗后视摄像头、12 颗粒超声波雷达,共 27 颗各类传感器,算力由两颗地平线 J6M 芯片提供,软件算法则采用了特斯拉同类一段式端到端智能驾驶辅助算法,支持城区、高速 NCA 以及 30 多项主动安全配置。

此外,深蓝 L06 还全系标配了采用宁德时代电芯的磷酸铁锂「金钟罩」电池,有 56.12kWh 和 68.82kWh 两种容量可选,支持 3C 快充,续航里程分别为 560km 和 670km。

深蓝强调,其「金钟罩」电池至今零自燃,且长期衰减无感知。

动力方面,深蓝 L06 全系采用后置单电机驱动,电机峰值功率 200kW,0-100km/h 加速时间最快为 5.9s。

为实现极致驾控,深蓝 L06 同时采用了同级唯一的一体化压铸车身、CTV 电池车身一体化集成技术,以及与特斯拉 Cybertruck 同款工艺的一体式热成型双门环,以及先进的后驱专用平台,配合首发搭载的「磁流变悬架技术」,深蓝 L06 以 85.6km/h 的麋鹿测试成绩成功超越了保时捷 911。

这项与 200 万元的法拉利 296 同源的悬架技术,将悬架内的介质更换为了「磁流变液」,在算法控制电流的作用下,可毫秒级改变减震器的软硬程度。

在 10ms 级的瞬时响应、1000 次/秒高频调节,以及全场景自适应软硬切换能力的加持下,「磁流变悬架」在快速过弯场景下能够迅速提升内侧减振器阻尼,从而减少车身侧倾;而在城市道路上,磁流变减振又能轻易过滤掉路面的振动、抑制红绿灯前的俯仰等等,保证舒适性,真正做到了「又快又稳」。

深蓝汽车 CEO 之前在预售发布会后,曾经发了一条微博说,「我们有信心它能成为 2025 年最具竞争力的新能源车型」。

深蓝 L06 这次一举将激光雷达、城区 NCA 等配置拉入了 13 万元的区间,全球首发的 3nm 芯片和「磁流变悬架」也足够有记忆点。

市场的反馈也足够热烈,上市价格发布 1 小时,深蓝 L06 的累计订单突破了 20000 辆。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


每日一题-设置交集大小至少为2🔴

给你一个二维整数数组 intervals ,其中 intervals[i] = [starti, endi] 表示从 startiendi 的所有整数,包括 startiendi

包含集合 是一个名为 nums 的数组,并满足 intervals 中的每个区间都 至少两个 整数在 nums 中。

  • 例如,如果 intervals = [[1,3], [3,7], [8,9]] ,那么 [1,2,4,7,8,9][2,3,4,8,9] 都符合 包含集合 的定义。

返回包含集合可能的最小大小。

 

示例 1:

输入:intervals = [[1,3],[3,7],[8,9]]
输出:5
解释:nums = [2, 3, 4, 8, 9].
可以证明不存在元素数量为 4 的包含集合。

示例 2:

输入:intervals = [[1,3],[1,4],[2,5],[3,5]]
输出:3
解释:nums = [2, 3, 4].
可以证明不存在元素数量为 2 的包含集合。 

示例 3:

输入:intervals = [[1,2],[2,3],[2,4],[4,5]]
输出:5
解释:nums = [1, 2, 3, 4, 5].
可以证明不存在元素数量为 4 的包含集合。 

 

提示:

  • 1 <= intervals.length <= 3000
  • intervals[i].length == 2
  • 0 <= starti < endi <= 108

【宫水三叶の相信科学系列】贪心运用题

贪心

不要被样例数据误导了,题目要我们求最小点集的数量,并不规定点集 S 是连续段。

为了方便,我们令 intervalsins

当只有一个线段时,我们可以在线段内取任意两点作为 S 成员,而当只有两个线段时,我们可以两个线段重合情况进行决策:

  1. 当两个线段完全不重合时,为满足题意,我们需要从两个线段中各取两个点,此时这四个点都可以任意取;
  2. 当两个线段仅有一个点重合,为满足 S 最小化的题意,我们可以先取重合点,然后再两个线段中各取一个;
  3. 当两个线段有两个及以上的点重合,此时在重合点中任选两个即可。

不难发现,当出现重合的所有情况中,必然可以归纳某个线段的边缘点上。即不存在两个线段存在重合点,仅发生在两线段的中间部分:

image.png

因此我们可以从边缘点决策进行入手。

具体的,我们可以按照「右端点从小到大,左端点从大到小」的双关键字排序,然后从前往后处理每个区间,处理过程中不断往 S 中添加元素,由于我们已对所有区间排序且从前往后处理,因此我们往 S 中增加元素的过程中必然是单调递增,同时在对新的后续区间考虑是否需要往 S 中添加元素来满足题意时,也是与 S 中的最大/次大值(点集中的边缘元素)做比较,因此我们可以使用两个变量 ab 分别代指当前集合 S 中的次大值和最大值(ab 初始化为足够小的值 $-1$),而无需将整个 S 存下来。

不失一般性的考虑,当我们处理到 $ins[i]$ 时,该如何决策:

  1. 若 $ins[i][0] > b$(当前区间的左端点大于当前点集 S 的最大值),说明 $ins[i]$ 完全不被 S 所覆盖,为满足题意,我们要在 $ins[i]$ 中选两个点,此时直观思路是选择 $ins[i]$ 最靠右的两个点(即 $ins[i][1] - 1$ 和 $ins[i][1]$);
  2. 若 $ins[i][0] > a$(即当前区间与点集 S 存在一个重合点 b,由于次大值 a 和 最大值 b 不总是相差 $1$,我们不能写成 $ins[i][0] == b$),此时为了满足 $ins[i]$ 至少被 $2$ 个点覆盖,我们需要在 $ins[i]$ 中额外选择一个点,此时直观思路是选择 $ins[i]$ 最靠右的点(即$ins[i][1]$);
  3. 其余情况,说明当前区间 $ins[i]$ 与点集 S 至少存在两个点 ab,此时无须额外引入其余点来覆盖 $ins[i]$。

上述情况是对「右端点从小到大」的必要性说明,而「左端点从大到小」目的是为了方便我们处理边界情况而引入的:若在右端点相同的情况下,如果「左端点从小到大」处理的话,会有重复的边缘点被加入 S

有同学对重复边缘点加入 S 不理解,假设 S 当前的次大值和最大值分别为 jk(其中 $k - j > 1$),如果后面有两个区间分别为 $[k - 1, d]$ 和 $[k + 1, d]$ 时,就会出现问题(其中 d 为满足条件的任意右端点)
更具体的:假设当前次大值和最大值分别为 $2$ 和 $4$,后续两个区间分别为 $[3, 8]$ 和 $[5, 8]$,你会发现先处理 $[3, 8]$ 的话,数值 $8$ 会被重复添加

上述决策存在直观判断,需要证明不存在比该做法取得的点集 S 更小的合法解

若存在更小的合法集合方案 A(最优解),根据我们最前面对两个线段的重合分析知道,由于存在任意选点均能满足覆盖要求的情况,因此最优解 A 的具体方案可能并不唯一。

因此首先我们先在不影响 A 的集合大小的前提下,对具体方案 A 中的非关键点(即那些被选择,但既不是某个具体区间的边缘点,也不是边缘点的相邻点)进行调整(修改为区间边缘点或边缘点的相邻点)

这样我们能够得到一个唯一的最优解具体方案,该方案既能取到最小点集大小,同时与贪心解 S 的选点有较大重合度。

此时如果贪心解并不是最优解的话,意味着贪心解中存在某些不必要的点(可去掉,同时不会影响覆盖要求)。

然后我们在回顾下,我们什么情况下会往 S 中进行加点,根据上述「不失一般性」的分析:

  1. 当 $ins[i][0] > b$ 时,我们会往 S 中添加两个点,若这个不必要的点是在这个分支中被添加的话,意味着当前 $ins[i]$ 可以不在此时被覆盖,而在后续其他区间 $ins[j]$ 被覆盖时被同步覆盖(其中 $j > i$),此时必然对应了我们重合分析中的后两种情况,可以将原本在 $ins[j]$ 中被选择的点,调整为 $ins[i]$ 的两个边缘点,结果不会变差(覆盖情况不变,点数不会变多):

image.png

即此时原本在最优解 A 中不存在,在贪心解 S 中存在的「不必要点」会变成「必要点」。

  1. 当 $ins[i] > a$ 时,我们会往 S 中添加一个点,若这个不必要的点是在这个分支被添加的话,分析方式同理,且情况 $1$ 不会发生,如果 $ins[i]$ 和 $ins[j]$ 只有一个重合点的话,起始 $ins[i][1]$ 不会是不必要点:

image.png

综上,我们可以经过两步的“调整”,将贪心解变为最优解:第一步调整是在最优解的任意具体方案 A 中发生,通过将所有非边缘点调整为边缘点,来得到一个唯一的最优解具体方案;然后通过反证法证明,贪心解 S 中并不存在所谓的可去掉的「不必要点」,从而证明「贪心解大小必然不会大于最优解的大小」,即 $S > A$ 不成立,$S \leq A$ 恒成立,再结合 A 是最优解的前提($A \leq S$),可得 $S = A$。

代码:

###Java

class Solution {
    public int intersectionSizeTwo(int[][] ins) {
        Arrays.sort(ins, (a, b)->{
            return a[1] != b[1] ? a[1] - b[1] : b[0] - a[0];
        });
        int a = -1, b = -1, ans = 0;
        for (int[] i : ins) {
            if (i[0] > b) {
                a = i[1] - 1; b = i[1];
                ans += 2;
            } else if (i[0] > a) {
                a = b; b = i[1];
                ans++;
            }
        }
        return ans;
    }
}

###TypeScript

function intersectionSizeTwo(ins: number[][]): number {
    ins = ins.sort((a, b)=> {
        return a[1] != b[1] ? a[1] - b[1] : b[0] - a[0]
    });
    let a = -1, b = -1, ans = 0
    for (const i of ins) {
        if (i[0] > b) {
            a = i[1] - 1; b = i[1]
            ans += 2
        } else if (i[0] > a) {
            a = b; b = i[1]
            ans++
        }
    }
    return ans
};
  • 时间复杂度:$O(n\log{n})$
  • 空间复杂度:$O(\log{n})$

最后

如果有帮助到你,请给题解点个赞和收藏,让更多的人看到 ~ ("▔□▔)/

也欢迎你 关注我 和 加入我们的「组队打卡」小群 ,提供写「证明」&「思路」的高质量题解。

所有题解已经加入 刷题指南,欢迎 star 哦 ~

设置交集大小至少为2【贪心】

方法一:贪心

我们对intervals进行排序,intervals[0]升序,inervals[1]降序,然后从后向前进行遍历。
为什么要一个升序一个降序呢?
假设我们有一个intervals = [[2,3],[3,4],[5,10],[5,8]] (已排好序), 只要我们满足了和[5,8]的交集大于等于2,则对于[5,10](左区间相同,右区间降序,保证在左区间相同的情况下让区间范围最小的在最右边)这个区间来说,必定是满足交集大于等于2的,因为小区间满足,大区间必然满足,反过来不一定,在左区间相同的情况下,我们取最小区间的两个元素就可以满足所有左区间相同的区间。因此我们贪心的取interval[n-1][0]interval[n-1][0] + 1做为开始的两个集合元素,设初始两个元素为curnext,则cur = intervals[n - 1][0],next = intervals[n - 1][0] + 1
然后开始分类讨论上一个区间[xi,yi]的情况,根据排序有xi <= cur

  • yi >= next ,则是一个大区间,一定满足交集为2的情况
  • yi < cur,那一定没有交集,我们还是贪心的取cur = xi,next = xi + 1
  • cur <= yi < next,有一个交集,我们贪心的取next = cur,cur = xi
    保证每次都是取左边界或者左边界和左边界+1
    image.png
    最后返回答案即可
    代码如下
class Solution {
    public int intersectionSizeTwo(int[][] intervals) {
        Arrays.sort(intervals, (o1, o2) -> o1[0] == o2[0] ? o2[1] - o1[1] : o1[0] - o2[0]);
        int n = intervals.length;
        //初始的两个元素
        int cur = intervals[n - 1][0];
        int next = intervals[n - 1][0] + 1;
        int ans = 2;
        //从后向前遍历
        for (int i = n - 2; i >= 0; i--) {
            //开始分类讨论
            if (intervals[i][1] >= next) {
                continue;
            } else if (intervals[i][1] < cur) {
                cur = intervals[i][0];
                next = intervals[i][0] + 1;
                ans = ans + 2;
            } else {
                next = cur;
                cur = intervals[i][0];
                ans++;
            }
        }
        return ans;
    }
}
class Solution:
    def intersectionSizeTwo(self, intervals: List[List[int]]) -> int:
        if not intervals:
            return 0
        intervals.sort(key = lambda x : (x[0], -x[1]))
        ans = 2
        cur, next = intervals[-1][0], intervals[-1][0]+1
        for xi, yi in reversed(intervals[:-1]):
            if yi >= next:
                continue
            elif yi < cur:
                cur = xi
                next = xi + 1
                ans += 2
            elif cur <= yi < next:
                next = cur
                cur = xi
                ans += 1
        return ans
class Solution {
public:
    int intersectionSizeTwo(vector<vector<int>>& intervals) {
        sort(intervals.begin(), intervals.end(), [](vector<int>& a, vector<int>& b){
            if(a[0] == b[0]){
                return a[1] > b[1];
            }
            return a[0] < b[0];
        });  // 左边界升序,若相同,右边界降序
        int res = 2, ls = intervals.back()[0], rs = ls + 1;
        for(int i = intervals.size() - 2; i >= 0; i--){
            if(intervals[i][1] >= rs){  // 有两个及以上的交点
                continue;
            }else if(intervals[i][1] < ls){  // 没有交点
                ls = intervals[i][0];
                rs = intervals[i][0] + 1;
                res += 2;
            }else{  // 一个交点
                rs = ls;
                ls = intervals[i][0];
                res++;
            }
        }
        return res;
    }
};

感谢@fergus-peng小伙伴的py代码
感谢@handsomechar小伙伴的c++代码
image.png
给大家写了一个打印结果的代码,方便大家理解
代码如下

    public int intersectionSizeTwo(int[][] intervals) {
        Arrays.sort(intervals, (o1, o2) -> o1[0] == o2[0] ? o2[1] - o1[1] : o1[0] - o2[0]);
        System.out.println("排序后intervals:" + Arrays.deepToString(intervals));
        int n = intervals.length;
        int cur = intervals[n - 1][0];
        int next = intervals[n - 1][0] + 1;
        int ans = 2;
        List<Integer> list = new ArrayList<>();
        list.add(cur);
        list.add(next);
        for (int i = n - 2; i >= 0; i--) {
            System.out.println("待比较区间:" + Arrays.toString(intervals[i]) + "\t当前集合S:" + list);
            if (intervals[i][1] >= next) {
                continue;
            } else if (intervals[i][1] < cur) {
                cur = intervals[i][0];
                next = intervals[i][0] + 1;
                ans = ans + 2;
                list.add(0, next);
                list.add(0, cur);
            } else {
                next = cur;
                cur = intervals[i][0];
                ans++;
                list.add(0, cur);
            }
        }
        return ans;
    }

示例:

int[][] intervals = {{2, 10}, {3, 7}, {3, 15}, {4, 11}, {6, 12}, {6, 16}, {7, 8}, {7, 11}, {7, 15}, {11, 12}};

//结果
排序后intervals:[[2, 10], [3, 15], [3, 7], [4, 11], [6, 16], [6, 12], [7, 15], [7, 11], [7, 8], [11, 12]]
待比较区间:[7, 8]当前集合S:[11, 12]
待比较区间:[7, 11]当前集合S:[7, 8, 11, 12]
待比较区间:[7, 15]当前集合S:[7, 8, 11, 12]
待比较区间:[6, 12]当前集合S:[7, 8, 11, 12]
待比较区间:[6, 16]当前集合S:[7, 8, 11, 12]
待比较区间:[4, 11]当前集合S:[7, 8, 11, 12]
待比较区间:[3, 7]当前集合S:[7, 8, 11, 12]
待比较区间:[3, 15]当前集合S:[3, 7, 8, 11, 12]
待比较区间:[2, 10]当前集合S:[3, 7, 8, 11, 12]

写题解不易,如果对您有帮助,记得关注 + 点赞 + 收藏呦!!!
每天都会更新每日一题题解,大家加油!!

设置交集大小至少为2

方法一:贪心

思路与算法

首先我们从稍简化的情况开始分析:如果题目条件为设置交集大小为 $1$,为了更好的分析我们将 $\textit{intervals}$ 按照 $[s,e]$ 序对进行升序排序,其中 $\textit{intervals}$ 为题目给出的区间集合,$s,e$ 为区间的左右边界。设排序后的 $\textit{intervals} = {[s_1,e_1,],\dots,[s_n,e_n]}$,其中 $n$ 为区间集合的大小,并满足 $\forall i,j \in [1,n],i < j$ 有 $s_i \leq s_j$ 成立。然后对于最后一个区间 $[s_n,e_n]$ 来说一定是要提供一个最后交集集合中的元素,那么我们思考我们选择该区间中哪个元素是最优的——最优的元素应该尽可能的把之前的区间尽可能的覆盖。那么我们选择该区间的开头元素 $s_n$ 一定是最优的,因为对于前面的某一个区间 $[s_i,s_j]$:

  • 如果 $s_j < s_n$:那么无论选择最后一个区间中的哪个数字都不会在区间 $[s_i,s_j]$ 中。
  • 否则 $s_j \geq s_n$:因为 $s_n \geq s_i$ 所以此时选择 $s_n$ 一定会在区间 $[s_i,s_j]$ 中。

即对于最后一个区间 $[s_n,e_n]$ 来说选择区间的开头元素 $s_n$ 一定是最优的。那么贪心的思路就出来了:排序后从后往前进行遍历,每次选取与当前交集集合相交为空的区间的最左边的元素即可,然后往前判断前面是否有区间能因此产生交集,产生交集的直接跳过即可。此时算法的时间复杂度为:$O(n \log n)$ 主要为排序的时间复杂度。对于这种情况具体也可以见本站题目 452. 用最少数量的箭引爆气球

那么我们用同样的思路来扩展到需要交集为 $m~,~m > 1$ 的情况:此时同样首先对 $\textit{intervals}$ 按照 $[s,e]$ 序对进行升序排序,然后我们需要额外记录每一个区间与最后交集集合中相交的元素(只记录到 $m$ 个为止)。同样我们从最后一个区间往前进行处理,如果该区间与交集集合相交元素个数小于 $m$ 个时,我们从该区间左边界开始往后添加不在交集集合中的元素,并往前进行更新需要更新的区间,重复该过程直至该区间与交集元素集合有 $m$ 个元素相交。到此就可以解决问题了,不过我们也可以来修改排序的规则——我们将区间 $[s,e]$ 序对按照 $s$ 升序,当 $s$ 相同时按照 $e$ 降序来进行排序,这样可以实现在处理与交集集合相交元素个数小于 $m$ 个的区间 $[s_i,e_i]$ 时,保证不足的元素都是在区间的开始部分,即我们可以直接从区间开始部分进行往交集集合中添加元素。

而对于本题来说,我们只需要取 $m = 2$ 的情况即可。

代码

###Python

class Solution:
    def intersectionSizeTwo(self, intervals: List[List[int]]) -> int:
        intervals.sort(key=lambda x: (x[0], -x[1]))
        ans, n, m = 0, len(intervals), 2
        vals = [[] for _ in range(n)]
        for i in range(n - 1, -1, -1):
            j = intervals[i][0]
            for k in range(len(vals[i]), m):
                ans += 1
                for p in range(i - 1, -1, -1):
                    if intervals[p][1] < j:
                        break
                    vals[p].append(j)
                j += 1
        return ans

###C++

class Solution {
public:
    void help(vector<vector<int>>& intervals, vector<vector<int>>& temp, int pos, int num) {
        for (int i = pos; i >= 0; i--) {
            if (intervals[i][1] < num) {
                break;
            }
            temp[i].push_back(num);
        }
    }

    int intersectionSizeTwo(vector<vector<int>>& intervals) {
        int n = intervals.size();
        int res = 0;
        int m = 2;
        sort(intervals.begin(), intervals.end(), [&](vector<int>& a, vector<int>& b) {
            if (a[0] == b[0]) {
                return a[1] > b[1];
            }
            return a[0] < b[0];
        });
        vector<vector<int>> temp(n);
        for (int i = n - 1; i >= 0; i--) {
            for (int j = intervals[i][0], k = temp[i].size(); k < m; j++, k++) {
                res++;
                help(intervals, temp, i - 1, j);
            }
        }
        return res;
    }
};

###Java

class Solution {
    public int intersectionSizeTwo(int[][] intervals) {
        int n = intervals.length;
        int res = 0;
        int m = 2;
        Arrays.sort(intervals, (a, b) -> {
            if (a[0] == b[0]) {
                return b[1] - a[1];
            }
            return a[0] - b[0];
        });
        List<Integer>[] temp = new List[n];
        for (int i = 0; i < n; i++) {
            temp[i] = new ArrayList<Integer>();
        }
        for (int i = n - 1; i >= 0; i--) {
            for (int j = intervals[i][0], k = temp[i].size(); k < m; j++, k++) {
                res++;
                help(intervals, temp, i - 1, j);
            }
        }
        return res;
    }

    public void help(int[][] intervals, List<Integer>[] temp, int pos, int num) {
        for (int i = pos; i >= 0; i--) {
            if (intervals[i][1] < num) {
                break;
            }
            temp[i].add(num);
        }
    }
}

###C#

public class Solution {
    public int IntersectionSizeTwo(int[][] intervals) {
        int n = intervals.Length;
        int res = 0;
        int m = 2;
        Array.Sort(intervals, (a, b) => {
            if (a[0] == b[0]) {
                return b[1] - a[1];
            }
            return a[0] - b[0];
        });
        IList<int>[] temp = new IList<int>[n];
        for (int i = 0; i < n; i++) {
            temp[i] = new List<int>();
        }
        for (int i = n - 1; i >= 0; i--) {
            for (int j = intervals[i][0], k = temp[i].Count; k < m; j++, k++) {
                res++;
                Help(intervals, temp, i - 1, j);
            }
        }
        return res;
    }

    public void Help(int[][] intervals, IList<int>[] temp, int pos, int num) {
        for (int i = pos; i >= 0; i--) {
            if (intervals[i][1] < num) {
                break;
            }
            temp[i].Add(num);
        }
    }
}

###C

static void help(int** intervals, int** temp, int *colSize, int pos, int num) {
    for (int i = pos; i >= 0; i --) {
        if (intervals[i][1] < num) {
            break;
        }
        temp[i][colSize[i]++] = num;
    }
}

static inline int cmp(const void* pa, const void* pb) {
    if ((*(int **)pa)[0] == (*(int **)pb)[0]) {
        return (*(int **)pb)[1] - (*(int **)pa)[1];
    }
    return (*(int **)pa)[0] - (*(int **)pb)[0];
}

int intersectionSizeTwo(int** intervals, int intervalsSize, int* intervalsColSize){
    int res = 0;
    int m = 2;
    qsort(intervals, intervalsSize, sizeof(int *), cmp);
    int **temp = (int **)malloc(sizeof(int *) * intervalsSize);
    for (int i = 0; i < intervalsSize; i++) {
        temp[i] = (int *)malloc(sizeof(int) * 2);
    }
    int *colSize = (int *)malloc(sizeof(int) * intervalsSize);
    memset(colSize, 0, sizeof(int) * intervalsSize);
    for (int i = intervalsSize - 1; i >= 0; i --) {
        for (int j = intervals[i][0], k = colSize[i]; k < m; j++, k++) {
            res++;
            help(intervals, temp, colSize, i - 1, j);
        }
    }
    for (int i = 0; i < intervalsSize; i++) {
        free(temp[i]);
    }
    free(colSize);
    return res;
}

###go

func intersectionSizeTwo(intervals [][]int) (ans int) {
    sort.Slice(intervals, func(i, j int) bool {
        a, b := intervals[i], intervals[j]
        return a[0] < b[0] || a[0] == b[0] && a[1] > b[1]
    })
    n, m := len(intervals), 2
    vals := make([][]int, n)
    for i := n - 1; i >= 0; i-- {
        for j, k := intervals[i][0], len(vals[i]); k < m; k++ {
            ans++
            for p := i - 1; p >= 0 && intervals[p][1] >= j; p-- {
                vals[p] = append(vals[p], j)
            }
            j++
        }
    }
    return
}

###JavaScript

var intersectionSizeTwo = function(intervals) {
    const n = intervals.length;
    let res = 0;
    let m = 2;
    intervals.sort((a, b) => {
        if (a[0] === b[0]) {
            return b[1] - a[1];
        }
        return a[0] - b[0];
    });
    const temp = new Array(n).fill(0);
    for (let i = 0; i < n; i++) {
        temp[i] = [];
    }

    const help = (intervals, temp, pos, num) => {
        for (let i = pos; i >= 0; i--) {
            if (intervals[i][1] < num) {
                break;
            }
            temp[i].push(num);
        }
    }

    for (let i = n - 1; i >= 0; i--) {
        for (let j = intervals[i][0], k = temp[i].length; k < m; j++, k++) {
            res++;
            help(intervals, temp, i - 1, j);
        }
    }
    return res;
};

复杂度分析

  • 时间复杂度:$O(n \log n + nm)$,其中 $n$ 为给定区间集合 $\textit{intervals}$ 的大小,$m$ 为设置交集大小,本题为 $2$。

  • 空间复杂度:$O(nm)$,其中 $n$ 为给定区间集合 $\textit{intervals}$ 的大小,$m$ 为设置交集的大小,本题为 $2$。主要开销为存储每一个区间与交集集合的相交的元素的开销。

TypeScript 简史:它是怎么拯救我的烂代码的

看烂代码的场景

接手老旧 JavaScript 项目的时候,盯着屏幕上的一行代码发呆,这种绝望你一定体会过:

JavaScript

function process(data) {
    return data.value + 10; // 此时 data 是 undefined,程序崩了
}

看着这个 data,我满脑子都是问号:

  • 它是对象还是数字?
  • 到底是谁传进来的?
  • 我要是改了它,会不会导致隔壁模块的页面挂掉?
  • 为什么明明是字符串 '10',结果拼成了 '1010'

这时候我就在想,要是代码能自己告诉我“我是谁,我从哪里来,我要去哪里”,该多好。

这就是 TypeScript 诞生的意义。它不是微软为了炫技造的轮子,而是为了解决 JavaScript 在大规模应用中“失控”的必然选择。


为什么我们需要 TypeScript?

JavaScript 的“娘胎顽疾”

JavaScript 诞生的时候,Brendan Eich 只用了 10 天。这事儿说穿了挺传奇,但也留下了隐患。

当时的场景很简单:验证一下表单,改改页面背景色。所以它的设计哲学是 “怎么方便怎么来”

JavaScript

var data = "hello";
data = 123;           // 随便改类型,没事
data.abcd = "what?";  // 随便加属性,也不报错

这种“自由”在写几十行代码时是天堂,但在写几十万行代码时就是地狱。

事情是怎么失控的?

随着 AjaxNode.js 的出现,前端不再是画页面的,而是写应用程序的。

想想也是,当代码量从 500 行变成 50,000 行,团队从 1 个人变成 20 个人:

  • 你写的 getUser(id),同事调用时传了个对象。
  • 后端 API 偷偷改了一个字段名,前端只有等到用户点击报错了才知道。
  • 重构?别逗了,改一行代码,心里都要祈祷半天。

问题的根源在于 JavaScript 是动态弱类型。它在运行前完全不知道自己错了,非要等到撞了南墙(报错)才回头。


它是怎么解决问题的?

核心思路:给 JS 穿上铠甲

TypeScript 的原理说穿了挺简单:它就是给 JavaScript 加上了类型约束,但在运行前又把这些约束脱得干干净净。

看个流程图就明白了:

代码段

graph LR
    A[TypeScript源码] -->|编译/检查| B(类型检查器)
    B -- 报错 --> C[修复代码]
    B -- 通过 --> D[抹除类型信息]
    D --> E[纯净的JavaScript]
    E --> F[浏览器/Node运行]

巧妙的“超集”策略

微软的大神 Anders Hejlsberg(这也是 C# 之父)非常聪明。他知道如果搞一门新语言,开发者肯定不买账(看看 Google 的 Dart 就知道了)。

所以他搞了个 “超集(Superset)” 策略:

  1. 向后兼容:任何合法的 JavaScript 代码,直接粘贴进 TypeScript 文件,它都能跑。你不需要重写代码。
  2. 类型擦除:TS 编译后就是普通的 JS。浏览器根本不知道 TS 的存在,不用装任何插件。

TypeScript

// TypeScript 写法
function add(a: number, b: number): number {
    return a + b;
}

// 编译出来的 JavaScript(类型全没了)
function add(a, b) {
    return a + b;
}

这招“瞒天过海”非常高明,既让你爽了(有类型检查),又让浏览器爽了(只认 JS)。


为什么 TS 能赢?(深度分析)

在 TS 出来之前,Google 的 Closure 和 Dart,甚至 Facebook 的 Flow 都尝试过解决这个问题。为什么最后是 TypeScript 统一了江湖?

对比分析

我们来看看这张技术演进图:

代码段

timeline
    title 前端类型探索之路
    2009 : Closure Compiler : 注释太繁琐
    2009 : CoffeeScript : 语法糖,无类型
    2011 : Dart : 甚至想换掉JS虚拟机
    2012 : TypeScript 诞生 : 拥抱JS,做超集
    2014 : Angular 宣布采用 TS
    2016 : VS Code 崛起
    2023 : 统治地位确立

TypeScript 胜出的关键点

  1. 工具链的降维打击

    这点必须得吹一下 VS Code。VS Code 是用 TS 写的,它对 TS 的支持简直是原生级的。

    • 智能补全:你打个点 .,属性全出来了,不用去翻文档。
    • 重构神器:按 F2 重命名一个变量,整个项目几百个文件里的引用全改好了。
  2. 渐进式的温柔

    CoffeeScript 和 Dart 要求你“学会新语法,忘掉旧习惯”。

    TypeScript 说:“没事,你先用 any 凑合着,等有空了再补类型。”这种低门槛让很多老项目敢于尝试迁移。

  3. 生态圈的马太效应

    现在你装个第三方库,如果没有自带 TypeScript 类型定义(d.ts),大家都会觉得这个库“不正规”。Angular、Vue3、React 全部深度拥抱 TS。

潜在的坑

当然,TS 也不是银弹,这里要注意几个软肋:

  • AnyScript 现象:很多新手遇到类型报错就写 any,结果写成了“带编译过程的 JavaScript”,完全失去了类型的意义。
  • 体操级类型:有时候为了描述一个复杂的动态结构,类型定义写得比业务逻辑还长,人称“类型体操”。
  • 编译时间:项目大了以后,tsc 跑一遍确实挺慢的(虽然现在有了 SWC/Esbuild 等加速方案)。

写在最后

TypeScript 的成功告诉我们要顺势而为。它没有试图颠覆 JavaScript,而是承认了 JS 的混乱,然后提供了一套工具来管理这种混乱。

下次当你接手一个全是 any 的 TS 项目时,你会知道:

  • 这哥们儿可能是在迁移初期。
  • 或者他只是单纯的懒。
  • 最重要的:至少你还能重构,因为编译器会教你做人。

如果你的团队还在用纯 JS 裸奔,赶紧试试 TS 吧。哪怕只是为了那个“点一下能出属性提示”的爽快感,也值了。


相关文档

只有前端 Leader 才会告诉你:那些年踩过的模块加载失败的坑(二)

最近项目准备要发布了,项目有比较多的一些代码提交和修复,发现在生产环境中,偶尔会遇到下面的错误:

TypeError: Failed to fetch dynamically imported module:
https://***-dev.***.internal.***.tech/assets/index-DUebgR3_.js

Failed to load module script: Expected a JavaScript-or-Wasm module script 
but the server responded with a MIME type of "text/html".

去做了一些调研,整理了这篇文章

🔍 问题原因分析

1. 根本原因

Nginx 配置问题:当浏览器请求不存在的静态资源文件时,nginx 返回了 index.html 而不是 404 错误。

原始的nginx配置:

location / {
    try_files $uri $uri/ /index.html;
}

这个配置作用是让前端路由(比如 /about/user/profile 这类路径)在刷新或直接访问时不会返回 404,导致所有找不到的文件(包括 /assets/ 下的 JS 文件)都返回 index.html(对应MIME type: text/html),但浏览器期望的是 JavaScript 文件。

2. 触发场景

我们的触发场景主要是场景A,其他两种也是可能会触发的场景

场景 A:版本更新后的缓存问题

1. 用户访问网站,浏览器缓存了 index.html(引用 index-ABC123.js)
2. 服务器部署新版本,生成新的哈希文件 index-DEF456.js
3. 用户刷新页面,浏览器使用缓存的 HTML,尝试加载已删除的 index-ABC123.js
4. 文件不存在,nginx 返回 index.html
5. 浏览器把 HTML 当作 JS 解析,抛出 TypeError

场景 B:部署不完整

1. CI/CD 部署过程中,HTML 文件已更新
2. 某些 chunk 文件因网络问题未完全上传
3. 用户访问时,HTML 引用了不存在的 chunk 文件
4. 触发模块加载错误

场景 C:CDN/浏览器缓存不一致

1. CDN 缓存了新版本的 HTML
2. 某些静态资源仍指向旧版本或缓存未更新
3. 导致文件引用不匹配

3. 错误链条

graph TD
    A[浏览器请求 /assets/index-DUebgR3_.js] --> B{文件是否存在?}
    B -->|否| C[nginx 执行 try_files]
    C --> D[返回 /index.html]
    D --> E[MIME type: text/html]
    E --> F[浏览器期望: application/javascript]
    F --> G[TypeError: MIME type 不匹配]

✅ 解决方案

方案 1:修复 Nginx 配置(核心)

修改内容

server {
    listen       80;
    server_name  _;

    root   /usr/share/nginx/html;
    index  index.html;

    # 静态资源:找不到返回 404,不返回 index.html
    location /assets/ {
        try_files $uri =404;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # SPA 路由:只对非静态资源路径生效
    location / {
        try_files $uri $uri/ /index.html;
        # 禁用 index.html 缓存,确保用户总是获取最新版本
        add_header Cache-Control "no-cache, no-store, must-revalidate";
    }

    # Gzip 压缩
    gzip on;
    gzip_types text/plain text/css application/javascript application/json application/xml image/svg+xml;
    gzip_min_length 1024;
}

改进点

  • /assets/ 路径返回真实的 404,避免误返回 HTML
  • ✅ 静态资源设置长期缓存(1年),提升性能
  • index.html 禁用缓存,防止引用过期的静态资源
  • ✅ 区分 SPA 路由和静态资源路由

📘 SPA 加载流程详解

单页应用的完整加载过程

很多人误以为 SPA 只是"返回 index.html 就完事了",实际上 index.html 只是入口,JS 文件才是应用的核心

🔄 完整加载流程(6个步骤)
用户访问: https://yourapp.com/users/123
    ↓
① 服务器返回 index.html(HTML 入口文件)
    ↓
② 浏览器解析 HTML,发现 <script src="/assets/index-DUebgR3_.js">
    ↓
③ 浏览器自动发起第二个请求: GET /assets/index-DUebgR3_.js
    ↓                           ⚠️ 我们的错误发生在这一步,由于项目更新,打包部署了新的版本,文件的hash值也发生了变化,但是本地之前启动项目的缓存请求的还是旧文件,期望拿到js文件,但是由于服务端找不到,返回了html,就出现了开头的错误
④ 服务器返回 JS 文件(包含 React 应用代码)
    ↓
⑤ 浏览器执行 JS:React 启动,读取 URL (/users/123)
    ↓
⑥ React Router 匹配路由,渲染 <UserProfile id="123" /> 组件
📄 index.html 和 JS 文件的关系

index.html 的实际内容(简化版):

<!DOCTYPE html>
<html>
<head>
    <title>Innies</title>
    <link rel="stylesheet" href="/assets/style-ABC123.css">
</head>
<body>
    <div id="root"></div>  <!-- ⚠️ 注意:这里是空的! -->

    <!-- ⚠️ 关键:这行代码触发浏览器请求 JS 文件 -->
    <script type="module" src="/assets/index-DUebgR3_.js"></script>
</body>
</html>

JS 文件包含真正的应用代码

// /assets/index-DUebgR3_.js 的内容
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { BrowserRouter } from 'react-router-dom';

// 这里才开始渲染页面内容
ReactDOM.render(
  <BrowserRouter>
    <App />  {/* 包含所有路由、组件、业务逻辑 */}
  </BrowserRouter>,
  document.getElementById('root')  // 找到 HTML 里的 <div id="root">,开始渲染
);

关键理解

  • index.html 只是一个空壳(只有一个空的 <div id="root">
  • 所有页面内容、路由、组件都在 JS 文件里
  • 没有 JS 文件,页面就是空白,什么都显示不出来
⚠️ 错误发生的位置

本文档解决的错误发生在第③步

正常流程

③ 浏览器请求: GET /assets/index-DUebgR3_.js
    ↓
✅ 服务器返回: JavaScript 文件(Content-Type: application/javascript)
    ↓
✅ 浏览器执行 JS,React 启动
    ↓
✅ 页面渲染成功

错误流程(修复前)

③ 浏览器请求: GET /assets/index-DUebgR3_.js
    ↓
❌ 服务器找不到文件(可能是旧版本文件已被删除)
    ↓
❌ Nginx 配置错误:try_files $uri $uri/ /index.html
    ↓
❌ 返回: index.html 内容(Content-Type: text/html)
    ↓
❌ 浏览器期望 JavaScript,但收到 HTML
    ↓
❌ TypeError: Expected JavaScript but got MIME type "text/html"
    ↓
❌ React 无法启动,页面白屏或崩溃
📊 请求和响应对比
步骤 正常情况 错误情况(修复前)
浏览器请求 GET /assets/index-DUebgR3_.js GET /assets/index-DUebgR3_.js
文件状态 ✅ 文件存在 ❌ 文件不存在(旧版本)
服务器返回 JavaScript 代码 ❌ index.html 内容
Content-Type application/javascript text/html
浏览器行为 ✅ 执行 JS,渲染页面 ❌ MIME type 不匹配报错
用户体验 ✅ 页面正常显示 ❌ 白屏或错误提示
💡 为什么 JS 文件找不到会导致整个应用崩溃?

因为 JS 文件包含:

  • ✅ React 核心代码
  • ✅ 所有组件定义
  • ✅ 路由配置
  • ✅ 状态管理
  • ✅ 业务逻辑

没有这个 JS 文件,index.html 只是一个空壳,无法渲染任何内容。

这就像:

  • index.html = 汽车的外壳
  • index-DUebgR3_.js = 发动机
  • 没有发动机,汽车就无法启动

🎯 为什么要区分 SPA 路由和静态资源路由?

问题背景: 单页应用(SPA)和传统的静态资源服务有本质区别,需要不同的处理策略。

📘 SPA 路由工作原理

单页应用(SPA)的核心机制

  1. 服务器层面:所有 URL 路径都返回同一个 index.html

    /users/123    → 服务器返回 index.html
    /dashboard    → 服务器返回 index.html
    /settings     → 服务器返回 index.html
    
  2. 浏览器层面:前端路由(如 React Router)解析 URL 并渲染对应组件

    // index.html 加载后,前端路由接管 URL 解析
    /users/123React Router 匹配 → <UserProfile id="123" />
    /dashboard    → React Router 匹配 → <Dashboard />
    /settings     → React Router 匹配 → <Settings />
    

为什么 /users/123 需要返回 index.html

典型场景:

  • 用户直接在浏览器输入 https://yourapp.com/users/123
  • 或在 /users/123 页面刷新浏览器
  • 服务器收到 HTTP 请求:GET /users/123

如果不返回 index.html 会发生什么?

❌ 服务器在文件系统中找不到 /users/123 文件
❌ 返回 404 错误
❌ 用户看到错误页面,应用无法加载

返回 index.html 后的完整流程

1. 服务器返回 index.html(包含 React 应用的启动代码)
2. 浏览器加载并执行 index.html 中的 JavaScript
3. React 应用启动
4. React Router 读取当前 URL: /users/123
5. 匹配路由规则,渲染 <UserProfile id="123" /> 组件
6. 用户看到正确的页面 ✅
📊 路由类型对比
类型 路径示例 期望行为 原因
SPA 路由 /users/123
/settings
/dashboard
返回 index.html 这些是前端路由,由 React Router 处理,服务器没有对应文件
静态资源 /assets/index-DUebgR3_.js
/assets/style.css
/favicon.ico
返回文件或 404 这些是真实的物理文件,不存在就应该报错

修复前的问题

location / {
    try_files $uri $uri/ /index.html;  # ❌ 所有路径都用这个规则
}

这会导致:

  • /users/123 → 返回 index.html ✓(正确)
  • /assets/missing.js → 返回 index.html ✗(错误!应该返回 404)

修复后的方案

# 规则 1:静态资源 - 严格匹配
location /assets/ {
    try_files $uri =404;  # 找不到就返回 404,绝不返回 HTML
}

# 规则 2:SPA 路由 - 兜底方案
location / {
    try_files $uri $uri/ /index.html;  # 找不到才返回 HTML
}

工作原理

请求: /users/123
  ↓ 不匹配 /assets/
  ↓ 进入 location /
  ↓ $uri 不存在 → 返回 index.html ✅
  
请求: /assets/index-ABC.js (存在)
  ↓ 匹配 /assets/
  ↓ $uri 存在 → 返回文件 ✅
  
请求: /assets/index-OLD.js (不存在)
  ↓ 匹配 /assets/
  ↓ $uri 不存在 → 返回 404 ✅(不是 HTML!)

核心收益

  1. 类型安全:浏览器期望 JS 文件,就不会收到 HTML 文件
  2. 快速失败:资源缺失立即返回 404,触发前端错误处理(重试/提示)
  3. 正确缓存:静态资源和 HTML 可以设置不同的缓存策略
  4. 问题可见:404 错误可以被监控系统捕获,便于及时发现部署问题

⚠️ 重要说明:404 不是终极解决方案

返回 404 的作用

❌ 修复前:返回 HTML → MIME type 错误 → 用户看到技术错误 → 无法恢复
✅ 修复后:返回 404 → 触发 error 事件 → 前端捕获错误 → 自动重试/提示用户

404 只是让问题"正确地暴露",真正的解决方案是 方案 3 的前端错误处理

  • 🔄 自动重试加载(处理临时网络问题)
  • 🔄 自动刷新页面(清除过期缓存)
  • 💬 友好的用户提示(引导用户清除缓存)

完整的解决链条

文件不存在 
  ↓
nginx 返回 404(不是 HTML)
  ↓
浏览器触发 error 事件
  ↓
前端错误处理器捕获
  ↓
自动重试(2次)或提示用户清除缓存
  ↓
问题解决 ✅

三个方案的角色

方案 角色 作用
方案 1 (Nginx) 🚦 正确的错误信号 让错误以正确的方式暴露(404 而非 HTML)
方案 2 (Vite) 🛡️ 减少问题发生 优化构建,减少 chunk 文件数量和依赖复杂度
方案 3 (前端) 🔧 自动修复 捕获错误并自动恢复,用户无感知或有友好提示

根本性的预防措施(见后文"预防措施"章节):

  • ✅ 禁用 index.html 缓存
  • ✅ 原子性部署(避免文件不完整)
  • ✅ 保留旧版本静态资源(避免缓存引用失效)

方案 2:优化 Vite 构建配置

修改内容

export default defineConfig(({ mode }) => {
  return {
    build: {
      rollupOptions: {
        output: {
          manualChunks: {
            // 拆分大型依赖,减少单个文件失败的影响
            'react-vendor': ['react', 'react-dom', 'react-router-dom'],
            'ui-vendor': ['@zhiman/design', 'framer-motion', 'lucide-react'],
          },
        },
      },
      assetsDir: 'assets',
      chunkSizeWarningLimit: 1000,
    },
    // ... 其他配置
  };
});

改进点

  • ✅ 合理拆分 chunk,避免单个巨大文件
  • ✅ 减少动态导入失败的影响范围
  • ✅ 提升首屏加载速度

方案 3:添加前端错误处理

新增文件src/utils/moduleLoadErrorHandler.ts

核心功能

export function setupModuleLoadErrorHandler(): void {
  // 监听全局错误
  window.addEventListener('error', (event) => {
    // 检测模块加载错误
    if (isModuleLoadError(event.message)) {
      // 自动重试(最多 2 次)
      if (reloadCount < MAX_RELOAD_ATTEMPTS) {
        sessionStorage.setItem(RELOAD_KEY, String(reloadCount + 1));
        setTimeout(() => window.location.reload(), 500);
      } else {
        // 显示友好的错误提示
        showErrorUI();
      }
    }
  });
}

特性

  • ✅ 自动检测模块加载失败
  • ✅ 智能重试机制(最多 2 次)
  • ✅ 友好的用户提示界面
  • ✅ 一键清除缓存并重新加载
  • ✅ 防止无限重载循环

使用方式

// src/main.tsx
import { setupModuleLoadErrorHandler } from './utils/moduleLoadErrorHandler';

setupModuleLoadErrorHandler();

📊 效果对比

修复前

用户体验:❌ 白屏 / 加载失败
错误信息:❌ 技术性错误提示
恢复方式:❌ 用户需要手动清除缓存
影响范围:❌ 版本更新时频繁出现

修复后

用户体验:✅ 自动重试 / 友好提示
错误信息:✅ 用户友好的提示界面
恢复方式:✅ 自动重试 + 一键清除缓存
影响范围:✅ 大幅减少错误发生率
  • 💡 总结与建议

    问题本质

    说白了就是:旧文件找不到了,Nginx 配置有问题,返回了错的东西。

    用户浏览器缓存的旧 HTML 里写着"去加载 index-ABC123.js",但服务器上这个文件早删了。正常情况应该返回 404,但 Nginx 配置写错了,返回了一个 HTML 页面。浏览器期望拿到 JS 文件,结果拿到 HTML,直接懵了,抛错。

    解决思路很简单

    核心就一句话:让 Nginx 该返回 404 就返回 404,别瞎返回 HTML。然后前端监听到 404 错误,自动刷新页面就行了。

    三个方案其实就是围绕这个思路:

    1. 改 Nginx:找不到文件就老老实实返回 404,别整那些花里胡哨的
    2. 前端兜底:监听加载失败,自动重试或者刷新页面,用户基本无感
    3. 优化打包:把文件拆小点,减少出问题的概率

    为什么这么简单的问题会困扰这么久?

    因为大多数人(包括我们一开始)都把 Nginx 配置写成了:

    nginx

    location / {
        try_files $uri $uri/ /index.html;  # 啥都找不到就返回 HTML
    }
    

    这个配置的本意是支持 SPA 前端路由,但副作用是连静态资源文件找不到也返回 HTML,这就埋了个大坑。

    三个方案的优先级

    如果时间紧迫,只能先做一个,建议顺序是:

    1. 先改 Nginx(5分钟搞定,治本)
    2. 再加前端兜底(半小时搞定,救急)
    3. 最后优化构建(锦上添花,可选)

    一些踩坑经验

    关于 Nginx 配置:

    • 静态资源目录(/assets/)要单独配置,找不到就返回 404
    • 测试方法:直接浏览器访问一个不存在的 /assets/xxx.js,看返回的是不是 404
    • 别偷懒,该分开配置就分开配置

    关于缓存策略:

    • index.html 千万别缓存,或者设置较短时间的缓存,
    • 这是问题的根源
    • 静态资源随便缓存,文件名带 hash,不会冲突
    • CDN 的缓存规则要和 Nginx 保持一致

前端跨标签页通信方案(下)

前情

平时开发很少有接触到有什么需求需要实现跨标签页通信,但最近因为一些变故,不得不重新开始找工作了,其中就有面试官问到一道题,跨标签页怎么实现数据通信,我当时只答出二种,面试完后特意重新查资料,因此有些文章

SharedWorker

共享工作线程可以在多个标签页之间共享数据和逻辑,通过postMessage通信

关键代码如下:

标签页1

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>SharedWorker0</title>
</head>
<body>
  <h1>SharedWorker0</h1>
  <button id="communication">SharedWorker0.html 发送消息</button>
  <script>
    // 主线程
    const worker = new SharedWorker('sw.js');

    // 发送消息
    document.getElementById('communication').addEventListener('click', () => {
      worker.port.postMessage('Hello from Tab:SharedWorker0.html');
    });
  </script>
</body>
</html>

标签页2

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>SharedWorker1</title>
</head>
<body>
  <h1>SharedWorker1</h1>
  <script>
    // 主线程
    const worker = new SharedWorker('sw.js');

    // 接收消息
    worker.port.onmessage = (e) => {
      console.log('Received:SharedWorker1.html', e.data);
    };
  </script>
</body>
</html>

sw.js关键代码:

const connections = [];

self.onconnect = (e) => {
  const port = e.ports[0];
  connections.push(port);
  
  port.onmessage = (e) => {
    // 广播给所有连接的页面
    connections.forEach(p => p.postMessage(e.data));
  };
};

动图演示:

20250923_203404.gif

提醒:

  • 同源标签才有效
  • 不同页面创建 SharedWorker 时,若指定的脚本路径不同(即使内容相同),会创建不同的 worker 实例
  • 页面与 SharedWorker 之间通过 MessagePort 通信,需通过 port.postMessage() 发送消息,通过 port.onmessage 接收消息
  • SharedWorker 无法访问 DOM、window 对象或页面的全局变量,仅能使用 JavaScript 核心 API 和部分 Web API(如 fetchWebSocket
  • 兼容性一般,安卓webview全系不兼容

Service Worker

专门用于同源标签页通信的 API,创建一个频道后,所有加入该频道的页面都能收到消息

关键代码如下:

标签页1

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>ServiceWorker0</title>
</head>
<body>
  <h1>ServiceWorker0</h1>
  <button id="sendBtn">发送消息</button>
  <script>
    // 注册ServiceWorker
    let swReg;
    navigator.serviceWorker.register('ServiceWorker.js')
      .then(reg => {
        swReg = reg;
        console.log('SW注册成功');
      });
    
    // 发送消息
    document.getElementById('sendBtn').addEventListener('click', () => {
      if (swReg && swReg.active) {
        swReg.active.postMessage('来自页面0的消息');
      }
    });
  </script>
</body>
</html>

标签页2

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>ServiceWorker1</title>
</head>
<body>
  <h1>ServiceWorker1</h1>
  <script>
    // 注册ServiceWorker
    navigator.serviceWorker.register('ServiceWorker.js')
      .then(() => console.log('SW注册成功'));
    
    // 接收消息
    navigator.serviceWorker.addEventListener('message', (e) => {
      console.log('---- Received:ServiceWorker1.html ----:',  e.data);
    });
  </script>
</body>
</html>

ServiceWorker.js关键代码

// 快速激活
self.addEventListener('install', e => e.waitUntil(self.skipWaiting()));
self.addEventListener('activate', e => e.waitUntil(self.clients.claim()));

// 消息转发
self.addEventListener('message', e => {
  self.clients.matchAll().then(clients => {
    clients.forEach(client => {
      if (client.id !== e.source.id) {
        client.postMessage(e.data);
      }
    });
  });
});

演示动图如下:

20250923_212126.gif

提醒:

  • Service Worker 要求页面必须在 HTTPS 环境 下运行(localhost 除外,方便本地开发),这是出于安全考虑,防止中间人攻击篡改 Service Worker 脚本
  • Service Worker 有严格的生命周期(安装、激活、空闲、销毁),一旦注册成功会长期运行在后台,更新 Service Worker 需满足两个条件:
  1. 脚本 URL 不变但内容有差异
  2. 需在 install 事件中调用 self.skipWaiting(),并在 activate 事件中调用 self.clients.claim() 让新 Worker 立即生效
  • Service Worker 的作用域由注册路径决定,默认只能控制其所在路径及子路径下的页面,例如:/sw.js 可控制全站,/js/sw.js 默认只能控制 /js/ 路径下的页面,可通过 scope 参数指定作用域,但不能超出注册文件所在路径的范围
  • 可在浏览器开发者工具的 Application > Service Workers 面板进行调试, • 查看当前运行的 Service Worker 状态 • 强制更新、停止或注销 Worker • 模拟离线环境
  • 主流浏览器都支持,使用的时候可以通过Is service worker ready?,测试兼容性

window.open + window.opener

如果标签页是通过window.open打开的,可以直接通过opener属性通信 父窗口,打开子窗口的页面关键代码:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>parent</title>
</head>
<body>
  <h1>window.open parent</h1>
  <button id="openBtn">打开子窗口</button>
  <button id="sendBtn">发送消息</button>
  <div id="messageDisplay"></div>
  <script>
    let childWindow = null;
    let messageHandler = null;
    
    // 打开子窗口
    document.getElementById('openBtn').addEventListener('click', () => {
      // 如果已有窗口,先关闭
      if (childWindow && !childWindow.closed) {
        childWindow.close();
      }
      childWindow = window.open('./children.html', 'childWindow');
    });

    // 发送消息
    document.getElementById('sendBtn').addEventListener('click', () => {
      if (childWindow && !childWindow.closed) {
      // window.location.origin限制接收域名
        childWindow.postMessage('Hello child', window.location.origin);
      } else {
        alert('请先打开子窗口');
      }
    });
    
    // 接收子窗口的消息
    messageHandler = (e) => {
      if (e.origin === window.location.origin && e.source !== window) {
        document.getElementById('messageDisplay').textContent = '收到消息: ' + e.data;
        console.log('父页面收到消息:', e.data);
      }
    };
    
    window.addEventListener('message', messageHandler);
  </script>
</body>
</html>

通过window.open打开的子页面关键代码:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>children</title>
</head>
<body>
  <h1>子窗口</h1>
  <button id="replyBtn">回复父窗口</button>
  <div id="messageDisplay"></div>
  
  <script>
    let messageHandler = null;
    
    // 只在页面加载完成后设置消息监听
    window.onload = function() {
      // 接收父页面消息
      messageHandler = (e) => {
        if (e.origin === window.location.origin && e.source !== window) {
          console.log('子页面收到消息:', e.data);
          
          // 显示收到的消息
          document.getElementById('messageDisplay').textContent = '收到消息: ' + e.data;
          
          window.opener.postMessage('子窗口已收到消息', e.origin);
        }
      };
      
      window.addEventListener('message', messageHandler);
    };
    
    // 手动回复按钮
    document.getElementById('replyBtn').addEventListener('click', () => {
      if (window.opener) {
        window.opener.postMessage('来自子窗口的回复', window.location.origin);
      }
    });
  </script>
</body>
</html>

提醒:

  • 允许跨域通信,但必须由开发者显式指定信任的源,避免恶意网站滥用
  • 在事件监听的时候记得判断e.source,避免自己发送的事件自己接收了
  • 若子窗口被关闭,父窗口中对它的引用(如 childWindow)会变成无效对象,调用其方法会报错
  • window.open使用会有一些限制,最好是在事件中使用,有的浏览器还会有权限提示,需要用户同意才行,若 window.open 被浏览器拦截(非用户主动触发),会返回 null,导致后续通信失败

总结

面试官有提到Service Worker也可以,我面试完后的查询资料尝试了这些方法,都挺顺利的,就是Service Worker折腾了一会才跑通,使用起来相比前面的一些方式,它稍微复杂一些,我觉得用于消息通信只是它的冰山一角,它有一个主要功能就是用来解决一些耗性能的计算密集任务

个人技术有限,如果你有更好的跨标签页通信方式,期待你的分享,你工作中有遇到这种跨标签页通信的需求么,如果有你用的是哪一种了,期待你的留言

解决 Monorepo 项目中 node-sass 安装失败的 Python 版本兼容性问题

解决 Monorepo 项目中 node-sass 安装失败的 Python 版本兼容性问题

问题背景

在最近的一个 Monorepo 项目(具体是 a-mono 中的 table-list 子项目)中,我遇到了一个令人头疼的依赖安装问题。项目在使用 eden-mono 工具安装依赖时卡在 node-sass 的构建阶段,导致整个开发流程受阻。

项目环境

  • 项目类型:Monorepo(使用 eden-mono 管理)
  • 构建工具:eden-mono
  • 依赖管理:pnpm

错误现象

在项目根目录执行 eden-mono install --filter=table-list 时,安装过程在 node-sass@6.0.1 的 postinstall 脚本处失败,报错信息如下:

ValueError: invalid mode: 'rU' while trying to load binding.gyp
gyp ERR! configure error
gyp ERR! stack Error: `gyp` failed with exit code: 1

问题分析

根本原因

经过深入分析,发现问题出在 Python 版本兼容性 上:

  1. node-gyp 版本过旧:项目使用的是 node-gyp@7.1.2,这个版本发布于 2020 年
  2. Python 版本过高:系统安装了 Python 3.11.13,而旧的 node-gyp 不支持
  3. 语法不兼容'rU' 模式是 Python 2 的语法,在 Python 3 中已被移除

技术细节

node-gyp 在构建过程中会调用 Python 脚本来生成构建配置,其中使用了 open(build_file_path, "rU") 这样的语法。Python 3.11 不再支持 'rU' 模式,导致构建失败。

尝试过的解决方案

方案一:升级 node-gyp

npm install -g node-gyp@latest

结果:失败,因为 monorepo 项目中的 node-sass 版本锁定了 node-gyp 版本,升级全局包无法影响项目内部依赖

方案二:配置 Python 路径

npm config set python /path/to/python3.10

结果:失败,npm 配置语法在较新版本中发生了变化,而且 monorepo 项目的依赖管理更加复杂

方案三:降级 Node.js 版本

nvm use 16.20.2

结果:部分成功,但仍然遇到 Python 兼容性问题,因为 node-gyp 仍然调用不兼容的 Python 版本

方案四:替换 node-sass 为 sass

# 在 monorepo 中尝试替换
npm uninstall node-sass
npm install sass

结果:理论上可行,但 monorepo 项目中存在复杂的间接依赖关系,无法完全替换 node-sass

最终解决方案:暴力卸载重装

在尝试了所有常规方法后,我决定采用最粗暴但有效的方法:完全卸载所有 Python 版本,只保留通过 pyenv 管理的 Python 3.10.19

执行步骤

  1. 查看当前 Python 版本
pyenv versions
which -a python3 python3.10 python3.11
  1. 卸载 Python 3.11
pyenv uninstall 3.11.9
brew uninstall python@3.11
  1. 卸载通过 Homebrew 安装的 Python 3.10
brew uninstall python@3.10
  1. 清理 monorepo 缓存和依赖
cd /Users/bytedance/Documents/work-space/ttam_core_mono/packages/campaign-list
rm -rf node_modules
rm -rf .pnpm-store
pnpm store prune
  1. 重新安装依赖(使用 eden-mono)
nvm exec 16.20.2 eden-mono install --filter=campaign-list

结果

成功! 安装过程顺利完成,node-sass 构建成功,monorepo 项目可以正常运行。

经验教训

版本兼容性的重要性

这次问题深刻说明了开发环境中版本兼容性的重要性:

  1. Node.js 版本:不同版本的 Node.js 对依赖包的支持程度不同
  2. Python 版本:构建工具的 Python 支持存在版本限制
  3. 依赖版本:间接依赖可能导致意想不到的兼容性问题

最佳实践建议

  1. 使用版本管理工具

    • Node.js:使用 nvm 管理版本,确保 .nvmrc 文件存在
    • Python:使用 pyenv 管理版本,确保 .python-version 文件存在
  2. 锁定环境版本

    • 在 monorepo 根目录和子项目目录都放置版本锁定文件
    • 明确指定项目所需的运行时版本
    • 定期检查和更新这些版本锁定文件
  3. 定期更新依赖

    • 避免使用过时的依赖包(如 node-sass)
    • 及时升级到官方推荐的替代方案(如 sass)
    • 在 monorepo 中使用批量更新工具
  4. 环境隔离

    • 为不同项目使用不同的虚拟环境
    • 避免全局安装可能产生冲突的工具
    • 考虑使用容器化技术隔离复杂的构建环境

总结

虽然通过暴力卸载重装解决了这个 monorepo 项目的问题,但这并不是最理想的方式。在理想情况下,我们应该:

  1. 提前规划好 monorepo 项目的运行时环境,考虑各子项目的兼容性
  2. 使用容器化技术(如 Docker)来标准化复杂的 monorepo 环境
  3. 建立完善的 CI/CD 流程来检测环境兼容性问题
  4. 为 monorepo 项目建立专门的构建环境管理策略

这次经历让我更加重视开发环境的标准化管理,特别是对于复杂的 monorepo 项目。希望这篇文章能帮助遇到类似问题的开发者少走弯路,特别是在处理 monorepo 项目中的环境兼容性问题时。


参考链接

Next.js第八章(路由处理程序)

路由处理程序(Route Handlers)

路由处理程序,可以让我们在Next.js中编写API接口,并且支持与客户端组件的交互,真正做到了什么叫前后端分离人不分离

文件结构

定义前端路由页面我们使用的page.tsx文件,而定义API接口我们使用的route.ts文件,并且他两都不受文件夹的限制,可以放在任何地方,只需要文件的名称以route.ts结尾即可。

注意:page.tsx文件和route.ts文件不能放在同一个文件夹下,否则会报错,因为Next.js就搞不清到底用哪一个了,所以我们最好把前后端代码分开。

为此我们可以定义一个api文件夹,然后在这个文件夹下创建一对应的模块例如user login register等。

目录结构如下

app/
├── api
│   ├── user
│   │   └── route.ts
│   ├── login
│   │   └── route.ts
│   └── register
│       └── route.ts

定义请求

Next.js是遵循RESTful API的规范,所以我们可以使用HTTP方法来定义请求。

export async function GET(request) {}
 
export async function HEAD(request) {}
 
export async function POST(request) {}
 
export async function PUT(request) {}
 
export async function DELETE(request) {}
 
export async function PATCH(request) {}
 
//如果没有定义OPTIONS方法,则Next.js会自动实现OPTIONS方法
export async function OPTIONS(request) {}

注意: 我们在定义这些请求方法的时候不能修改方法名称而且必须是大写,否则无效。

工具准备: 打开vsCode / Cursor 找到插件市场搜索REST Client,安装完成后,我们可以使用REST Client来测试API接口。

image.png

定义GET请求

src/app/api/user/route.ts

import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
    const query = request.nextUrl.searchParams; //接受url中的参数
    console.log(query.get('id'));
    return NextResponse.json({ message: 'Get request successful' }); //返回json数据
}

REST client测试:

在src目录新建test.http文件,编写测试请求

src/test.http

GET http://localhost:3000/api/user?id=123 HTTP/1.1

image.png

定义Post请求

src/app/api/user/route.ts

import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest){
    //const body = await request.formData(); //接受formData数据
    //const body = await request.text(); //接受text数据
    //const body = await request.arrayBuffer(); //接受arrayBuffer数据
    //const body = await request.blob(); //接受blob数据
    const body = await request.json(); //接受json数据
    console.log(body); //打印请求体中的数据
    return NextResponse.json({ message: 'Post request successful', body },{status: 201});
     //返回json数据
}

REST client测试:

src/test.http

POST http://localhost:3000/api/user HTTP/1.1
Content-Type: application/json

{
    "name": "张三",
    "age": 18
}

image.png

动态参数

我们可以在路由中使用方括号[]来定义动态参数,例如/api/user/[id],其中[id]就是动态参数,这个参数可以在请求中传递,这个跟前端路由的动态路由类似。

src/app/api/user/[id]/route.ts

接受动态参参数,需要在第二个参数解构{ params },需注意这个参数是异步的,所以需要使用await来等待参数解析完成。

import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest, 
{ params }: { params: Promise<{ id: string }> }) {
    const { id } = await params;
    console.log(id);
    return NextResponse.json({ message: `Hello, ${id}!` });
}

REST client测试:

src/test.http

GET http://localhost:3000/api/user/886 HTTP/1.1

image.png

cookie

Next.js也内置了cookie的操作可以方便让我们读写,接下来我们用一个登录的例子来演示如何使用cookie。

安装手动挡组件库shadcn/ui官网地址

npx shadcn@latest init 

为什么使用这个组件库?因为这个组件库是把组件放入你项目的目录下面,这样做的好处是可以让你随时修改组件库样式,并且还能通过AI分析修改组件库

安装button,input组件

npx shadcn@latest add button
npx shadcn@latest add input

新建login接口 src/app/api/login/route.ts

import { cookies } from "next/headers"; //引入cookies
import { NextRequest, NextResponse } from "next/server"; //引入NextRequest, NextResponse
//模拟登录成功后设置cookie
export async function POST(request: NextRequest) {
    const body = await request.json();
    if(body.username === 'admin' && body.password === '123456'){
        const cookieStore = await cookies(); //获取cookie
        cookieStore.set('token', '123456',{
            httpOnly: true, //只允许在服务器端访问
            maxAge: 60 * 60 * 24 * 30, //30天
        });
        return NextResponse.json({ code: 1 }, { status: 200 });
    }else{
        return NextResponse.json({ code: 0 }, { status: 401 });
    }
}
//检查登录状态
export async function GET(request: NextRequest) {
    const cookieStore = await cookies();
    const token = cookieStore.get('token');
    if(token && token.value === '123456'){
        return NextResponse.json({ code:1 }, { status: 200 });
    }else{
        return NextResponse.json({ code:0 }, { status: 401 });
    }
}

src/app/page.tsx

'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useRouter } from 'next/navigation';

export default  function HomePage() {
    const router = useRouter();
    const [username, setUsername] = useState('');
    const [password, setPassword] = useState('');
    const handleLogin = () => {
        fetch('/api/login', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({ username, password }),
        }).then(res => {
            return res.json();
        }).then(data => {
            if(data.code === 1){
                router.push('/home');
            }
        });
    }
    return (
        <div className='mt-10 flex flex-col items-center justify-center gap-4'>
            <Input value={username} onChange={(e) => setUsername(e.target.value)} className='w-[250px]' placeholder="请输入用户名" />
            <Input value={password} onChange={(e) => setPassword(e.target.value)} className='w-[250px]' placeholder="请输入密码" />
            <Button onClick={handleLogin}>登录</Button>
        </div>
    )
}

src/app/home/page.tsx

'use client';
import { useEffect } from 'react';
import { redirect } from 'next/navigation';
const checkLogin = async () => {
    const res = await fetch('/api/login');
    const data = await res.json();
    if (data.code === 1) {
        return true;
    } else {
        redirect('/');
    }
}
export default function HomePage() {
    useEffect(() => {
        checkLogin()    
    }, []);
    return <div>你已经登录进入home页面</div>;
}

123.gif

Cloudflare 崩溃梗图

1. 新闻

昨天,Cloudflare 崩了。

随后,OpenAI、X、Spotify、AWS、Shopify 等大型网站也崩了。

据说全球 20% 的网站都受到波及,不知道你是否也被影响了?

2. 事故原因

整个事故持续了 5 个小时,根据 Cloudflare 的报告,最初公司怀疑是遭到了超大规模 DDoS 攻击,不过很快就发现了核心问题。

事故的根本原因是因为 Cloudflare 内部的一套用于识别和阻断恶意机器人流量的自动生成配置文件。

该配置文件在例行升级后规模意外变大,远超系统预期,撑爆了路由网络流量的软件限制,继而导致大量流量被标记为爬虫而被 Ban。

CEO 发布了道歉声明:

不过这也不是第一次发生这种大规模事故了。

一个月前,亚马逊 AWS 刚出现持续故障,超过一千个网站和在线应用数小时瘫痪。

今年 7 月,美国网络安全服务提供商 CrowdStrike 的一次软件升级错误则造成全球范围蓝屏事故,机场停航、银行受阻、医院手术延期,影响持续多日。

3. 梗图

每次这种大事故都会有不少梗图出现,这次也不少。

3.1. 第一天上班

苦了这位缩写为 SB 的老哥 😂

3.2. 真正的底座

原本你以为的 Cloudflare:

经过这次事故,实际的 Cloudflare:

3.3. 死循环

3.4. 按秒赔偿

3.5. 影响到我了

3.6. 影响惨了

3.7. 这是发动战争了?

3.8. 加速失败

3.9. mc 亦有记载

基本数据类型Symbol的基本应用场景

Symbol 作为 ES6 新增的基本数据类型,核心特性是唯一性不可枚举性,在实际项目中主要用于解决命名冲突、保护对象私有属性等场景。以下是具体的应用举例及代码实现:

一、作为对象的唯一属性名,避免属性冲突

当多人协作开发或引入第三方库时,普通字符串属性名容易被覆盖,Symbol 可确保属性唯一。

示例:组件库的私有属性

// 定义唯一的 Symbol 属性
const internalState = Symbol('internalState');

class Button {
  constructor() {
    // 用 Symbol 作为私有属性名,外部无法直接访问
    this[internalState] = {
      clicked: false,
      disabled: false
    };
  }

  click() {
    if (!this[internalState].disabled) {
      this[internalState].clicked = true;
      console.log('按钮被点击');
    }
  }

  disable() {
    this[internalState].disabled = true;
  }
}

const btn = new Button();
btn.click(); // 正常执行

// 外部无法通过常规方式访问或修改 internalState
console.log(btn.internalState); // undefined
console.log(btn[Symbol('internalState')]); // undefined(Symbol 是唯一的)

二、定义常量,避免魔术字符串

魔术字符串(直接写在代码中的字符串)易出错且难维护,用 Symbol 定义唯一常量更可靠。

示例:状态管理中的事件类型

// event-types.js
export const EVENT_TYPES = {
  LOGIN: Symbol('login'),
  LOGOUT: Symbol('logout'),
  UPDATE_USER: Symbol('updateUser')
};

// 使用常量
function handleEvent(eventType) {
  switch (eventType) {
    case EVENT_TYPES.LOGIN:
      console.log('用户登录');
      break;
    case EVENT_TYPES.LOGOUT:
      console.log('用户登出');
      break;
    default:
      console.log('未知事件');
  }
}

handleEvent(EVENT_TYPES.LOGIN); // 输出“用户登录”

三、实现对象的 “私有属性”

虽然 JavaScript 没有真正的私有属性,但 Symbol 属性默认不可被 for...inObject.keys() 枚举,可模拟私有属性。

示例:类的私有方法 / 属性

const privateMethod = Symbol('privateMethod');

class User {
  constructor(name) {
    this.name = name; // 公共属性
    this[Symbol('id')] = Math.random().toString(36).slice(2); // 私有属性
  }

  [privateMethod]() {
    // 私有方法,外部无法调用
    return `用户ID:${this[Symbol('id')]}`;
  }

  getInfo() {
    // 公共方法间接调用私有方法
    return `${this.name} - ${this[privateMethod]()}`;
  }
}

const user = new User('Alice');
console.log(user.getInfo()); // 正常输出

// 无法枚举 Symbol 属性
console.log(Object.keys(user)); // ['name']
for (const key in user) {
  console.log(key); // 仅输出 'name'
}

四、自定义迭代器(Iterator)

Symbol.iterator 是内置 Symbol,用于定义对象的迭代器,让对象可被 for...of 遍历。

示例:自定义可迭代对象

const iterableObj = {
  data: ['a', 'b', 'c'],
  [Symbol.iterator]() {
    let index = 0;
    return {
      next: () => {
        if (index < this.data.length) {
          return { value: this.data[index++], done: false };
        } else {
          return { done: true };
        }
      }
    };
  }
};

// 可通过 for...of 遍历
for (const item of iterableObj) {
  console.log(item); // 输出 a、b、c
}

五、Vue 中的应用:自定义组件的 v-model 修饰符

在 Vue 3 中,可通过 Symbol 定义自定义的 v-model 修饰符,避免与内置修饰符冲突。

示例:Vue 组件的自定义修饰符

// 定义唯一的修饰符 Symbol
const trimSymbol = Symbol('trim');

// 组件内
export default {
  props: {
    modelValue: String,
    modelModifiers: {
      default: () => ({})
    }
  },
  emits: ['update:modelValue'],
  methods: {
    handleInput(e) {
      let value = e.target.value;
      // 判断是否使用自定义修饰符
      if (this.modelModifiers[trimSymbol]) {
        value = value.trim();
      }
      this.$emit('update:modelValue', value);
    }
  }
};

总结

Symbol 在项目中的核心应用场景包括:

  1. 避免属性名冲突(多人协作 / 第三方库集成);
  2. 模拟私有属性 / 方法(不可枚举特性);
  3. 定义唯一常量(替代魔术字符串);
  4. 扩展内置对象行为(如自定义迭代器)。

【AI省流快讯】Cloudflare 炸了 / Gemini 3 来了 / Antigravity 独家实测 (附:无法登录解法)

1. Cloudflare 挂了

🤡 昨晚陆续刷到 "CF挂了" 的消息,没太在意,直到无法打开" 盗版漫画" 站点,我才意识到问题的严重性:

🤣 原因众说纷纭,刷到这哥们的 "梗图",差点把我笑岔气:

😃 还有人猜测可能是 Google 发布的 "哈基米 3" (Gemini) 发起的攻击:

时间线

  • 19:30】用户开始报告网站无法访问,出现10xx、52x、50x系列错误;Cloudflare Dashboard无法访问;部分Cloudflare域名解析中断。
  • 19:48】Cloudflare正式确认服务异常,启动紧急调查。
  • 20:03】持续调查中,未发现明显进展。
  • 20:13】部分服务开始恢复,但错误率仍高于正常水平。
  • 20:21】监测到部分服务恢复迹象;多次反复出现故障与恢复的波动;20:23、20:55等时间点再次中断。
  • 21:04】技术团队紧急关闭伦敦节点的WARP服务接入以控制影响范围。
  • 21:09】官方确认定位到根本原因,开始实施修复方案。
  • 21:13】Cloudflare Access与WARP服务全面恢复,错误率回落至日常水平。
  • 22:12】X应用恢复。
  • 22:22】Cloudflare状态页更新:"我们正在继续努力修复此问题"。
  • 22:34】状态页再次更新:"我们已经部署了一项变更,已恢复仪表板服务。我们仍在努力解决对整体应用服务的影响"。
  • 22:42】全局恢复完成,Cloudflare宣布事件解决,后续监控与处理继续进行中。

Cloudflare 发言人 Jackie Dutton在官方声明中表示,故障源于一个 用于管理威胁流量的自动生成配置文件。该配置文件原本用于防护潜在安全威胁,但由于文件规模异常庞大,导致多项内部系统在处理流量时发生故障:

截止目前,Cloudflare 全球网络服务已全面恢复,受影响的X、ChatGPT、Facebook 等主流平台均已恢复正常使用。😀 在网上看到大佬的原因分析,也贴下:

😆 难兄难弟啊,前阵子 亚马逊AWS 的大规模宕机 (10.20,美东区域数据库权限和DNS管理系统配置故障),故障持续约15小时,直接造成全球互联网大面积混乱。

2. Gemini 3 来了

😄 千呼万唤的 "哈基米 3" (Gemini) 终于来了,不过竟然没搞个发布会,只是在 官方博客 发下文章:

《A new era of intelligence with Gemini 3》

先简要回顾了一下 Gemini 系列的发展历程:

  • Gemini 1:着重在 "原生多模态" (文本+图像) 和 "长上下文窗口"。
  • Gemini 2:开始推动 "智能代理式 (agentic) " 与 "推理与思考" 能力。

Gemini 3 在上述基础上进一步提升,方称其为迄今 "最智能、最安全" 的模型:

  • 推理能力 & 多模态理解:在各种 AI 基准测试 (benchmarks) 上表现优异:LMArena (1501 Elo)、GPQA Diamond (91.9%)、MMMU-Pro (81%)、Video-MMMU (87.6%)、SimpleQA Verified (72.1%)。模型能更好理解背景、意图,给予更有深度、少空话的回答
  • Gemini 3 Deep Think:"深思" 增强模式,可进行更深的链式推理、更强代码执行与工具调用,提升复杂问题的求解能力,Humanity's Last Exam (41.0%)、GPQA Diamond (93.8%)、ARC-AGI-2 (带代码执行,45.1%)。该模式将在数周内向 Google AI Ultra 订阅用户开放。

三大应用场景

学习

  • 模型支持文本、图像、视频、音频、代码等多模态输入,100w token 的上下文窗口。
  • 如:可将手写不同语言的食谱翻译并制作家庭食谱;分析视频运动比赛 (如Picklebal) 帮助你提高训练。
  • 可在 Google 搜索中的 "AI Mode" 借助 Gemini 3 提供生成式 UI、互动工具、仿真体验。

构建

  • 强 "零样本生成" 能力:不用给示例、不用教,只说想法,直接生成你想要的东西。能处理复杂 提示/指令 (提示/指令),生成更丰富、互动性更强的 Web UI。基准测试:WebDev Arena (比谁能更好地完成Web开发任务,1487 Elo,战绩亮眼)、Terminal-Bench 2.0 (54.2%,命令行处理真实开发任务的能力)、SWE-bench Verified (软件工程能力-修bug、补功能,76.2%-非常高,大部分模型在30%-40%)。
  • 可在 Google 的 AI Studio、Vertex AI、Gemini CLI、以及新出的 AI IDE-Google Antigravity 中使用。第三方平台也支持,如:Cursor、GitHub、JetBrains、Manus、Replit 等。

计划

  • 在长期多步骤任务中表现提升,如:Vending-Bench 2 中可 "模拟一年" 运营决策。
  • 新增 Gemini Agent 工具,能够代表用户自动完成多步骤复杂任务,如管理邮箱、自动化工作流程和旅行计划,且仍受用户控制。已向 Google AI Ultra 用户开放早期体验。

😄 打开 ai.studio 直接就能看到最新的模型了:


谷歌官方 在演示中展示了三个 Vibe Coding 例子:

  • AI 课程平台登录页:通过简单 Prompt ("新布鲁特主义风格,创意有趣设计,平滑滚动动画,谷歌色彩,深浅主题"),直接生成了一个完整的、具有动画效果和深浅主题切换的登陆页。
  • SaaS 数据看板:用户上传 CSV 数据文件和参考设计截图,自动生成了一个具有图表、筛选器、深色主题的专业数据仪表盘。
  • 互动游戏:通过复杂 Prompt (涉及React、Three.js、3D 效果等技术细节),生成一个完全可玩的3D游戏。

😄 国内 自媒体 基本都是在吹它的 "前端能力" (看效果图确实挺6的):

  • 能生成精确的 SVG 矢量图:包括复杂的动画 SVG (如:旋转风扇动画),而非简单栅格图。
  • 3D 和动画:支持生成 Three.js 3D 模型、WebGL 着色器、CSS 动画等高级视觉效果。
  • 完成应用框架:能理解复杂的技术栈要求 (React、Three.js Fiber、TypeScript 等),生成模块化、结构清晰的代码。
  • 注解修改:用户可以在生成的界面上用 "标注" 的方式指出要修改的地方 (画圈、画箭头、添加文字),Gemini 3 会理解这些视觉标注并精确修改代码。这得益于它 多模态理解能力的显著提升 (对屏幕截图的理解准确率达到 72.7%,达到现有水平的两倍)。
  • 去 "AI味":排版、色彩搭配、组件结构看起来是 "精心设计" 的,而非生硬地套模版。

🤔 目前杰哥还没 深度体验 这个新模型,不好评价,只实测下这个新出的 AI IDE —— Antigravity 吧~

3. Google Antigravity (反重力)

3.1. 下载安装

下载地址:

下载 Google Antigravity

下载完双击安装:

接着是不断按 Next 的 傻瓜式安装 (是否从VS Code 或 Cursor 导入设置):

选主题:

选使用 Agent 的方式 & 编辑器配置 (默认就好):

😐 最后,大部分人会卡在登录这里,杰哥也是折腾了一早上,买了两个号才登上的。

3.2. 登录问题的解决

3.2.1. 代理问题

如图,先检查 TUN (全局/系统代理) 模式有没有开:

接着看 代理 的 "地区",香港是不行滴,群里有人说新加坡/日本可以,杰哥用的 美国,其次是 代理 的 "质量"。

3.2.2. 账号问题

代理没问题了,基本是能自动打开浏览器,跳转到授权页,然后授权成功的:

接着返回 Antigravity 可能会出现这两种情况:

这极大概率就是 "Google账号" 的问题,先访问下述网址,查看:账号当前的国家或地区版本

《Google 服务条款》

比如我的号:

🤷‍♀️ 香港肯定是不行的,可以访问下述地址申请修改 (一年只能改一次 ❗️)

《账号关联地区更改请求》

具体操作:

😐 改完,如果还不行的话,那应该是 "账号本身有问题" 或者触发了 Google 莫名其妙的拦截规则。我一开始在海鲜市场买了一个 "美区" 的老号,一直卡 Setting Up 那里转。后面又收了个 "日区" 的号,秒进 ❗️❗️❗️

😄 还有个群友提供了一个野路子:

登Google play,在美区买本0刀的免费电子书,就成美区了。

💡 反正进不去,就是 "代理" 和 "账号" 的问题!我现在的组合是:日区号 + 美国代理。都没问题,会进入这个是否允许采集信息的页面,取消勾选,然后 Next

接着就能来到 IDE 的主页面了:

3.3. 初体验

🤣 熟悉的 VS Code 套壳界面,还是很有亲切感的,右侧有常规的 AI Chat

除了选模型外,还支持选模式:

Ctrl + E 可以打开类似于 Cursor Agents 模式的 "Agent Manager":

上面我写了一个,让 Antigravity 基于 Claudeflare 故障信息生成一个用于发布到 自媒体平台 的长图的 简单的Prompt,发送后可以看到 Agent 开始干活:

涉及到命令执行,让你 Accept

觉得烦可以点下右侧切成 Turbo 模式:

活干完,要预览,跳转 Chrome,提示安装一个 浏览器插件

以便 Agent 能直接操作浏览器 (如获取页面节点、自动化、截图等)。最后看下生成效果:

🤔 同样的 Prompt,分别看下 Claude 4.5GPT 5 的生成效果:

🤣 哈哈,你更 Pick (喜欢) 哪个模型生成的页面呢?

3.4. 限额

群里有小伙伴没蹬几次就出现了这个:

看了下官网:

《Google Antigravity Plans》

💡 额度 由Google动态决定 (基于系统容量、防止滥用),每五小时刷新一次,额度与任务复杂度相关。🐶 官方表示:只有 极少数高强度用户 会撞到每5小时上限。

😄 所以这个额度是 "不透明" 的,L站 有人说不一定得等五个小时,等了十几分钟又可以用了~

【开源】耗时数月、我开发了一款功能全面【30W行代码】的AI图床

AI编程发展迅猛,现在如果你是一个全栈开发,借助AI编辑器,比如Trae你可以开发很多你以往无法实现的功能,并且效率会大大提示,TareCoding能力已经非常智强了,借助他,我完成了这个30W行代码的开源项目,现在,想把这个项目推荐给你。

当前文章主要是介绍我们开发出的这个项目,在后续,将会新开一个文章专门介绍AI Coding的各种技巧,如何使用他,才能让他在大型项目中依然如虎添翼。

如果你想快速了解此项目、你可以访问PixelPunk官方文档

如果你想快速体验此项目、你可以访问V1版本,前往体验功能。

如果你想完整体验前后台全面的功能、你可以访问测试环境。【root 123456】

如果您认可我的项目,愿意为我献上一个宝贵的Star,你可以前往PixelPunk项目。

image.png

开发这样一个项目在现在这个时间来看似乎没什么必要,实际上开发这样一个项目的原因其实就是在年初的时候失业了一段时间,闲在家里无聊,于是想着做一个自己的开源项目,最开始其实做的是另一个类型的项目,开发了一段时间感觉有些无聊就转战做了现在的这个项目PixelPunk图床, 其实并不只是想要做一个图床,只是当前来说的一期功能比较贴合图床,后期的跌代准备支持更多格式包括视频文档等等,并且由于AI多模态模型的能力发展迅速,后期结合AI可以实现更多可玩性比较高的内容。

市面上已经有了非常多的开源图床了,开发这样一个项目要追求一些差异点才会有价值,本质上来说这类服务其实核心就是个存图片而已,其他功能并不是很重要,但是作为个人使用用户来说,除了存图也愿意去尝试一些便捷的功能,于是思考到这个点之后,我开始了这个项目的开发,项目的命名[PixelPunk] 是我让AI起的,因为要做就要做一个不一样的有特点,我要做一个ui上就不一样的图床出来,于是有了此命名,中文翻译过来是像素朋克,由于像素风格感觉ui不是很适合工具类网站使用,于是我选择了赛博朋克风格,围绕这个ui来开发了此项目。

项目概览

首先呢项目从开发就一个全栈项目,前后端放一起了,采用的技术栈是 go+vue, 前端会将打包的文件放入到go中一起打包为二进制安装包,这样部署起来将非常简单。 项目分为了用户端和管理端,并且同属于一个项目,不分离开发,为了符合我们定制化的ui,所有组件都是自定义的组件,项目使用了70+自定义组件。 项目接入了多模态AI大模型,在以前我们的图床要实现各种功能需要对接各种各样的平台,现在有了AI,我们只需要一个模型就能完成非常多的功能,比如【语义化描述图片】,【**图片OCR识别】,【**图片自动分类】,【**图片自动打标】,【**图标自动审核NSFW、违规图、敏感图、血腥图等等】,【图片颜色提取】等等功能都只需要配置一个AI模型即可,对于成本而言,比之前的三方API可能更加便宜

关于部署

作为一个开源项目,我想的是需要使用者使用起来足够简单,部署起来也足够快,本身来讲,我们项目需要用来这些内容,mysql|Sqlite + Redis|系统内存 + qdrant向量数据库, 这三件套,为了安装简单,我将向量数据库直接集成到安装包,用户可以无需关系任何内容,我们可以做到0配置启动项目,不需要你做任何配置即可超快部署项目。

我们提供了两种部署方式,一种是安装包部署,你可以下载对应平台的安装包**.zip安装包**,下载到你的服务器,解压之后里面有一个install.sh,直接sh ./install.sh即可安装,当然手动部署 可能还需要两个步骤,你可以使用我们的脚本直接进行部署,也可以看看我们的部署文档,PixelPunk部署文档

安装包一键部署 curl -fsSL https://download.pixelpunk.cc/shell/install.sh | bash

docker-compose一键部署脚本 curl -fsSL https://download.pixelpunk.cc/shell/docker-install.sh | bash

我们的部署非常简单,你只需要执行完脚本即可直接启动项目,我们的docker-compose部署方式已经配置了所有数据库缓存等信息,启动项目进入 http://ip:9520 会自动跳转到安装页面,添加管理员账号密码即可完成安装, 如果使用安装包模式呢,那么就支持你可以自定义选择数据库,可以填写自己的mysql,也可以使用系统内置的Sqlite,可以选择自己的向量数据库和缓存,也可以使用系统内置的,自由选择,总之,一键脚本预估20S就可以帮你安装并且启动项目,无需你的任何配置,希望会自己内置生成一些必要信息,比如jwt,系统安装后你可以进入后台管理进行修改。

image.png

项目部分功能

我们的项目功能可以说已经非常全面了,并且还在持续迭代,目前代码总行数已经达到了30W行,很多功能需要你自己体验,我们覆盖了主流图床的全部功能,并且还在进一步持续加入更多有趣的元素,我们可以列举一些功能做简要说明。

10+精美主题

作为个人使用的工具,我一直在持续优化UI和交互,始终认为,UI还是很重要的一个步骤,目前的UI还不够精美,也是后续会持续调整的一个点,目前提供了10多套主题,并且您可以自定义主题,我在项目中放置了主题开发文档,你可以根据模板文件去替换一套变量即可完成一套主题的开发。

image.png

image.png

多语言双风格

我们目前内置了三种语言中英日,并且为了迎合我们PixelPunk风格的特色,我们新增了一种风格选项,你可以选择赛博朋克风格的文案,让系统的所有内容提示充满赛博味道~

image.png

image.png

多布局系统

我们网站为了更好的工作体验,提供了两种的布局方式,传统布局,工作布局,既可以使用传统的轻量化的布局让人轻松,也可以使用工作台布局让工作更高效。并且您还可以在后台限制这些功能,使用固定布局而不开放这些功能,在后台管理部分都可以实现。

image.png

image.png

多种登录方式

我们内置默认使用传统邮箱的登录方式,并且支持关闭注册,邮箱配置也是后台配置即可,同时系统对接了GithubGoogleLinuxDo三种接入成本非常低的快捷登录方式,并且可能由于你是国内服务器,无法直接访问这些服务,所以系统贴心的准备了(代理服务)配置,让你即使是国内服务器也依然可以顺利完成这些快捷登录。

image.png

image.png

强大的特色文件上传

文件上传是一个基础的功能,所有图床都支持,我们当然也支持,我们在此功能上进行了耐心的打磨,支持很多特色功能,

  • 支持后台动态配置允许格式,动态配置上传图片限制尺寸
  • 支持大文件分片上传断点续传等功能。
  • 支持秒传,后台动态开启是否启用
  • 支持游客模式,限制游客上传数量,并限制上传资源有效时间
  • 支持自定义资源保存时间,后台可配置用户允许选择有效期,精确到分钟
  • 支持重复图检测,重复图自动秒传,不占用空间
  • 支持水印,并且特色化水印,开发了一个水印专用面板用于你配置特色化水印,支持文字、图片水印。
  • 支持中断上传,取消上传
  • 支持上传实时进度提示
  • 支持自动优化图片自定义上传文件夹
  • 支持文件夹上传,拖拽上传,项目内全局上传(任意页面都支持上传)
  • 支持原图复制,MD格式复制,HTML格式复制,缩略图格式复制,全部图片链接批量复制
  • 支持公开、私密、受保护,三种权限设置,公开图片可以在任何地方显示并且授权给管理员可以用于推荐,并且在作者首页可以展示对外,私密则仅自己可见,系统其他人不可见,受保护权限图片只能在系统观看打开,无法产生链接,除自己登录系统外,其他人无法观看。
  • 支持持久化,你可以选择图片并且上传中,跳转到任何页面而不会中断你的上传,你可以在上传过程中干任何事情
  • 支持特色悬浮球,当你不在上传页面的其他页面,如果有上传内容,会有一个特色上传球实时告知您上传的进度,并且你可以随意拖动控制悬浮球的位置。

image.png

image.png

image.png

超过上传渠道支持

作为一个图床的基本素养,都会支持对接三方,目前我们已经支持了10+的三方云储存渠道,并且添加时候可以测试渠道保障你的配置,首先我们支持服务器自身存储,这也是系统默认渠道,你可以在后台渠道配置更多,比如阿里云COS,腾讯云OSS,七牛云,雨云,又拍云,S3,Cloudfare R2,WebDav,AZure,Sftp,Ftp,等等渠道,S3协议本身就可以支持非常多的基于S3协议的渠道了,并且,如果你想要更多渠道,可以去往我们官网网站提起诉求,我们可以很快支持新的渠道。

image.png

image.png

特色AI功能

AI也是我们图床的一大特色,我们利用AI做了这些事情

  • 自动分类
  • 自动打标
  • 语义化图片,提取信息,提取色调
  • 实现ai自然语言搜索
  • 实现相似图搜索
  • 实现NSFW违规图审核

而这些,仅仅只需要配置一个openai的gpt4-mini即可完成,后续会支持更多渠道(目前仅支持配置OPENAI格式的渠道),目前测试感觉gpt-4.1-mini足以胜任工作,并且价格低廉,性价比很高。

  • 违规图片检测 可以自定义等级 宽松或严格

image.png

  • 自然语言搜索图片

image.png

  • 相似图搜索

image.png

  • 图片ai描述

image.png

  • 自动分类打标签

image.png

文件夹功能

文件分类是一个和合理的诉求,所以我们可以自定义创建文件夹,同样可以控制不同的权限,并且文件夹可以无限嵌套 你可以自定义多层级的文件夹 合理管理你的文件,并且我们支持文件夹移动图片移动批量操作右键菜单拖拽排序等等特色功能,你可以灵活的管理你的文件。

image.png

  • 右键菜单

image.png

分享系统

我们拥有大量素材,或者一些收藏资源需要分享给好友,我们可以任意创建分享,可以分享自己的文件夹,图片,也可以组合分享,也可以分享用户公开的推荐图,选择任意探索广场的图进行分享,并且可以统计访问次数,限制访问次数,达到访问次数关闭分享,密码分享,限制时间有效期分享,邮箱通知等等功能。

您可以观看我们的演示分享内容

  • 创建分享

image.png

image.png

防盗链管理

图床安全始终是一个问题,经常会遇到被盗刷的风险,由于流量费用贵,鄙人有幸被刷破产过一次(TMD),我们加入了防盗链配置,可以配置白黑名单域名|IP,可以配置网站refer等内容,并且对于违规的用户,我们可以配置让其302到他地址,可以自定义返回固定图,也可以直接拒绝

当我们对接三方渠道的时候,正常情况我们会拿到远程url地址,我们依然可以在后台配置渠道将其隐藏远程url地址,如果配置,那么域名请求将会从我们服务器代理获取,可以隐藏掉三方的地址,或者配置私有存储桶通过秘钥去动态获取图片,防止你的图片被盗刷大量流量

image.png

开放API

图床的基本功能之一,我们可以生成秘钥在三方进行上传文件,不同的是,我们系统支持了设置秘钥时可以限制其使用的空间限制上传次数,可以指定上传的文件夹,指定文件格式等等,并位置提供了完整的上传文档,支持单文件上传,多文件上传

image.png

随机图片

经常会有人需要一个随机图片的API,但是受限于使用别人的不够稳定,也不够灵活,于是我们开放了一个随机API功能,你可以动态的配置,选择其绑定你需要随机的图片,比如指定随机任意文件夹,指定返回方式302重定向,或直接返回图片,你可以点击pixelpunk随机图片API演示 ,每次刷新你可以获取新的图片。

image.png

空间带宽控制

我们允许为用户配置限制的使用空间和流量,后台动态灵活配置这些内容,保证多用户使用的时候限制用户使用量。

更多功能

image.png

image.png

image.png

image.png

image.png

总之我们的功能远不止如此,我们还有很多有意思的功能,一些更多的细节需要你去探索,比如,公告系统、消息通知、活动日志、限制登录、IP登录记录,超全的管理系统、埋点统计、访客系统等等模块,这是我个人第一个花费较多时间开发的一套系统,目前对比市面上所有的开源图床,自我认为是一款相对功能最全面的图床,耗费了我大量时间。

如果佬友花费时间看到了这里,那么希望能收获你的一个宝贵的Star,后续的功能我依然会持续跌代,如果你有任何需求,可以私信我,如果合理,我可以无偿免费优先加入到后续跌代中去。

待更新预期功能

  • 后端多语言适配
  • UI 美化
  • Desktop 端开发
  • 更多格式支持 (视频|文档)
  • 交互体验优化
  • 更多渠道支持
  • 更多AI接入
  • 图片处理工具箱

Node+Express+MySQL 后端生产环境部署,实现注册功能(三)

一、部署前准备

  • 本地环境:MacOS(开发端)
  • 服务器环境:阿里云 Ubuntu 22.04 轻量应用服务器
  • 技术栈:Node.js + Express + MySQL
  • 核心目标:将本地开发完成的 Express 后端项目部署到阿里云,实现公网访问接口

二、服务器环境配置(最终生效配置)

1. 登录服务器

通过 Mac 终端 SSH 连接服务器:

ssh root@你的服务器公网IP # 例如:ssh root@47.101.129.155

输入服务器登录密码即可进入。

2. 安装核心依赖软件
# 更新系统包
sudo apt update && sudo apt upgrade -y

# 安装Node.js(v16+)
curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash -
sudo apt install -y nodejs

# 安装MySQL服务器
sudo apt install -y mysql-server

# 安装PM2(Node服务进程管理)
sudo npm install pm2 -g

# 安装Nginx(反向代理)
sudo apt install -y nginx

创建本地数据库

  1. 确保数据库已创建:用 MySQL Bench 连接本地 MySQL(root/admin123/3306),创建数据库 mydb(字符集 utf8mb4,排序规则 utf8mb4_unicode_ci)。

三、创建用户表(存储注册信息)

在 MySQL Bench 中,对 mydb 数据库执行以下 SQL,创建 users 表(用于存储注册用户):

USE mydb; -- 切换到 mydb 数据库

CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY, -- 自增主键
  email VARCHAR(100) NOT NULL UNIQUE, -- 邮箱(唯一,避免重复注册)
  password VARCHAR(255) NOT NULL, -- 加密后的密码(bcrypt 加密后长度固定60)
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP, -- 注册时间
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -- 更新时间
);

四 新建项目

创建文件夹node-api-test

  1. 安装依赖:需要 mysql2(数据库连接)、bcrypt(密码加密,不能明文存储)、express-validator(参数校验),dotenv 判断环境变量

npm init -y

npm install mysql2 bcrypt express-validator deotnev

2. 多环境配置文件(区分测试 / 正式)

在项目根目录创建 多个 .env 文件,分别对应不同环境:

project/
├── .env.development  # 开发环境(本地调试)
├── .env.production   # 生产环境(正式服务器)
├── .env.test         # 测试环境(可选,测试服务器)
└── .gitignore        # 忽略 .env* 文件,避免提交到 Git

文件内容示例

NODE_ENV=development  # 标识环境
DB_HOST=localhost     # 本地数据库地址
DB_USER=root          # 本地数据库账号
DB_PASSWORD=admin123  # 本地数据库密码
DB_NAME=mydb          # 本地数据库名
API_PORT=3000         # 开发环境端口

.env.production(生产环境):

NODE_ENV=production
DB_HOST=10.0.0.1      # 服务器数据库地址(内网 IP)
DB_USER=prod_user     # 服务器数据库账号(非 root,更安全)
DB_PASSWORD=Prod@123  # 服务器数据库密码(复杂密码)
DB_NAME=mydb_prod     # 生产环境数据库名(可与开发环境不同)
API_PORT=3000         # 生产环境端口
3. 在代码中加载对应环境的配置

db/mysql.js,根据 NODE_ENV 自动加载对应的 .env 文件:

// db/mysql.js(ESM 版)
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';

// 解决 ESM 中 __dirname 问题
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// 1. 确定当前环境(默认 development)
const env = process.env.NODE_ENV || 'development';

// 2. 加载对应环境的 .env 文件(如 .env.development 或 .env.production)
const envPath = path.resolve(__dirname, `../.env.${env}`);
dotenv.config({ path: envPath });  // 加载指定路径的 .env 文件

// 3. 从 process.env 中读取配置(环境变量全部是字符串类型)
const dbConfig = {
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  port: Number(process.env.DB_PORT) || 3306,  // 转换为数字
  connectionLimit: 10,
};

// 创建连接池
const pool = mysql.createPool(dbConfig);

// 测试连接时打印当前环境
export async function testDbConnection() {
  try {
    await pool.getConnection();
    console.log(`✅ 数据库连接成功(环境:${env},数据库:${dbConfig.database}`);
  } catch (err) {
    console.error(`❌ 数据库连接失败(环境:${env}):`, err.message);
    throw err;
  }
}

export { pool };

app.js如下:


import express from 'express'
import bodyParser from 'body-parser'
import userRouter from './routes/user.js'
import { testDbConnection } from './db/mysql.js'
import HttpError from './utils/HttpError.js' // 导入自定义错误类

const app = express()
// 从环境变量读取端口(对应.env中的API_PORT)
const port = process.env.API_PORT || 3000

// 解析JSON请求(必须,否则无法获取req.body)
app.use(bodyParser.json())

// 挂载用户模块路由
app.use('/api/user', userRouter)

// 全局错误处理中间件(必须放在所有路由和中间件之后)
app.use((err, req, res, next) => {
  // 1. 处理自定义HttpError
  if (err instanceof HttpError) {
    return res.status(err.statusCode).json({
      status: err.statusCode, // 业务错误状态码(如400)
      message: err.message, // 错误提示信息
      errors: err.errors, // 详细错误列表(如参数校验错误)
    })
  }

  // 2. 处理系统错误(如数据库连接失败、代码bug等)
  console.error('系统错误堆栈:', err.stack) // 打印堆栈,方便后端调试
  res.status(500).json({
    status: 500,
    message:
      process.env.NODE_ENV === 'production'
        ? '服务器内部错误,请稍后重试' // 生产环境隐藏具体错误
        : `系统错误:${err.message}`, // 开发环境显示具体错误(便于调试)
    errors: [],
  })
})

// 启动服务(端口来自环境变量)
app.listen(port, () => {
  console.log(
    `服务启动成功(环境:${process.env.NODE_ENV}):http://localhost:${port}`
  )
  testDbConnection() // 启动时验证数据库连接
})

注册接口编写

在根目录下新建routes/user.js

import express from 'express'
import { body, validationResult } from 'express-validator'
import bcrypt from 'bcrypt'
import { pool } from '../db/mysql.js'
import HttpError from '../utils/HttpError.js'

const router = express.Router()

// 注册接口:POST /api/user/register
router.post(
  '/register',
  // 参数校验(字段名与前端传入、数据库字段一致)
  [
    body('email').isEmail().withMessage('邮箱格式错误'), // 对应数据库email字段
    body('password').isLength({ min: 6 }).withMessage('密码至少6位'), // 对应password字段
    body('nickname')
      .optional()
      .isLength({ max: 50 })
      .withMessage('昵称最多50字'), // 对应nickname字段
  ],
  async (req, res, next) => {
    try {
      // 校验参数
      const errors = validationResult(req)
      if (!errors.isEmpty()) {
        throw new HttpError(400, '参数校验失败', errors.array())
      }

      // 解构前端传入的参数(字段名与数据库字段一致)
      const { email, password, nickname } = req.body

      // 1. 检查邮箱是否已注册(SQL中使用email字段,与数据库一致)
      const [existingUsers] = await pool.query(
        'SELECT id FROM users WHERE email = ?', // WHERE条件用email字段
        [email]
      )
      if (existingUsers.length > 0) {
        throw new HttpError(400, '该邮箱已被注册')
      }

      // 2. 密码加密
      //   const hashedPassword = await bcrypt.hash(password, 10)

      // 3. 插入数据库(字段名与数据库表完全一致)
      const [result] = await pool.query(
        'INSERT INTO users (email, password, nickname) VALUES (?, ?, ?)', // 字段顺序:email, password, nickname
        [email, password, nickname || null] // 对应字段的值
      )

      // 4. 返回结果(包含数据库自动生成的id和字段)
      res.status(200).json({
        code: 200,
        message: '注册成功',
        data: {
          userId: result.insertId, // 数据库自增id
          email: email, // 与数据库email字段一致
          nickname: nickname || null, // 与数据库nickname字段一致
          createdAt: new Date().toISOString(),
        },
      })
    } catch (err) {
      next(err)
    }
  }
)

// 获取所有用户
router.get('/allUsers', async (req, res, next) => {
  try {
    const [users] = await pool.query('SELECT * FROM users')
    res.status(200).json({
      code: 200,
      message: '获取成功',
      data: users,
    })
  } catch (err) {
    next(err)
  }
})

export default router

本地postman测试

image.png

数据库查看这条数据

image.png

下一篇学习如何把代码发布到服务器,通过域名来访问接口,实现注册,顺便把前端页面也发布上去

【Amis源码阅读】低代码如何实现交互?(上)

基于 6.13.0 版本

前期回顾

  1. 【Amis源码阅读】组件注册方法远比预想的多!
  2. 【Amis源码阅读】如何将json配置渲染成页面?

前言

  • 组件渲染搞定了,那组件如何进行交互呢?amis提出了事件动作的概念,在监听到事件后通过动作做出反应
    • 事件:
      • 渲染器事件:组件内部执行的事件,会暴露给外部监听。比如初始化、点击、值变化等事件
      • 广播事件:全局事件,其他组件可在自身监听相关广播事件
    • 动作:监听到事件时,希望执行的逻辑。比如打开弹窗、toast提示、刷新接口等
  • 本篇先聊事件的工作逻辑,从常用的渲染器事件入手(渲染器等同组件)

渲染器事件

onEvent事件监听

  • amis支持onEvent的形式监听组件事件的触发时机,比如组件被点击时触发一个toast。那写入onEvent中的动作是何时何地被执行的呢?
{
  "onEvent": {
    "click": {
      "actions": [ 
        {
          "actionType": "toast",
          "args": {
            "msgType": "success",
            "msg": "点击成功"
          }
        }
      ]
    }
  }
}

组件中的事件触发

  • 以常见的Page组件中init(初始化)事件为例,它实际就是在类组件的componentDidMount生命周期(挂载阶段)中触发了一次,dispatchEvent就是事件的入口了
// packages/amis/src/renderers/Page.tsx

export default class Page extends React.Component<PageProps> {
...

async componentDidMount() {
    const {
      initApi,
      initFetch,
      initFetchOn,
      store,
      messages,
      data,
      dispatchEvent,
      env
    } = this.props;

    this.mounted = true;

    const rendererEvent = await dispatchEvent('init', data, this);
...
  }
}
  • 再以Tpl组件中的click(点击)、mouseenter(鼠标移入)、mouseleave(鼠标移出)事件为例,可以直观的看出他们就是在组件绑定的onClickonMouseEnteronMouseLeave事件中执行了一遍dispatchEvent
  • 此时可以推测出,onEvent应该是在dispatchEvent中被执行了
// packages/amis/src/renderers/Tpl.tsx

export interface TplSchema extends BaseSchema {
...

@autobind
  handleClick(e: React.MouseEvent<HTMLDivElement>) {
    const {dispatchEvent, data} = this.props;
    dispatchEvent(e, data);
  }
  
  @autobind
  handleMouseEnter(e: React.MouseEvent<any>) {
    const {dispatchEvent, data} = this.props;
    dispatchEvent(e, data);
  }

  @autobind
  handleMouseLeave(e: React.MouseEvent<any>) {
    const {dispatchEvent, data} = this.props;
    dispatchEvent(e, data);
  }
  
  render() {
  return (
      <Component
      ...
        onClick={this.handleClick}
        onMouseEnter={this.handleMouseEnter}
        onMouseLeave={this.handleMouseLeave}
        {...testIdBuilder?.getChild('tpl')?.getTestId()}
      >
        ...
      </Component>
    );
  }
}

工作流

触发事件(dispatchEvent)

  • broadcast参数可忽略,没用(factory.tsx中的类型定义也说明了这点)
  • rendererEventListeners是个全局变量(事件队列),所有的事件监听器都存储在这
  • dispatchEvent流程
    • bindEvent绑定事件,返回unbindEvent销毁函数
    • createRendererEvent创建事件对象
    • 事件队列里的事件按权重排序确定执行优先级
    • 遍历事件队列,若有事件有debounce属性,就设置防抖,同时设置executing为真;若没有就直接执行事件(runAction
    • 遍历事件队列的时候会通过checkExecuted函数计数,当遍历完毕后(也就意味着事件都执行完毕了,会等待防抖的事件执行完),执行unbindEvent销毁事件
// packages/amis-core/src/utils/renderer-event.ts

let rendererEventListeners: RendererEventListener[] = [];
...

// 触发事件
export async function dispatchEvent(
  e: string | React.MouseEvent<any>,
  renderer: React.Component<RendererProps>,
  scoped: IScopedContext,
  data: any,
  broadcast?: RendererEvent<any>
): Promise<RendererEvent<any> | void> {
  let unbindEvent: ((eventName?: string) => void) | null | undefined = null;
  const eventName = typeof e === 'string' ? e : e.type;

  const from = renderer?.props.id || renderer?.props.name || '';
...

  broadcast && renderer.props.onBroadcast?.(e as string, broadcast, data);

  if (!broadcast) {
    const eventConfig = renderer?.props?.onEvent?.[eventName];

    if (!eventConfig) {
      // 没命中也没关系
      return Promise.resolve();
    }

    unbindEvent = bindEvent(renderer);
  }
  // 没有可处理的监听
  if (!rendererEventListeners.length) {
    return Promise.resolve();
  }
  // 如果是广播动作,就直接复用
  const rendererEvent =
    broadcast ||
    createRendererEvent(eventName, {
      env: renderer?.props?.env,
      nativeEvent: e,
      data,
      scoped
    });

  // 过滤&排序
  const listeners = rendererEventListeners
    .filter(
      (item: RendererEventListener) =>
        item.type === eventName &&
        (broadcast
          ? true
          : item.renderer === renderer &&
            item.actions === renderer.props?.onEvent?.[eventName].actions)
    )
    .sort(
      (prev: RendererEventListener, next: RendererEventListener) =>
        next.weight - prev.weight
    );
  let executedCount = 0;
  const checkExecuted = () => {
    executedCount++;
    if (executedCount === listeners.length) {
      unbindEvent?.(eventName);
    }
  };
  for (let listener of listeners) {
    const {
      wait = 100,
      trailing = true,
      leading = false,
      maxWait = 10000
    } = listener?.debounce || {};
    if (listener?.debounce) {
      const debounced = debounce(
        async () => {
          await runActions(listener.actions, listener.renderer, rendererEvent);
          checkExecuted();
        },
        wait,
        {
          trailing,
          leading,
          maxWait
        }
      );
      rendererEventListeners.forEach(item => {
        // 找到事件队列中正在执行的事件加上标识,下次待执行队列就会把这个事件过滤掉
        if (
          item.renderer === listener.renderer &&
          listener.type === item.type
        ) {
          item.executing = true;
          item.debounceInstance = debounced;
        }
      });
      debounced();
    } else {
      await runActions(listener.actions, listener.renderer, rendererEvent);
      checkExecuted();
    }

    if (listener?.track) {
      const {id: trackId, name: trackName} = listener.track;
      renderer?.props?.env?.tracker({
        eventType: listener.type,
        eventData: {
          trackId,
          trackName
        }
      });
    }

    // 停止后续监听器执行
    if (rendererEvent.stoped) {
      break;
    }
  }
  return Promise.resolve(rendererEvent);
}

绑定事件(bindEvent)

  • 所谓绑定事件就是把事件推入事件队列
  • 首先会遍历onEvent中的内容
  • 然后处理防抖场景:如果存在相同的事件且在防抖时间内(executing为真),则取消旧事件防抖并移除旧事件,把新事件加入事件队列。比如说,事件队列中存有用户触发了3次的事件a(假设都在防抖时间内),则前2次事件在bindEvent阶段会被删除,只保留第3次事件
  • 如果不存在上述情况,直接加入事件队列
  • 最终都是返回解绑事件的函数(从事件队列中移除)
// packages/amis-core/src/utils/renderer-event.ts

// 绑定事件
export const bindEvent = (renderer: any) => {
  if (!renderer) {
    return undefined;
  }
  const listeners: EventListeners = renderer.props.$schema.onEvent;
  if (listeners) {
    // 暂存
    for (let key of Object.keys(listeners)) {
      const listener = rendererEventListeners.find(
        (item: RendererEventListener) =>
          item.renderer === renderer &&
          item.type === key &&
          item.actions === listeners[key].actions
      );
    // 存在相同的事件且在防抖时间内
      if (listener?.executing) {
        listener?.debounceInstance?.cancel?.();
        rendererEventListeners = rendererEventListeners.filter(
          (item: RendererEventListener) =>
            !(
              item.renderer === listener.renderer && item.type === listener.type
            )
        );
        listener.actions.length &&
          rendererEventListeners.push({
            renderer,
            type: key,
            debounce: listener.debounce || null,
            track: listeners[key].track || null,
            weight: listener.weight || 0,
            actions: listener.actions
          });
      }
      if (!listener && listeners[key].actions?.length) {
        rendererEventListeners.push({
          renderer,
          type: key,
          debounce: listeners[key].debounce || null,
          track: listeners[key].track || null,
          weight: listeners[key].weight || 0,
          actions: listeners[key].actions
        });
      }
    }
    return (eventName?: string) => {
      // eventName用来避免过滤广播事件
      rendererEventListeners = rendererEventListeners.filter(
        (item: RendererEventListener) =>
          // 如果 eventName 为 undefined,表示全部解绑,否则解绑指定事件
          eventName === undefined
            ? item.renderer !== renderer
            : item.renderer !== renderer || item.type !== eventName
      );
    };
  }

  return undefined;
};

执行动作(runActions)

  • 这里只是一个执行动作的前置处理
  • 遍历动作,通过getActionByType查找动作实例,若没有则判断是否是组件专有动作(组件都有可调用),若再没有则判断是否是打开页面相关的动作,若还是没有则直接调用组件自定义的动作
  • 实际的动作执行还是在runAction中,等下一篇再完整的分析动作相关流程
// packages/amis-core/src/actions/Action.ts

export const runActions = async (
  actions: ListenerAction | ListenerAction[],
  renderer: ListenerContext,
  event: any
) => {
  if (!Array.isArray(actions)) {
    actions = [actions];
  }

  for (const actionConfig of actions) {
    let actionInstrance = getActionByType(actionConfig.actionType);

    // 如果存在指定组件ID,说明是组件专有动作
    if (
      !actionInstrance &&
      (actionConfig.componentId || actionConfig.componentName)
    ) {
      actionInstrance = [
        'static',
        'nonstatic',
        'show',
        'visibility',
        'hidden',
        'enabled',
        'disabled',
        'usability'
      ].includes(actionConfig.actionType)
        ? getActionByType('status')
        : getActionByType('component');
    } else if (['url', 'link', 'jump'].includes(actionConfig.actionType)) {
      // 打开页面动作
      actionInstrance = getActionByType('openlink');
    }

    // 找不到就通过组件专有动作完成
    if (!actionInstrance) {
      actionInstrance = getActionByType('component');
    }

    try {
      // 这些节点的子节点运行逻辑由节点内部实现
      await runAction(actionInstrance, actionConfig, renderer, event);
    } catch (e) {
      ...
    }

    if (event.stoped) {
      break;
    }
  }
};

设计特性

全局事件管理

  • 通过rendererEventListeners队列统一管理,和react的事件委托有点类似
  • 支持跨组件通信
  • 支持全局权重排序、防抖

延迟绑定,执行完销毁

  • 事件都是触发后,在bindEvent中绑定的(加入事件队列),减少内存占用
  • 然后执行完毕会立即销毁,避免内存泄漏

广播事件

  • 独立于渲染器事件的全局事件,基于BroadcastChannel类实现

工作流

  • 由于是全局事件,肯定得优先绑定了

绑定事件入口

  • 组件渲染(渲染流程可参考之前的组件渲染篇)时绑定
  • 组件生成时都会传入childRef,直接在组件的ref上通过bindGlobalEvent绑定了广播事件
// packages/amis-core/src/SchemaRenderer.tsx

import {
  bindEvent,
  bindGlobalEventForRenderer as bindGlobalEvent
} from './utils/renderer-event';

export class SchemaRenderer extends React.Component<SchemaRendererProps, any> {
...
@autobind
  childRef(ref: any) {
    ...
    while (ref?.getWrappedInstance?.()) {
      ref = ref.getWrappedInstance();
    }

    ...

    if (ref) {
      // 这里无法区分监听的是不是广播,所以又bind一下,主要是为了绑广播
      this.unbindEvent?.();
      this.unbindGlobalEvent?.();

      this.unbindEvent = bindEvent(ref);
      this.unbindGlobalEvent = bindGlobalEvent(ref);
    }
    ...
  }
  
  render(): JSX.Element | null {
...

    let component = supportRef ? (
      <Component {...props} ref={this.childRef} storeRef={this.storeRef} />
    ) : (
      <Component
        {...props}
        forwardedRef={this.childRef}
        storeRef={this.storeRef}
      />
    );

    ...

    return this.props.env.enableAMISDebug ? (
      <DebugWrapper renderer={renderer}>{component}</DebugWrapper>
    ) : (
      component
    );
  }
}

绑定事件(bindGlobalEventForRenderer)

  • 这里并未区分广播事件
  • 遍历事件,创建BroadcastChannel对象,推入bcs广播事件队列
  • 挂载onmessage消息监听,接收到广播时通过runActions触发动作
  • 最终返回一个注销广播实例的函数
  • 小疑问:这里直接把renderer.props.$schema.onEvent中所有的动作都绑定了广播事件,虽然统一管理了广播事件的绑定,但是绑定了很多多余的动作,这里实际可以判断actionTypebroadcast才绑定?
// packages/amis-core/src/utils/renderer-event.ts

export const bindGlobalEventForRenderer = (renderer: any) => {
  ...
  const listeners: EventListeners = renderer.props.$schema.onEvent;
  let bcs: Array<{
    renderer: any;
    bc: BroadcastChannel;
  }> = [];
  if (listeners) {
    for (let key of Object.keys(listeners)) {
      const listener = listeners[key];
      ...
      const bc = new BroadcastChannel(key);
      bcs.push({
        renderer: renderer,
        bc
      });
      bc.onmessage = e => {
        const { eventName, data } = e.data;
        const rendererEvent = createRendererEvent(eventName, {
          env: renderer?.props?.env,
          nativeEvent: eventName,
          scoped: renderer?.context,
          data
        });
        // 过滤掉当前的广播事件,避免循环广播
        const actions = listener.actions.filter(
          a => !(a.actionType === 'broadcast' && a.eventName === eventName)
        );

        runActions(actions, renderer, rendererEvent);
      };
    }
    return () => {
      bcs
        .filter(item => item.renderer === renderer)
        .forEach(item => item.bc.close());
    };
  }
  return void 0;
};

触发事件(dispatchGlobalEventForRenderer)

  • 广播动作packages/amis-core/src/actions/BroadcastAction.ts中调用了dispatchGlobalEventForRenderer
  • 代码较短,内部直接调用dispatchGlobalEvent方法,然后创建BroadcastChannel实例发送消息,然后关闭,齐活!
  • 接收消息的地方就是上文bindGlobalEventForRenderer中挂载了onmessage事件的地方,不赘述
// packages/amis-core/src/utils/renderer-event.ts

export async function dispatchGlobalEventForRenderer(
  eventName: string,
  renderer: React.Component<RendererProps>,
  scoped: IScopedContext,
  data: any,
  broadcast: RendererEvent<any>
) {
  ...
  dispatchGlobalEvent(eventName, data);
}

export async function dispatchGlobalEvent(eventName: string, data: any) {
  ...

  const bc = new BroadcastChannel(eventName);
  bc.postMessage({
    eventName,
    data
  });
  bc.close();
}

解绑事件

  • 广播事件是长期绑定的,只有在组件卸载时才解绑
// packages/amis-core/src/SchemaRenderer.tsx

import {
  bindEvent,
  bindGlobalEventForRenderer as bindGlobalEvent
} from './utils/renderer-event';

export class SchemaRenderer extends React.Component<SchemaRendererProps, any> {
...
componentWillUnmount() {
    this.unbindEvent?.();
    this.unbindGlobalEvent?.();
  }

}

总结

  • amis的事件管理还是挺值得学习的
  • 渲染器事件就是在组件的执行过程中开了个口子,支持插入想执行的逻辑
  • 广播事件就是依赖BroadcastChannel的原生功能
  • 下篇再写动作,脑子不够用了

东杰智能:拟向遨博山东采购机器人产品,金额2432.5万元

36氪获悉,东杰智能公告,公司拟与关联方遨博(山东)智能机器人有限公司签订采购合同,货物总价格(含税)2432.5万元。公司全资子公司太原东杰装备有限公司生产车间于2009年投入使用,建设较早,自动化水平偏低。为了提升公司生产车间的自动化水平,降低成本,本次采购遨博焊接机器人50台用于提升焊接效率及质量。因公司海辰菏泽项目及马来西亚PTT项目等需要,采购遨博焊接机器人250台。为了提升员工的办公体验,采购5台双臂咖啡机器人,分别设置在公司及子公司办公区域,供全体员工及客户使用。
❌