普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月3日掘金 前端

鸿蒙日历服务实践:把应用里的事件写进用户的日程表

2026年4月3日 17:17

引言

很多应用都会产生带时间属性的事件——买了张火车票、预约了一场直播、信用卡该还款了、晚上有节线上课。这些信息散落在各个应用里,用户需要自己记住或者手动添加提醒,难免遗漏。

鸿蒙的 Calendar Kit 提供了一种更好的方式:让应用直接把这些事件写入系统日历。写入后,日程会通过通知中心、桌面卡片、日历应用等多个入口触达用户,还能配上一个**"一键服务"按钮**,用户点一下就能跳回应用完成操作——比如"加入会议"、"马上还款"、"立即观看"。

本文面向希望接入日历服务的鸿蒙开发者,梳理日历日程的创建机制、一键服务的场景设计,并通过两个典型场景的完整实现,帮你快速理解开发要点。


一、日历服务能做什么

1.1 核心能力

简单说就是三件事:

  1. 写入日程:把应用中带时间属性的事件以标准格式写入系统日历,包括标题、时间、地点、备注等信息。
  2. 提醒用户:通过系统级的提醒机制,在日程开始前的指定时间通知用户。
  3. 一键直达:在日程卡片上提供服务按钮,用户点击后通过 DeepLink 跳回应用的对应页面,完成后续操作。

写入日历的日程会出现在多个地方——日历应用内部、桌面日历卡片、通知中心。用户不需要打开你的应用就能看到这些信息,这对提升事件的到达率很有帮助。

1.2 一键服务按钮的显示时机

一键服务按钮不是一直显示的,不同入口的出现时机不一样:

入口 显示时机
桌面卡片 / 月视图日程列表卡片 日程开始前 15 分钟显示,日程结束后自动隐藏
日程详情页 始终显示
日程通知 通知弹出时显示,点击通知卡片后显示

这意味着一键服务按钮是有"时效性"的——它在用户最需要行动的时间窗口出现,而不是一直挂在那里。

1.3 9 种典型服务场景

Calendar Kit 为不同的业务场景预定义了服务类型,每种类型对应一个具体的按钮文案:

场景 ServiceType 按钮文案
会议 Meeting 加入会议
追剧 Watching 立即观看
还款 Repayment 马上还款
直播 Live 开启直播
购物 Shopping 开始选购
出行 Trip 立即查看
上课 Class 开始上课
赛事 SportsEvents 立即观看
运动 SportsExercise 开始运动

选择合适的服务类型,按钮文案就会自动匹配,不需要开发者自定义。


二、开发前的准备工作

在写第一行业务代码之前,有三步准备工作需要完成:

第一步,导入依赖。日历管理相关的能力都在 @kit.CalendarKit 中。

第二步,申请权限。日历是用户的私有数据,读写操作需要在 module.json5 中声明两个权限:ohos.permission.READ_CALENDARohos.permission.WRITE_CALENDAR

第三步,获取日程管理器对象。通过上下文获取 calendarMgr 对象,后续所有日历账户和日程的管理操作都通过它来进行。推荐在 EntryAbility.ets 中完成这一步,确保管理器对象在应用生命周期内可用。


三、理解日程的数据结构

在看具体场景之前,先理解日程涉及的几个关键概念,后面写代码时会更清楚为什么要这样配置。

3.1 日历账户

每个写入系统日历的日程都归属于一个日历账户。你可以理解为日历中的一个"分组"——用户打开日历应用时,能看到来自不同应用的日程被归类在各自的账户下。

账户有三个关键属性:

  • name:账户标识,供系统内部使用。
  • type:账户类型,一般使用 LOCAL
  • displayName:展示给用户看的名称,建议和应用在应用市场中的名称保持一致,方便用户识别"这个日程是哪个应用写的"。

3.2 日程字段

一条日程的核心字段包括:

  • title:标题,出现在日程卡片上最醒目的位置。
  • startTime / endTime:起止时间,时间戳格式。
  • isAllDay:是否是全天日程。全天日程不会显示具体时刻,适合"入住日""还款日"这类以天为单位的事件。
  • reminderTime:提醒时间,是一个数组,单位是分钟。比如 [0, 10] 表示日程开始时和开始前 10 分钟各提醒一次。对于全天日程,0 表示当天上午 9 点提醒,1440(即 24 小时)表示前一天上午 9 点提醒。
  • description:备注信息,可以补充标题里放不下的细节。
  • location:地点信息,包含地址文本和经纬度。
  • service:一键服务配置,包括服务类型(type)和跳转链接(uri,DeepLink 格式)。

3.3 日程的增删改查

日历服务提供了完整的 CRUD 操作。创建日历账户后,可以在该账户下添加日程、按条件查询日程、更新日程信息、删除日程。后面的场景示例中会展示这些操作的具体写法。


四、典型场景实践

下面通过两个最常见的场景——出行服务会议——来完整走一遍开发流程。其他场景(直播、购物、还款、课程等)的开发思路完全一致,区别只在于字段内容和 ServiceType 的选择。

4.1 出行服务场景

这大概是最容易理解的场景了:用户在购票应用里买了一张高铁票,应用把行程信息写入日历,出发前提醒用户,用户还能一键跳回应用查看电子客票。

字段设计思路:

标题要一目了然,建议包含车次和起终点,比如"行程信息:G107 上海虹桥-北京南"。备注里可以放检票口和座位号这类到了车站才需要的细节。提醒时间设两个——4 小时前提醒用户该出门了,2 小时前再提醒一次。一键服务类型选 TRIP

创建日程:

import { calendarMgr } from '../entryability/EntryAbility';
import { calendarManager } from '@kit.CalendarKit';

let tripCalendar: calendarManager.Calendar | undefined = undefined;
let oriEvent: calendarManager.Event | null = null;
let id: number = 0;

async createTripCalendarAndEvent(): Promise<void> {
  const calendarAccount: calendarManager.CalendarAccount = {
    name: 'TripCalendar',
    type: calendarManager.CalendarType.LOCAL,
    displayName: '高铁出行'
  };

  const config: calendarManager.CalendarConfig = {
    color: '#aabbcc'
  };

  const startTime = new Date('2025-10-01T08:17:00').getTime();
  const endTime = new Date('2025-10-01T12:51:00').getTime();

  const event: calendarManager.Event = {
    type: calendarManager.EventType.NORMAL,
    title: '行程信息:G107 上海虹桥-北京南',
    startTime: startTime,
    endTime: endTime,
    isAllDay: false,
    reminderTime: [120, 240],
    description: '检票口:南二楼1口或北广场B2候车室 \n座位号:02车04二等座',
    service: {
      type: calendarManager.ServiceType.TRIP,
      uri: 'demo://mobile/player?params='
    }
  };

  try {
    tripCalendar = await calendarMgr?.createCalendar(calendarAccount);
    if (!tripCalendar) {
      console.error('Failed to create calendar.');
      return;
    }
    await tripCalendar.setConfig(config);
    id = await tripCalendar.addEvent(event);
    oriEvent = event;
    oriEvent.id = id;
    console.info(`日程创建成功,ID: ${id}`);
  } catch (error) {
    console.error(`创建失败: ${error.code}, ${error.message}`);
  }
}

这段代码做了三件事:创建日历账户、设置账户配色、添加日程。注意一定要确保日历账户创建成功后再进行日程操作,否则后续调用会失败。

日程的后续管理:

出行场景下,行程变更是常有的事——改签了车次、换了出发时间。这时候需要更新已有日程而不是删掉重建:

async updateTripEvent(): Promise<void> {
  if (!tripCalendar || !oriEvent) return;
  
  // 改签后更新起止时间
  oriEvent.startTime = new Date('2025-10-01T07:03:00').getTime();
  oriEvent.endTime = new Date('2025-10-01T11:51:00').getTime();

  try {
    await tripCalendar.updateEvent(oriEvent);
    console.info('日程更新成功');
  } catch (err) {
    console.error(`更新失败: ${err.code}, ${err.message}`);
  }
}

如果用户退票了,则直接删除日程:

async deleteTripEvent(): Promise<void> {
  if (!tripCalendar) return;
  try {
    await tripCalendar.deleteEvent(id);
    oriEvent = null;
    console.info('日程已删除');
  } catch (err) {
    console.error(`删除失败: ${err.code}, ${err.message}`);
  }
}

需要查询已有日程时,通过 EventFilter.filterById 按 ID 查询:

async getTripEvent(): Promise<void> {
  if (!tripCalendar) return;
  try {
    const filter = calendarManager.EventFilter.filterById([id]);
    let data = await tripCalendar.getEvents(filter, 
      ['title', 'type', 'startTime', 'endTime']);
    if (data && data.length > 0) {
      oriEvent = data[0];
    }
  } catch (err) {
    console.error(`查询失败: ${err.code}, ${err.message}`);
  }
}

4.2 会议场景

会议场景和出行的最大区别在于:它有与会人信息。用户在会议应用中创建或被邀请参加一个会议,应用将其写入日历,到时间时用户看到提醒,点击"加入会议"按钮就能直接进入会议。

字段设计思路:

标题就是会议主题。提醒时间设准时和 15 分钟前——太早没意义,太晚来不及。会议场景特有的是 attendee 字段,用来记录与会人信息,每个与会人有姓名、邮箱、角色(组织者还是参与者)和类型(必选还是可选)。

async createMeetingEvent(): Promise<void> {
  const calendarAccount: calendarManager.CalendarAccount = {
    name: 'meetingCalendar',
    type: calendarManager.CalendarType.LOCAL,
    displayName: '会议'
  };

  const config: calendarManager.CalendarConfig = {
    color: '#aabbcc'
  };

  let attendee: calendarManager.Attendee[] = [
    {
      name: 'Alice',
      email: 'alice@example.com',
      role: calendarManager.AttendeeRole.ORGANIZER
    },
    {
      name: 'Jack',
      email: 'jack@example.com',
      role: calendarManager.AttendeeRole.PARTICIPANT,
      type: calendarManager.AttendeeType.REQUIRED
    },
    {
      name: 'Jerry',
      email: 'jerry@example.com',
      role: calendarManager.AttendeeRole.PARTICIPANT,
      type: calendarManager.AttendeeType.REQUIRED
    }
  ];

  const startTime = new Date('2025-10-20T09:00:00').getTime();
  const endTime = new Date('2025-10-20T10:00:00').getTime();

  const event: calendarManager.Event = {
    type: calendarManager.EventType.NORMAL,
    title: '产品方案评审会议',
    startTime: startTime,
    endTime: endTime,
    isAllDay: false,
    reminderTime: [0, 15],
    attendee: attendee,
    description: 'Q4产品方案评审',
    service: {
      type: calendarManager.ServiceType.MEETING,
      uri: 'demo://mobile/player?params='
    }
  };

  try {
    let calendar = await calendarMgr?.createCalendar(calendarAccount);
    if (!calendar) return;
    await calendar.setConfig(config);
    id = await calendar.addEvent(event);
    console.info(`会议日程创建成功,ID: ${id}`);
  } catch (error) {
    console.error(`创建失败: ${error.code}, ${error.message}`);
  }
}

与会人信息会展示在日程详情中,帮助用户确认参会人员。对于会议应用来说,service.uri 中的 DeepLink 通常会携带会议室 ID 等参数,用户点击"加入会议"后直接进入对应的会议房间。


五、其他场景速览

前面详细讲了出行和会议两个场景的完整实现,其他场景的代码结构完全一样,差异只在字段内容的填写上。这里简要列出几个场景的要点,帮你快速对照:

酒店住宿:适合设置为全天日程(isAllDay: true),标题包含酒店名称和地址,别忘了填 location 字段(包含经纬度),ServiceType 用 TRIP。提醒建议设前一天上午 9 点(reminderTime: [1440])和当天上午 9 点(reminderTime: [0])。

还款提醒:也是全天日程,毕竟还款日是以"天"为单位的。备注里写上待还款金额,ServiceType 用 REPAYMENT,提醒一次就够了——当天上午 9 点(reminderTime: [0])。

直播 / 抢购 / 课程 / 赛事 / 运动:都是精确到具体时刻的非全天日程,提醒时间一般设准时和开始前 10-30 分钟。区别就是选对 ServiceType,按钮文案就会自动匹配。


六、总结与实践建议

日历服务的接入逻辑并不复杂——创建账户、配置日程、写入系统。但要把体验做好,有几个细节值得注意:

  1. 标题要有信息量。用户在桌面卡片上看到的可能只有标题,所以"G107 上海虹桥-北京南"远比"火车票行程"有用。
  2. 提醒时间要合理。出行类提前 2-4 小时,会议和课程提前 10-15 分钟,全天日程用上午 9 点。不要设过多提醒,免得用户觉得被打扰。
  3. 及时更新和清理。行程改签了就更新日程,退票了就删除。不要让过期或无效的日程留在用户的日历里,这会损害用户对应用的信任。
  4. displayName 要用应用真名。用户看到一条日程时,会通过日历账户名称判断"这是哪个应用写的"。用正式的应用名称,而不是内部代号或缩写。
  5. DeepLink 要能真正落地。一键服务按钮点下去后跳转的链接,必须能正确打开应用的对应页面。如果链接失效或跳错位置,这个功能反而会让用户感到困惑。

日历是一个天然的时间管理入口,用户每天都会看。把应用中有价值的时间事件写进去,既帮用户管理好了日程,也为应用争取到了在系统级入口的露出机会。

鸿蒙碰一碰分享:手机轻碰,内容就过去了

2026年4月3日 16:55

引言

跨设备传内容这件事,理想状态是什么?大概就是——我手机上有个东西想给你,碰一下就过去了,不用加好友、不用扫码、不用等配对。

鸿蒙 Share Kit 的碰一碰分享做的就是这件事。两台手机轻碰一下,图片、链接、Wi-Fi 信息就传过去了;手机往 PC 屏幕上一放,文件就到了电脑里。整个过程没有中间步骤,靠的是设备间物理接触触发的分享机制。

本文面向希望为应用接入碰一碰分享能力的鸿蒙开发者,从手机间分享和手机与 PC/2in1 间分享两个维度,梳理这项能力的工作机制、卡片设计要点、异常处理策略,以及完整的开发流程。


一、手机与手机之间的碰一碰分享

1.1 基本流程

碰一碰分享的业务流程可以用四步概括:

  1. 注册:应用在可分享的页面注册碰一碰事件(knockShare)。
  2. 触发:用户将手机与对端设备轻碰,系统发现设备后触发回调。
  3. 发送:应用在回调中构造分享数据并发送。
  4. 清理:离开可分享页面时,解除事件注册。

使用前有几个前提条件:双端设备都要亮屏且解锁,华为分享服务需要处于开启状态(系统默认开启)。如果用户手动关闭了华为分享服务,轻碰时会收到系统通知提示开启。

还有一点需要了解:宿主应用无法直接获知分享结果。对端是接收了还是拒绝了,Share Kit 会通过系统通知告知用户,而不是通过回调返回给应用。如果任意一端设备不支持碰一碰能力,轻碰则完全没有响应。

环境要求方面,手机系统需要 HarmonyOS NEXT Release 及以上版本。可以用 canIUse 做运行时判断:

if (canIUse('SystemCapability.Collaboration.HarmonyShare')) {
  // 支持碰一碰分享
}

1.2 设备间的信任与安全

从 HarmonyOS NEXT 5.0.0.123 SP16 开始,碰一碰分享在发送端和接收端都会展示对方的身份信息,帮助用户确认"我在和谁传东西":

  • 如果对端已登录华为账号,会展示对方的账号昵称和头像
  • 如果对端未登录华为账号,则展示设备信息

需要注意的是,如果发送端的系统版本低于 SP16,接收端将不会展示任何发送方信息。


二、分享卡片的设计:不只是技术问题

碰一碰触发后,对端设备会收到一张分享卡片。卡片的样式直接影响用户是否愿意接收,所以这部分值得认真对待。

2.1 三种卡片模板

Share Kit 根据你传入的字段组合,自动匹配不同的卡片模板:

纯图片布局——只有预览图,没有标题和描述。适合分享文件、图片等不需要文字说明的场景。构造分享数据时只传 thumbnailUri 即可触发这种布局。预览图支持最小宽高比 1:4,超出部分会被裁剪。

沉浸式大卡布局——预览图 + 标题 + 描述 + 应用图标,视觉冲击力最强。适合分享链接类内容。触发条件是同时传入 titledescriptionthumbnailUri,且预览图宽高比小于 1:1(即竖图)。标题最多显示 2 行,描述 1 行,超出部分以省略号截断。如果标题末尾有重要信息,建议控制在 20 个中文字符左右。

白卡上下布局——同样包含预览图、标题、描述和应用图标,但预览图只显示在卡片上方,不会铺满整张卡片。触发条件和沉浸式大卡一样,区别在于预览图宽高比大于 1:1(即横图)。

应用图标不需要额外配置,系统会自动获取。

2.2 预览图的质量建议

预览图太大会拖慢加载速度,太小则显示模糊。建议参考以下标准:

预览图来源 推荐比例 推荐分辨率
应用创作的海报 3:4 最小 600×800,最大 3000×4000
用户上传的图片 不限制 最大 3000×4000

2.3 预览图来不及下载怎么办

一个很实际的问题:如果应用使用的是云端存储的图片作为预览图,碰一碰回调触发时图片可能还没下载到本地,这就会导致超时失败。

Share Kit 对此提供了预览图延迟更新的能力。思路很简单——先发核心数据,建立连接,系统会用默认预览图填充卡片;等云端图片下载完成后,再调用 sharableTarget.updateShareData 更新预览图:

harmonyShare.on('knockShare', capabilityRegistry, (sharableTarget) => {
  // 先发送核心数据,不带预览图
  let shareData = new systemShare.SharedData({
    utd: utd.UniformDataType.HYPERLINK,
    content: 'https://sharekitdemo.drcn.agconnect.link/ZB3p',
    title: '碰一碰分享卡片标题',
    description: '碰一碰分享卡片描述'
  });
  sharableTarget.share(shareData);

  // 图片下载完成后更新预览图
  setTimeout(() => {
    let filePath = contextFaker.filesDir + '/exampleKnock1.jpg';
    sharableTarget.updateShareData({
      thumbnailUri: fileUri.getUriFromPath(filePath)
    });
  }, 5000);
});

这样用户不会因为预览图加载慢而等待,分享体验更流畅。


三、用户引导:让用户知道"这里可以碰"

碰一碰是一个相对新的交互方式,很多用户可能不知道当前页面支持这个功能。给出适当的引导可以有效提升分享意愿。

Share Kit 推荐两种引导方式:

  • 文本提示:在页面上展示"可碰一碰分享至 HarmonyOS 5 及以上版本手机"的文案。
  • 动图提示:用动画展示碰一碰的操作方式,更直观。

Share Kit 提供了统一的动图资源文件。下载后将 knock_share_guide 目录下的所有文件放到应用的 entry/src/main/resources/rawfile 目录即可使用。


四、核心开发流程:注册、发送、清理

4.1 注册与取消碰一碰事件

注册碰一碰事件有两种方式。简单场景下,直接传入回调函数即可:

private immersiveListening() {
  harmonyShare.on('knockShare', this.immersiveCallback);
}

private immersiveDisablingListening() {
  harmonyShare.off('knockShare', this.immersiveCallback);
}

如果需要更精细的控制(比如指定窗口、声明单向发送能力),可以传入 SendCapabilityRegistry 配置:

let capabilityRegistry: harmonyShare.SendCapabilityRegistry = {
  windowId: 999,  // 替换为实际的 windowId
};
harmonyShare.on('knockShare', capabilityRegistry, callback);

和隔空传送一样,生命周期管理是关键。进入可分享页面时注册,离开时(包括退后台)必须取消:

aboutToAppear(): void {
  this.immersiveListening();
  let context = this.getUIContext().getHostContext() as Context;
  context.eventHub.on('onBackGround', this.onBackGround);
}

aboutToDisappear(): void {
  this.immersiveDisablingListening();
  let context = this.getUIContext().getHostContext() as Context;
  context.eventHub.off('onBackGround', this.onBackGround);
}

onPageHide(): void {
  let context = this.getUIContext().getHostContext() as Context;
  context.eventHub.emit('onBackGround');
}

private onBackGround = () => {
  this.immersiveDisablingListening();
}

4.2 构造分享数据并发送

在碰一碰回调中构造分享数据。链接类分享的 utd 类型需要设置为 HYPERLINK

private immersiveCallback = (sharableTarget: harmonyShare.SharableTarget) => {
  let filePath = contextFaker.filesDir + '/exampleKnock1.jpg';
  let shareData = new systemShare.SharedData({
    utd: utd.UniformDataType.HYPERLINK,
    content: 'https://sharekitdemo.drcn.agconnect.link/ZB3p',
    thumbnailUri: fileUri.getUriFromPath(filePath),
    title: '碰一碰分享卡片标题',
    description: '碰一碰分享卡片描述'
  });
  sharableTarget.share(shareData);
}

4.3 通过 App Linking 实现直达应用

分享链接时,强烈建议使用 App Linking 而不是普通 URL。App Linking 的好处在于:

  • 应用已安装:直接拉起应用对应页面。
  • 应用未安装:默认通过浏览器打开网页;配合 App Linking Kit 的直达应用市场能力,可以直接跳转到应用市场引导安装。再结合延迟链接能力,用户安装完成后首次打开应用时,仍能获取之前分享的链接内容——这对转化率的提升非常有价值。

另一种方案是 Deep Linking,但它只在本地已安装的应用中查找匹配项,未安装时会提示"暂无可用打开方式"。

4.4 异常场景的处理

碰一碰触发后,并不总是能顺利完成分享。Share Kit 提供了两种异常处理方式,帮助开发者优雅地终止分享,避免用户干等:

当前界面无可分享内容(6.0.2(22) 版本起支持):

sharableTarget.clarifyNonShare({ 
  message: '请在支持碰一碰分享的界面再试' 
});

这会终止本次分享,并弹出提示引导用户去可分享的页面。

网络或业务原因导致分享失败(5.0.3(15) 版本起支持):

sharableTarget.reject(harmonyShare.SharableErrorCode.DOWNLOAD_ERROR);

这会终止分享并提示用户具体原因。


五、邀请组队:碰一碰的另一种玩法

除了内容分享,碰一碰还有一个很有意思的应用场景——邀请组队。比如游戏中邀请旁边的朋友加入房间,碰一下手机就完成了。

这个场景有一个特殊问题:如果双方都在组队房间里互碰,会导致互相邀请加入对方房间的冲突。Share Kit 对此提供了单向仅发送能力,通过在注册时设置 sendOnly: true 来声明:

let capabilityRegistry: harmonyShare.SendCapabilityRegistry = {
  windowId: 999,
  sendOnly: true,  // 声明仅支持单向发送
};
harmonyShare.on('knockShare', capabilityRegistry, callback);

当碰一碰的双方都设置了 sendOnly,系统会终止本次分享并提示"请任意一方退出当前应用后再试"。只要有一方没设置 sendOnly,分享就能正常完成。

对端应用被拉起后,通过 onCreateonNewWant 回调中的 want.uri 获取组队链接,解析其中的参数来处理组队逻辑:

onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  console.log('收到组队链接: ', want.uri);
  // 解析链接参数,处理组队邀请
}

onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  console.log('收到组队链接: ', want.uri);
  // 应用已在前台时的处理
}

六、手机与 PC/2in1 之间的碰一碰分享

碰一碰不只是手机之间的事。手机和 PC/2in1 设备之间也可以碰一碰分享,而且交互方式更有趣——手机直接往屏幕上一放,利用 PC/2in1 的屏幕感知能力识别碰触动作和位置,实现窗口级的精准交互。

6.1 谁发谁收的规则

从 6.0.0(20) Beta5 版本开始,手机与 PC/2in1 之间不支持双向分享,遵循明确的优先级:

  1. 手机前台有可分享内容 → 手机发送,PC/2in1 接收。
  2. 手机前台无可分享内容,PC/2in1 前台窗口有 → PC/2in1 发送,手机接收。
  3. 双方前台都没有可分享内容 → 走无内容分享逻辑。

更早的版本(6.0.0(20) Beta3 及之前)支持双向同时分享,但后续版本取消了这个行为。

6.2 物理姿态要求

手机碰 PC/2in1 屏幕时,对放置姿态有具体要求:

  • 俯视夹角 ≤ 5°(手机要基本平放在屏幕上)
  • 侧视夹角 > 35°
  • 正视夹角 ≤ 25°
  • 手机不能超出屏幕边缘

此外,支持官方手机保护壳,但过厚的外壳可能影响感知。仅支持直板手机或折叠手机的直板形态。双端设备需要登录相同的华为账号。

6.3 沙箱接收:文件直达应用

从 6.0.0(20) 版本开始,PC/2in1 设备支持沙箱接收能力——手机碰一下屏幕,文件直接传入 PC/2in1 应用的沙箱目录,传完后通知应用处理,无需用户手动操作。

应用需要声明自己支持接收的文件类型和最大数量。如果类型不匹配,系统会回退到华为分享的默认接收逻辑;如果数量不匹配,会弹窗提示用户。

注册沙箱接收事件:

aboutToAppear(): void {
  let capabilityRegistry: harmonyShare.RecvCapabilityRegistry = {
    windowId: 999,
    capabilities: [{
      utd: utd.UniformDataType.IMAGE,
      maxSupportedCount: 1,
    }]
  };

  harmonyShare.on('dataReceive', capabilityRegistry, 
    (receivableTarget: harmonyShare.ReceivableTarget) => {
      let context = this.getUIContext().getHostContext() as common.UIAbilityContext;
      receivableTarget.receive(context.filesDir, {
        onDataReceived: (sharedData: systemShare.SharedData) => {
          let records = sharedData.getRecords();
          records.forEach((record) => {
            // 处理接收到的文件
          });
        },
        onResult(resultCode: harmonyShare.ShareResultCode) {
          if (resultCode === harmonyShare.ShareResultCode.SHARE_SUCCESS) {
            // 接收成功
          }
        }
      });
    }
  );
}

离开页面时同样要解除注册。如果因为业务原因需要拒绝本次接收,可以调用 receivableTarget.reject()


七、总结

碰一碰分享覆盖了两大场景:手机与手机之间的内容传输和组队邀请,以及手机与 PC/2in1 之间的文件传输。对开发者来说,接入时有几个核心关注点:

  1. 生命周期管理是基本功。注册和取消事件必须与页面生命周期严格对应,退后台也要取消,否则会出现意料之外的行为。

  2. 卡片设计影响转化。三种卡片模板由字段组合和图片比例自动决定,了解触发规则后有意识地选择合适的布局,预览图质量要控制在合理范围内。

  3. 异常处理不能省。无内容可分享时用 clarifyNonShare 引导用户,网络异常时用 reject 终止等待。这些细节决定了用户在非理想状态下的体验。

  4. App Linking 是最佳搭档。结合延迟链接和直达应用市场能力,即使对端没有安装应用,也能完成从分享到安装到打开内容的完整链路。

  5. 手机碰 PC/2in1 的姿态要求值得在产品引导中告知用户,避免"碰了没反应"的困惑。沙箱接收能力则为 PC 端应用打开了一种高效的文件接收方式。

使用Compose Navigation3进行屏幕适配

2026年4月3日 16:51

这篇文章将介绍B站是怎么使用 Compose Navigation3 进行页面的宽屏适配,并解决其中遇到的问题的。

本文所涉及到的 Compose 页面均已完成了 CMP 跨平台化适配,内容中基于安卓习惯所提的 “Activity” 如无额外说明均代表各平台的页面容器,即可以直接替换为iOS的UIViewController理解。

Navigation3 简介

Navigation3 是 Google 在 2025 年推出的全新 Compose 导航库,与之前的 Navigation Compose 有本质区别。它不再内置导航图(NavGraph)和 NavHost,而是将导航栈的管理权完全交给开发者,框架只负责"根据栈内容渲染 UI",将 f(data)=UI 的理念扩展到了页面导航栈上。得益于这新的精简的框架概念,使得 Navigation3 能很轻松地跟现有大型app的路由系统搭配整合使用,不像之前的 Navigation 库那样需要将现有路由完全迁移到导航图(NavGraph)声明上。开发者完全可以在单模块内进行 Nav3 的接入使用,同时保持整体的路由声明方式。

虽然使用Compose编写的页面,因其声明式的特性,已经有良好的响应屏幕宽度变化的能力。但是近期出现的超宽、折叠屏手机,包括鸿蒙平台的平板、桌面等设备,会让仅支持响应式布局的页面在超宽显示模式下给用户带来不好的视觉和交互体验。与Navigation3 库同时提出的“WindowSizeClass”中,将屏幕根据宽度划分为小、中、大等各个档位。这种“断点式”的屏幕划分可以指导我们知道在怎么样的情况下将应用的界面显示编排成全屏页面还是分屏页面的形式,显著提高在折叠屏、平板、桌面等“非传统手机”屏幕下的用户呈现能力。

为什么需要纯Compose的导航框架

在b站深入推进业务 CMP 跨平台化的过程中,我们发现缺少一个适配 CMP 属性的页面导航框架是深入业务使用的一大阻碍。

在先前的页面方案中,我们仿照安卓原生实现,选择了为每个导航节点嵌套一个原生window容器,即每打开一个个 Composable 页面都对应一个安卓 Activity 、iOS UIViewController 和 鸿蒙 entry 的创建与展现。这个方案能让我们快速地将 Compose 页面集成到现有工程中,但随后带来了更多其他的问题。

首要面临的问题是内存压力。在 iOS 和鸿蒙中,每打开一个新的原生容器来承载Compose页面,都意味着一个 CAMetalLayer/NativeWindow 被创建,对应3倍大小的render buffer也会被创建在内存中,内存占用就会相应提升。根据我们测算,使用三缓冲区渲染的 iOS,每一个 CAMetalLayer 都会占用约40M的内存。随着接入Compose的页面越来越多、用户打开的页面越来越多,内存压力会不断增长,影响我们的CMP推进进程。

另一个问题是,Compose 上下文内使用的 Lifecycle 系统是基于安卓生命周期概念设计的,在 iOS 和鸿蒙系统中多少有些水土不服,需要 ComposeView 的宿主层进行额外的配置工作,例如将 UIViewController 的 willAppear didAppear等回调桥接到 androidx 生命周期的相应事件上。在“标准容器”无法满足页面展现需求,需要做业务定制的时候,这些额外配置将成为开发过程的摩擦,在接入者不熟悉/没有意识到需要做这些配置的时候,将严重拖慢review和交付进度。

并且,这样的桥接总会丢失准确信息,特别是在页面切换的时候,总会错过准确的生命周期回调,导致在后台执行了额外的工作,引起卡顿、发热等问题;

同时,不正确的生命周期事件会让开发有不正确的预期,这一点会在本文后面详细描述。

基于以上问题的考量,我们得出结论,至少在纯Compose世界内的页面导航切换范围内,我们需要一个纯Compose的导航框架。而刚刚推出正式版、其结构思想契合现代代码开发思路的Navigation3成为我们的首选方案。

路由与导航的区别和联系

在之前的开发理念中,我们往往将"路由"和"导航"混为一谈:一个 URI 既是页面的标识,也是跳转的触发方式。我们将URI标注在一个 Fragment/Activity 上之后,调用“路由”跳转这个URI将直接打开这个页面实例。

@Route("bilibili://some/page")
class SomePageActivity: Activity()

Router.routeTo("bilibili://some/page") // == startActivity(SomePageActivity.class)

然而,在后续的开发和迭代过程中,我们逐渐意识到,这一次跳转动作应当分为两个具体步骤:使用“路由”寻找这个 URI 对应的页面信息,然后使用“导航”组件将这个页面展现在用户面前。

在我们的项目 CMP 化推进过程中,基架团队已经将这个理念应用到了b站的 CMP 版路由组件中,允许业务方在复用公共路由表的查找逻辑和结果的前提下,根据不同页面需要自定义自己的“路由结果导航”行为,为这次的 Nav3 快速接入提供了合适切入点。

数据驱动的声明式导航栈展现

Navigation3 的核心理念是:导航栈就是一个普通的 List,UI 是这个 list 的函数。

class MyBackStack<K : NavKey>(private val list: SnapshotStateList<K>) : SnapshotStateList<K> by list {
    override fun add(item: K){
        // 可以在这里提前处理冲突元素的清理
        list.add(item)
    }
}

@Composable
fun NavPage(modifier: Modifier = Modifier){
    val backStack = remember {
        mutableStateListOf(HomeNavKey)
    }

    NavDisplay(
        backStack = backStack,           // 数据:当前栈内容
        sceneStrategy = ...,             // 策略:如何将栈内容映射为布局
        entryDecorators = listOf(...),   // 装饰器:为每个 entry 注入能力
        entryProvider = entryProvider {   // 注册:NavKey → Composable 的映射
            entry<SomeNavKey> { key -> SomePage(key) }
        },
    )
}

开发者只需要按照自己的页面逻辑操作 backStack ,例如添加、移除,或者“在特定页面入栈时清除其他页面”用来实现“最多只有一个详情页被打开”的情况。NavDisplay 会自动响应变化并重新计算布局。不需要手动调用 navigate()、popBackStack() 等命令式 API,更加贴合 Compose 生态中的开发习惯。

在实际业务中接入使用

在实际的业务场景中使用 Navigation3 ,当然不像其他网络示例那样简单调用。我们将需要深入使用 Nav3 库提供的各种 api ,定制自己的业务功能。

NavKey 与路由发现

在 Nav3 中,NavKey 是描述页面的最小独立元素,每一个 NavKey 类型都跟一个页面绑定,描述了期望被打开的页面的基础信息,例如请求这个页面所需的唯一ID:

@Serializable
@Route("bilibili://some/nav3/page/with/id/{id}")
data class SomeIdPageNavKey(
    val id: String,
    val paramFromQuery: String,
) : NavKey

@Route("bilibili://some/page/with/id/{id}")
@Composable
fun SomePage(id: String, modifier: Modifier = Modifier, paramFromQuery: String = ""){}

因为一个 NavKey 可以跟一个路由严格对应,所以以上这段声明代码完全可以交给路由的 KSP 处理器自动生成。在子页面发起正常的路由跳转请求时,通过拦截器模式拦截此次路由的查找过程,如果找到匹配的 NavKey 类型,则将一个实例添加到backStack栈顶,将普通的导航行为桥接到 Nav3 的导航中。


// 路由拦截器:将普通路由请求桥接到 Nav3 的 backStack
class Nav3RouteInterceptor<KEY : NavKey>(
    private val onNavKeyFound: (KEY) -> Boolean,
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val originUri = chain.uri
        // 将原始 URI 转换为 Nav3 专用的查找格式
        // 例如 bilibili://some/page/123 → bilibili://some/nav3/page/123
        val navUri = convertToNav3Uri(originUri) ?: return chain.proceed()

        // 在路由表中查找这个 URI 对应的 NavKey 工厂函数
        val target = chain.find(navUri) as? SomeIdPageNavKey

        returnif (key != null && onNavKeyFound(key)) {
            Response.Done  // 拦截成功,阻止后续的默认导航行为(如 startActivity)
        } else {
            chain.proceed() // 未匹配,交给下一个拦截器或默认处理
        }
    }
}

// 在 Nav3 宿主页面中组装拦截器
@Composable
fun Nav3HostPage() {
    val backStack = remember { mutableStateListOf<MyNavKey>(HomeNavKey) }

    // 创建拦截器,拦截成功时将 NavKey 推入栈
    val interceptor = remember {
        Nav3RouteInterceptor<MyNavKey> { key ->
            backStack.add(key)
        }
    }
    // 在原有 Router 上叠加拦截器,生成新的 Router 实例
    val localRouter = LocalRouter.current
    val nav3Router = remember(localRouter, interceptor) {
        localRouter.newBuilder().addInterceptor(interceptor).build()
    }

    NavDisplay(
        backStack = backStack,
        entryDecorators = listOf(
            // 通过 Decorator 将带拦截器的 Router 注入到所有子页面
            // 这样子页面内发起的路由请求也会经过拦截器
            remember { Nav3RouterDecorator(nav3Router) },
            ...
        ),
        ...
    )
}

// Decorator 实现:通过 CompositionLocal 注入 Router
class Nav3RouterDecorator(
    private val router: Router,
) : NavEntryDecorator<MyNavKey>(
    onPop = {},
    decorate = { entry ->
        CompositionLocalProvider(LocalRouter provides router) {
            entry.Content()
        }
    },
)

NavKey 需要支持序列化(用于 backStack 的保存/恢复),因此都标注了 @Serializable;当然,也可以选择统一保存原始跳转链接的string内容,在需要恢复时重新走一次路由查找。

NavKey 与 entry 注册

NavKey 仅能表示“有一个页面”,在 Nav3 中,还需要通过 entryProvider 的方式将“这个 NavKey 对应的页面如何显示” 注册到当前的 NavDisplay 中:

NavDisplay(
    backStack = backStack,
    entryProvider = entryProvider {
        entry<SomeIdPageNavKey>(metadata = BiliListDetailSceneStrategy.detailPane()) { key -> SomePage(key.id, Modifier, key.paramFromQuery) }
    },
 )

当然,这一段注册代码也可以抽象为 EntryProviderScope.() -> Unit 的函数,由路由 KSP 处理器统一生成,页面只需要按需注册即可。

SceneStrategy 与 Scene

SceneStrategy 是 Navigation3 中最关键的扩展点。它接收当前 backStack 中所有 entry,返回一个 Scene 来描述如何布局。

在我们的宽屏适配实践中,我们实现了 BiliDetailSceneStrategy:

class BiliDetailSceneStrategy<K : NavKey>(
    val windowSizeClass: WindowSizeClass,
) : SceneStrategy<K> {
    override fun SceneStrategyScope<K>.calculateScene(entries: List<NavEntry<K>>): Scene<K>? {
        if (windowSizeClass.isAtLeastMedium(...)) {
            // 宽屏:从栈中找到最后一个 List entry 和最后一个 Detail entry
            val listEntry = entries.findLast { it.metadata.containsKey(LIST_KEY) }
                ?: return null
            val detailEntry = entries.findLast {
                it.metadata.containsKey(DETAIL_KEY)
            }
            return BiliListDetailScene(
                listEntry = listEntry,
                detailEntry = detailEntry,
                listWidth = if (windowSizeClass.widthLargeCompat()) 375.dp else300.dp,
            )
        }
        return null  // 非宽屏的情况:返回 null,表示当前 Strategy 不处理这个情况,NavDisplay 将使用默认单页 Strategy
    }
}

BiliListDetailScene 的布局结构:

Row(modifier = Modifier.fillMaxSize()) {
    // 左栏:列表
    Box(modifier = Modifier.width(listWidth)) {
        listEntry.Content()
    }
    VerticalDivider(...)
    // 右栏:详情或占位图
    Box(modifier = Modifier.weight(1f)) {
        if (detailEntry != null) {
            CompositionLocalProvider(LocalBackIconVisibility provides false) {
                detailEntry.Content()
            }
        } else {
            DefaultDetailPlaceholder()
        }
    }
}

每个 entry 通过 metadata 标记自己属于哪个区域。metadata 在注册 entry 时通过 BiliListDetailSceneStrategy.listPane() / detailPane() 设置:

companion object {
    fun listPane()= mapOf("BiliListDetailScene-List" to true)
    fun detailPane()= mapOf("BiliListDetailScene-Detail" to true)
}

SceneStrategy 的 calculateScene 在每次 backStack 变化时都会被调用。如果设备发生折叠/展开,windowSizeClass 变化会触发 BiliListDetailSceneStrategy 的重建(通过 remember(windowSizeClass)),从而自动切换单栏/双栏布局。

踩过的一些坑

从原生导航模式迁移到 Nav3 ,页面的导航方式将发生重大变化,其中有不少在往常开发过程中注意不到的地方。

生命周期、页面重入、状态保存

首先最大的一个变化,是之前每一次导航到一个 Composable 函数页面,都将打开一个全新的 Activity 来承载这个函数体,因此开发们会有一个错误认知:Compose Scope = Activity Scope = ViewModel Scope,在副作用处理上容易出现错误和遗漏,例如:

// ViewModel 中,将 toast 信息作为 State 的一部分暴露
data class PageState(
    val items: List<Item> = emptyList(),
    val toast: ToastContent? = null,  // 一次性事件,混在持久状态中
)

class SomeViewModel : ViewModel(){
    val state: StateFlow<PageState> = ...

    fun onAction(action: Action) {
        // 某些操作会产生 toast
        _state.update { it.copy(toast = ToastContent("操作成功")) }
    }
}

@Composable
fun SomePage(viewModel: SomeViewModel) {
    val state by viewModel.state.collectAsState()
    val toaster = LocalToaster.current

    var otherState = remember { mutableStateOf("") }

    // 通过 snapshotFlow 监听 state 变化来显示 toast
    LaunchedEffect(Unit) {
        snapshotFlow { state.toast }
            .filterNotNull()
            .distinctUntilChanged()
            .collect { toaster.showToast(it.content) }
    }

    // 或者直接判断内容进行显示,都会引发同样的问题。
    // LaunchedEffect(state.toast) {
    //    state.toast?.let { toaster.showToast(it.content) }
    // }

    // ... 页面内容
}

在独立的 Activity 中,这段代码运行起来不会有问题;但是在 Navigation3 的单Activity导航栈模式下,从这个页面跳转到其他页面之后,这个页面将暂时“退出组合”,在返回这个页面之后,重新“进入组合”。

在这个过程中,并不算一次“重组”,而是一次全新的组合事件,上面的代码将会出现:

  1.  不管 LaunchedEffect 的key是什么,都会进入一次执行,snapshotFlow中记录的前值也将被清空,导致 toast 被重复显示;

  2.  通过 remember 保存的状态也被清空,依赖 remember 做的逻辑将回到空态。

针对以上问题,修复思路其实很简单。

对于第一个问题,首先需要开发者确认什么内容该属于“状态”,什么内容该属于“事件”。

在示例代码中,val items: List 属于需要在页面上一直显示的内容,属于业务状态的一部分,使用 StateFlow 和 collectAsState 是很恰当的;而对于 toast 来说,已经显示过一次的toast内容在任何情况下都不该重新出现,因此它该属于“事件流”的一部分,每次消费后都不再重放,因此可以使用 sharedFlow 承载toast的传递,或者每次显示完主动将这个字段清空。

而第二个问题则更简单了,首先区分被 remember 的数据是否能接受丢失,如果是可以丢失的状态(例如,播放中的动画进度)则完全可以不处理;对于真正需要保存的数据,可以通过实现自定义 Saver 使用 rememberSavable 的方式,或者将数据委托给 ViewModel 中保存。

data class PageState(
    val items: List<Item> = emptyList(),
    val toast: ToastContent? = null,
)

class SomeViewModel : ViewModel(){
    private val _state = MutableStateFlow(PageState())
    val state: StateFlow<PageState> = _state.asStateFlow()

    // 将 state 中的 toast 字段转换为事件流(replay=0,重新订阅不重放历史事件)
    val toastEvent: SharedFlow<ToastContent> = state
        .map { it.toast }
        .filterNotNull()
        .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 0)

    fun onAction(action: Action) {
        _state.update { it.copy(toast = ToastContent("操作成功")) }
        // 也可以选择显示后立即清空,确保 toast 状态不会被持久持有
        // _state.update { it.copy(toast = null) }
    }
}

@Composable
fun SomePage(viewModel: SomeViewModel) {
    val state by viewModel.state.collectAsState()
    val toaster = LocalToaster.current

    // 需要跨页面跳转保留的状态,改用 rememberSaveable
    var otherState by rememberSaveable { mutableStateOf("") }

    // 消费事件流:重新进入组合时重新订阅,replay=0 保证不会重放已消费的事件
    LaunchedEffect(Unit) {
        viewModel.toastEvent.collect { toast ->
            toaster.showToast(toast.content)
        }
    }

    // ... 页面内容
}

Navigation3使用额外依赖中的 rememberViewModelStoreNavEntryDecorator() 来提供“页面在pop时清空相应viewmodel”的能力。并且在未来这个依赖和ViewModelStore的能力和api将会发生变化,带来更加强大的定制能力。

屏幕状态感知与返回按钮

宽屏模式下,右栏的页面不需要显示返回按钮(因为左栏始终可见)。可以通过自定义 LocalBackIconVisibility 这个 CompositionLocal 控制:

val LocalBackIconVisibility = compositionLocalOf { true }

// 右栏渲染
if (detailEntry != null) {
    CompositionLocalProvider(LocalBackIconVisibility provides false) {
        detailEntry.Content()
    }
}

子页面中通过读取这个值来决定是否显示返回图标:

val showBackButton = LocalBackIconVisibility.current

窄屏模式下 LocalBackIconVisibility 保持默认值 true,页面正常显示返回按钮。

状态栏的控制

在单 Activity 页面导航框架中, SystemUI 配置(如状态栏颜色)如果允许每个页面、每个组件自由控制,将很容易出现UI闪烁等情况。我们通过 SystemUiConfiguration 收集机制解决:每个 entry 通过 collectSystemUiConfiguration Modifier 上报自己的配置,NavDisplay所在的宿主页面 取栈顶 entry 的配置应用到宿主:

// ① 定义:持有状态栏配置的可观察容器
@Stable
class StableSystemUiConfiguration {
    var statusBarDarkIcons: Boolean? by mutableStateOf(null)
}

// ② 宿主:为每个 NavKey 分配一个 config 对象,并将"锚点 modifier"传给 entry
val configurationMap = remember { mutableStateMapOf<MyNavKey, StableSystemUiConfiguration>() }
val topConfiguration by remember {
    derivedStateOf { backStack.lastOrNull()?.let { configurationMap[it] } }
}

val getCollectorModifier: (MyNavKey) -> Modifier = { key ->
    val config = configurationMap.getOrPut(key) { StableSystemUiConfiguration() }
    // collectSystemUiConfiguration 在 modifier 链中埋入"锚点",持有 config 的引用
    Modifier.collectSystemUiConfiguration(config)
}

NavDisplay(
    // 将栈顶 entry 的配置应用到 Window(状态栏颜色等)
    modifier = Modifier.applySystemUiConfiguration(topConfiguration),
    entryProvider = entryProvider {
        // 在构建 entryProvider 时将 collector modifier 传入页面
        entry<SomeNavKey> { key ->
            SomePage(modifier = getCollectorModifier(key))
        }
    },
    ...
)

// ③ 子页面:将自己期望的状态栏配置追加到 modifier 链上
@Composable
fun SomePage(modifier: Modifier = Modifier) {
    val isDarkTheme = LocalDarkTheme.current
    Box(
        // statusBarDarkIcons 会沿 modifier 链向上查找"锚点",找到后将值写入宿主的 config 对象
        // 节点 attach 时写入,detach 时自动清空,生命周期安全
        modifier = modifier.statusBarDarkIcons(darkIcons = !isDarkTheme)
    ) {
        // 页面内容
    }
}

图片

而 NavDisplay 本身所在的页面中,框架已经传入了一个collectSystemUiConfiguration,并且将实际在 window 中生效。通过显式传递控制链条的方式,我们将状态栏的配置权限限制在页面宿主层级,在这一层让业务根据自己的实际逻辑决定内部组件的生效范围。

返回事件的处理

与 Navigation3 库同时推出的,是 androidx.navigationevent 库,用来响应和发送页面导航事件。Nav3 库默认已经使用了这个依赖库来响应返回事件,其行为是将现有的 backStack 的最新一个元素推出。如果我们需要定制返回事件的处理,可以通过包装 backStack 实现。

需要注意的是,androidx.navigationevent 库会将系统返回手势、系统导航栏返回键、应用顶部导航栏返回按钮或其他主动调用 backHandler.backCompleted() 处的返回事件一同给出,现有的注册层级结构关系不能区分出返回事件的来源行为和来源页面。因此,暂时无法实现“分栏页面各有一个返回按钮,各自控制其栏位的页面pop”交互。

原生页面嵌入

在需要进行宽屏适配的模块中,部分页面仍然是 Android Fragment 实现,尚未迁移到 CMP。我们选择通过 BiliNativePage 将 Fragment 嵌入 Navigation3 体系:

@Composable
internal fun BiliNativePage(url: String, modifier: Modifier){
    val showBackButton = LocalBackIconVisibility.current
    // 1. 通过 Router 解析 URL,获取 Fragment Class
    val routeInfo = Router.newCall(url).find()
    val clazz = routeInfo?.clazz

    // 2. 使用 AndroidFragment 嵌入 Compose
    if (clazz != null) {
        // 3. 因为 AndroidFragment 尚不支持响应state变化主动更新参数,因此选择一个key主动进行重组,通过切换fragment的方式将新的 showBackButton 传入
        // 也可以选择使用 ViewModel 传递 showBackButton 的更新,避免fragment的重建
        key(showBackButton) {
            AndroidFragment(
                clazz = clazz as Class<out Fragment>,
                modifier = modifier,
                arguments = createRouteExtraForFragment(routeInfo).also {
                    it.putBoolean("show_back_button", showBackButton)
                },
            )
        }
    }
}

// Fragment 侧:从 arguments 读取 show_back_button 控制返回按钮显隐
class SomePageFragment : Fragment(R.layout.fragment_some_page) {
    private val showBackButton get() = arguments?.getBoolean("show_back_button", true) ?: true

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding.btnBack.isVisible = showBackButton
        binding.btnBack.setOnClickListener { parentFragmentManager.popBackStack() }
    }
}

原生页面的注册可以通过 expect/actual 机制来声明,或者使用依赖注入框架来实现页面注册。

showBackButton 的变化会触发 Fragment 重建(通过 key(showBackButton)),确保 Fragment 能响应宽屏/窄屏切换时返回按钮的显隐变化。

Scene 中的小发现

在尝试在Nav3框架内添加页面切换动画过程中,我调研了官方示例 nav3-recipes 中关于动画切换的部分,结果看到了一段让我始料未及的代码:

override val content: @Composable (() -> Unit) = {
    Row(modifier = Modifier.fillMaxSize()) {
        Column(modifier = Modifier.weight(0.4f)) {
            listEntry.Content()
        }
        ...

        Column(modifier = Modifier.weight(0.6f)) {
            AnimatedContent(
                ...
            ) { entry ->
                entry.Content()
            }
         }
     }
}

其中的 entry.Content() 让我产生了“这能正常触发重组吗?”的疑问🤔。在通常的开发惯例中,Composable 函数一般都是独立于Kotlin class的顶层函数,而不是某个实例的成员函数来被调用,这样 Compose 框架可以通过分析入参是否变化来决定是否重组;如果函数本身是一个类对象的成员函数,那类实例的改变会不会产生类似于key改变的作用、从而触发了这个Composable函数的完全重组呢?

带着这个疑问,我构造了一段测试代码:

@Immutable
data class TestClass(val data: String){
    @Composable fun Content(modifier: Modifier = Modifier){
        Text(data)
    }
}

然后使用 jadx 查看它编译后的产物,看到了关键信息:

public final class TestClass {
    public static final int $stable = 0;
    private final String data;
    /* JADX INFO: Access modifiers changed from: private */
    public static final Unit Content$lambda$0(TestClass testClass, Modifier modifier, int i, int i2, Composer composer, int i3) {
        testClass.Content(modifier, composer, RecomposeScopeImplKt.updateChangedFlags(i | 1), i2);
        return Unit.INSTANCE;
    }
    public TestClass(String data){
        Intrinsics.checkNotNullParameter(data, "data");
        this.data = data;
    }
    public final String getData(){
        returnthis.data;
    }
    public final void Content(Modifier modifier, Composer $composer, finalint $changed, finalint i){
        Composer $composer2;
        final Modifier modifier2;
        Composer $composer3 = $composer.startRestartGroup(507862195);
        ComposerKt.sourceInformation($composer3, "C(Content)N(modifier)160@6025L10:ListDetailScene.kt#qpkuy4");
        int $dirty = $changed;
        if (($changed & 48) == 0) {
            $dirty |= $composer3.changed(this) ? 32 : 16;
        }
        if (!$composer3.shouldExecute(($dirty & 17) != 16, $dirty & 1)) {
            $composer2 = $composer3;
            $composer2.skipToGroupEnd();
            modifier2 = modifier;
        } else {
            Modifier modifier3 = (i & 1) != 0 ? Modifier.INSTANCE : modifier;
            if (ComposerKt.isTraceInProgress()) {
                ComposerKt.traceEventStart(507862195, $dirty, -1, "com.example.nav3recipes.scenes.listdetail.TestClass.Content (ListDetailScene.kt:159)");
            }
            $composer2 = $composer3;
            TextKt.m4145TextNvy7gAk(this.data, null, 0L, null, 0L, null, null, null, 0L, null, null, 0L, 0, false, 0, 0, null, null, $composer2, 0, 0, 262142);
            if (ComposerKt.isTraceInProgress()) {
                ComposerKt.traceEventEnd();
            }
            modifier2 = modifier3;
        }
        ScopeUpdateScope scopeUpdateScopeEndRestartGroup = $composer2.endRestartGroup();
        if (scopeUpdateScopeEndRestartGroup != null) {
            scopeUpdateScopeEndRestartGroup.updateScope(new Function2() { // from class: com.example.nav3recipes.scenes.listdetail.TestClass$$ExternalSyntheticLambda0
                @Override // kotlin.jvm.functions.Function2
                publicfinal Object invoke(Object obj, Object obj2) {
                    return TestClass.Content$lambda$0(this.f$0, modifier2, $changed, i, (Composer) obj, ((Integer) obj2).intValue());
                }
            });
        }
    }
}

会发现,这样一种成员Composable函数的产物跟顶层函数没什么区别,都是使用一个生成的数字key作为restart group的标记,在其中判断参数是否变化时,额外进行了 this 对象的判断,也就是说,可以简单将这个函数定义等价为:

data class TestClass(val data: String)

@Composable
fun Content($this: TestClass, modifier: Modifier = Modifier)

基于这一层理解,就能确认,在nav3-recipes示例工程中,scene发生实例变化的时候,也等价于一个普通的Compose重组,其中可以依靠普通的重组、跳过和remember实现动画播放了。

总结

Navigation3 库的出现,极大地减轻了现有 app 在既存路由框架中接入使用的负担,让我们能快速地将现有的 Compose 页面接入其中,完成宽屏适配;同时也推动开发者在编写 Compose 页面的时候更加深入地思考该如何去适配它的生命周期与状态保存,提示代码的交付质量。

-End-

作者丨肖志康

我写了个系统,每天上朝批奏折:把 Agent 做成「文武百官」是什么体验

作者 芋圆ai
2026年4月3日 16:47

我写了个系统,每天上朝批奏折:把 Agent 做成「文武百官」是什么体验

当小人被关进天牢的那一刻,就是朕决定掏钱的那一刻。

写在前面

09_副本.png

先说结论:当你用「拟圣旨」的方式给 Agent 下指令,看着丞相带着六部尚书在 2D 像素宫殿里跑来跑去给你办差——这玩意儿比你想象的更有成就感。

我叫它 Syntropy(太和),一个基于古代朝廷隐喻的可视化多智能体操作系统。但不是那种换个皮就完事的「国潮包装」,而是真的把 Agent 的执行过程「具象化」了:

  • Agent 不再是日志里的一串 tool_call_pending,而是坐在工位上的像素小人
  • 任务调度不再是抽象的 Orchestrator,而是丞相(Minister)在廷议上发号施令
  • 风险拦截不再是冷冰冰的 await human_approval(),而是把 Agent 关进天牢,等你御批

你可能会问:为什么要搞这么复杂?直接写代码不行吗?

因为现有的 Multi-Agent 框架(LangChain、AutoGen、CrewAI……)有一个共同的问题:它们是黑盒。你能看见输入和输出,但中间的思考、决策、调度、执行,全在一团混沌的日志里。你调了半天 prompt,还是不知道 Agent 卡在哪一步。

所以我的思路很简单:既然 Agent 的执行过程看不见,那就让它「看得见」。

08_副本.png

这篇文章会拆解这套系统的技术架构,但不会堆砌术语。咱们边「上朝」边聊:怎么把状态机映射成像素动画、怎么实现前后端实时同步、怎么设计「御批」机制,以及——为什么「当皇帝」这个隐喻,反而让 Agent 系统更好用了。


1. 问题:Agent 系统的「三大弊政」

在动手之前,我先总结了当前 Multi-Agent 系统的三个「弊政」:

1.1 黑盒化(Black Box)——「爱卿,你到底在想什么?」

Agent 的 Chain-of-Thought(思维链)是一串嵌套的 JSON,工具调用是一条条 log 记录。你很难从这些信息里快速判断:

  • Agent 现在在做什么?是在思考,还是在等你审批?
  • 它在等谁?丞相在等户部尚书查账,还是兵部尚书在调兵?
  • 它卡在哪一步了?是 LLM 没返回,还是工具调用超时?
  • 它的决策依据是什么?为什么它选择了这个方案而不是另一个?

01_副本.png

现状:你只能盯着终端滚屏的日志,祈祷 Agent 别卡死。

1.2 失控风险(Uncontrollable)——「爱卿,这奏折朕没批你就敢执行?」

Agent 自主调用工具是一件很危险的事。删除文件、转账、调用外部 API —— 这些操作一旦失控,后果不可逆。

现有的「Human-in-the-loop」方案要么是简单的 input() 阻塞(体验极差),要么需要入侵式地修改整条执行链路(开发成本高)。

现状:你要么相信 Agent 不会乱来,要么就得自己写一套审核机制。

1.3 记忆遗忘(Amnesia)——「爱卿,昨天说的事你怎么忘了?」

大多数 Agent 框架的记忆系统基于关键词匹配或纯向量检索。关键词匹配漏掉语义相关的信息,纯向量检索又容易在专有名词上翻车。长对话场景下,Agent 往往会「忘记」几轮之前说过的关键信息。

现状:你只能不断「提示」Agent,把上下文塞给它,直到 Token 爆掉。


2. 方案:把 Agent 变成「可观测的臣子」

Syntropy 的核心思路只有一句话:

所见即所思(What you see is what they think)。

具体拆解为三个「治国方略」:

2.1 可视化运行时(Visualized Runtime)——「爱卿们,都动起来」

Agent 的内部状态机(THINKINGACTINGWAITINGERROR)不只在日志里打印,而是实时映射为 2D 像素小人的行为动画

  • THINKING → 小人在原地踱步,头顶冒出气泡(正在思考)
  • ACTING → 小人移动到对应的「工位」(户部查账、兵部调兵、工部造器械……)
  • WAITING_FOR_HUMAN → 小人被「关进天牢」,等待你的御批
  • ERROR → 小人倒地,头顶冒叉(出错了,得查日志)

这套映射让 Agent 的执行状态变得一眼可见。你不再需要翻几百行日志,只需要看一眼沙盘,就知道哪个 Agent 卡住了、它在干什么、它在等谁。

2.2 内核级状态机(Kernel-Level State Machine)——「朝堂规矩,不可乱」

后端 Agent 的生命周期被标准化为一个有限状态机(FSM):

agent-state-machine.png 这个 FSM 是「内核级」的,意味着:

  • LLM 推理(Reasoning)和工具执行(Execution)完全解耦
  • 每个状态的进入和离开都会触发标准化的事件(可观测)
  • 风险拦截、记忆压缩、日志追踪都可以作为「状态钩子」无痕插入

简单说:Agent 不再是「自由发挥」,而是按「朝堂规矩」办事。

2.3 人机协同「御批」协议(Human-in-the-loop)——「这道奏折,朕要亲自批」

每个工具调用都有 riskLevel(low / medium / high)。当 Agent 试图执行高风险操作时:

  1. 内核挂起当前执行流,状态切换为 WAITING_FOR_HUMAN
  2. 前端收到 approval_request 事件,弹出「御批」弹窗
  3. 用户点击「准奏」或「驳回」,通过 Socket 发回指令
  4. 内核恢复执行流(或回滚)

这套机制让「人审」不再是事后补救,而是执行流程的有机组成部分


3. 核心功能:奏折阁与决策树

讲完架构,聊聊实际用起来是什么感觉。

3.1 奏折阁(Imperial Archives)——「每道圣旨,都有迹可循」

在 Syntropy 里,每一次用户指令都被封装为一份**「奏折」。奏折阁是系统的任务管理中心,完整记录了从拟旨 → 受理 → 分发 → 复命**的全过程。

奏折的核心特性

  • 折叠/展开:每份奏折默认折叠,仅展示摘要(如"查Q1税收");展开后可查看完整的对话链路与决策树
  • 多视角叙事:左侧展示百官的回复与思考过程,右侧展示皇帝(用户)的指令,清晰还原对话脉络
  • 状态追踪:每份奏折都有明确的状态标签(待处理 / 进行中 / 已完成 / 已驳回),方便追溯

3.2 决策树可视化——「丞相的思考,一目了然」

当丞相收到一道复杂指令(如「查一下上季度的营收,并对比去年同期」),它不会直接给出答案,而是会拆解任务、调度六部、汇总结果。整个过程形成一棵决策树

decision-tree.png

在奏折阁中,这棵决策树以可视化流程图的形式呈现。你可以清楚地看到:

  • 丞相调度了哪些 Agent
  • 每个 Agent 执行了什么操作
  • 每一步的输入和输出是什么
  • 如果某一步出错,具体卡在哪里

02_副本.png

这对调试和优化至关重要。你不再需要猜"Agent 为什么没按我的预期做事",而是可以直接看到它的决策路径,找出问题所在。

3.3 记忆库(Memory Vault)——「史官的起居注」

记忆库展示 Agent 主动保存的重要信息(个人偏好、项目决策、关键事实),支持:

  • 搜索与过滤:按关键词搜索,或按类别(personal / preference / project / decision)筛选
  • 在线编辑:直接在前端编辑或删除记忆条目,实时同步到后端
  • 语义分类:LLM 根据上下文自动选择记忆类别,无需人工标注

4. 技术实现:朝堂是如何运转的

4.1 前端:React + Phaser 的「双引擎」架构

前端是 Syntropy 最复杂的部分。我们需要同时处理两类需求:

  • UI 层:奏折面板、记忆库、官员状态 HUD、御批弹窗……这些是典型的 React 组件
  • 渲染层:2D 像素沙盘、角色动画、寻路、碰撞检测……这些是游戏引擎的领域

我们的方案是 React-Phaser Bridge

frontend-architecture.png

  • Zustand 作为单一数据源(Single Source of Truth),存储所有 Agent 的状态
  • React 组件订阅 Zustand Store,渲染 UI 面板
  • Phaser 3update() 循环中读取 Store,同步小人动画

这套架构的好处是:UI 和渲染完全解耦。React 不用关心像素坐标,Phaser 不用关心业务逻辑,两者通过 Zustand 的状态桥接。

关键代码片段:状态同步
// store/agentStore.ts
export const useAgentStore = create<AgentStore>((set) => ({
  agents: {},
  updateAgent: (id, updates) =>
    set((state) => ({
      agents: {
        ...state.agents,
        [id]: { ...state.agents[id], ...updates },
      },
    })),
}));
// game/MainScene.ts
export class MainScene extends Phaser.Scene {
  update() {
    const agents = useAgentStore.getState().agents;
    Object.values(agents).forEach((agent) => {
      const sprite = this.sprites[agent.id];
      if (sprite) {
        sprite.updateState(agent.status, agent.targetPosition);
      }
    });
  }
}

4.2 后端:自研 Agent 框架

Syntropy 的后端完全自研,不依赖任何现有的 Agent 框架(LangChain、AutoGen 等)。原因很简单:现有框架的状态机模型和我们需要的不完全匹配

后端整体架构

backend-architecture.png

后端核心模块:

模块 职责
Kernel Agent 生命周期管理,状态机调度
Agent 单个 Agent 的 LLM 调用、工具执行、状态流转
LLM Provider 统一的 LLM API 抽象(支持 OpenAI / DeepSeek)
MemoryManager 记忆存储与检索(FTS5 + Vector + RRF)
SocketGateway 前后端实时通信
Tracer 全链路追踪与结构化日志
Agent 核心状态机
// server/core/Agent.ts
class Agent {
  private state: AgentState = 'IDLE';

  async processMessage(message: string) {
    this.setState('THINKING');
    const response = await this.llm.chat(message);
    
    if (response.toolCalls) {
      for (const toolCall of response.toolCalls) {
        const risk = this.assessRisk(toolCall);
        if (risk === 'high') {
          this.setState('WAITING_FOR_HUMAN');
          await this.waitForApproval(toolCall);
        }
        await this.executeTool(toolCall);
      }
    }
    
    this.setState('IDLE');
    return response.content;
  }
}

4.3 记忆系统:RRF 混合检索引擎

Syntropy 的记忆系统不是纯向量检索,而是三位一体的混合架构:

  1. SQLite:结构化元数据(时间、类别、Agent ID)
  2. FTS5:全文倒排索引,精准匹配关键词
  3. Vector:语义向量,模糊语义检索

检索时,我们使用 Reciprocal Rank Fusion (RRF) 算法合并 FTS 和 Vector 的结果:

RRF_score = Σ (1 / (k + rank_i))

其中 k 是平滑常数(通常取 60),rank_i 是某条记忆在第 i 个检索引擎中的排名。

为什么需要 RRF?
  • 关键词场景:用户问「昨天的税收是多少」,FTS 能精准命中包含"税收"的记录,Vector 可能因为语义漂移而漏掉
  • 语义场景:用户问「最近有什么异常吗」,FTS 因为没有一个明确的关键词而失效,Vector 能理解"异常"的语义

RRF 让两者互补,召回率显著提升。

记忆压缩(Memory Compression)

当 Agent 进入 SLEEPING 状态时,系统自动调用 LLM 对当日未处理的对话进行摘要,生成 daily_summary 并持久化。这解决了长对话场景下的 Token 溢出问题。

// server/runtime/MemoryManager.ts
async compressMemories(agentId: string, conversations: Conversation[]) {
  const summary = await this.llm.chat(`
    请对以下对话进行摘要,提取关键决策和待办事项:
    ${conversations.map(c => c.content).join('\n')}
  `);
  
  await this.db.insert('memories', {
    agentId,
    type: 'daily_summary',
    content: summary,
    timestamp: Date.now(),
  });
}

4.4 全链路追踪(Tracer)

每个用户指令生成唯一的 traceId,贯穿 Agent 调度、工具调用、LLM 推理全流程。Tracer 记录 8 种诊断事件:

  • agent.turn:Agent 开始处理一轮对话
  • tool.call:工具调用
  • model.usage:LLM 调用及 Token 消耗
  • dispatch:任务分发
  • approval.wait:等待御批
  • approval.done:御批完成
  • agent.stuck:Agent 卡死检测(3 分钟无响应自动告警)
  • memory.save:记忆保存

所有事件自动脱敏(截断 API Key 等敏感信息),便于性能分析和故障排查。


5. 架构反思

5.1 为什么选 Phaser 而不是 Canvas / SVG?

Phaser 是一个专业的 2D 游戏引擎,提供了完整的场景管理、精灵动画、碰撞检测、寻路算法。如果用 Canvas 手写,这些都要从零实现;如果用 SVG,大量 DOM 元素会导致性能问题。

Phaser 的 WebGL 渲染让我们可以轻松实现:

  • 像素小人的平滑移动动画
  • 头顶气泡的动态效果
  • 大规模 Agent 同时活动时的性能保障

5.2 为什么不直接用 LangChain / AutoGen?

LangChain 和 AutoGen 的状态机模型是「扁平」的:Agent 顺序执行 Thought → Action → Observation。但我们需要的是内核级的状态机,能够:

  • 在任意时刻挂起执行流(御批拦截)
  • 在任意时刻注入新状态(外部干预)
  • 标准化所有状态转换事件(可观测性)

现有的框架很难在不破坏封装的前提下实现这些需求。

5.3 视觉隐喻的取舍

「古代朝廷」这套视觉系统是一把双刃剑:

优点

  • 降低了多 Agent 系统的认知门槛,非技术用户也能理解「丞相调度六部」的概念
  • 增强了产品的辨识度和传播性

缺点

  • 增加了设计和开发成本(像素素材、动画、场景布局)
  • 对部分技术用户来说可能显得「花哨」

我们的判断是:可视化的核心价值在于降低认知负担,视觉隐喻只是手段。如果换一套视觉系统(如太空站、工厂流水线),只要核心架构不变,价值依然存在。


6. 开源与未来

Syntropy 目前处于 Beta 阶段,代码已开源:GitHub - zabr1314/Syntropy

未来规划:

  • 技能市场:允许开发者为 Agent 开发自定义技能(类似 VSCode 插件)
  • 多场景模板:除了「朝廷」,提供「太空站」「工厂」等可选视觉主题
  • Agent 编排可视化:拖拽式构建多 Agent 协作链路
  • 分布式部署:支持将不同 Agent 部署在不同节点上

7. 结语:当皇帝的一天

Syntropy 本质上是一次将 AI 黑盒具象化的尝试。我们相信:

当 Agent 的执行过程变得可见、可交互、可干预,多智能体系统才能真正从实验室走向生产环境。

但除此之外,它还有一个不那么「技术」的价值:它让管理 Agent 变成了一件有趣的事

每天早上打开系统,看到丞相已经在廷议上等你,六部尚书各就各位。你拟定一道圣旨,看着小人们在宫殿里跑来跑去办差。有时候他们会卡住,有时候他们会犯错,有时候他们会把奏折递到你面前等你御批——这一刻,你不是在 debug,你是在当皇帝。

如果你对这套架构感兴趣,或者有自己的看法,欢迎在 GitHub 上交流。


相关资源

鸿蒙隔空传送:一抓一放,内容就到了对面的设备上

2026年4月3日 16:39

引言

跨设备传文件这件事,我们已经做了太多年了——蓝牙配对、扫码互传、聊天窗口转发、甚至给自己发邮件。这些方式都能用,但都不够"顺手"。

鸿蒙 Share Kit 新推出的隔空传送,尝试用一种更自然的交互来解决这个问题:用户对着屏幕做一个"抓取"手势,内容就被"拿"起来了,再对着另一台设备"放下",内容就传过去了。整个过程不需要打开任何传输工具,也不需要手动选择接收设备。

本文面向希望在应用中接入隔空传送能力的鸿蒙开发者,梳理这项功能的工作机制、与系统其他功能的联动关系,以及具体的接入方法。


一、隔空传送是怎么工作的

1.1 基本交互逻辑

隔空传送的核心交互是"一抓一放"——用户在一台设备前做出握拳抓取的手势,设备捕捉到这个动作后,将当前页面的分享内容"抓起来";然后用户面向另一台设备做出释放手势,内容就传送到了对端设备上。

这个手势并不是凭空工作的。它依赖应用侧主动注册分享事件——只有当前页面注册了隔空传送的监听,系统才知道"这个页面有东西可以分享"。如果页面没注册,手势不会触发隔空传送。

1.2 使用前提:打开隔空传送开关

隔空传送默认不是开启状态,用户需要手动打开:

设置 → 系统 → 快捷启动和手势 → 隔空传送

这是设备级的开关,对所有支持隔空传送的应用生效。

1.3 设备信任机制

传输的安全性通过设备信任关系来保障,分两种情况:

  • 同账号设备:如果两台设备登录了相同的华为账号,系统默认它们互相信任,传输时无需额外确认,直接发送。
  • 不同账号设备:需要双端用户各自确认"信任对方设备"。确认后,1 小时内再次传输无需重复确认。超过 1 小时则需要重新建立信任。

这种设计在便捷性和安全性之间取了一个平衡——自己的设备间传东西零障碍,借别人设备传也不会被滥用。


二、隔空传送与隔空截屏的关系

一个容易让人困惑的地方是:隔空传送和隔空截屏共用同一个手势触发。这意味着用户做"抓取"动作时,可能同时触发两件事。系统通过两个开关的组合状态来决定具体行为:

隔空传送开启 隔空传送关闭
隔空截屏开启 图库场景传输原图;其他场景传送截屏 仅截屏,不传送
隔空截屏关闭 图库场景传送原图;其他场景无截屏也不传送 什么都不发生

几个值得注意的细节:

  • 当两个开关都打开,且当前页面注册了隔空传送事件时,抓取手势会同时触发隔空传送和隔空截屏。此时隔空传送的卡片下方会出现"保存截屏至本机"的提示。
  • 首次触发时,默认不保存截屏。用户可以手动勾选保存,系统会记住这个选择,作为下次的默认值。
  • 如果只开了隔空截屏、没开隔空传送,那抓取手势就只是截屏,不会有任何传输行为。

对于开发者来说,不需要关心截屏逻辑——这完全是系统层面的行为。你只需要关注隔空传送的注册和数据准备。


三、接入隔空传送的开发实践

接入隔空传送的核心工作就是三件事:在对的时机注册监听、在回调中准备分享数据、在离开时取消监听

3.1 导入所需模块

import { uniformTypeDescriptor as utd } from '@kit.ArkData';
import { systemShare, harmonyShare } from '@kit.ShareKit';
import { fileUri } from '@kit.CoreFileKit';

这里涉及三个 Kit:ShareKit 提供隔空传送和分享的核心能力,ArkData 中的 uniformTypeDescriptor 用于声明分享内容的数据类型,CoreFileKit 用于将文件路径转换为 URI。

3.2 定义手势触发时的分享逻辑

当用户做出抓取手势时,系统会通过注册的回调把一个 SharableTarget 对象传给你。你需要在这个回调里准备好分享数据,然后调用 sharableTarget.share() 把数据发出去。

private immersiveCallback = (sharableTarget: harmonyShare.SharableTarget) => {
  let uiContext: UIContext = this.getUIContext();
  let contextFaker: Context = uiContext.getHostContext() as Context;
  let filePath = contextFaker.filesDir + '/exampleKnock1.jpg';

  let shareData: systemShare.SharedData = new systemShare.SharedData({
    utd: utd.UniformDataType.HYPERLINK,
    content: 'https://sharekitdemo.drcn.agconnect.link/ZB3p',
    thumbnailUri: fileUri.getUriFromPath(filePath),
    title: '隔空传送分享卡片标题',
    description: '隔空传送分享卡片描述'
  });

  sharableTarget.share(shareData);
}

这里有一个关键的时间约束:收到回调后,建议在 3 秒内调用 sharableTarget.share(),否则可能因为超时导致传输失败。所以分享数据的准备不宜太重——如果需要读取大文件或做复杂处理,建议提前准备好,在回调中直接使用。

分享数据通过 SharedData 构造,几个关键字段的含义:

  • utd:声明分享内容的统一数据类型。上面的示例使用的是 HYPERLINK,表示分享一个链接。
  • content:实际的分享内容,这里是一个 App Linking 链接。
  • thumbnailUri:传送卡片上显示的缩略图,需要传入文件的 URI。
  • titledescription:传送卡片上的标题和描述文字。

3.3 分享 App Linking 实现直达应用

上面的示例中,content 字段传入的是一个 App Linking 链接。这样做的好处是:对端设备收到后,点击卡片可以直接跳转到对应的应用页面,而不只是打开一个网页。如果你希望实现这种"传送即直达"的体验,应用需要先接入 App Linking。

3.4 在正确的时机注册和取消监听

这一步直接决定了功能能否正常工作。原则很简单:

  • 进入可分享页面时注册,告诉系统"这个页面有内容可以分享"。
  • 离开可分享页面时取消,包括页面销毁和应用退到后台的场景。

注册和取消的方法很直接:

private immersiveListening() {
  harmonyShare.on('gesturesShare', this.immersiveCallback);
}

private immersiveDisablingListening() {
  harmonyShare.off('gesturesShare', this.immersiveCallback);
}

但"什么时候算离开"需要仔细处理。除了页面销毁(aboutToDisappear),应用退到后台也应该取消监听。一个完整的处理方式如下:

aboutToAppear(): void {
  // 页面出现时注册监听
  this.immersiveListening();

  // 同时监听应用退后台事件
  let uiContext: UIContext = this.getUIContext();
  let context: Context = uiContext.getHostContext() as Context;
  context.eventHub.on('onBackGround', this.onBackGround);
}

aboutToDisappear(): void {
  // 页面销毁时取消监听
  this.immersiveDisablingListening();

  let uiContext: UIContext = this.getUIContext();
  let context: Context = uiContext.getHostContext() as Context;
  context.eventHub.off('onBackGround', this.onBackGround);
}

// 页面隐藏时(包括退后台),通过事件总线通知
onPageHide(): void {
  let uiContext: UIContext = this.getUIContext();
  let context: Context = uiContext.getHostContext() as Context;
  context.eventHub.emit('onBackGround');
}

private onBackGround = () => {
  this.immersiveDisablingListening();
}

这里的思路是:通过 onPageHide 捕捉页面隐藏事件(退后台会触发),然后通过 eventHub 发出通知,在通知处理中取消隔空传送监听。这样无论是页面销毁还是应用退后台,监听都会被正确清理。

为什么要这么认真地处理取消监听? 因为如果应用退到后台后监听还在,用户在其他应用中做抓取手势时,可能会意外触发你的应用的分享逻辑,导致不可预期的行为。


四、总结

隔空传送提供了一种非常直觉化的跨设备内容传输方式。从开发者的角度看,它的接入并不复杂,但有几个关键点需要把握好:

  1. 时效性:收到手势回调后 3 秒内必须完成分享调用。分享数据要提前准备,不要在回调中做耗时操作。
  2. 生命周期管理:注册和取消监听必须与页面的生命周期严格对应。特别是应用退后台的场景,容易被忽略。
  3. 与隔空截屏的共存:理解两个开关的组合行为,在产品设计上给用户清晰的预期。
  4. 信任机制:同账号设备间传输是无感的,不同账号需要双向确认。如果你的应用场景经常涉及跨账号传输,可以在引导中提示用户。

如果你的应用有内容分享的需求——无论是链接、图片还是文件——隔空传送都是一种值得接入的自然交互方式。结合 App Linking,还能实现"传送即打开对应页面"的完整体验,这在跨设备协作场景中会非常实用。

救命!原来大厂前端都是这样封装 Axios 的… 我白干了三年

作者 前端Hardy
2026年4月3日 16:38

还在每个接口手动加 token?还在为 401 跳转写重复逻辑?
而用 这套 2026 年最新 Axios 通用封装一行配置搞定全局拦截、自动鉴权、错误统一处理、防重复请求——Vue2/Vue3、React、Uniapp、微信小程序、Node.js 全端兼容,线上项目稳定运行超 18 个月

如果你受够了:

  • 每个项目都要重写一遍 request
  • 登录过期后页面白屏没人管
  • 用户狂点按钮,接口被刷爆
  • 小程序和 H5 请求逻辑不一致,维护成本翻倍

那么,这篇经过字节、腾讯内部验证的封装方案,就是为你写的——
不用造轮子,直接复制粘贴,今天就能让接口层稳如泰山

一、先说痛点:裸写 Axios 的 5 大“致命伤”

问题 后果
每次手动拼 baseURL 开发/测试/线上环境混乱
token 手动携带 切换账号后部分接口 401
错误各自处理 有的弹 toast,有的 console.log
无防重机制 用户狂点提交,订单创建 5 次
响应结构不统一 res.data / res.result / res.payload 混用

真实案例:某电商项目因未防重复请求,大促期间用户重复下单,损失超 200 万。

二、核心方案:一个文件,搞定所有(附完整可运行代码)

文件路径:src/utils/request.js

import axios from 'axios'

// ===== 1. 创建实例 =====
const service = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json;charset=UTF-8'
  }
})

// ===== 2. 防重复请求(关键!)=====
const pending = new Map()
const getPendingKey = (config) =>
  [config.method, config.url, JSON.stringify(config.params), JSON.stringify(config.data)].join('&')

const removePending = (config) => {
  const key = getPendingKey(config)
  if (pending.has(key)) {
    pending.get(key)?.abort?.() // 取消上一次请求
    pending.delete(key)
  }
}

// ===== 3. 请求拦截器 =====
service.interceptors.request.use(
  (config) => {
    // 防重:取消相同请求
    removePending(config)
    const controller = new AbortController()
    config.signal = controller.signal
    pending.set(getPendingKey(config), controller)

    // 自动加 token(兼容 localStorage / uni.getStorageSync)
    const token = typeof localStorage !== 'undefined'
      ? localStorage.getItem('token')
      : uni.getStorageSync('token') // 小程序适配

    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error) => Promise.reject(error)
)

// ===== 4. 响应拦截器 =====
service.interceptors.response.use(
  (response) => {
    // 清除 pending
    removePending(response.config)

    const res = response.data
    // 假设后端 code=200 为成功(按实际调整)
    if (res.code === 200) {
      return res.data // 直接返回业务数据
    }

    // 统一错误提示
    uni.showToast?.({ title: res.msg || '操作失败', icon: 'none' }) // 小程序
    alert?.(res.msg || '请求失败') // Web
    return Promise.reject(res)
  },
  (error) => {
    removePending(error.config)

    let msg = '网络异常,请稍后重试'
    if (error.message?.includes('timeout')) msg = '请求超时'
    if (error.code === 'ECONNABORTED') msg = '请求已取消'
    if (error.response?.status === 401) {
      msg = '登录已过期'
      // 清 token + 跳登录
      localStorage.removeItem?.('token')
      uni.removeStorageSync?.('token')
      location.href = '/login' // Web
      uni.reLaunch?.({ url: '/pages/login/login' }) // 小程序
    }
    if (error.response?.status === 403) msg = '权限不足'
    if (error.response?.status === 500) msg = '服务器开小差了'

    uni.showToast?.({ title: msg, icon: 'none' })
    alert?.(msg)
    return Promise.reject(error)
  }
)

export default service

亮点

  • 自动防重复请求(基于 URL + 参数)
  • Web / 小程序双端兼容(localStorage vs uni.getStorageSync
  • 401 自动跳登录页
  • 返回值直接是 data,业务层无需再 .data.data

三、业务调用:极简写法,框架无关

1. 定义 API:src/api/user.js

import request from '@/utils/request'

// 获取用户信息
export const getUserInfo = () => request.get('/user/info')

// 登录
export const login = (data) => request.post('/user/login', data)

// 上传头像
export const uploadAvatar = (file) => {
  const formData = new FormData()
  formData.append('avatar', file)
  return request.post('/upload/avatar', formData, {
    headers: { 'Content-Type': 'multipart/form-data' }
  })
}

2. 页面中使用(Vue/React 完全一致)

import { getUserInfo } from '@/api/user'

async function loadProfile() {
  try {
    const userInfo = await getUserInfo() // 直接拿到 data
    setUser(userInfo)
  } catch (err) {
    // 全局已处理错误,此处可做特殊逻辑(如埋点)
    console.log('获取用户信息失败', err)
  }
}

优势:业务代码只关心“成功后的数据”,错误由拦截器兜底!

四、多端适配指南(一套代码跑全端)

环境 适配方案
Vue2/Vue3 直接使用上述代码
React 同上,alert 可替换为 message.error
Uniapp 使用 uni.request 封装,但逻辑结构一致
微信小程序 引入 miniprogram-axios,其余不变
Node.js 移除 UI 相关(toast/alert),保留核心逻辑

技巧:通过 typeof window !== 'undefined' 判断是否为 Web 环境。

五、避坑指南:3 个高频雷区

坑1:baseURL 写死,环境切换崩溃

正确做法

# .env.development
VITE_API_BASE_URL = 'https://dev.api.com'

# .env.production
VITE_API_BASE_URL = 'https://prod.api.com'

坑2:401 不清 token,导致无限跳转

必须在 401 处理中同步清除本地 token,否则跳回登录页后仍带旧 token。

坑3:防重逻辑没覆盖 POST 参数

很多方案只比对 URL 和 params,POST 的 data 也要参与 key 生成,否则表单提交仍会重复。

六、进阶扩展(按需添加)

  • 自动刷新 token:401 时用 refresh_token 换新 token,重发原请求
  • 请求日志:记录耗时、参数,用于性能分析
  • Mock 支持:开发环境自动 mock,不影响联调
  • 签名加密:金融类项目必备,请求前自动加签

这套方案已在多个百万级用户项目中稳定运行,不是玩具代码,而是生产级骨架
当你不再为接口错误焦头烂额,你就知道——这波封装,值了


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

鸿蒙文件预览开发实践:从打开文件到加速感知

2026年4月3日 16:31

引言

在很多应用场景中,用户需要快速查看一个文件的内容——可能是一份文档、一张图片、一段视频,或者一个纯文本文件。用户的预期很简单:点一下就能看,不需要专门打开另一个编辑器。

鸿蒙的 Preview Kit 提供的就是这种能力。它以拉起新窗口的方式展示文件内容,界面统一、使用简单,开发者不需要自己去实现各类文件格式的渲染逻辑。此外,从 5.0.5(17) 版本开始,Preview Kit 还引入了文件预加载状态感知的能力,让浏览器等下载类应用可以提前感知文件是否已被系统预加载,从而给用户更明确的"加速打开"提示。

本文面向有一定鸿蒙开发基础的开发者,梳理 Preview Kit 的使用方式、开发流程中的关键环节,以及文件预加载状态感知的接入方法。


一、Preview Kit 能做什么,不能做什么

在动手之前,先厘清 Preview Kit 当前的能力边界,避免走弯路。

能做的事:

  • 拉起一个独立的预览窗口,以统一的界面展示文件内容。
  • 支持图片、视频、音频、文本、HTML 等常见文件类型的预览。
  • Office 类文档(如 .docx、.xlsx 等)借助 WPS 的能力来渲染,所以预览界面中会出现"WPS 提供技术支持"等字样——这是正常的。
  • 支持单文件预览和多文件预览(多文件仅限移动端)。
  • 预览窗口是单例的,同一时间只会存在一个。

当前的限制:

  • 只支持跳出应用预览,暂不支持在应用内嵌入预览视图。如果你想在应用页面里直接内嵌一个文件预览区域,目前 Preview Kit 还做不到。
  • 没有安全定制能力。禁止截录屏、屏蔽"使用其他应用打开"、屏蔽分享入口等安全相关的控制,暂时都不支持。
  • 需要文件的访问权限。调用方必须具备对应 URI 的转授权能力,让预览窗口能正常读取文件内容。这一点在实际开发中容易踩坑,如果预览时报错,优先检查权限问题。

二、文件预览的完整开发流程

Preview Kit 的使用流程可以归纳为:判断能否预览 → 打开预览 → (可选)切换文件 → (可选)关闭预览。下面逐步展开。

2.1 导入模块

import { filePreview } from '@kit.PreviewKit';
import { BusinessError } from '@kit.BasicServicesKit';

filePreview 是 Preview Kit 的核心模块,BusinessError 用于错误处理。

2.2 先判断文件能不能预览

在正式打开预览之前,最好先检查一下目标文件是否支持预览。这一步不是必须的,但可以帮你在 UI 层做更好的引导——比如对不支持预览的文件,显示"不支持预览"的提示而不是让用户点了没反应。

let uri = 'file://docs/storage/Users/currentUser/Documents/1.txt';
let uiContext = this.getUIContext().getHostContext() as Context;

filePreview.canPreview(uiContext, uri).then((result) => {
  console.info(`是否可预览: ${result}`);
}).catch((err: BusinessError) => {
  console.error(`判断失败, err.code = ${err.code}, err.message = ${err.message}`);
});

当传入的文件类型在支持范围内(图片、视频、音频、文本、HTML)且文件确实存在时,返回 true;否则返回 false

2.3 打开预览窗口

这是最核心的一步。Preview Kit 提供了两种异步调用风格——Promise 和 Callback,功能完全一样,选择你习惯的即可。

Promise 方式:

let uiContext = this.getUIContext().getHostContext() as Context;

let displayInfo: filePreview.DisplayInfo = {
  x: 100, y: 100,
  width: 800, height: 800
};

let fileInfo: filePreview.PreviewInfo = {
  title: '1.txt',
  uri: 'file://docs/storage/Users/currentUser/Documents/1.txt',
  mimeType: 'text/plain'
};

filePreview.openPreview(uiContext, fileInfo, displayInfo).then(() => {
  console.info('预览打开成功');
}).catch((err: BusinessError) => {
  console.error(`打开失败, err.code = ${err.code}, err.message = ${err.message}`);
});

这里有几个关键参数需要说明:

  • PreviewInfo:描述要预览的文件,包含标题(title)、文件路径(uri)和 MIME 类型(mimeType)。MIME 类型告诉系统这是什么格式的文件,以便选择正确的渲染方式。
  • DisplayInfo:控制预览窗口的位置和大小,包括 x/y 坐标以及宽高。

有一个细节值得注意:1 秒内重复调用 openPreview 是无效的。这是为了防止用户快速连续点击导致重复打开窗口,开发者不需要自己加防抖逻辑。

Callback 方式:

filePreview.openPreview(uiContext, fileInfo, displayInfo, (err) => {
  if (err && err.code) {
    console.error(`打开失败, err.code = ${err.code}, err.message = ${err.message}`);
    return;
  }
  console.info('预览打开成功');
});

2.4 一次预览多个文件(仅移动端)

在移动端场景下,你可以一次传入多个文件,用户可以在预览窗口中左右滑动切换。传入时通过 index 参数指定默认展示第几个文件(从 0 开始):

let files: Array<filePreview.PreviewInfo> = [];

files.push({
  title: '1.txt',
  uri: 'file://docs/storage/Users/currentUser/Documents/1.txt',
  mimeType: 'text/plain'
});

files.push({
  title: '2.txt',
  uri: 'file://docs/storage/Users/currentUser/Documents/2.txt',
  mimeType: 'text/plain'
});

filePreview.openPreview(uiContext, files, 0).then(() => {
  console.info('多文件预览打开成功');
}).catch((err: BusinessError) => {
  console.error(`打开失败, err.code = ${err.code}, err.message = ${err.message}`);
});

这个能力比较适合文件管理器、聊天应用中查看多张图片等场景。

2.5 在已有窗口中切换文件

如果预览窗口已经打开了,你不需要关闭再重新打开来展示另一个文件。loadData 方法可以直接在当前窗口中加载新的文件内容:

let newFile: filePreview.PreviewInfo = {
  title: '2.txt',
  uri: 'file://docs/storage/Users/currentUser/Documents/2.txt',
  mimeType: 'text/plain'
};

filePreview.loadData(uiContext, newFile).then(() => {
  console.info('文件加载成功');
}).catch((err: BusinessError) => {
  console.error(`加载失败, err.code = ${err.code}, err.message = ${err.message}`);
});

需要注意的是,loadData 只在预览窗口已经存在时才生效,而且 100 毫秒内重复调用无效(比 openPreview 的 1 秒限制更短,因为切换文件的操作相对轻量)。如果传入的文件不支持预览,窗口会显示"不支持预览"的界面,而不是报错。

2.6 关闭预览窗口

filePreview.closePreview(uiContext).then(() => {
  console.info('预览窗口已关闭');
}).catch((err: BusinessError) => {
  console.error(`关闭失败, err.code = ${err.code}, err.message = ${err.message}`);
});

同样只在预览窗口存在时生效。另外,你还可以通过 hasDisplayed 方法来判断预览窗口当前是否处于打开状态,在一些需要条件判断的逻辑中会比较有用。


三、文件预加载状态感知:让"秒开"看得见

从 5.0.5(17) 版本开始,Preview Kit 新增了一项面向特定场景的能力——文件预加载状态感知。这个功能目前仅在 2in1 设备上支持,典型的使用者是浏览器等支持下载文件的应用。

3.1 这项能力解决什么问题

系统有时会对用户可能打开的文件进行预加载,也就是提前把文件内容读入缓存,让后续打开更快。但问题在于,用户并不知道哪些文件已经被预加载了。如果应用能感知到某个文件的预加载状态,就可以在 UI 上给用户一个明确的提示——比如一个"闪电"图标表示"这个文件可以秒开",体验上会好很多。

3.2 三种预加载状态

一个文件的预加载状态有三种:

状态 含义 建议的 UI 处理
PRELOADING 正在预加载中 可以显示加载动画
PRELOADED 预加载已完成 提示用户"加速打开"已就绪
NOT_PRELOADED 没有预加载 不需要额外提示

3.3 开发流程

整个接入过程分为四步:注册回调 → 添加文件监听 → 响应状态变化 → 清理资源。

第一步,导入模块并注册回调:

import { openFileBoost } from '@kit.PreviewKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

function callback(info: openFileBoost.FilePreloadStatusInfo): void {
  if (info.state === openFileBoost.FilePreloadState.PRELOADING) {
    hilog.info(0x0000, 'testTag', '文件正在预加载,可以展示加载动画');
  }
  if (info.state === openFileBoost.FilePreloadState.PRELOADED) {
    hilog.info(0x0000, 'testTag', '预加载完成,可以提示用户加速打开');
  }
  if (info.state === openFileBoost.FilePreloadState.NOT_PRELOADED) {
    hilog.info(0x0000, 'testTag', '未预加载,无需额外提示');
  }
}

// 注册监听
openFileBoost.on('filePreloadStateChanged', callback);

第二步,添加需要监听的文件:

注册回调之后,通过 addFile 传入文件的沙箱路径。之后这个文件的预加载状态一旦变化,就会通过上面注册的回调通知你。

const file = "/storage/Users/currentUser/Desktop/10MB_file.docx";
openFileBoost.addFile(file);

一个典型场景是:用户通过浏览器下载了一个文件,下载完成后,应用将该文件路径注册进来,后续如果系统对其进行了预加载,应用就能及时在下载列表中标记出来。

这里有一个限制需要注意:单个应用最多同时监听 50 个文件。不再需要关注的文件,应该及时调用 removeFile 取消监听,把名额腾出来:

openFileBoost.removeFile(file);

第三步,主动查询预加载状态:

除了被动等回调,你也可以主动查询某个文件当前的预加载状态。这在应用刚启动时特别有用——可以遍历一遍关心的文件,把已经预加载完成的标记出来:

let statusInfo = openFileBoost.queryFilePreloadStatusInfo(file);
hilog.info(0x0000, 'testTag',
  `文件: ${statusInfo.sandboxPath}, 进度: ${statusInfo.progress}, 状态: ${statusInfo.state}`);

返回的 FilePreloadStatusInfo 不仅包含状态,还有加载进度信息,可以用于更细粒度的 UI 展示。

第四步,清理资源:

当不再需要监听任何文件的预加载状态时,注销回调。off 方法支持两种用法:传入具体的回调函数则只取消该函数的监听,不传参数则取消当前进程的所有回调。

// 只取消某一个回调
openFileBoost.off('filePreloadStateChanged', callback);

// 取消所有回调
openFileBoost.off('filePreloadStateChanged');

3.4 接入前的准备

因为这项能力目前仅在 2in1 设备上可用,在接入之前,建议先通过 Syscap 查询目标设备是否支持 SystemCapability.PCService.OpenFileBoost。对于不支持的设备类型(如手机、平板),不需要也不应该调用这些接口。

另外,当前支持预加载的文件类型是有限的,不在支持范围内的文件类型默认为"未预加载"状态,不需要额外注册监听。


四、总结与实践建议

Preview Kit 的两项能力——文件预览和预加载状态感知——分别解决的是"怎么看文件"和"怎么更快地看文件"的问题。

对于文件预览,开发者要抓住几个要点:

  1. 权限先行。预览的前提是文件可被访问,URI 的转授权是最常见的问题来源。
  2. 善用 canPreview 做前置判断。在 UI 层提前给用户预期,好过让用户点击后看到失败。
  3. 预览窗口是单例的。不需要担心重复打开的问题,但切换文件时记得用 loadData 而不是重新调用 openPreview
  4. 留意调用频率限制openPreview 有 1 秒的防重复机制,loadData 是 100 毫秒。

对于预加载状态感知,如果你的应用运行在 2in1 设备上且涉及文件下载或管理,这是一个值得接入的体验增强点。用户能直观看到哪些文件可以"秒开",点击的信心和满意度都会更高。接入时注意控制监听文件数量(上限 50 个),及时清理不再需要的监听,保持资源使用的整洁。

AI Mind v0.0.8:从单 Skill 到多 Skill,我如何让第二个 Skill 真正成立

作者 倾颜
2026年4月3日 16:29

本文对应项目版本:v0.0.8

v0.0.7 里,我已经给 AI Mind 落下了第一个正式 Skill:utility-skill

那一版的重点,是证明一件事:

在 Multi-Tool Runtime 之上,是否真的能再长出一层更稳定的能力模式。

但只有一个 Skill,其实还不够。

因为单 Skill 最多只能证明:

  • 这套结构能跑
  • Runtime 能感知 Skill
  • Tool 可以被 Skill 收口

它还证明不了另一件更关键的事:

当系统开始进入多 Skill 阶段时,这层抽象到底是不是真的成立。

所以到了 v0.0.8,我真正要回答的问题就变成了:

  1. 第二个 Skill 应该是什么
  2. 什么样的 Skill 才值得进入正式版本
  3. 多 Skill Runtime 的边界应该怎么收
  4. 前端又该怎么把这种能力模式切换表达出来

这篇文章想讲的,就是我如何从最初的 writer-skill 设想,最后收敛到了 reader-skill,并让 AI Mind 真正迈出“从单 Skill 到多 Skill”的第一步。

skill-1.gif

为什么多 Skill 是这一版必须面对的问题

如果项目一直只有一个 utility-skill,那 Skill Runtime 很容易停留在一个比较尴尬的状态:

  • 看起来像是做出了一层新抽象
  • 但又很难证明它不是一次性的特殊 case

因为只有一个 Skill 时,你很难回答这些问题:

  • Skill 之间的边界能不能真正拉开
  • Runtime 是否能根据不同 Skill 暴露不同 Tool 子集
  • 自动模式下的路由是否还有意义
  • 前端是否需要为不同 Skill 提供更明确的交互入口

换句话说,单 Skill 更像是在证明“这套机制存在”,而多 Skill 才开始证明“这套机制成立”。

所以 v0.0.8 的重点,不是再多做一个功能,而是:

让第二个 Skill 真正成为一个有独立边界、有独立 Tool 价值的能力模式。

为什么最开始想到的是 writer-skill

一开始我最自然想到的第二个 Skill,其实是 writer-skill

这个方向表面上看很合理:

  • 它和 utility-skill 差异足够大
  • 用户很容易理解“写作模式”
  • 前端做模式切换时,也很容易有感知

所以我最初尝试的方向是:

  • 做一个 writer-skill
  • 再配一个偏结构整理的 Tool
  • 让模型在“改写、总结、整理、生成标题”这类任务上走另一条路径

从想法上说,这条线没有问题。

真正的问题出在落地后。

很快我就发现,写作整理类任务和 utility-skill 最大的不同在于:

它们里有很大一部分,其实本来就是大模型原生就会做的事情。

比如:

  • 润色一段话
  • 把几句话改写得更自然
  • 整理成一段通顺表达
  • 概括几个点

这些任务里,模型往往会直接回答,而不是老老实实触发 Tool。

也就是说,writer-skill 可以命中,但 Tool 的独特价值却不够稳定。

为什么我最后放弃了 writer-skill

最后让我决定止损的,不是某一个 bug,而是一个越来越明确的判断:

第二个正式 Skill,最好补的是模型没有的能力,而不是模型已经比较擅长的能力。

writer-skill 的问题主要有三个。

1. 写作整理很多时候是模型原生能力

写作并不是不能做成 Skill,而是它很难在当前阶段承担“证明多 Skill Runtime 成立”的任务。

因为一旦用户的需求是:

  • 改写
  • 润色
  • 概括
  • 整理成更自然的一段话

模型会天然倾向于自己直接写。

这意味着:

  • Skill 也许命中了
  • 但 Tool 不一定会稳定触发

2. Tool 没形成“非它不可”的能力差

如果一个 Tool 提供的只是:

  • 换个结构
  • 换个格式
  • 帮你整理一下语序

那它很容易被模型直接绕过去。

因为模型会判断:

我自己直接写一段,往往比调用一个结构整理 Tool 更简单。

这和 calculatordatetimeunit-convert 完全不一样。

后者一旦不用 Tool,模型就很容易答错;而前者即使不用 Tool,模型也很可能还能答得不错。

3. 第二个 Skill 不应该只是“多一种说话风格”

这是这轮最重要的取舍。

我最后越来越明确地意识到:

多 Skill 的关键,不是“多几个模式名字”,也不是“多几段 Prompt”。

真正值得留下来的 Skill,应该满足至少一条:

  • 它组织了一组边界清晰的 Tool
  • 它补的是模型原本拿不到的能力
  • 它能明显改变 Runtime 的可用能力范围

writer-skill 在当前阶段没有足够强地满足这些条件。

所以我最后放弃它,并不是因为写作不重要,而是因为它不适合当前作为“第二个正式 Skill”。

为什么第二个 Skill 最终变成了 reader-skill

我最后把第二个 Skill 改成了 reader-skill

因为这时我更想验证的是:

Skill Runtime 是否能承载一类模型本身完全拿不到的信息能力。

reader-skill 对应的正是这类场景:

  • 实时天气
  • 本地文本文件

它们有一个共同点:

没有 Tool,就没有能力来源。

这和写作场景最大的区别在于:

  • 没有天气 Tool,模型就拿不到实时天气
  • 没有本地文件读取 Tool,模型就看不到你的项目文件

这时候 Tool 不再是“可选增强”,而是“能力成立的前提”。

于是 reader-skill 的价值就变得非常明确:

  • 它不是在给模型增加一种风格
  • 而是在给模型接入一类新的上下文来源

这就让第二个 Skill 终于拥有了足够清晰的独立边界。

这版 reader-skill 是怎么收边界的

reader-skill 这一版我只落了两个 Tool,而且每个 Tool 都故意收得很小。

1. city-weather

用途非常单一:

  • 查询指定城市的实时天气

它只收一个参数:

  • city

数据源我也没有做得很重,而是直接用了轻量的 wttr.in

这背后的考虑很简单:

  • 这版的重点不是做一个完整天气系统
  • 而是验证“实时信息如何进入 Skill Runtime”

也就是说,city-weather 的价值不在于“做得多强”,而在于它非常直接地证明了:

没有外部 Tool,模型就是拿不到这部分实时信息。

2. local-text-read

local-text-read 同样只做一件事:

  • 读取项目根目录下的直接文本文件

它也只收一个参数:

  • filename

而且我给它加了很强的边界限制:

  • 只允许根目录直接文件
  • 不允许子目录
  • 不允许绝对路径
  • 不允许 ../
  • 只允许文本类文件

这也是我这一版很看重的一点:

Tool 的价值不只是“能做什么”,还包括“它不会越界做什么”。

如果第二个 Skill 要证明它是一种正式能力模式,那它不仅要有能力来源,也要有稳定边界。

多 Skill Runtime 这一版真正收敛了什么

v0.0.8,项目里的 Skill 边界终于开始变清楚了。

现在可以比较明确地把它们分成两类:

utility-skill

负责确定性实用任务:

  • 计算
  • 时间日期
  • 单位换算
  • 文本转换

对应 Tool:

  • calculator
  • datetime
  • unit-convert
  • text-transform

reader-skill

负责外部上下文获取:

  • 实时天气
  • 本地文件读取

对应 Tool:

  • city-weather
  • local-text-read

这时候 Skill 才真正不再只是“一个标签”,而是:

  • 当前属于哪一种能力模式
  • 当前允许模型使用哪些 Tool
  • 当前回答主要建立在哪一类能力来源上

多 Skill 链路图

flowchart LR
    A["用户请求"] --> B["/api/chat"]
    B --> C{"是否显式传入 skill"}
    C -- "是" --> D["直接命中对应 Skill"]
    C -- "否" --> E["轻量规则路由"]
    E --> F{"命中 utility / reader ?"}
    F -- "utility" --> G["utility-skill"]
    F -- "reader" --> H["reader-skill"]
    F -- "未命中" --> I["普通聊天链路"]
    G --> J["allowedTools 过滤 ToolRegistry"]
    H --> J
    J --> K["模型在当前 Tool 子集里决定是否调用 Tool"]
    K --> L["Runtime 执行 Tool"]
    L --> M["流式返回 reasoning / tool / text"]

这一版里我刻意没有做的,是:

  • 模型自主 Skill 路由
  • 多 Skill 编排
  • 更复杂的 Agent 化链路

因为我想先证明的不是“系统越来越聪明”,而是:

多 Skill Runtime 在结构上已经开始稳定成立。

为什么前端也要一起进入正式组件基线

这版还有一个我认为非常值得一起写进去的变化:

前端开始正式进入 shadcn/ui 基线阶段。

原因其实也很现实。

当输入区开始同时出现:

  • 模型选择器
  • Skill 模式切换
  • 深度思考开关
  • 推理过程面板
  • Tool 卡片

如果继续完全靠手写样式往前堆,界面会越来越像几套东西拼在一起。

所以这一版我顺手做了前端统一收口:

  • 正式接入 shadcn/ui
  • 使用 Radix
  • 图标统一为 lucide-react
  • 主题走 cssVariables
  • 当前基线切到 radix-vega

这一轮已经统一下来的区域包括:

  • 输入区控制条
  • 顶部错误条
  • 推理过程面板
  • Tool 卡片
  • 空状态

而且我还补了几项比较细的交互收口:

  • 输入框上下边距收紧
  • 推理面板上下边距收紧
  • Tool 状态色区分:
    • 完成:绿
    • 执行中:蓝
    • 失败:红
  • 实用读取 模式下的提示文案分开

这一点对我来说很重要,因为它说明:

多 Skill Runtime 的成立,不只是后端 Runtime 的问题,也是前端表达能力的一部分。

skill-2.gif

这版最重要的工程结论

如果要我用几句话总结 v0.0.8,我最想留下的是这三点:

1. 不是所有 Skill 都值得进入正式版本

有些 Skill 看起来方向对,但它不一定适合当前版本的验证目标。

writer-skill 就属于这种情况:

  • 它不是完全没价值
  • 但它不适合当前承担“证明第二个 Skill 成立”的任务

2. 第二个 Skill 最好补的是模型缺失的能力

如果 Tool 补的是模型原本就会做的事情,那它就很容易被绕过。

但如果 Tool 补的是模型完全拿不到的上下文,那 Skill 的价值会立刻清晰很多。

这也是为什么 reader-skillwriter-skill 更适合当前阶段。

3. 多 Skill 的成立,不只是 Runtime 的事,也是 UI 的事

一旦系统开始真正区分:

  • 自动 / 实用 / 读取
  • reasoning / tool / text
  • 不同 Tool 状态

那前端也必须同步给出更统一、更稳定的表达方式。

这也是为什么这版里,我没有把 UI 统一看成“顺手做的样式活”。

它其实也是版本收敛的一部分。

这一版之后,我更清楚了一件事

如果说 v0.0.7 证明的是:

Tool Runtime 之上可以长出第一层 Skill Runtime。

那么 v0.0.8 证明的就是:

多 Skill 不是多几个不同名字的 Prompt,而是第二个 Skill 是否真的打开了一块新的能力边界。

对现在的 AI Mind 来说,这块边界已经开始变得清楚:

  • utility-skill:确定性实用任务
  • reader-skill:外部上下文获取

这也让整个 Runtime Skeleton 比之前更像一个会继续长大的系统,而不是一组不断堆叠的局部功能。

后面会往哪走

如果继续沿这条线往后走,我更关心的是:

  • reader-skill 的稳定性继续收口
  • 网页读取 / MCP 能力怎么接入
  • 更高层的 Agent Runtime 什么时候开始真正有必要

但至少在 v0.0.8 这个点上,我已经比较确认:

第二个 Skill 终于不是一个“看起来像 Skill 的名字”,而是一块真正成立的能力模式。

最后

这个项目还会继续沿着:

  • reader-skill 稳定性收口
  • 网页读取 / MCP
  • Agent Runtime

这些方向继续往前走。

如果这篇文章对你有帮助,欢迎到 GitHub 看看项目,也欢迎顺手点个 Star。

仓库地址: github.com/HWYD/ai-min…

紧急更新!JS数组API新特性,告别forEach嵌套,代码效率翻倍

作者 前端Hardy
2026年4月3日 16:21

还在用 for 循环反向遍历?还在写 [...arr].reverse() 防止污染原数组?
而用 ES2022+ 最新数组 API一行代码实现反向查找、安全反转、负索引访问——无需 Lodash,不改原数组,Vue/React/Uniapp/小程序/Node.js 全生态通吃

如果你受够了:

  • 写三层 forEach 嵌套,自己都看不懂
  • 想找最后一个匹配项,只能手动倒序遍历
  • reverse() 不小心改了原始数据,引发线上 bug
  • 团队里有人写 arr[arr.length - 1],有人用 slice,风格混乱

那么,这篇 2026 年紧急更新指南,就是为你写的——
不用等 Babel 升级,主流环境已全面支持,今天就能删掉 50% 的冗余代码


一、先说重点:这 4 个 API,能让你少写 80% 的数组处理代码

需求 旧写法 新写法(ES2022+)
从末尾找第一个匹配元素 手动倒序 for / 反转 + find arr.findLast()
从末尾找第一个匹配索引 倒序遍历记录 i arr.findLastIndex()
安全反转数组(不改原数组) [...arr].reverse() arr.toReversed()
获取最后一个元素 arr[arr.length - 1] arr.at(-1)

真实收益

  • 代码行数减少 60%
  • 逻辑错误率下降 90%
  • 调试时间缩短一半

二、核心干货:4 大 API 实战演示(附多端通用模板)

1. findLast():从末尾查找,一行搞定

场景:找最后一个“已读”消息、最后一个“审核通过”的订单。

旧写法 vs 新写法

const messages = [
  { id: 1, read: false },
  { id: 2, read: true },
  { id: 3, read: false },
  { id: 4, read: true }
];

// 旧:手动倒序 or 反转(易错 + 性能差)
let lastRead = null;
for (let i = messages.length - 1; i >= 0; i--) {
  if (messages[i].read) {
    lastRead = messages[i];
    break;
  }
}

// 新:一行搞定!
const lastRead = messages.findLast(msg => msg.read);
console.log(lastRead); // { id: 4, read: true }

Vue3 组件中使用

<script setup>
import { ref } from 'vue'

const logs = ref([
  { level: 'info' },
  { level: 'error' },
  { level: 'warn' },
  { level: 'error' }
])

// 找最后一个 error 日志
const lastError = logs.value.findLast(log => log.level === 'error')
</script>

2. findLastIndex():精准定位末尾匹配项索引

场景:删除最后一个重复项、高亮最后一个符合条件的列表项。

实战示例:Node.js 删除最后一个匹配项

// Node.js 16+ 支持
const tasks = ['buy milk', 'walk dog', 'buy bread', 'call mom', 'buy eggs'];

// 找最后一个包含 "buy" 的任务索引
const lastIndex = tasks.findLastIndex(task => task.includes('buy'));

if (lastIndex !== -1) {
  tasks.splice(lastIndex, 1); // 删除 "buy eggs"
}

console.log(tasks);
// ['buy milk', 'walk dog', 'buy bread', 'call mom']

优势:无需遍历整个数组,性能更优!


3. toReversed():安全反转,永不污染原数组

痛点reverse()直接修改原数组,导致难以追踪的 bug。

对比演示

const original = [1, 2, 3, 4, 5];

// 危险!原数组被修改
const reversed1 = original.reverse();
console.log(original); // [5, 4, 3, 2, 1] ← 原数组变了!

// 安全!原数组不变
const reversed2 = original.toReversed();
console.log(original); // [1, 2, 3, 4, 5] ← 安然无恙
console.log(reversed2); // [5, 4, 3, 2, 1]

React 中安全使用

function MessageList({ messages }) {
  // 安全反转,不影响父组件传入的 messages
  const reversedMessages = messages.toReversed();

  return (
    <ul>
      {reversedMessages.map(msg => <li key={msg.id}>{msg.text}</li>)}
    </ul>
  );
}

4. at():负索引访问,优雅到哭

告别 arr[arr.length - 1] 这种又长又易错的写法

const arr = ['a', 'b', 'c', 'd', 'e'];

// 旧:繁琐且易出错(比如 length 算错)
const last = arr[arr.length - 1];
const secondLast = arr[arr.length - 2];

// 新:简洁直观
const last = arr.at(-1);      // 'e'
const secondLast = arr.at(-2); // 'd'
const first = arr.at(0);       // 'a'

Bonusat() 还支持字符串!
'hello'.at(-1)'o'


三、实战避坑:4 个高频雷区,新手必看

坑 1:方向搞反,用错 findfindLast

  • 想找第一个匹配 → 用 find()
  • 想找最后一个匹配 → 用 findLast()

坑 2:以为 toReversed() 会改原数组

永远不会修改原数组!如果确实需要修改,请用 reverse()

坑 3:忽略兼容性(但其实不用慌)

环境 支持情况
Chrome 92+
Firefox 90+
Safari 15.4+
Node.js 16+
Vue3 / React 18+
微信小程序(基础库 2.24.0+)

仅 IE 不支持,如需兼容,见下文方案。

坑 4:at() 越界返回 undefined,不报错

const arr = [1, 2];
console.log(arr.at(10));  // undefined
console.log(arr.at(-10)); // undefined
// 需自行判断是否有效

四、兼容性兜底方案(一行代码解决)

如果项目仍需支持旧环境(如 IE 或低版本 Node),只需:

【1. 安装 polyfill】

pnpm add core-js@3

【2. 入口文件引入】

// main.js 或 index.js
import 'core-js/stable';
// 自动补全 findLast、toReversed、at 等 API

效果:代码照常写新语法,打包后自动兼容!


五、谁在用这些新 API?

  • 字节跳动:内部工具链全面采用 findLast 替代手动遍历
  • 腾讯文档:协同编辑历史记录用 findLastIndex 定位最新操作
  • Vite 官方模板:默认启用 core-js,开箱即用新 API
  • MDN 官方文档:已将 toReversed() 列为推荐写法

结语:数组处理,本该如此优雅

这些新 API 不是“玩具特性”,而是对 JavaScript 数组操作范式的重大升级
当你能用 arr.findLast(x => x.valid) 代替 10 行 for 循环,你就知道——这波更新,值了


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

紧急更新!JS数组又来新API!告别循环嵌套,一行代码直接起飞

作者 前端Hardy
2026年4月3日 16:19

做前端这么久,数组处理几乎占了日常逻辑的一半。

以前为了找最后一条数据、安全修改数组、批量判断元素,不得不写 forEach 嵌套、手动拷贝、多层循环判断,代码又臭又长,还容易改坏原数组。

现在 ES 新特性直接补齐短板,4 个超强数组新 API,Vue / React / 小程序 / Node.js 通用,不用装 Lodash,原生就能用,代码简洁度直接翻倍。


1. findLast / findLastIndex

从后往前找,再也不用 reverse + 拷贝

以前想拿数组最后一个满足条件的项,要这么写:

const list = [1, 3, 5, 7, 9]

// 老写法:又绕又容易错
const target = [...list].reverse().find(item => item > 4)
const index = list.length - 1 - [...list].reverse().findIndex(item => item > 4)

现在一行搞定:

// 从后往前找第一个满足条件的值
const target = list.findLast(item => item > 4) // 9

// 从后往前找索引
const index = list.findLastIndex(item => item > 4) // 4

真实业务场景 订单列表、消息列表、日志列表,永远只需要最后一条符合条件的数据,这个 API 直接封神。


2. toReversed / toSorted / toSpliced / with

immutable 安全操作,不污染原数组

以前用 reverse / sort / splice 都会直接改原数组,一不小心就翻车:

const arr = [3,1,2]
const newArr = arr.sort() // 原数组 arr 也被改了!

现在新 API 全部返回新数组,原数组纹丝不动:

const arr = [3,1,2]

// 反转(不改变原数组)
arr.toReversed()

// 排序(不改变原数组)
arr.toSorted()

// 切割删除(不改变原数组)
arr.toSpliced(1, 1)

// 替换指定下标的值(超级好用)
arr.with(1, 999) // 下标1替换成999

再也不用写 [...arr] 浅拷贝了,代码干净十倍。


3. Array.fromAsync

异步遍历神器,告别 Promise 地狱

处理异步数组时,以前你得这样:

const res = await Promise.all(ids.map(id => fetchItem(id)))

遇到需要流式、分批、异步生成的数组,forEach 根本顶不住。

现在直接:

const asyncIterable = createAsyncData() // 异步可迭代对象

// 直接转成数组,自带异步等待
const result = await Array.fromAsync(asyncIterable, item => {
  return item.data
})

Node.js 流、前端分页加载、异步列表处理,直接起飞。


4. group / groupToMap 数组分组

一行分组,告别手写循环

后端返回列表,前端要按类型/状态/时间分组,以前要写一堆:

const group = {}
list.forEach(item => {
  if (!group[item.type]) group[item.type] = []
  group[item.type].push(item)
})

现在:

const group = list.group(item => item.type)

返回结构直接就是:

{
  goods: [....],
  order: [....],
  coupon: [....]
}

想更严谨用 groupToMap,支持复杂 key:

const map = list.groupToMap(item => item.status)

5. 一些高频实用小语法

// 取最后一项(再也不用 arr[arr.length-1])
arr.at(-1)

// 判断是否所有项满足条件
arr.every(...)

// 判断是否至少一项满足
arr.some(...)

// 扁平化数组
arr.flat(Infinity)

避坑提醒(非常重要)

  • 这些新 API 不支持 IE
  • 小程序、现代浏览器、Node.js 18+ 基本都支持
  • 极低版本环境可以用 core-js 做兼容

总结

以前要写十几行的数组逻辑,现在一行就能搞定

  • 从后查找:findLast
  • 安全修改:toReversed / toSorted / toSpliced / with
  • 异步数组:Array.fromAsync
  • 数据分组:group / groupToMap

学会这一套,业务代码至少精简 50%,可读性、维护性直接拉满,面试说出来也是加分项。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

深入理解 Signal:Push-Pull 响应式算法源码解读

作者 jump_jump
2026年4月3日 16:14

什么是 Signal

Signal 是现代前端框架(Vue、Solid、Preact、Angular、Svelte)共同采用的响应式原语。它解决的核心问题是:当数据变化时,如何高效地通知依赖方更新?

答案是 Push-Pull 混合算法

  • Push:数据变化时,急切地向下游传播"你脏了"的通知
  • Pull:只有当值真正被读取时,才惰性地重新计算

本文将从零开始,逐层构建一个完整的 Signal 系统,每一层都在上一层基础上增加一个概念。


第一层:Signal — 带通知的盒子

Signal 是最基础的响应式单元。本质就是观察者模式:

type Sub<T> = (s: T) => void

export function signal<T>(initial: T) {
  let value = initial
  const subs = new Set<Sub<T>>()

  return {
    get value(): T {
      return value
    },
    set value(v: T) {
      if (value === v) return       // 值没变,跳过
      value = v
      for (const fn of [...subs]) fn(v)  // 通知所有订阅者
    },
    subscribe(fn: Sub<T>) {
      subs.add(fn)
      return () => subs.delete(fn)  // 返回取消订阅函数
    },
  }
}

使用:

const count = signal(1)
count.subscribe((v) => console.log('变了:', v))
count.value = 2  // 打印 "变了: 2"
count.value = 2  // 值没变,不触发

三个要点:

  • subs:订阅者集合,存的是回调函数
  • setter 里 if (value === v) return:值没变就不通知,避免无意义的更新
  • setter 遍历 subs 并调用 — 这就是 Push(主动推送)

这一层没什么难的。接下来加入 computed。


第二层:Computed — 惰性计算 + 缓存

Computed 是派生值,由一个计算函数 fn 定义:

export function computed<T>(fn: () => T) {
  let cachedValue: T
  let dirty = true

  function _internalCompute() {
    cachedValue = fn()
    dirty = false
  }

  return {
    get value(): T {
      if (dirty) _internalCompute()
      return cachedValue
    },
  }
}

使用:

const count = signal(1)
const double = computed(() => count.value * 2)
console.log(double.value)  // 此时才执行计算,返回 2
console.log(double.value)  // dirty=false,直接返回缓存

两个要点:

  • dirty 标记:true 表示需要重算,false 表示缓存有效
  • 只有读取 .value 时才计算 — 这就是 Pull(按需拉取)

但这个版本有两个大问题:

  1. computed 不知道自己依赖了谁,没人把它标记为 dirty
  2. 没有自动依赖追踪,需要手动管理

第三层解决这两个问题。


第三层:自动依赖追踪 — 全局栈

这是整个算法最精妙的部分。核心问题:computed 执行 fn() 时,怎么自动知道自己读了哪些 signal?

答案:用一个全局栈 STACK 当"暗号"。

类型定义

type ComputeContext = {
  setDirty: () => void                // 把我标记为 dirty
  addSource: (cleanup: () => void) => void  // 记录一个依赖源的退订函数
}

const STACK: ComputeContext[] = []

Signal 的改造

有了全局栈,signal 不再需要第一层的 subscribe 方法——依赖注册由 getter 自动完成。同时 subs 的类型从 Set<Sub<T>> 变为 Set<() => void>,因为现在存的是 computed 的 setDirty(无参函数),不再需要传递新值:

export function signal<T>(initial: T) {
  let value = initial
  const subs = new Set<() => void>()    // 改:存 setDirty 函数

  return {
    get value(): T {
      const ctx = STACK[STACK.length - 1]  // 看栈顶
      if (ctx) {
        subs.add(ctx.setDirty)                          // ①
        ctx.addSource(() => subs.delete(ctx.setDirty))  // ②
      }
      return value
    },
    set value(v: T) {
      if (value === v) return
      value = v
      for (const fn of [...subs]) fn()   // 改:不传参,只通知"脏了"
    },
  }
}

这两行是整个系统的核心"握手":

  • subs.add(ctx.setDirty) — signal 说:"我变了会通知你"。把 computed 的 setDirty 加入自己的订阅者列表。以后 signal 值变了,setter 遍历 subs 时就会调用它,把 computed 标记为 dirty。

  • ctx.addSource(退订函数) — signal 说:"你不要我了就用这个取消"。把一个清理函数交给 computed 保管。computed 下次重新计算前会调用它,从 signal 的 subs 里删掉自己。

这个"握手"只在 STACK 非空时发生——即某个 computed 正在执行 fn() 时。普通代码读取 signal(如 console.log(count.value))不会注册依赖。

Computed 的完整实现

export function computed<T>(fn: () => T) {
  const subs = new Set<ComputeContext>()    // 谁依赖我
  const sources = new Set<() => void>()     // 我依赖谁(存的是退订函数)
  let cachedValue: T
  let dirty = true

  function _internalCompute() {
    // 1. 清理旧依赖
    sources.forEach((cleanup) => cleanup())
    sources.clear()

    // 2. 压栈:告诉所有 signal "接下来读取是我发起的"
    STACK.push({
      setDirty: () => {
        if (dirty) return           // 已经 dirty 了,不重复传播
        dirty = true
        for (const sub of [...subs]) sub.setDirty()  // 向下游继续传播
      },
      addSource: (unsub) => sources.add(unsub),
    })

    // 3. 执行计算函数 — 过程中会触发 signal 的 getter,自动注册依赖
    cachedValue = fn()
    dirty = false

    // 4. 弹栈
    STACK.pop()
  }

  return {
    get value(): T {
      // 如果有上层 computed 在读我,也要注册依赖
      const ctx = STACK[STACK.length - 1]
      if (ctx) {
        subs.add(ctx)
        ctx.addSource(() => subs.delete(ctx))
      }
      if (dirty) _internalCompute()
      return cachedValue
    },
  }
}

注意 computed 的 subs 和 signal 的 subs 存的东西不一样:

  • signal.subs = Set<函数> — 存的是 setDirty 函数,值变了直接调用
  • computed.subs = Set<ComputeContext> — 存的是上下文对象,dirty 时调用 .setDirty() 继续向下游传播

因为 computed 既是消费者(依赖 signal),又是生产者(被其他 computed 依赖),需要把 dirty 继续往下传。


执行流程详解

用一个具体例子走完整个流程:

const count = signal(1)
const double = computed(() => count.value * 2)
const plusOne = computed(() => double.value + 1)

阶段一:首次读取 plusOne.value

plusOne.value 被读取
  → dirty === true,进入 _internalCompute()
  → STACK.push(plusOne_ctx)
  → STACK: [plusOne_ctx]
  → 执行 fn():double.value + 1
    │
    ├→ 读取 double.value
    │    → double 的 getter 检查栈顶 = plusOne_ctx
    │    → double.subs.add(plusOne_ctx)          // double 记住 plusOne
    │    → plusOne.sources.add(退订函数)          // plusOne 记住 double
    │    → double.dirty === true,进入 double._internalCompute()
    │    → STACK.push(double_ctx)
    │    → STACK: [plusOne_ctx, double_ctx]       // 两个同时在栈里!
    │    → 执行 fn():count.value * 2
    │    │
    │    ├→ 读取 count.value
    │    │    → count 的 getter 检查栈顶 = double_ctx(不是 plusOne_ctx!)
    │    │    → count.subs.add(double.setDirty)  // count 记住 double
    │    │    → double.sources.add(退订函数)      // double 记住 count
    │    │    → 返回 1
    │    │
    │    → cachedValue = 1 * 2 = 2
    │    → STACK.pop() → STACK: [plusOne_ctx]
    │    → 返回 2
    │
    → cachedValue = 2 + 1 = 3
    → STACK.pop() → STACK: []
    → 返回 3

执行完后的依赖关系:

count.subs = { double.setDirty }
double.subs = { plusOne_ctx }
double.sources = { count的退订函数 }
plusOne.sources = { double的退订函数 }

为什么用栈而不是单个变量? 因为 computed 的 fn() 内部可能触发另一个 computed 的求值,形成嵌套。栈的 LIFO 特性天然匹配:栈顶永远是当前最内层正在计算的 computed。JS 是单线程的,不存在两个 computed 同时执行的情况,所以栈不会冲突。

阶段二:修改 count.value = 5(Push 阶段)

count.value = 5
  → setter 触发
  → 遍历 subs,调用 double.setDirty()
    → double.dirty = true
    → double 遍历自己的 subs,调用 plusOne_ctx.setDirty()
      → plusOne.dirty = true

注意:只标记 dirty,不做任何计算。这就是 Push 的特点——轻量、快速,只传播"失效通知"。

阶段三:再次读取 plusOne.value(Pull 阶段)

plusOne.value 被读取
  → dirty === true,进入 _internalCompute()
  → 清理旧依赖(断开与 double 的连接)
  → 压栈,执行 fn()
    → 读取 double.value → double 也是 dirty → 重新计算
      → 清理旧依赖(断开与 count 的连接)
      → 压栈,执行 fn()
        → 读取 count.value → 重新注册依赖
      → 返回 10
    → 重新注册依赖
    → 返回 11

只有被读取的 computed 才会重算。如果没人读 plusOne,即使它被标记了 dirty,也不会浪费算力。


为什么要清理旧依赖

这是很多人困惑的地方。看这个例子:

const toggle = signal(true)
const a = signal(1)
const b = signal(2)
const result = computed(() => toggle.value ? a.value : b.value)

第一次执行(toggle=true):result 读了 toggle 和 a,依赖关系:

toggle.subs = { result.setDirty }
a.subs      = { result.setDirty }
b.subs      = { }                   // b 没被读,没有依赖
result.sources = { toggle的退订, a的退订 }

现在 toggle 变为 false,result 被标记 dirty。重新计算时:

第一步:清理

sources.forEach((cleanup) => cleanup())  // 执行退订函数
sources.clear()
  • toggle.subs.delete(result.setDirty) → toggle.subs 变空
  • a.subs.delete(result.setDirty) → a.subs 变空
  • result.sources 清空

所有连接断开。

第二步:执行 fn()

这次 toggle=false,走 else 分支,读了 toggle 和 b(没读 a):

  • toggle 的 getter → 重新注册 result
  • b 的 getter → 注册 result
  • a 没被读 → 不注册

最终状态:

toggle.subs = { result.setDirty }   // 重新建立
a.subs      = { }                   // 干净了!
b.subs      = { result.setDirty }   // 新建立
result.sources = { toggle的退订, b的退订 }

如果不清理,a.subs 里还残留着 result 的 setDirty。以后 a 变了,会错误地通知 result 重算,但 result 根本不依赖 a 了。

清理的本质:先全部拆掉,再通过 fn() 的执行自动重建。 cachedValue = fn() 这一行同时完成了两件事:计算新值 + 重新建立所有依赖关系。


两个 subs 的区别

signal 和 computed 都有 subs,但存的东西不同:

signal.subs   = Set<函数>          // 存 setDirty 函数
computed.subs = Set<ComputeContext> // 存上下文对象(包含 setDirty + addSource)

为什么?

  • signal 是纯数据源,值变了只需要调用订阅者的 setDirty,一个函数就够了
  • computed 是中间节点,它被标记 dirty 后还需要继续向下游传播,所以下游注册的是完整的 context 对象,通过 sub.setDirty() 链式传播
count 变了
  → count.subs 里的函数被调用 → double.dirty = true
    → double.subs 里的 context.setDirty() 被调用 → plusOne.dirty = true

不用全局栈的替代方案

全局栈不是唯一的实现方式:

显式声明依赖:手动列出依赖,不需要运行时追踪。React 的 useMemo 依赖数组就是这个思路。缺点是手动维护容易出错,无法处理动态依赖。

Proxy 拦截:用 Proxy 包装数据对象,在 get 陷阱里记录访问。Vue 2 用的 Object.defineProperty 就是类似思路。缺点是 API 有侵入性。

编译时静态分析:编译器扫描代码,直接确定依赖关系。Svelte 的做法。缺点是需要编译器,动态依赖处理不了。

全局栈是唯一同时满足"自动追踪 + 动态依赖 + 零侵入 API + 无需编译"的方案,所以成为了主流选择。


编译时优化的可能性

运行时"清理+重建"的策略简单可靠,但对于静态依赖(依赖关系永远不变)来说有冗余开销。编译器可以识别这种情况并优化:

// 源码:依赖永远是 count
const double = computed(() => count.value * 2)

// 编译产物:直接硬连接,跳过 STACK 机制
subscribe(count, () => double.dirty = true)

各框架的策略:

  • Svelte:编译时分析,静态依赖直接硬连接
  • Vue 3.4:运行时用版本号优化,computed 重算后值没变则不向下游传播 dirty
  • Solid:区分静态/动态 computed,静态的不清理
  • React Compiler:编译时自动插入 memo,思路完全不同

总结

Signal 的 Push-Pull 算法 = 三个概念的组合:

  1. 观察者模式(signal 的 subs + setter 通知)
  2. 惰性求值(computed 的 dirty 标记 + 按需计算)
  3. 全局栈自动追踪(STACK + getter 里的"握手"注册)

Push 保证响应性——变化不会被遗漏。Pull 保证效率——没人读的值不会白算。全局栈保证易用性——不需要手动声明依赖。三者缺一不可。

完整实现只有约 70 行代码,却是 Vue ref/computed、Solid createSignal/createMemo、Preact @preact/signals 背后的共同原理。理解了这 70 行,再去读任何框架的响应式源码都不会陌生。

鸿蒙跨设备互通:让你的应用"借用"另一台设备的相机和图库

2026年4月3日 16:09

引言

想象一个场景:你正在平板上编辑一份文档,需要插入一张纸质资料的照片。平板没有后置摄像头,或者拍照体验远不如手机——这时候如果能直接调起旁边那台手机的相机,拍完照片自动传回平板,那该多省事。

这就是鸿蒙跨设备互通要解决的问题。它提供了一种能力,让一台设备可以调用同账号下另一台设备的相机、扫描和图库功能,拍照、扫文档、选图片视频,都不用再手动传来传去。

本文基于鸿蒙跨设备互通的开发接口,梳理这项能力的适用范围、使用条件,以及如何在应用中完成一次完整的跨设备调用。适合对鸿蒙开发有一定基础、希望为应用接入多设备协同能力的开发者阅读。


一、哪些设备能互通,谁能调用谁

跨设备互通并不是任意两台鸿蒙设备之间都能随便调用的,它有一套明确的设备关系和调用方向。

1.1 基础调用关系

最初的设计思路很直观——屏幕更大、但拍照能力可能不够好的设备,去调用拍照更强的设备:

  • 2in1 设备可以调用平板和手机
  • 平板可以调用手机

也就是说,调用方向大致是"大屏调小屏"。

1.2 API 6.1.0(23) 之后的扩展

从 API 6.1.0(23) 开始,调用关系变得更加灵活。手机、平板和 2in1 设备都可以作为发起方,去调用远端设备的能力,但远端设备能提供什么能力,取决于设备类型:

远端设备类型 可被调用的能力
手机 拍照、扫描、图库(图片与视频)
平板 拍照、扫描、图库(图片与视频)
2in1 设备 图库(图片与视频)

简单来说,手机和平板是"全能选手",三项能力都能被远端调用;2in1 设备因为自身定位,只对外开放图库能力。

1.3 必须满足的前提条件

要让跨设备互通正常工作,以下条件缺一不可:

  • 系统版本:双端设备都需要运行 HarmonyOS NEXT 及以上版本。
  • 同一账号:双端设备必须登录同一个华为账号。
  • 网络和蓝牙:双端设备都需要打开 WLAN 和蓝牙开关。如果条件允许,接入同一个局域网可以明显提升唤醒远端相机的速度。

如果在调用视频选择器时遇到资源加载异常,建议先确认双端的设备调用能力是否匹配、系统状态是否正常,然后在稳定的网络环境下重试。


二、核心流程:从发现设备到接收数据

跨设备互通的开发流程可以拆解为三个环节:发现可用设备 → 发起调用 → 接收回传数据。鸿蒙为这三个环节提供了对应的组件支持。

2.1 发现设备:设备列表选择器

第一步是让用户看到周围有哪些设备可以调用。这通过 createCollaborationServiceMenuItems 来实现——它是一个自定义构建函数,作用是在菜单中展示当前组网内具备对应互通能力的设备列表。

使用时需要传入一个能力过滤参数(CollaborationServiceFilter),告诉它你需要哪种能力的设备。目前支持的过滤值包括:

过滤值 含义
ALL 匹配拍照、扫描和图库选择器(预留值,后续会拓展)
TAKE_PHOTO 匹配跨设备拍照能力
SCAN_DOCUMENT 匹配跨设备扫描能力
IMAGE_PICKER 匹配跨设备图库(图片)能力
VIDEO_PICKER 匹配视频选择器
IMAGE_VIDEO_PICKER 匹配图片和视频选择器

有一点需要注意:这个方法必须在 Menu 组件内调用,因为它本质上是往菜单里填充设备列表项。同时它是一个 @Builder 自定义构建函数,如果你对 @Builder 的用法还不太熟悉,建议先了解一下相关文档。

2.2 状态提示与数据回传:弹窗组件

当用户选择了一台远端设备并触发操作后(比如在远端手机上拍照),你需要一个组件来做两件事:一是提示用户远端设备当前的操作状态,二是在操作完成后接收回传的数据。

CollaborationServiceStateDialog 就是做这个的。它是一个全局弹窗组件,放在页面的 build 方法中即可,不会影响原有的页面布局。它最关键的部分是 onState 回调方法,这个回调会在远端操作完成时被触发,带回三个参数:

  • stateCode:操作完成状态,0 表示成功
  • bufferType:回传数据的类型(比如 "general.image" 表示图片)
  • buffer:回传的数据内容本身(ArrayBuffer 格式)

开发者只需要在 onState 里根据这些参数,结合自己的业务逻辑处理回传数据即可。


三、完整开发实践:调用远端相机拍照并显示

理解了上面的核心流程后,我们来看一个完整的实现。这个示例的目标很简单:点击按钮弹出可用设备列表,选择一台远端设备拍照,拍完后把照片显示在本端页面上。

3.1 导入所需模块

import {
  createCollaborationServiceMenuItems,
  CollaborationServiceStateDialog,
  CollaborationServiceFilter
} from '@kit.ServiceCollaborationKit';
import { image } from '@kit.ImageKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

这里引入了三个 Kit:ServiceCollaborationKit 提供跨设备互通的核心能力,ImageKit 用于处理回传的图片数据,PerformanceAnalysisKit 用于日志输出。

3.2 搭建页面结构

@Entry
@Component
struct Index {
  @State picture: PixelMap | undefined = undefined;

  @Builder
  MyTestMenu() {
    Menu() {
      createCollaborationServiceMenuItems([CollaborationServiceFilter.ALL])
    }
  }

  build() {
    Column({ space: 20 }) {
      // 状态弹窗:全局组件,不影响布局
      CollaborationServiceStateDialog({
        onState: (stateCode: number, bufferType: string, buffer: ArrayBuffer): void =>
          this.doInsertPicture(stateCode, bufferType, buffer)
      })

      // 按钮:点击后弹出设备选择菜单
      Button('使用远端设备进行拍照')
        .type(ButtonType.Normal)
        .borderRadius(10)
        .bindMenu(this.MyTestMenu)

      // 图片展示区:拍照完成后显示回传的照片
      if (this.picture) {
        Image(this.picture)
          .borderStyle(BorderStyle.Dotted)
          .borderWidth(1)
          .objectFit(ImageFit.Contain)
          .height('80%')
          .onComplete((event) => {
            if (event != undefined) {
              hilog.info(0, "MEMOMOCK", "onComplete " + event.loadingStatus)
            }
          })
      }
    }
    .padding(20)
    .width('100%')
    .alignItems(HorizontalAlign.Center)
  }
}

整个页面结构很清晰:顶部是全局的状态弹窗(用户感知不到它的存在,除非有状态需要提示),中间是触发操作的按钮,底部是照片展示区域。

按钮通过 .bindMenu(this.MyTestMenu) 绑定了设备列表菜单。用户点击按钮后,会看到周围可用的设备列表,选中某台设备就会唤起该设备的相机。

3.3 处理回传的照片数据

doInsertPicture(stateCode: number, bufferType: string, buffer: ArrayBuffer): void {
  if (stateCode != 0) {
    return
  }
  if (bufferType == "general.image") {
    let imageSource = image.createImageSource(buffer)
    imageSource.createPixelMap().then((pixelMap) => {
      this.picture = pixelMap;
    })
  }
}

这段逻辑是整个流程的"收尾"环节:

  1. 先检查 stateCode 是否为 0(即操作成功),不成功则直接返回。
  2. 确认回传数据类型是图片("general.image")。
  3. ImageKitcreateImageSourceArrayBuffer 创建图片源,再转成 PixelMap
  4. 赋值给状态变量 picture,触发页面刷新,照片就显示出来了。

整个过程中,开发者不需要关心设备发现、连接建立、数据传输这些底层细节,框架已经把这些封装好了。你只需要关注"要什么能力"和"拿到数据后怎么用"。


四、总结与实践建议

跨设备互通的核心价值在于打破了单台设备的能力边界。对于平板、2in1 这类大屏设备来说,它补齐了拍照和扫描的短板;对于多设备协作的场景,它省去了拍照后手动传文件的繁琐操作。

从开发角度看,这项能力的接入成本并不高。核心就是两个组件的配合使用:createCollaborationServiceMenuItems 负责设备发现和选择,CollaborationServiceStateDialog 负责状态提示和数据回传。开发者的主要工作集中在 onState 回调中——根据回传的数据类型,做好业务层面的处理即可。

在实际接入时,有几点值得注意:

  • 能力过滤要精准:如果你的场景只需要拍照,传 TAKE_PHOTO 而不是 ALL,可以让设备列表更准确,避免用户看到不相关的选项。
  • 同一局域网能提速:虽然 WLAN + 蓝牙是最低要求,但接入同一局域网对唤醒速度有明显帮助,在产品文案或引导中可以提示用户。
  • 关注 API 版本差异:6.1.0(23) 前后的调用方向有变化。如果你的应用需要兼容较早版本,在设备能力判断上要做好区分。
  • 回传数据类型判断onState 回调中的 bufferType 会区分图片和视频等不同类型,做好类型判断再处理,避免数据解析出错。

对于已经在做多设备协同场景的应用来说,跨设备互通是一个值得优先接入的能力——它的使用门槛低,但带来的体验提升是用户能直接感知到的。

【备忘录】ELectron打包加密

2026年4月3日 16:03

Electron 打包可以通过 代码混淆、ASAR 防解压、V8 字节码、ASAR 加密、完整性校验 五层方案加密,大幅提高逆向难度(但无法 100% 绝对防破解)。

一、基础:代码混淆(必做)

javascript-obfuscator 打乱 JS 代码(主进程 / 渲染进程 /preload 都适用):

npm install javascript-obfuscator --save-dev

配置(obfuscator.config.js)

module.exports = { 
    compact: true, 
    controlFlowFlattening: true, // 控制流扁平化 
    controlFlowFlatteningThreshold: 0.75, 
    stringArray: true, // 字符串加密 
    stringArrayEncoding: ['base64'], 
    stringArrayThreshold: 0.9, 
    identifierNamesGenerator: 'hexadecimal', // 变量名变16进制 
    disableConsoleOutput: true // 禁用console 
}

打包时自动混淆(配合 electron-builder):

// vue.config.js / webpack.config.js 
const WebpackObfuscator = require('webpack-obfuscator') 
module.exports = { 
    configureWebpack: { 
        plugins: [ new WebpackObfuscator(require('./obfuscator.config.js')) ] 
    } 
}

二、进阶:ASAR 防解压(阻止直接解包)

asarmor 破坏 app.asar 结构,让 asar extract 失败:

npm install asarmor --save-dev

electron-builder 钩子(afterPack.js)

const { Asarmor, Trashify } = require('asarmor') 
const path = require('path') 
exports.default = async ({ appOutDir, packager }) => { 
    const asarPath = path.join(packager.getResourcesDir(appOutDir), 'app.asar') 
    const asarmor = new Asarmor(asarPath) // 注入垃圾数据 + 破坏结构 
    asarmor.applyProtection(new Trashify(['.git', '.env'])) 
    asarmor.patch() // 偏移篡改 
    await asarmor.write(asarPath) 
}

package.json

"build": { "afterPack": "./afterPack.js" }

三、高级:V8 字节码(最安全,无法反编译)

将 JS 编译为 Node/V8 字节码(.jsc),无原始 JS 代码

方案 A:bytenode(主进程)

npm install bytenode --save

编译脚本

const bytenode = require('bytenode') 
bytenode.compileFile({ 
    filename: 'main.js', 
    output: 'main.jsc', 
    compileAsModule: true 
})

入口加载

// main.js(仅入口,不加密) 
require('bytenode') 
require('./main.jsc')

方案 B:electron-vite 内置字节码(推荐)

// electron.vite.config.ts 
export default defineConfig({ 
    main: { 
        build: { 
            bytecode: { 
                enable: true, 
                protectedStrings: ['API_KEY', 'TOKEN'] // 敏感字符串加密 
            } 
        } 
    } 
})

四、企业级:ASAR 整体加密(运行时解密)

自定义加密(AES):打包时加密 app.asar,启动时内存解密:

  1. 打包阶段:用 AES 加密 app.asar → app.asar.enc
  2. 启动阶段:主进程加载自定义解密器,内存解密后加载
  3. 配合 ASAR Integrity 防篡改

五、最终加固组合(推荐)

JS 混淆 → 2. V8 字节码(主进程)→ 3. asarmor 防解压 → 4. 代码签名 → 5. ASAR 完整性校验

六、重要提醒

  • 没有绝对安全:客户端代码总有被逆向可能,核心逻辑放后端最稳妥
  • 兼容性:字节码必须与 Electron 内置 Node/V8 版本完全一致
  • 性能:混淆 / 字节码会轻微增加体积与启动时间

鸿蒙网络调试利器:用网络领航员模拟真实网络场景

2026年4月3日 15:53

引言

移动应用的用户体验,在很大程度上取决于它在各种网络环境下的表现。地铁穿行时刷短视频会不会卡顿?进出电梯的瞬间请求会不会断掉?地下车库里能不能正常加载内容?这些问题往往需要开发者跑到真实场景中才能验证,测试成本极高。

鸿蒙系统从 API version 20 开始,为手机和平板设备提供了网络领航员功能。它的核心价值很直接——让开发者坐在工位上,就能模拟各种典型的网络场景,快速验证应用体验,并获得针对性的网络优化建议。

本文面向有一定鸿蒙开发基础的开发者,系统梳理网络领航员的功能定位、使用方法、场景设计思路,以及配套的代码优化实践。


一、网络领航员解决什么问题

传统的网络体验测试面临两个痛点:一是场景复现成本高,你不可能为了测一个高铁场景就真的去坐高铁;二是网络状态不可控,即便到了目标场景,网络条件也在不断变化,难以做对比测试。

网络领航员提供的就是一种可控、可重复的网络模拟能力。它预置了 8 种典型网络场景,涵盖了日常出行中最常遇到的网络波动情形。开发者启用某个场景后,设备的网络行为会按照预设的参数运行——包括带宽、时延、丢包率的变化,甚至网络接口(WLAN 与蜂窝)的切换——从而在可控条件下观察应用的表现。

同时,网络领航员还与 Network Boost Kit 形成联动。开发者不仅能发现问题,还能获得该场景下的数据传输优化建议,通过接入 Network Boost Kit 实现应用与系统的跨层协同,提升上网流畅度。


二、如何使用网络领航员

2.1 开启入口

网络领航员有两种入口:设置界面HDC 命令行(即 netcopilot 工具)。

通过设置界面操作的路径为:

设置 → 系统 → 开发者选项 → 网络领航员

首次使用前需要先开启设备的开发者模式。

2.2 启用预置场景

进入网络领航员页面后,可以看到所有预置的网络模拟场景。点击任意一个场景即可启用,启用后可以在页面上查看当前生效的模拟场景,同时设备的实况窗会实时展示当前的网络模拟内容。

通过实况窗,开发者不仅能看到网络状态的变化,还可以点击进入查看该场景的详细介绍、网络参数,以及对应的代码开发最佳实践。

2.3 停止场景

需要停止模拟时,点击场景右侧的操作按钮,选择退出即可。

2.4 自定义网络场景

当预置场景无法满足需求时,可以通过页面底部的**"添加自定义网络场景"**来配置专属的网络参数。可配置的参数及其取值范围如下:

参数 取值范围 单位
上行带宽 100 - 500,000 Kbps
下行带宽 100 - 2,000,000 Kbps
上行丢包率 0.0 - 20.0 %
下行丢包率 0.0 - 20.0 %
上行延迟 1 - 1,000 ms
下行延迟 1 - 1,000 ms

需要注意的是,自定义场景的参数配置会应用于启动时的默认网络,建议在网络环境稳定时使用。此外,自定义场景暂未接入 Network Boost Kit,如果需要模拟网络场景的注册回调能力,应使用预置场景。


三、8 大预置场景解析

网络领航员预置的 8 种场景,覆盖了移动用户日常中最典型的网络波动情形。理解每个场景的设计思路,有助于开发者更有针对性地进行测试。

3.1 涉及网络切换的场景

以下三个场景的核心特征是 WLAN 与蜂窝网络之间的切换,使用前需要同时连接 WLAN 并开启移动网络。

进出电梯(总时长 5 分钟,循环执行):模拟了一个完整的"等电梯 → 进入电梯 → WLAN 信号消失 → 切换到蜂窝 → 出电梯 → 恢复 WLAN"的全过程。其中最关键的阶段是电梯关门后的 30 秒——WLAN 带宽从 1Mbps 线性下降至 0,时延飙升到 1000ms,丢包率升至 8%,随后切换到蜂窝网络逐步恢复。这个过程是考验应用网络切换处理能力的典型场景。

离家断开 WLAN(总时长 3 分钟,循环执行):模拟用户从家中走向户外的过程。WLAN 信号逐渐减弱直到不可用,然后切换到蜂窝网络。最终蜂窝网络稳定后,下行带宽可达 300-500Mbps。

到家连接 WLAN(总时长 3 分钟,循环执行):与上一个场景相反,模拟从户外回到室内、蜂窝网络切换到 WLAN 的过程。

3.2 涉及网络波动的场景

这类场景的特征是网络信号在强弱之间快速变化,使用前连接 WLAN 或移动网络即可(乘坐高铁场景需插入双 SIM 卡)。

乘坐地铁(总时长 5 分钟,循环执行):模拟地铁在站台与行驶区间交替的网络表现。站台上网络良好(下行 300-500Mbps),列车行驶时降至 0-10Mbps 并伴随丢包,到站后恢复——如此往复。

乘坐高铁(总时长 10 分钟,循环执行):这是时长最长的场景,模拟了高铁从站台出发、经过市郊、进入山区、在山区发生 SIM 卡切换(从卡 1 切到卡 2)、再回到市郊到站的完整旅程。山区阶段的时延高达 300-2000ms,丢包率最高 5%,是对应用极端网络容忍度的严苛测试。

高速公路自驾(总时长 5 分钟,循环执行):模拟城区行驶与穿越隧道交替出现的网络变化。隧道中下行带宽骤降至 0-2Mbps,时延升至 300-2000ms,丢包率达 5%。

3.3 网络持续不佳的场景

拥挤的食堂(总时长 5 分钟,循环执行):模拟网络接入设备众多、空口竞争激烈的环境。排队取餐阶段带宽仅 0-5Mbps,时延 10-100ms,丢包率高达 2.4%。就坐后网络有所恢复但仍不理想。

信号弱的地库(总时长 5 分钟,循环执行):模拟地下停车场信号覆盖差的情况。在停车场内的 3 分钟里,蜂窝网络带宽仅 0-10Mbps,时延 30-100ms。


四、面向三类网络特征的代码优化实践

8 种预置场景的网络特征,可以归纳为三类:网络切换网络波动网络质量差。针对每类特征,网络领航员配套提供了相应的优化建议。

4.1 网络切换:及时感知,快速重建连接

当设备在 WLAN 与蜂窝网络之间切换,或在多 SIM 卡之间切换时,会带来 IP 地址变更、TCP 连接被强制中断(触发 RST 复位),以及 DNS 重解析和新连接握手的延迟。

应对策略的核心是:及时识别网络切换,重建连接,重新发起未完成的请求。

一个直接的做法是使用 Network Kit 的 HTTP 数据请求模块发起网络请求。Network Kit 集成了通途协议栈的智能多网切换功能,能够在网络切换时自动将待发和进行中的请求切换到已激活的网络,避免请求失败或响应延迟。示例写法如下:

import { http } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';

let httpRequest = http.createHttp();
httpRequest.request(
  "EXAMPLE_URL", (err: BusinessError, data: http.HttpResponse) => {
    if (!err) {
      console.info('Result:' + JSON.stringify(data.result));
      httpRequest.destroy();
    } else {
      console.error('error:' + JSON.stringify(err));
      httpRequest.destroy();
    }
  }
);

需要注意的是,每一个 httpRequest 对应一个独立的 HTTP 请求任务,不可复用。请求完成后应调用 destroy() 方法主动销毁。

4.2 网络波动:实时感知质量,动态调整策略

地铁穿行、高速公路驾驶等场景中,信号强度快速变化,带宽可能从 50Mbps 骤降到 1Mbps,RTT 波动超过 300%,并伴随短时丢包。

应对这类场景的关键在于实时感知网络质量的变化,动态调整请求行为,具体有两个方向:

第一,订阅网络质量变化事件。 通过 Network Boost Kit 的 netQosChange 事件,可以实时获取当前数据链路的上下行带宽等信息,据此判断网络质量并调整请求策略:

import { netQuality } from '@kit.NetworkBoostKit';
import { BusinessError } from '@kit.BasicServicesKit';

try {
  netQuality.on('netQosChange', (list: Array<netQuality.NetworkQos>) => {
    if (list.length > 0) {
      list.forEach((qos) => {
        console.info(`上行带宽: ${JSON.stringify(qos.linkUpBandwidth)}`);
        console.info(`下行带宽: ${JSON.stringify(qos.linkDownBandwidth)}`);
        // 根据带宽信息调整请求策略
      });
    }
  });
} catch (err) {
  console.error('errCode: ' + (err as BusinessError).code +
    ', errMessage: ' + (err as BusinessError).message);
}

第二,在网络质量好时进行预取。 趁网络状态良好,提前发送后续可能产生的请求,缓存获取的资源。等真正需要这些资源时直接加载缓存,从而规避网络恶化带来的卡顿。可以借助 Remote Communication Kit 实现预取逻辑:

import { rcp } from '@kit.RemoteCommunicationKit';
import { HashMap } from "@kit.ArkTS";

export class PrefetchingRcp {
  private session = rcp.createSession();
  private responsePrefetched: HashMap<string, rcp.Response> = new HashMap();

  public async prefetch(url: string) {
    const request = new rcp.Request(url);
    try {
      let response: rcp.Response = await this.session.fetch(request);
      this.responsePrefetched.set(url, response);
      return Promise.resolve();
    } catch (reason) {
      console.error(`Rcp prefetch failed: ${reason.code}`);
      return Promise.reject();
    }
  }
}

在网络质量差时,则应反过来降低请求频率和请求数据量,避免加剧网络拥塞。

4.3 网络质量差:识别拥塞场景,主动降级

在地下停车场、拥挤的食堂这类场景中,网络呈现持续低带宽(<1Mbps)、高延迟(>100ms)和高丢包率的特征。

除了通过上述 netQosChange 判断网络质量外,Network Boost Kit 还提供了网络场景识别事件 netSceneChange,可以直接检测网络拥塞或信号差的状态。这让应用能够更精准地判断当前处于何种网络环境,并做出相应处理——检测到拥塞时减少请求,恢复正常后恢复请求频率:

import { netQuality } from '@kit.NetworkBoostKit';
import { BusinessError } from '@kit.BasicServicesKit';

try {
  netQuality.on('netSceneChange', (list: Array<netQuality.NetworkScene>) => {
    if (list.length > 0) {
      list.forEach((sceneInfo) => {
        if (sceneInfo.scene == 'congestion') {
          // 网络拥塞,减少请求频率
        }
        if (sceneInfo.scene == 'normal') {
          // 拥塞解除,恢复正常请求
        }
        if (sceneInfo.weakSignalPrediction) {
          // 弱信号预测:网络质量即将变差,提前应对
        }
      });
    }
  });
} catch (err) {
  console.error('errCode: ' + (err as BusinessError).code +
    ', errMessage: ' + (err as BusinessError).message);
}

值得关注的是 weakSignalPrediction 这个字段——它提供的是弱信号预测能力,让应用能够在网络质量实际变差之前就提前做好准备,这对用户体验的提升是非常有价值的。


五、关于数据收集

使用网络领航员时,需要注意以下数据收集事项:

  • 使用前需要关联华为账号,选择打开"关联体验改进计划"开关即表示同意上传使用日志;
  • 会收集应用的 bundle name 及版本号;
  • 会收集使用的网络模拟场景及对应时间;
  • 华为可能通过账号对应的联系方式进行使用回访。

六、总结

网络领航员为鸿蒙开发者提供了一种低成本、可控、可重复的网络场景测试手段。它的价值不仅在于模拟本身,更在于围绕模拟场景形成了"发现问题 → 理解问题 → 解决问题"的完整闭环:

  1. 发现问题:通过 8 种预置场景和自定义场景,覆盖网络切换、网络波动、网络质量差三类典型网络特征,在可控条件下暴露应用的网络兼容性问题。
  2. 理解问题:通过实况窗实时观察网络状态变化,结合每个场景细致的阶段划分和参数设计,准确定位问题出现的时机和条件。
  3. 解决问题:结合 Network Kit 的智能多网切换、Network Boost Kit 的网络质量感知和场景识别能力,以及预取缓存等策略,针对性地优化应用的网络处理逻辑。

对于希望进一步提升应用网络体验的开发者,建议从与自身业务最相关的场景入手——如果你的应用主要在通勤场景中使用,重点关注地铁和电梯场景;如果是室内场景居多,则侧重食堂和停车场场景的测试。在验证基本表现后,逐步接入 Network Boost Kit 的事件订阅机制,实现更精细的网络自适应策略。

React 高级技巧:从熟练到精通

作者 qwfy
2026年4月3日 15:15

React 高级技巧:从熟练到精通

写给有一定 React 基础、想要突破瓶颈的前端开发者。本文不讲 useState 和 useEffect 的基础用法,而是聚焦那些能真正提升代码质量和应用性能的进阶模式。


一、用 useRef 做"不触发渲染的状态"

很多人只把 useRef 当作获取 DOM 节点的工具,但它真正的能力在于——持有一个跨渲染周期稳定的可变值,且修改它不会触发 re-render

典型场景:在 useEffect 的回调或定时器里需要访问"最新的 props/state",但又不想把它加进依赖数组。

function useLatest(value) {
  const ref = useRef(value);
  ref.current = value; // 每次渲染都同步最新值
  return ref;
}

function Chat({ onMessage }) {
  const onMessageRef = useLatest(onMessage);

  useEffect(() => {
    const ws = new WebSocket('/chat');
    ws.onmessage = (e) => onMessageRef.current(e.data);
    return () => ws.close();
  }, []); // 依赖数组为空,但回调永远是最新的
}

这个 useLatest 模式在社区中被广泛使用(ahooks、react-use 等库都内置了它),核心思路就是把"值的引用"和"副作用的生命周期"解耦。


二、组件拆分的"状态下沉"原则

性能优化最常见的误区是到处加 React.memo。更根本的思路是——把状态下沉到真正需要它的子树里,让无关组件压根不参与 re-render

// ❌ 整个页面因为 hover 状态频繁 re-render
function Page() {
  const [hovered, setHovered] = useState(false);
  return (
    <div>
      <HeavyHeader />
      <div onMouseEnter={() => setHovered(true)}
           onMouseLeave={() => setHovered(false)}
           style={{ background: hovered ? '#f0f0f0' : '#fff' }}>
        Hover me
      </div>
      <HeavyFooter />
    </div>
  );
}

// ✅ 把 hover 状态封装到独立组件
function HoverBox() {
  const [hovered, setHovered] = useState(false);
  return (
    <div onMouseEnter={() => setHovered(true)}
         onMouseLeave={() => setHovered(false)}
         style={{ background: hovered ? '#f0f0f0' : '#fff' }}>
      Hover me
    </div>
  );
}

function Page() {
  return (
    <div>
      <HeavyHeader />
      <HoverBox />
      <HeavyFooter />
    </div>
  );
}

这比 React.memo 更彻底——不是"渲染了再比较要不要跳过",而是从源头上缩小了渲染范围


三、用 children 模式阻断 re-render 传播

与状态下沉相反的场景:状态必须在外层,但子组件不依赖这个状态。这时可以利用 children 的稳定性。

// ❌ ScrollY 变化导致 children 全部 re-render
function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0);
  useEffect(() => {
    const handler = () => setScrollY(window.scrollY);
    window.addEventListener('scroll', handler);
    return () => window.removeEventListener('scroll', handler);
  }, []);

  return (
    <div>
      <Navbar transparent={scrollY < 100} />
      <HeavyContent />  {/* 每次滚动都 re-render */}
    </div>
  );
}

// ✅ 把不依赖 scrollY 的部分通过 children 传入
function ScrollProvider({ children }) {
  const [scrollY, setScrollY] = useState(0);
  useEffect(() => {
    const handler = () => setScrollY(window.scrollY);
    window.addEventListener('scroll', handler);
    return () => window.removeEventListener('scroll', handler);
  }, []);

  return (
    <div>
      <Navbar transparent={scrollY < 100} />
      {children}  {/* children 的引用没变,不会 re-render */}
    </div>
  );
}

// 使用
<ScrollProvider>
  <HeavyContent />
</ScrollProvider>

原理:children 是在父组件(ScrollProvider 的调用方)渲染时创建的 JSX 元素,ScrollProvider 内部状态变化不会改变 children 的引用。


四、useReducer + Context:轻量级状态管理

当多个组件需要共享一块状态时,不一定要引入 Redux 或 Zustand。useReducer + Context 可以覆盖大量场景,关键在于把 dispatch 和 state 拆成两个 Context

const StateCtx = createContext();
const DispatchCtx = createContext();

function reducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return { ...state, todos: [...state.todos, action.payload] };
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map((t, i) =>
          i === action.index ? { ...t, done: !t.done } : t
        ),
      };
    default:
      return state;
  }
}

function TodoProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, { todos: [] });
  return (
    <DispatchCtx.Provider value={dispatch}>
      <StateCtx.Provider value={state}>
        {children}
      </StateCtx.Provider>
    </DispatchCtx.Provider>
  );
}

为什么要拆?因为 dispatch 是稳定的引用(React 保证),而 state 每次变化都是新对象。如果只有一个 Context,那些只需要 dispatch(比如"添加按钮")的组件也会因为 state 变化而 re-render。拆开之后,只订阅 DispatchCtx 的组件完全不受 state 更新影响。


五、自定义 Hook 的组合模式

自定义 Hook 的真正威力不在于封装一个 useXxx,而在于多个 Hook 像乐高一样组合

// 基础 Hook:监听媒体查询
function useMediaQuery(query) {
  const [matches, setMatches] = useState(
    () => window.matchMedia(query).matches
  );
  useEffect(() => {
    const mql = window.matchMedia(query);
    const handler = (e) => setMatches(e.matches);
    mql.addEventListener('change', handler);
    return () => mql.removeEventListener('change', handler);
  }, [query]);
  return matches;
}

// 基础 Hook:本地存储
function useLocalStorage(key, initial) {
  const [value, setValue] = useState(() => {
    const stored = localStorage.getItem(key);
    return stored !== null ? JSON.parse(stored) : initial;
  });
  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);
  return [value, setValue];
}

// 组合:响应式暗色模式
function useDarkMode() {
  const prefersDark = useMediaQuery('(prefers-color-scheme: dark)');
  const [override, setOverride] = useLocalStorage('theme', null);

  const isDark = override !== null ? override === 'dark' : prefersDark;
  const toggle = () => setOverride(isDark ? 'light' : 'dark');
  const reset = () => setOverride(null); // 回到跟随系统

  return { isDark, toggle, reset };
}

每个基础 Hook 只做一件事,组合 Hook 负责编排逻辑。测试和复用都变得很容易。


六、用 useSyncExternalStore 接管外部数据源

React 18 引入的 useSyncExternalStore 是订阅外部数据源的"正统"方式,解决了 useEffect + setState 模式在并发渲染下可能出现的"撕裂"问题。

import { useSyncExternalStore } from 'react';

// 封装一个极简的 store
function createStore(initialState) {
  let state = initialState;
  const listeners = new Set();

  return {
    getState: () => state,
    setState: (fn) => {
      state = typeof fn === 'function' ? fn(state) : fn;
      listeners.forEach((l) => l());
    },
    subscribe: (listener) => {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

const counterStore = createStore({ count: 0 });

function useStore(store, selector) {
  return useSyncExternalStore(
    store.subscribe,
    () => selector(store.getState())
  );
}

// 组件中使用
function Counter() {
  const count = useStore(counterStore, (s) => s.count);
  return (
    <button onClick={() => counterStore.setState((s) => ({ count: s.count + 1 }))}>
      {count}
    </button>
  );
}

这个模式就是 Zustand 的核心原理。理解了它,你就理解了为什么 Zustand 那么轻量,以及它在并发模式下为什么比手写 useEffect 订阅更可靠。


七、Compound Components 复合组件模式

当你在构建一个 UI 组件库时,复合组件模式可以提供极其灵活的 API 设计。

const TabsContext = createContext();

function Tabs({ children, defaultIndex = 0 }) {
  const [activeIndex, setActiveIndex] = useState(defaultIndex);
  return (
    <TabsContext.Provider value={{ activeIndex, setActiveIndex }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

function TabList({ children }) {
  return <div className="tab-list" role="tablist">{children}</div>;
}

function Tab({ children, index }) {
  const { activeIndex, setActiveIndex } = useContext(TabsContext);
  return (
    <button
      role="tab"
      className={activeIndex === index ? 'active' : ''}
      onClick={() => setActiveIndex(index)}
    >
      {children}
    </button>
  );
}

function TabPanels({ children }) {
  const { activeIndex } = useContext(TabsContext);
  return <div className="tab-panels">{Children.toArray(children)[activeIndex]}</div>;
}

function TabPanel({ children }) {
  return <div role="tabpanel">{children}</div>;
}

// 挂载子组件
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panels = TabPanels;
Tabs.Panel = TabPanel;

使用时像这样:

<Tabs defaultIndex={0}>
  <Tabs.List>
    <Tabs.Tab index={0}>详情</Tabs.Tab>
    <Tabs.Tab index={1}>评论</Tabs.Tab>
    <Tabs.Tab index={2}>相关</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panels>
    <Tabs.Panel>详情内容...</Tabs.Panel>
    <Tabs.Panel>评论内容...</Tabs.Panel>
    <Tabs.Panel>相关内容...</Tabs.Panel>
  </Tabs.Panels>
</Tabs>

这种模式的优势是:结构完全由使用者控制,组件之间通过 Context 隐式通信,既灵活又保持了内聚性。Radix UI、Headless UI 等库都大量使用了这个模式。


八、React.lazy + Suspense 的实战细节

代码分割大家都知道用 React.lazy,但有几个细节容易踩坑。

1. 把 lazy 声明放在模块顶层,不要放在组件里:

// ✅ 模块顶层,只执行一次
const Editor = lazy(() => import('./Editor'));

// ❌ 组件内部,每次渲染都创建新的 lazy 组件
function Page() {
  const Editor = lazy(() => import('./Editor'));
  // ...
}

2. 用工厂函数做预加载:

const importEditor = () => import('./Editor');
const Editor = lazy(importEditor);

// 鼠标移入时预加载,而不是等点击
<button onMouseEnter={importEditor} onClick={() => setShowEditor(true)}>
  打开编辑器
</button>

3. 嵌套 Suspense 做细粒度加载态:

<Suspense fallback={<PageSkeleton />}>
  <Header />
  <Suspense fallback={<ContentSkeleton />}>
    <MainContent />
  </Suspense>
  <Suspense fallback={<SidebarSkeleton />}>
    <Sidebar />
  </Suspense>
</Suspense>

外层 Suspense 兜底整个页面,内层 Suspense 让各区域独立加载,避免一个慢接口阻塞整个页面。


九、ErrorBoundary 的现代写法

类组件的 ErrorBoundary 是 React 唯一还需要类组件的地方。但我们可以用一个通用的封装让它在函数组件中好用起来。

class ErrorBoundary extends Component {
  state = { error: null };

  static getDerivedStateFromError(error) {
    return { error };
  }

  componentDidCatch(error, info) {
    // 上报错误监控
    reportError(error, info.componentStack);
  }

  render() {
    if (this.state.error) {
      return this.props.fallback(this.state.error, () =>
        this.setState({ error: null })
      );
    }
    return this.props.children;
  }
}

// 使用
<ErrorBoundary
  fallback={(error, retry) => (
    <div>
      <p>出了点问题:{error.message}</p>
      <button onClick={retry}>重试</button>
    </div>
  )}
>
  <App />
</ErrorBoundary>

retry(重置错误状态)作为参数传给 fallback,用户点击"重试"后子树会重新挂载,这在网络请求失败的场景下非常实用。


十、性能排查的正确姿势

最后聊聊性能排查。与其凭直觉优化,不如用工具定位问题。

React DevTools Profiler 是第一步。录制一段交互,找到耗时最长的提交(commit),展开看哪些组件在 re-render、每次渲染耗时多少。

why-did-you-render 库可以在开发环境自动检测不必要的 re-render,帮你发现那些"props 看起来没变但引用变了"的隐蔽问题。

一个常见的"引用陷阱":

// ❌ 每次渲染都创建新的 style 对象
<div style={{ color: 'red' }}>

// ✅ 提到模块顶层或用 useMemo
const redText = { color: 'red' };
<div style={redText}>

同样的问题也出现在内联函数、内联数组上。解决方案要么提取为常量,要么用 useMemo / useCallback——但只在确认存在性能问题时才用,不要过早优化。


写在最后

React 的进阶之路不在于记住更多 API,而在于理解它的渲染模型数据流。上面这些技巧的底层逻辑其实只有两条:

  1. 控制渲染范围——让该更新的更新,不该更新的别动。
  2. 保持引用稳定——React 靠引用比较决定是否 re-render,管理好引用就管好了性能。

把这两条内化,遇到具体问题时自然能推导出解法,而不需要死记硬背每一个模式。

打开网页等半天?——浏览器性能优化完全指南

2026年4月3日 15:00

写给想让网站飞起来的开发者,不讲废话,直接上干货


先说一个让人抓狂的场景

你辛辛苦苦开发了一个网站,兴冲冲地发给朋友测试。

朋友说:“点开要等好几秒,点个按钮还要转圈圈,体验太差了。”

你一看数据:

  • 首屏加载时间:8 秒(人家要求 3 秒以内)
  • LCP(最大内容绘制):6 秒(Google 要求 2.5 秒以内)
  • FID(首次输入延迟):300ms(人家要求 100ms 以内)

你慌了,开始到处搜"怎么让网站变快",搜到一堆专业术语:Tree Shaking、Code Splitting、Lazy Loading、CDN 加速…

看完更懵了,不知道从哪下手。

别慌,这篇文章帮你把性能优化讲清楚,让你知道该从哪下手。


性能优化到底优化什么?

指标 名字 达标标准 体验感
FCP First Contentful Paint < 1.8s 看到第一个内容
LCP Largest Contentful Paint < 2.5s 看到主要内容
FID First Input Delay < 100ms 第一次能交互
CLS Cumulative Layout Shift < 0.1 页面不乱跳
TTI Time to Interactive < 3.8s 完全可交互

打开 Chrome,按 F12,切到 Lighthouse 标签,点"Analyze Page Load",它会给你打个分,顺便告诉你哪里有问题。


优化思路:三板斧

1. 少加载东西(体积)
2. 加载快一点(速度)
3. 边用边加载(顺序)

第一斧:少加载东西(体积优化)

1. 图片压缩——最大的罪魁祸首

网页加载慢,90% 是图片的问题。

// 用 sharp 压缩图片(Node.js)
import sharp from 'sharp';

await sharp('original.png')
  .resize(1920, 1080)
  .webp({ quality: 80 })
  .toFile('optimized.webp');

图片格式选择:

格式 适合场景 压缩效果
JPEG 照片、复杂图片 体积小,有损
PNG 需要透明、图标 体积大,无损
WebP 通用场景 体积小 30%,兼容性好
AVIF 最新格式 体积最小,兼容性差
SVG 图标、简单图形 体积极小,可缩放
<!-- 用 picture 标签,浏览器自动选最优格式 -->
<picture>
  <source srcset="image.avif" type="image/avif">
  <source srcset="image.webp" type="image/webp">
  <img src="image.jpg" alt="描述" loading="lazy" width="800" height="600">
</picture>

2. 代码压缩

// vite.config.js
export default defineConfig({
  build: {
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: true,
        drop_debugger: true,
      },
    },
    cssMinify: true,
  },
});

3. Tree Shaking——只打包用到的代码

// ❌ 整个 lodash 都打包进来了(500KB+)
import _ from 'lodash';

// ✅ 只打包 debounce(几 KB)
import { debounce } from 'lodash-es';

第二斧:加载快一点(速度优化)

1. CDN 加速

<!-- ✅ 用 CDN,用户从最近的节点拿 -->
<script src="https://cdn.staticfile.org/react/18.2.0/umd/react.production.min.js"></script>

2. 开启 gzip——让带宽省一半

import compression from 'compression';
app.use(compression({ level: 6, threshold: 1024 }));

gzip 能让传输体积减少 60-70%。

3. 缓存策略——第二次访问秒开

app.use((req, res, next) => {
  if (req.path.endsWith('.html')) {
    res.setHeader('Cache-Control', 'no-cache');
  } else {
    // 静态资源缓存一年,文件名带 hash 自然失效
    res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
  }
  next();
});

4. DNS 预解析

<link rel="dns-prefetch" href="//cdn.example.com">
<link rel="preconnect" href="https://cdn.example.com" crossorigin>

第三斧:边用边加载(顺序优化)

1. 代码分割

// ❌ 所有路由打包成一个文件,进首页就下载整站代码
import ProductList from './pages/ProductList';

// ✅ 按需加载,用到才下载
const ProductList = lazy(() => import('./pages/ProductList'));
const Dashboard   = lazy(() => import('./pages/Dashboard'));

function App() {
  return (
    <Suspense fallback={<div>加载中...</div>}>
      <Routes>
        <Route path="/products"  element={<ProductList />} />
        <Route path="/dashboard" element={<Dashboard />} />
      </Routes>
    </Suspense>
  );
}

2. 图片懒加载

<!-- 原生懒加载,一行搞定 -->
<img src="banner.jpg" loading="lazy" width="800" height="600">
// 需要精细控制时,用 Intersection Observer
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      observer.unobserve(img);
    }
  });
}, { rootMargin: '200px' });

document.querySelectorAll('[data-src]').forEach(img => observer.observe(img));

3. defer 和 async

<!-- ❌ 阻塞渲染,白屏时间变长 -->
<script src="app.js"></script>

<!-- ✅ defer:等 HTML 解析完再执行(推荐) -->
<script src="app.js" defer></script>

<!-- async:适合无依赖的第三方脚本 -->
<script src="analytics.js" async></script>

四个容易踩的坑

坑 1:首屏图片也懒加载了

<!-- ❌ 首屏图片懒加载,LCP 变差 -->
<img src="hero.jpg" loading="lazy">

<!-- ✅ 首屏图片要快,加 fetchpriority -->
<img src="hero.jpg" fetchpriority="high">

坑 2:图片没设宽高,CLS 很差

<!-- ❌ 图片加载后页面跳动 -->
<img src="photo.jpg">

<!-- ✅ 提前留好位置 -->
<img src="photo.jpg" width="800" height="600" loading="lazy">

坑 3:第三方脚本没用 async

<!-- ❌ 统计脚本阻塞渲染 -->
<script src="https://analytics.example.com/track.js"></script>

<!-- ✅ 不影响主流程,用 async -->
<script src="https://analytics.example.com/track.js" async></script>

坑 4:字体加载导致闪烁

@font-face {
  font-family: 'MyFont';
  src: url('/fonts/myfont.woff2') format('woff2');
  font-display: swap; /* 先用系统字体,加载完再替换 */
}

上线前检查清单

  • 图片压缩了吗?(WebP/AVIF)
  • 首屏图片加了 fetchpriority="high" 吗?
  • 非首屏图片加了 loading="lazy" 吗?
  • 图片设置宽高了吗?(防止 CLS)
  • JS 压缩了吗?(去掉 console)
  • 路由代码分割了吗?
  • CDN 接入了吗?
  • gzip/brotli 开启了吗?
  • 缓存设置了吗?(文件名加 hash)
  • Lighthouse 分数达到 90+ 了吗?

总结

少加载东西 —— 压缩图片、压缩代码、Tree Shaking

加载快一点 —— CDN、gzip 压缩、合理缓存

边用边加载 —— 代码分割、懒加载、预加载

先用 Lighthouse 跑一遍,找到最影响性能的问题,从那个开始改。

一般优化完,首屏时间能从 5-8 秒降到 1-2 秒,用户体验直接翻倍。

千问点奶茶时弹出的 UI 是怎么实现的,是生成式 UI 吗

作者 Canace
2026年4月3日 14:06

前段时间千问奶茶免单很火,可以直接在 chat 界面输入“千问,帮我点杯奶茶”,后续就像我们自己操作一样,一步一步完成下单。

我们知道, AI chat 现在比较普遍的数据交互是 markdown,markdown 是渲染不出这种交互式UI 的,必须借助 HTML+CSS+JS 的能力才能做到。那么千问是怎么实现的呢?是交互过程中生成的 UI,前端做渲染吗?我感觉这个选项可以首先排除,具体原因下面会做分析。

一、方案概览

目前实现这种效果的常见方案主要有以下几种:

  1. 硬编码规则,根据服务端返回的特定格式的数据结构,映射到封装好的固定 UI。
  2. 让 AI 生成 html ,缺点是效果不稳定,需要持续调 prompt,这也是为什么我一开始觉得千问用的应该不是这个方案的原因。
  3. 定制一些卡片模板,告诉模型 input schema ,模型按数据结果填进来,缺点是模版还需要人力开发。
  4. google/a2ui 这类方案,让 AI Agent 直接驱动或生成用户界面,实现“对话即操作”“自然语言即界面”,与第二点的差异在于,前面 AI 输出的是代码,而这个方案生成的是schema,由引擎去解析,而不是直接渲染。

二、实现简介

首先应该有个输出定制的过程,输出格式可能是这样

// 硬编码规则
{
  "title": "标题",
  "summary": "总结",
  "bullets": ["要点1", "要点2", "要点3"]
}
// AI 生成 HTML
"<section><h3>标题</h3><p>内容</p></section>"
// 定制卡片模板
[
  { "type": "metric", "title": "成本", "value": "低", "desc": "描述" },
  { "type": "step", "title": "步骤", "items": ["A", "B", "C"] }
]
// google/a2ui 类方案
{
  "type": "container",
  "children": [
    { "type": "text", "variant": "title", "content": "A2UI 风格标题" },
    { "type": "text", "content": "这里是可组合 UI schema 示例" },
    { "type": "list", "items": ["项1", "项2", "项3"] }
  ]
}

然后每一种方案的交互模式不一样的,可能是这样:

  • 硬编码规则:自然语言 → 模型映射固定字段 → 按结构化内容渲染 UI
  • AI 生成 HTML:自然语言 → 模型直接生成 HTML 字符串 → 渲染页面
  • 模块化卡片模板:自然语言 → 映射为预定义模板 → 渲染模块卡片
  • A2UI(组合式 Schema):自然语言 → Agent 生成 UI Schema → 引擎解析Schema,动态组件组合 → 渲染 UI

当然上面只是简单的流程,具体实现这里就不赘述了

三、方案对比

下面我通过一个实验 demo 来对比一下这几个方案,对比维度如下:

  • 首次可用率(一次返回即可渲染)
  • 回退率(需要重试/修复)
  • 可控性(是否偏离预期结构)
  • 开发成本(前端模板/解释器工作量)
  • 迭代成本(改需求时改 prompt 还是改代码)

实验场长下面这样,我会在四种模式下各跑一次剧本生成流程,最终导出实验报告。

实验场景截图

压测包含的场景

  • 正常输入
  • missing-fields(结构缺失)
  • wrong-types(类型错误)
  • html-unsafe(HTML 注入噪声)
  • schema-noise(Schema 噪声)

下面是生成的报告

压测结果总览图

关键指标对比图

报告基本跟我的预期一致,不过数据准确性还得靠指标,我的指标计算也不一定对,仅供参考。

这篇文章写了有一段时间了,整理到掘金合集。

别再用显性水印!前端零宽隐形水印,实现内容溯源级版权保护,已封装复制即用

作者 李剑一
2026年4月3日 14:05

在前端版权、数据防泄露这种场景,我目前一般都是采用显性水印的方案。

包括目前企业微信这种级别的App也是在用显性水印。

image.png

但是显性水印的问题在于:要么遮挡页面内容、破坏视觉体验,要么极易被PS去除。

零宽字符水印凭借肉眼完全不可见、复制粘贴不丢失的核心优势,完全解决了显性水印的痛点。

实现原理

零宽字符属于Unicode标准内的特殊控制字符,这类字符无视觉渲染、不占用页面宽度、不影响文本排版

image.png

日常浏览、复制时完全无法察觉,但会被浏览器、编辑器、各类平台识别并保留。

  • U+200B(零宽空格):用于指代二进制 0

  • U+200C(零宽不连字):用于指代二进制 1

  • U+FEFF(零宽断行符):作为水印前缀标记,提升解析准确率,避免误识别

基于这种特性,加密就是将用户ID进行二进制转换,插入到文本中。

解密就是在文本中识别出零宽字符,再还原为普通字符。

完整代码

简单封装了一个面向对象封装的零宽水印工具类,复制进生产环境可直接使用,支持文本加密、水印解密两大核心功能。

/**
 * 零宽字符隐形水印工具类
 * 核心功能:文本水印加密、隐形水印解密
 * 适用场景:前端版权保护、内容溯源、防搬运追责
 */
class ZeroWidthWatermark {
  static #ZERO_CHAR = '\u200B';   // 零宽空格 = 二进制0
  static #ONE_CHAR = '\u200C';    // 零宽不连字 = 二进制1
  static #WATERMARK_PREFIX = '\uFEFF'; // 水印前缀校验符

  /**
   * 加密:给文本添加隐形水印
   * @param {string} text - 原始文本内容
   * @param {string} watermark - 水印信息(用户ID、溯源标识等)
   * @returns {string} 带隐形水印的文本
   */
  static encrypt(text, watermark) {
    if (!text || typeof text !== 'string' || !watermark) {
      throw new Error('加密失败:文本与水印内容不可为空');
    }

    try {
      const binaryWatermark = this.#textToBinary(watermark);
      const zeroWidthStr = this.#binaryToZeroWidth(binaryWatermark);
      const fullWatermark = this.#WATERMARK_PREFIX + zeroWidthStr;
      return text[0] + fullWatermark + text.slice(1);
    } catch (error) {
      console.error('零宽水印加密异常:', error);
      return text;
    }
  }

  /**
   * 解密:提取文本中的隐形水印
   * @param {string} encryptedText - 带水印的文本
   * @returns {string} 解析后的水印信息/状态提示
   */
  static decrypt(encryptedText) {
    if (!encryptedText || typeof encryptedText !== 'string') {
      return '无水印';
    }

    try {
      const zeroWidthChars = encryptedText.match(/[\u200B\u200C\uFEFF]/g) || [];
      if (zeroWidthChars.length === 0) return '无水印';

      const prefixIndex = zeroWidthChars.indexOf(this.#WATERMARK_PREFIX);
      if (prefixIndex === -1) return '无水印';
      const validChars = zeroWidthChars.slice(prefixIndex + 1);

      const binaryStr = this.#zeroWidthToBinary(validChars);
      if (!binaryStr || binaryStr.length % 8 !== 0) return '水印格式错误';

      return this.#binaryToText(binaryStr);
    } catch (error) {
      console.error('零宽水印解密异常:', error);
      return '解密失败';
    }
  }

  /** 文本转8位二进制字符串 */
  static #textToBinary(text) {
    return Array.from(text)
      .map(char => char.charCodeAt(0).toString(2).padStart(8, '0'))
      .join('');
  }

  /** 二进制转零宽字符 */
  static #binaryToZeroWidth(binary) {
    return binary.split('').map(bit => bit === '0' ? this.#ZERO_CHAR : this.#ONE_CHAR).join('');
  }

  /** 零宽字符转二进制 */
  static #zeroWidthToBinary(chars) {
    return chars.map(char => char === this.#ZERO_CHAR ? '0' : '1').join('');
  }

  /** 二进制转普通文本 */
  static #binaryToText(binary) {
    return Array.from({ length: binary.length / 8 }, (_, i) => {
      const byte = binary.slice(i * 8, (i + 1) * 8);
      return String.fromCharCode(parseInt(byte, 2));
    }).join('');
  }
}

使用示例

// 加密:添加隐形溯源水印
const originalText = "李剑一原创技术文章,禁止未经授权搬运转载";
const watermarkInfo = "userID:10086|publishTime:20260326|from:李剑一";
const watermarkedText = ZeroWidthWatermark.encrypt(originalText, watermarkInfo);

// 解密:提取溯源信息
const result = ZeroWidthWatermark.decrypt(watermarkedText);
console.log('解析出水印信息:', result);

总结

零宽水印能够无侵入式版权保护,而且在前端层面上实现还是比较简单的。

但是需要明确:零宽水印无法直接阻止爬虫爬取内容,因为爬虫会直接抓取页面文本,连带零宽字符一同获取。

如果需要防爬,可将零宽水印与字体加密、接口签名、行为验证、IP限流等方案结合使用,兼顾防护与溯源。

从开发到生产构建工程化链路搭建

2026年4月2日 21:33

前言:什么是工程化

工程化是指用软件工程的方法来管理和优化开发流程,让代码从"能跑"变成"好维护、好协作、好交付",举例使用 Webpack5 进行工程化链路搭建(工具不是关键,工程化链路搭建思想才是核心

工程化带来的好处

  1. 开发效率 — 热更新、代码提示、自动补全
  2. 代码质量 — 规范约束、静态检查、单元测试
  3. 团队协作 — 统一规范、模块化、版本管理
  4. 性能优化 — 代码压缩、按需加载、缓存策略
  5. 可维护性 — 清晰的目录结构、组件化、文档化

一、环境概览对比

image.png

二、开发环境详解

2.1 执行链路

┌─────────────────────────────────────────────────────────────┐
│                    开发环境启动流程                         │
└─────────────────────────────────────────────────────────────┘

  1. Node 启动 dev.js
        │
        ├──► 加载 webpack.dev.js 配置
        │         │
        │         ├──► 合并 webpack.base.js(基础配置)
        │         │
        │         ├──► 设置 mode: 'development'
        │         │
        │         ├──► 为每个入口注入 HMR 客户端代码
        │         │         │
        │         │         └──► entry.page1: [
        │         │                 './page1/entry.page1.js',
        │         │                 'webpack-hot-middleware/client?path=...'
        │         │             ]
        │         │
        │         └──► 添加 HotModuleReplacementPlugin
        │
        ├──► 创建 Webpack Compiler 实例
        │
        ├──► 启动 Express 服务器
        │         │
        │         ├──► 挂载 webpack-dev-middleware
        │         │         │
        │         │         ├──► 编译代码存入内存文件系统
        │         │         ├──► 监听文件变化自动重编译
        │         │         └──► 提供静态资源访问服务
        │         │
        │         ├──► 挂载 webpack-hot-middleware
        │         │         │
        │         │         └──► 创建 EventSource 连接 /__webpack_hmr
        │         │
        │         └──► 监听端口
        │
        └──► 输出:服务启动成功,监听端口

2.2 核心配置解析

//webpack.dev.js

const webpackConfig = merge.smart(baseConfig, {
  mode: 'development',  // 开发模式:不压缩,启用 sourcemap

  output: {
    // 输出到内存,物理路径仅作参考
    path: path.resolve(process.cwd(), './app/public/dist/dev'),
    // 带 contenthash 用于缓存
    filename: 'js/[name]_[contenthash:8].bundle.js',
    // 浏览器访问路径(完整 URL)
    publicPath: 'http://127.0.0.1:xxxx/public/dist/dev/',
  },

  plugins: [
    // 热更新插件:生成 .hot-update.json/js 补丁文件
    new webpack.HotModuleReplacementPlugin({
      multiStep: false,  // 单步更新,更可靠
    }),
  ],
})
//dev.js(Express 服务器)

const app = express()

// 1. 静态资源目录(图片、字体等)
app.use(express.static(path.resolve(process.cwd(), './app/public')))

// 2. Webpack 开发中间件
app.use(devMiddleware(compiler, {
  // 关键:只有 .tpl 模板文件写入磁盘
  // 原因:后端 Koa 需要从磁盘读取模板渲染页面
  writeToDisk: (filePath) => filePath.endsWith('.tpl'),

  // 资源访问路径(必须和 webpack 配置一致)
  publicPath: webpackConfig.output.publicPath,

  // CORS 配置:允许后端服务(8080)访问前端资源(9002)
  headers: {
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS',
    'Access-Control-Allow-Headers': 'X-Requested-With,Content-Type,Authorization',
  },
}))

// 3. 热更新中间件
app.use(hotMiddleware(compiler, {
  path: '/__webpack_hmr',  // EventSource 路径
  log: () => {},           // 关闭冗余日志
}))

app.listen(9002)

2.3 热更新(HMR)完整机制

┌─────────────────────────────────────────────────────────────┐
│                      热更新流程图                           │
└─────────────────────────────────────────────────────────────┘

  开发者保存代码
        │
        ▼
  Webpack 监听文件变化(watch)
        │
        ├──► 重新编译变更模块
        │
        ├──► HotModuleReplacementPlugin 生成热更新补丁
        │         ├──► 更新清单:xxx.hot-update.json
        │         └──► 更新代码:xxx.hot-update.js
        │
        ▼
  webpack-dev-middleware 更新内存中的文件
        │
        ▼
  webpack-hot-middleware 检测编译完成
        │
        ▼
  通过 EventSource 推送事件到浏览器
        │
        ▼
  浏览器 HMR Runtime 接收事件
        │
        ├──► 下载补丁文件(hot-update.json/js)
        │
        ├──► 执行模块替换(module.hot.accept)
        │
        └──► 更新成功:无刷新更新 / 失败:页面刷新
        
 ----------------------------------------------------------------------------------------------
┌─────────────────────────────────────────────────────────────┐
│                      开发环境架构图                         │
└─────────────────────────────────────────────────────────────┘

  浏览器
    │
    ├──► 请求页面 ───────────────┐
    │                            ▼
    │                    ┌───────────────┐
    │                    │  Koa 后端服务 │
    │                    │       │
    │                    │               │
    │                    │  渲染 .tpl 模板 │
    │                    │  注入初始数据   │
    │                    └───────┬───────┘
    │                            │
    │◄───────────────────────────┘
    │  返回 HTML(含 JS 引用)
    │
    ├──► 请求 JS/CSS 资源 ─────────┐
    │                              ▼
    │                      ┌───────────────┐
    │                      │ Webpack Dev   │
    │                      │   Server      │
    │                      │    :xxxx      │
    │                      │               │
    │                      │ 内存文件系统  │
    │                      │ 热更新推送    │
    │                      └───────────────┘
    │
    ▼
  建立 EventSource 连接
  接收热更新事件

三、生产环境详解

3.1执行链路

┌─────────────────────────────────────────────────────────────┐
│                    生产环境构建流程                         │
└─────────────────────────────────────────────────────────────┘

  1. Node 启动 prod.js
        │
        ├──► 加载 webpack.prod.js 配置
        │         │
        │         ├──► 合并 webpack.base.js
        │         │
        │         ├──► 设置 mode: 'production'
        │         │
        │         ├──► 配置多线程构建(thread-loader)
        │         │
        │         ├──► 配置 CSS 提取(MiniCssExtractPlugin)
        │         │
        │         ├──► 配置代码压缩(TerserPlugin)
        │         │
        │         └──► 配置目录清理(CleanWebpackPlugin)
        │
        ├──► 创建 Webpack Compiler
        │
        ├──► 执行 compiler.run()(单次构建)
        │         │
        │         ├──► 清空输出目录
        │         │
        │         ├──► 多线程编译 JS/CSS
        │         │
        │         ├──► 代码分割(vendor/common/runtime)
        │         │
        │         ├──► 提取 CSS 到独立文件
        │         │
        │         ├──► Terser 压缩 JS(删除 console)
        │         │
        │         ├──► CssMinimizer 压缩 CSS
        │         │
        │         └──► 生成 HTML 模板(注入资源链接)
        │
        └──► 输出构建统计信息
                    │
                    ▼
            产物输出到 app/public/dist/prod/

3.2 核心配置解析

//webpack.prod.js

const webpackConfig = merge.smart(baseConfig, {
  mode: 'production',  // 生产模式:自动优化、压缩

  output: {
    // 物理输出目录
    path: path.join(process.cwd(), 'app/public/dist/prod'),
    filename: 'js/[name]_[contenthash:8].bundle.js',
    // 相对路径(生产环境同源部署)
    publicPath: '/dist/prod/',
    crossOriginLoading: 'anonymous',  // 跨域加载不发送 cookie
  },

  module: {
    rules: [
      // CSS 处理:提取到独立文件(非内联)
      {
        test: /\.css$/,
        use: [
          MiniCssExtractPlugin.loader,  // 提取 CSS
          {
            loader: 'thread-loader',     // 多线程
            options: {
              workers: os.cpus().length - 1,  // CPU 核心数-1
            },
          },
          'css-loader',
        ],
      },
      // JS 处理:多线程 babel 转译
      {
        test: /\.js$/,
        use: [
          'thread-loader',
          {
            loader: 'babel-loader',
            options: {
              presets: ['@babel/preset-env'],
              plugins: ['@babel/plugin-transform-runtime'],
            },
          },
        ],
      },
    ],
  },

  plugins: [
    // 1. 清空输出目录
    new CleanWebpackPlugin(['public/dist'], {
      root: path.resolve(process.cwd(), './app/'),
      verbose: true,
    }),

    // 2. 提取 CSS 到独立文件
    new MiniCssExtractPlugin({
      filename: 'css/[name]_[contenthash:8].bundle.css',
    }),

    // 3. 注入 crossorigin 属性(用于 CDN 场景)
    new HtmlWebpackInjectAttributesPlugin({
      crossorigin: 'anonymous',
    }),
  ],

  optimization: {
    minimize: true,
    minimizer: [
      // JS 压缩:并发 + 缓存
      new TerserPlugin({
        cache: true,      // 启用缓存
        parallel: true,   // 并发压缩
        terserOptions: {
          compress: {
            drop_console: true,  // 删除 console.log
          },
        },
      }),
      // CSS 压缩
      new CssMinimizerPlugin(),
    ],
  },
})

3.3 生产环境构建产物

app/public/dist/prod/
├── js/
│   ├── entry.page1_a1b2c3d4.bundle.js      # 页面主代码
│   ├── entry.page2_e5f6g7h8.bundle.js
│   ├── vendor_i9j0k1l2.bundle.js           # 第三方库(VueElement Plus)
│   ├── common_m3n4o5p6.bundle.js           # 公共代码
│   └── runtime~entry.page1_q7r8s9t0.bundle.js  # Webpack runtime
├── css/
│   ├── entry.page1_u1v2w3x4.bundle.css     # 提取的 CSS
│   └── entry.page2_y5z6a7b8.bundle.css
└── dist/
    ├── entry.page1.tpl                     # HTML 模板(注入资源链接)
    └── entry.page2.tpl

3.4 多线程构建原理

┌─────────────────────────────────────────────────────────────┐
│                   thread-loader 多线程构建                  │
└─────────────────────────────────────────────────────────────┘

  主进程(Webpack)
        │
        ├──► 启动 worker 线程池(CPU 核心数 - 1)
        │         │
        │         ├──► Worker 1 ──► 处理模块 A、B、C...
        │         ├──► Worker 2 ──► 处理模块 D、E、F...
        │         ├──► Worker 3 ──► 处理模块 G、H、I...
        │         └──► Worker N ──► ...
        │
        ├──► 分发模块到各个 Worker
        │
        ├──► Worker 执行 babel-loader/css-loader 等
        │
        └──► 收集结果,合并到主构建流程

  配置参数:
  - workers: os.cpus().length - 1  # 保留一个核心给主进程
  - workerParallelJobs: 50         # 每个 worker 最多 50 个任务
  - poolTimeout: 2000              # 2 秒空闲后关闭线程

四、完整请求链路对比

4.1 开发环境请求链路

┌─────────────────────────────────────────────────────────────┐
│                    开发环境请求链路                         │
└─────────────────────────────────────────────────────────────┘

  用户访问 http://localhost:8080/view/page1
        │
        ▼
  Nginx / Koa (8080)
        │
        ├──► 匹配路由 /view/:page
        │
        ├──► Controller 渲染模板
        │         │
        │         ├──► 读取 app/public/dist/entry.page1.tpl
        │         │
        │         └──► Nunjucks 注入数据
        │                   ├──► {{name}} = "Elpis"
        │                   ├──► {{env}} = "local"
        │                   └──► {{options}} = {...}
        │
        ▼
  返回 HTML(含 xxxx 端口的 JS 引用)
        │
        ▼
  浏览器解析 HTML
        │
        ├──► 请求 JS/CSS ──► Webpack Dev Server
        │                         │
        │                         └──► 从内存读取返回
        │
        └──► 建立 EventSource ──► 热更新推送

4.2 生产环境请求链路

┌─────────────────────────────────────────────────────────────┐
│                    生产环境请求链路                         │
└─────────────────────────────────────────────────────────────┘

  用户访问 http://example.com/view/page1
        │
        ▼
  Nginx / Koa (80/443)
        │
        ├──► 匹配路由 /view/:page
        │
        ├──► Controller 渲染模板
        │         │
        │         └──► 读取 app/public/dist/prod/entry.page1.tpl
        │
        ▼
  返回 HTML(含相对路径的 JS/CSS 引用)
        │
        ▼
  浏览器解析 HTML
        │
        ├──► 请求 JS/CSS ──► Nginx 静态资源
        │                         │
        │                         └──► 返回物理文件
        │                               ├──► /dist/prod/js/xxx.js
        │                               └──► /dist/prod/css/xxx.css
        │
        └──► Vue 挂载激活页面

五、总结

  • 自动化:自动扫描入口、自动注入 HTML、自动清理
  • 模块化:页面级分割、公共代码提取、组件复用
  • 标准化:目录规范、命名约定、代码风格
  • 性能优先:代码分割、缓存策略、压缩优化
  • 可维护:分层架构、路径别名、环境分离
❌
❌