阅读视图

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

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 等,周刊内容也会不断优化改进,希望你们能够喜欢。

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

每日一题-从根到叶的二进制数之和🟢

给出一棵二叉树,其上每个结点的值都是 0 或 1 。每一条从根到叶的路径都代表一个从最高有效位开始的二进制数。

  • 例如,如果路径为 0 -> 1 -> 1 -> 0 -> 1,那么它表示二进制数 01101,也就是 13 。

对树上的每一片叶子,我们都要找出从根到该叶子的路径所表示的数字。

返回这些数字之和。题目数据保证答案是一个 32 位 整数。

 

示例 1:

输入:root = [1,0,1,0,1,0,1]
输出:22
解释:(100) + (101) + (110) + (111) = 4 + 5 + 6 + 7 = 22

示例 2:

输入:root = [0]
输出:0

 

提示:

  • 树中的节点数在 [1, 1000] 范围内
  • Node.val 仅为 01 

【宫水三叶】树的遍历运用题

递归

容易想到「递归」进行求解,在 DFS 过程中记录下当前的值为多少,假设遍历到当前节点 $x$ 前,记录的值为 $cur$,那么根据题意,我们需要先将 $cur$ 进行整体左移(腾出最后一位),然后将节点 $x$ 的值放置最低位来得到新的值,并继续进行递归。

递归有使用「函数返回值」和「全局变量」两种实现方式。

代码:

###Java

class Solution {
    public int sumRootToLeaf(TreeNode root) {
        return dfs(root, 0);
    }
    int dfs(TreeNode root, int cur) {
        int ans = 0, ncur = (cur << 1) + root.val;
        if (root.left != null) ans += dfs(root.left, ncur);
        if (root.right != null) ans += dfs(root.right, ncur);
        return root.left == null && root.right == null ? ncur : ans;
    }
}

###Java

class Solution {
    int ans = 0;
    public int sumRootToLeaf(TreeNode root) {
        dfs(root, 0);
        return ans;
    }
    void dfs(TreeNode root, int cur) {
        int ncur = (cur << 1) + root.val;
        if (root.left != null) dfs(root.left, ncur);
        if (root.right != null) dfs(root.right, ncur);
        if (root.left == null && root.right == null) ans += ncur;
    }
}
  • 时间复杂度:$O(n)$
  • 空间复杂度:忽略递归带来的额外空间开销,复杂度为 $O(1)$

迭代

自然也可以使用「迭代」进行求解。

为了不引入除「队列」以外的其他数据结构,当我们可以把某个节点 $x$ 放出队列时,先将其的值修改为当前遍历路径对应的二进制数。

代码:

###Java

class Solution {
    public int sumRootToLeaf(TreeNode root) {
        int ans = 0;
        Deque<TreeNode> d = new ArrayDeque<>();
        d.addLast(root);
        while (!d.isEmpty()) {
            TreeNode poll = d.pollFirst();
            if (poll.left != null) {
                poll.left.val = (poll.val << 1) + poll.left.val;
                d.addLast(poll.left);
            }
            if (poll.right != null) {
                poll.right.val = (poll.val << 1) + poll.right.val;
                d.addLast(poll.right);
            }
            if (poll.left == null && poll.right == null) ans += poll.val;
        }
        return ans;
    }
}
  • 时间复杂度:$O(n)$
  • 空间复杂度:$O(n)$

最后

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

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

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

【递归三部曲「图解过程」】

思路分析:
本题最直观的思路就是直接递归遍历即可:按照访问根节点——左子树——右子树——的方式遍历这棵树,而在访问左子树或者右子树的时候,我们按照同样的方式遍历,直到遍历完整棵树。

实现步骤:

  1. 确定递归函数的参数和返回值: 因为本题需要递归遍历每个节点而且当前节点的计算用到了上次计算的结果,所以函数返回值是 计算结果值;
  2. 确定终止条件: 当然是当前遍历的节点是空了,那么本层递归就要要结束了,所以如果当前遍历的这个节点是空,就直接 $return$ $0$;
  3. 确定单层递归的逻辑
    • 如果节点是叶子节点,返回它对应的数字 $sum$;
    • 如果节点是非叶子节点,返回它的左子树和右子树对应的结果之和;

【图解举例】
204DB7DA-60D4-4A39-B7C6-C6B21687D4C0_1_201_a.jpeg

具体代码如下:

###C++

class Solution {
public:
    int traserval(TreeNode *root, int sum) {
        if(root == NULL) return 0;
        sum = (sum << 1) | root->val;
        if(!root->left && !root->right) return sum;
        int leftVal  = traserval(root->left, sum);
        int rightVal = traserval(root->right, sum);
        return leftVal + rightVal;
    }

    int sumRootToLeaf(TreeNode* root) {
        return traserval(root, 0);
    }
};

复杂度分析:

  • 时间复杂度:$O(n)$,其中 $n$ 是二叉搜索树的节点数;
  • 空间复杂度:$O(n)$。

从根到叶的二进制数之和

前言

关于二叉树后序遍历的详细说明请参考「145. 二叉树的后序遍历的官方题解」。

方法一:递归

后序遍历的访问顺序为:左子树——右子树——根节点。我们对根节点 $\textit{root}$ 进行后序遍历:

  • 如果节点是叶子节点,返回它对应的数字 $\textit{val}$。

  • 如果节点是非叶子节点,返回它的左子树和右子树对应的结果之和。

###Python

class Solution:
    def sumRootToLeaf(self, root: Optional[TreeNode]) -> int:
        def dfs(node: Optional[TreeNode], val: int) -> int:
            if node is None:
                return 0
            val = (val << 1) | node.val
            if node.left is None and node.right is None:
                return val
            return dfs(node.left, val) + dfs(node.right, val)
        return dfs(root, 0)

###C++

class Solution {
public:
    int dfs(TreeNode *root, int val) {
        if (root == nullptr) {
            return 0;
        }
        val = (val << 1) | root->val;
        if (root->left == nullptr && root->right == nullptr) {
            return val;
        }
        return dfs(root->left, val) + dfs(root->right, val);
    }

    int sumRootToLeaf(TreeNode* root) {
        return dfs(root, 0);
    }
};

###Java

class Solution {
    public int sumRootToLeaf(TreeNode root) {
        return dfs(root, 0);
    }

    public int dfs(TreeNode root, int val) {
        if (root == null) {
            return 0;
        }
        val = (val << 1) | root.val;
        if (root.left == null && root.right == null) {
            return val;
        }
        return dfs(root.left, val) + dfs(root.right, val);
    }
}

###C#

public class Solution {
    public int SumRootToLeaf(TreeNode root) {
        return DFS(root, 0);
    }

    public int DFS(TreeNode root, int val) {
        if (root == null) {
            return 0;
        }
        val = (val << 1) | root.val;
        if (root.left == null && root.right == null) {
            return val;
        }
        return DFS(root.left, val) + DFS(root.right, val);
    }
}

###C

int dfs(struct TreeNode *root, int val) {
    if (root == NULL) {
        return 0;
    }
    val = (val << 1) | root->val;
    if (root->left == NULL && root->right == NULL) {
        return val;
    }
    return dfs(root->left, val) + dfs(root->right, val);
}

int sumRootToLeaf(struct TreeNode* root){
    return dfs(root, 0);
}

###JavaScript

var sumRootToLeaf = function(root) {
    const dfs = (root, val) => {
        if (!root) {
            return 0;
        }
        val = (val << 1) | root.val;
        if (!root.left&& !root.right) {
            return val;
        }
        return dfs(root.left, val) + dfs(root.right, val);
    }
    return dfs(root, 0);
};

###go

func dfs(node *TreeNode, val int) int {
    if node == nil {
        return 0
    }
    val = val<<1 | node.Val
    if node.Left == nil && node.Right == nil {
        return val
    }
    return dfs(node.Left, val) + dfs(node.Right, val)
}

func sumRootToLeaf(root *TreeNode) int {
    return dfs(root, 0)
}

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 是节点数目。总共访问 $n$ 个节点。

  • 空间复杂度:$O(n)$。递归栈需要 $O(n)$ 的空间。

方法二:迭代

我们用栈来模拟递归,同时使用一个 $\textit{prev}$ 指针来记录先前访问的节点。算法步骤如下:

  1. 如果节点 $\textit{root}$ 非空,我们将不断地将它及它的左节点压入栈中。

  2. 我们从栈中获取节点:

    • 该节点的右节点为空或者等于 $\textit{prev}$,说明该节点的左子树及右子树都已经被访问,我们将它出栈。如果该节点是叶子节点,我们将它对应的数字 $\textit{val}$ 加入结果中。设置 $\textit{prev}$ 为该节点,设置 $\textit{root}$ 为空指针。

    • 该节点的右节点非空且不等于 $\textit{prev}$,我们令 $\textit{root}$ 指向该节点的右节点。

  3. 如果 $\textit{root}$ 为空指针或者栈空,中止算法,否则重复步骤 $1$。

需要注意的是,每次出入栈都需要更新 $\textit{val}$。

###Python

class Solution:
    def sumRootToLeaf(self, root: Optional[TreeNode]) -> int:
        ans = val = 0
        st = []
        pre = None
        while root or st:
            while root:
                val = (val << 1) | root.val
                st.append(root)
                root = root.left
            root = st[-1]
            if root.right is None or root.right == pre:
                if root.left is None and root.right is None:
                    ans += val
                val >>= 1
                st.pop()
                pre = root
                root = None
            else:
                root = root.right
        return ans

###C++

class Solution {
public:
    int sumRootToLeaf(TreeNode* root) {
        stack<TreeNode *> st;
        int val = 0, ret = 0;
        TreeNode *prev = nullptr;
        while (root != nullptr || !st.empty()) {
            while (root != nullptr) {
                val = (val << 1) | root->val;
                st.push(root);
                root = root->left;
            }
            root = st.top();
            if (root->right == nullptr || root->right == prev) {
                if (root->left == nullptr && root->right == nullptr) {
                    ret += val;
                }
                val >>= 1;
                st.pop();
                prev = root;
                root = nullptr;
            } else {
                root = root->right;
            }
        }
        return ret;
    }
};

###Java

class Solution {
    public int sumRootToLeaf(TreeNode root) {
        Deque<TreeNode> stack = new ArrayDeque<TreeNode>();
        int val = 0, ret = 0;
        TreeNode prev = null;
        while (root != null || !stack.isEmpty()) {
            while (root != null) {
                val = (val << 1) | root.val;
                stack.push(root);
                root = root.left;
            }
            root = stack.peek();
            if (root.right == null || root.right == prev) {
                if (root.left == null && root.right == null) {
                    ret += val;
                }
                val >>= 1;
                stack.pop();
                prev = root;
                root = null;
            } else {
                root = root.right;
            }
        }
        return ret;
    }
}

###C#

public class Solution {
    public int SumRootToLeaf(TreeNode root) {
        Stack<TreeNode> stack = new Stack<TreeNode>();
        int val = 0, ret = 0;
        TreeNode prev = null;
        while (root != null || stack.Count > 0) {
            while (root != null) {
                val = (val << 1) | root.val;
                stack.Push(root);
                root = root.left;
            }
            root = stack.Peek();
            if (root.right == null || root.right == prev) {
                if (root.left == null && root.right == null) {
                    ret += val;
                }
                val >>= 1;
                stack.Pop();
                prev = root;
                root = null;
            } else {
                root = root.right;
            }
        }
        return ret;
    }
}

###C

#define MAX_NODE_SIZE 1000

int sumRootToLeaf(struct TreeNode* root) {
    struct TreeNode ** stack = (struct TreeNode **)malloc(sizeof(struct TreeNode *) * MAX_NODE_SIZE);
    int top = 0;
    int val = 0, ret = 0;
    struct TreeNode *prev = NULL;
    while (root != NULL || top) {
        while (root != NULL) {
            val = (val << 1) | root->val;
            stack[top++] = root;
            root = root->left;
        }
        root = stack[top - 1];
        if (root->right == NULL || root->right == prev) {
            if (root->left == NULL && root->right == NULL) {
                ret += val;
            }
            val >>= 1;
            top--;
            prev = root;
            root = NULL;
        } else {
            root = root->right;
        }
    }
    free(stack);
    return ret;
}

###JavaScript

var sumRootToLeaf = function(root) {
    const stack = [];
    let val = 0, ret = 0;
    let prev = null;
    while (root || stack.length) {
        while (root) {
            val = (val << 1) | root.val;
            stack.push(root);
            root = root.left;
        }
        root = stack[stack.length - 1];
        if (!root.right || root.right === prev) {
            if (!root.left && !root.right) {
                ret += val;
            }
            val >>= 1;
            stack.pop();
            prev = root;
            root = null;
        } else {
            root = root.right;
        }
    }
    return ret;
};

###go

func sumRootToLeaf(root *TreeNode) (ans int) {
    val, st := 0, []*TreeNode{}
    var pre *TreeNode
    for root != nil || len(st) > 0 {
        for root != nil {
            val = val<<1 | root.Val
            st = append(st, root)
            root = root.Left
        }
        root = st[len(st)-1]
        if root.Right == nil || root.Right == pre {
            if root.Left == nil && root.Right == nil {
                ans += val
            }
            val >>= 1
            st = st[:len(st)-1]
            pre = root
            root = nil
        } else {
            root = root.Right
        }
    }
    return
}

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 是节点数目。总共访问 $n$ 个节点。

  • 空间复杂度:$O(n)$。栈最多压入 $n$ 个节点。

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. 错误处理:统一的错误捕获和上报机制

每日一题-检查一个字符串是否包含所有长度为 K 的二进制子串🟡

给你一个二进制字符串 s 和一个整数 k 。如果所有长度为 k 的二进制字符串都是 s 的子串,请返回 true ,否则请返回 false

 

示例 1:

输入:s = "00110110", k = 2
输出:true
解释:长度为 2 的二进制串包括 "00","01","10" 和 "11"。它们分别是 s 中下标为 0,1,3,2 开始的长度为 2 的子串。

示例 2:

输入:s = "0110", k = 1
输出:true
解释:长度为 1 的二进制串包括 "0" 和 "1",显然它们都是 s 的子串。

示例 3:

输入:s = "0110", k = 2
输出:false
解释:长度为 2 的二进制串 "00" 没有出现在 s 中。

 

提示:

  • 1 <= s.length <= 5 * 105
  • s[i] 不是'0' 就是 '1'
  • 1 <= k <= 20

两种方法:暴力 / 位运算(Python/Java/C++/Go)

方法一:暴力

暴力枚举所有长为 $k$ 的子串,保存到一个哈希集合中。

如果最终哈希集合的大小恰好等于 $2^k$,那么说明所有长为 $k$ 的二进制串都在 $s$ 中。

###py

class Solution:
    def hasAllCodes(self, s: str, k: int) -> bool:
        st = {s[i - k: i] for i in range(k, len(s) + 1)}
        return len(st) == 1 << k

###java

class Solution {
    public boolean hasAllCodes(String s, int k) {
        Set<String> set = new HashSet<>();
        for (int i = k; i <= s.length(); i++) {
            set.add(s.substring(i - k, i));
        }
        return set.size() == (1 << k);
    }
}

###cpp

class Solution {
public:
    bool hasAllCodes(string s, int k) {
        unordered_set<string> st;
        for (int i = k; i <= s.size(); i++) {
            st.insert(s.substr(i - k, k));
        }
        return st.size() == (1 << k);
    }
};

###go

func hasAllCodes(s string, k int) bool {
set := map[string]struct{}{}
for i := k; i <= len(s); i++ {
set[s[i-k:i]] = struct{}{}
}
return len(set) == 1<<k
}

复杂度分析

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

方法二:位运算滑窗

把子串转成整数,保存到哈希集合或者布尔数组中。

小优化:如果循环过程中发现已经找到 $2^k$ 个不同的二进制数,可以提前返回 $\texttt{true}$。

###py

class Solution:
    def hasAllCodes(self, s: str, k: int) -> bool:
        MASK = (1 << k) - 1
        st = set()  # 更快的写法见另一份代码【Python3 列表】
        x = 0
        for i, ch in enumerate(s):
            # 把 ch 加到 x 的末尾:x 整体左移一位,然后或上 ch
            # &MASK 目的是去掉超出 k 的比特位
            x = (x << 1 & MASK) | int(ch)
            if i >= k - 1:
                st.add(x)
        return len(st) == 1 << k

###py

class Solution:
    def hasAllCodes(self, s: str, k: int) -> bool:
        MASK = (1 << k) - 1
        has = [False] * (1 << k)
        cnt = x = 0
        for i, ch in enumerate(s):
            # 把 ch 加到 x 的末尾:x 整体左移一位,然后或上 ch
            # &MASK 目的是去掉超出 k 的比特位
            x = (x << 1 & MASK) | int(ch)
            if i < k - 1 or has[x]:
                continue
            has[x] = True
            cnt += 1
            if cnt == 1 << k:
                return True
        return False

###java

class Solution {
    public boolean hasAllCodes(String s, int k) {
        final int MASK = (1 << k) - 1;
        boolean[] has = new boolean[1 << k];
        int cnt = 0;
        int x = 0;
        for (int i = 0; i < s.length() && cnt < (1 << k); i++) {
            char ch = s.charAt(i);
            // 把 ch 加到 x 的末尾:x 整体左移一位,然后或上 ch&1
            // &MASK 目的是去掉超出 k 的比特位
            x = (x << 1 & MASK) | (ch & 1);
            if (i >= k - 1 && !has[x]) {
                has[x] = true;
                cnt++;
            }
        }
        return cnt == (1 << k);
    }
}

###cpp

class Solution {
public:
    bool hasAllCodes(string s, int k) {
        const int MASK = (1 << k) - 1;
        vector<int8_t> has(1 << k);
        int cnt = 0;
        int x = 0;
        for (int i = 0; i < s.size() && cnt < (1 << k); i++) {
            // 把 s[i] 加到 x 的末尾:x 整体左移一位,然后或上 s[i]&1
            // &MASK 目的是去掉超出 k 的比特位
            x = (x << 1 & MASK) | (s[i] & 1);
            if (i >= k - 1 && !has[x]) {
                has[x] = true;
                cnt++;
            }
        }
        return cnt == (1 << k);
    }
};

###go

func hasAllCodes(s string, k int) bool {
has := make([]bool, 1<<k)
cnt := 0
mask := 1<<k - 1
x := 0
for i, ch := range s {
// 把 ch 加到 x 的末尾:x 整体左移一位,然后或上 ch&1
// &mask 目的是去掉超出 k 的比特位
x = x<<1&mask | int(ch&1)
if i < k-1 || has[x] {
continue
}
has[x] = true
cnt++
if cnt == 1<<k {
return true
}
}
return false
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $s$ 的长度。
  • 空间复杂度:$\mathcal{O}(n-k)$ 或 $\mathcal{O}(2^k)$,取决于实现。

分类题单

如何科学刷题?

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

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

检查一个字符串是否包含所有长度为 K 的二进制子串

方法一:哈希表

我们遍历字符串 $s$,并用一个哈希集合(HashSet)存储所有长度为 $k$ 的子串。在遍历完成后,只需要判断哈希集合中是否有 $2^k$ 项即可,这是因为长度为 $k$ 的二进制串的数量为 $2^k$。

注意到如果 $s$ 包含 $2^k$ 个长度为 $k$ 的二进制串,那么它的长度至少为 $2^k+k-1$。因此我们可以在遍历前判断 $s$ 是否足够长。

###C++

class Solution {
public:
    bool hasAllCodes(string s, int k) {
        if (s.size() < (1 << k) + k - 1) {
            return false;
        }

        unordered_set<string> exists;
        for (int i = 0; i + k <= s.size(); ++i) {
            exists.insert(move(s.substr(i, k)));
        }
        return exists.size() == (1 << k);
    }
};

###C++

class Solution {
public:
    bool hasAllCodes(string s, int k) {
        if (s.size() < (1 << k) + k - 1) {
            return false;
        }

        string_view sv(s);
        unordered_set<string_view> exists;
        for (int i = 0; i + k <= s.size(); ++i) {
            exists.insert(sv.substr(i, k));
        }
        return exists.size() == (1 << k);
    }
};

###Python

class Solution:
    def hasAllCodes(self, s: str, k: int) -> bool:
        if len(s) < (1 << k) + k - 1:
            return False
        
        exists = set(s[i:i+k] for i in range(len(s) - k + 1))
        return len(exists) == (1 << k)

###Java

class Solution {
    public boolean hasAllCodes(String s, int k) {
        if (s.length() < (1 << k) + k - 1) {
            return false;
        }

        Set<String> exists = new HashSet<String>();
        for (int i = 0; i + k <= s.length(); ++i) {
            exists.add(s.substring(i, i + k));
        }
        return exists.size() == (1 << k);
    }
}

###C#

public class Solution {
    public bool HasAllCodes(string s, int k) {
        if (s.Length < (1 << k) + k - 1) {
            return false;
        }

        HashSet<string> exists = new HashSet<string>();
        for (int i = 0; i + k <= s.Length; ++i) {
            exists.Add(s.Substring(i, k));
        }
        return exists.Count == (1 << k);
    }
}

###Go

func hasAllCodes(s string, k int) bool {
    if len(s) < (1 << k) + k - 1 {
        return false
    }

    exists := make(map[string]bool)
    for i := 0; i + k <= len(s); i++ {
        substring := s[i:i+k]
        exists[substring] = true
    }
    return len(exists) == (1 << k)
}

###C


typedef struct {
    char *key;
    UT_hash_handle hh;
} HashItem; 

HashItem *hashFindItem(HashItem **obj, char *key) {
    HashItem *pEntry = NULL;
    HASH_FIND_STR(*obj, key, pEntry);
    return pEntry;
}

bool hashAddItem(HashItem **obj, char *key) {
    if (hashFindItem(obj, key)) {
        return false;
    }
    HashItem *pEntry = (HashItem *)malloc(sizeof(HashItem));
    pEntry->key = strdup(key);
    HASH_ADD_STR(*obj, key, pEntry);
    return true;
}

void hashFree(HashItem **obj) {
    HashItem *curr = NULL, *tmp = NULL;
    HASH_ITER(hh, *obj, curr, tmp) {
        HASH_DEL(*obj, curr); 
        free(curr->key); 
        free(curr);             
    }
}

bool hasAllCodes(char* s, int k) {
    int len = strlen(s);
    int total = 1 << k;
    if (len < total + k - 1) {
        return false;
    }

    HashItem *exists = NULL;
    for (int i = 0; i + k <= len; ++i) {
        char tmp[k + 1];
        strncpy(tmp, s + i, k);
        tmp[k] = '\0';
        hashAddItem(&exists, tmp);
    }

    bool ret = HASH_COUNT(exists) == (1 << k);
    hashFree(&exists);
    return ret;
}

###JavaScript

var hasAllCodes = function(s, k) {
    if (s.length < (1 << k) + k - 1) {
        return false;
    }

    const exists = new Set();
    for (let i = 0; i + k <= s.length; ++i) {
        exists.add(s.substring(i, i + k));
    }
    return exists.size === (1 << k);
};

###TypeScript

function hasAllCodes(s: string, k: number): boolean {
    if (s.length < (1 << k) + k - 1) {
        return false;
    }

    const exists = new Set<string>();
    for (let i = 0; i + k <= s.length; ++i) {
        exists.add(s.substring(i, i + k));
    }
    return exists.size === (1 << k);
}

###Rust

use std::collections::HashSet;

impl Solution {
    pub fn has_all_codes(s: String, k: i32) -> bool {
        let k = k as usize;
        let total = 1 << k;
        
        if s.len() < total + k - 1 {
            return false;
        }

        let mut exists = HashSet::new();
        for i in 0..=(s.len() - k) {
            exists.insert(&s[i..i + k]);
        }
        exists.len() == total
    }
}

复杂度分析

  • 时间复杂度:$O(k * |s|)$,其中 $|s|$ 是字符串 $s$ 的长度。将长度为 $k$ 的字符串加入哈希集合的时间复杂度为 $O(k)$,即为计算哈希值的时间。

  • 空间复杂度:$O(k * 2^k)$。哈希集合中最多有 $2^k$ 项,每一项是一个长度为 $k$ 的字符串。

方法二:哈希表 + 滑动窗口

我们可以借助滑动窗口,对方法一进行优化。

假设我们当前遍历到的长度为 $k$ 的子串为

$$
s_i, s_{i+1}, \cdots, s_{i+k-1}
$$

它的下一个子串为

$$
s_{i+1}, s_{i+2}, \cdots, s_{i+k}
$$

由于这些子串都是二进制串,我们可以将其表示成对应的十进制整数的形式,即

$$
\begin{aligned}
& \textit{num}i &= s_i * 2^{k-1} + s{i+1} * 2^{k-2} + \cdots + s_{i+k-1} * 2^0 \
& \textit{num}{i+1} &= s{i+1} * 2^{k-1} + s_{i+2} * 2^{k-2} + \cdots + s_{i+k} * 2^0 \
\end{aligned}
$$

那么我们可以将这些十进制整数作为哈希表中的项。由于每一个长度为 $k$ 的二进制串都唯一对应了一个十进制整数,因此这样做与方法一是一致的。与二进制串本身不同的是,我们可以在 $O(1)$ 的时间内通过 $\textit{num}i$ 得到 $\textit{num}{i+1}$,即:

$$
num_{i+1} = (num_{i} - s_i * 2^{k-1}) * 2 + s_{i+k}
$$

这样以来,我们在遍历 $s$ 的过程中只维护子串对应的十进制整数,而不需要对字符串进行操作,从而减少了时间复杂度。

###C++

class Solution {
public:
    bool hasAllCodes(string s, int k) {
        if (s.size() < (1 << k) + k - 1) {
            return false;
        }

        int num = stoi(s.substr(0, k), nullptr, 2);
        unordered_set<int> exists = {num};
        
        for (int i = 1; i + k <= s.size(); ++i) {
            num = (num - ((s[i - 1] - '0') << (k - 1))) * 2 + (s[i + k - 1] - '0');
            exists.insert(num);
        }
        return exists.size() == (1 << k);
    }
};

###Python

class Solution:
    def hasAllCodes(self, s: str, k: int) -> bool:
        if len(s) < (1 << k) + k - 1:
            return False
        
        num = int(s[:k], base=2)
        exists = set([num])

        for i in range(1, len(s) - k + 1):
            num = (num - ((ord(s[i - 1]) - 48) << (k - 1))) * 2 + (ord(s[i + k - 1]) - 48)
            exists.add(num)
        
        return len(exists) == (1 << k)

###Java

class Solution {
    public boolean hasAllCodes(String s, int k) {
        if (s.length() < (1 << k) + k - 1) {
            return false;
        }

        int num = Integer.parseInt(s.substring(0, k), 2);
        Set<Integer> exists = new HashSet<Integer>();
        exists.add(num);
        
        for (int i = 1; i + k <= s.length(); ++i) {
            num = (num - ((s.charAt(i - 1) - '0') << (k - 1))) * 2 + (s.charAt(i + k - 1) - '0');
            exists.add(num);
        }
        return exists.size() == (1 << k);
    }
}

###C#

public class Solution {
    public bool HasAllCodes(string s, int k) {
        if (s.Length < (1 << k) + k - 1) {
            return false;
        }

        int num = Convert.ToInt32(s.Substring(0, k), 2);
        HashSet<int> exists = new HashSet<int> { num };
        
        for (int i = 1; i + k <= s.Length; ++i) {
            num = (num - ((s[i - 1] - '0') << (k - 1))) * 2 + (s[i + k - 1] - '0');
            exists.Add(num);
        }
        return exists.Count == (1 << k);
    }
}

###Go

func hasAllCodes(s string, k int) bool {
    if len(s) < (1 << k) + k - 1 {
        return false
    }

    num := 0
    for i := 0; i < k; i++ {
        num = num << 1
        if s[i] == '1' {
            num |= 1
        }
    }
    
    exists := make(map[int]bool)
    exists[num] = true
    for i := 1; i + k <= len(s); i++ {
        num = (num - (int(s[i-1]-'0') << (k-1))) * 2 + int(s[i+k-1]-'0')
        exists[num] = true
    }
    return len(exists) == (1 << k)
}

###C

typedef struct {
    int key;        
    UT_hash_handle hh;
} HashItem;

HashItem *hashFindItem(HashItem **obj, int key) {
    HashItem *pEntry = NULL;
    HASH_FIND_INT(*obj, &key, pEntry);
    return pEntry;
}

bool hashAddItem(HashItem **obj, int key) {
    if (hashFindItem(obj, key)) {
        return false;
    }
    HashItem *pEntry = (HashItem *)malloc(sizeof(HashItem));
    pEntry->key = key;
    HASH_ADD_INT(*obj, key, pEntry);
    return true;
}

void hashFree(HashItem **obj) {
    HashItem *curr = NULL, *tmp = NULL;
    HASH_ITER(hh, *obj, curr, tmp) {
        HASH_DEL(*obj, curr);
        free(curr);
    }
}

bool hasAllCodes(char* s, int k) {
    int len = strlen(s);
    int total = 1 << k;
    if (len < total + k - 1) {
        return false;
    }

    int num = 0;
    for (int i = 0; i < k; i++) {
        num = (num << 1) | (s[i] - '0');
    }
    
    HashItem *exists = NULL;
    hashAddItem(&exists, num);
    for (int i = k; i < len; i++) {
        int mask = (1 << k) - 1;
        num = ((num << 1) | (s[i] - '0')) & mask;
        hashAddItem(&exists, num);
    }

    bool ret = HASH_COUNT(exists) == total;
    hashFree(&exists);
    return ret;
}

###JavaScript

var hasAllCodes = function(s, k) {
    if (s.length < (1 << k) + k - 1) {
        return false;
    }

    let num = parseInt(s.substring(0, k), 2);
    const exists = new Set([num]);
    for (let i = 1; i + k <= s.length; ++i) {
        num = (num - (parseInt(s[i - 1]) << (k - 1))) * 2 + parseInt(s[i + k - 1]);
        exists.add(num);
    }
    return exists.size === (1 << k);
};

###TypeScript

function hasAllCodes(s: string, k: number): boolean {
    if (s.length < (1 << k) + k - 1) {
        return false;
    }

    let num = parseInt(s.substring(0, k), 2);
    const exists = new Set<number>([num]);
    for (let i = 1; i + k <= s.length; ++i) {
        num = (num - (parseInt(s[i - 1]) << (k - 1))) * 2 + parseInt(s[i + k - 1]);
        exists.add(num);
    }
    return exists.size === (1 << k);
}

###Rust

use std::collections::HashSet;

impl Solution {
    pub fn has_all_codes(s: String, k: i32) -> bool {
        let k = k as usize;
        let total = 1 << k;
        
        if s.len() < total + k - 1 {
            return false;
        }

        let bytes = s.as_bytes();
        let mut num = 0;
        for i in 0..k {
            num = (num << 1) | (bytes[i] - b'0') as usize;
        }

        let mut exists = HashSet::new();
        exists.insert(num);
        for i in 1..=bytes.len() - k {
            let high_bit = ((bytes[i - 1] - b'0') as usize) << (k - 1);
            num = (num - high_bit) << 1 | (bytes[i + k - 1] - b'0') as usize;
            exists.insert(num);
        }
        
        exists.len() == total
    }
}

复杂度分析

  • 时间复杂度:$O(|s|)$,其中 $|s|$ 是字符串 $s$ 的长度。

  • 空间复杂度:$O(2^k)$。哈希集合中最多有 $2^k$ 项,每一项是一个十进制整数。

哈希表中存字符串,时间复杂度不是 O(n),真正的 O(n) 解在这里。

大多数题解,都是在哈希表中存储字符串。类似如下的代码:

class Solution {
public:
    bool hasAllCodes(string s, int k) {

        unordered_set<string> set;
        for(int i = 0; i + k <= s.size(); i ++) set.insert(s.substr(i, k));
        return set.size() == (1 << k);
    }
};

但其实,这样做,因为哈希表中存的是长度为 k 的子串。每次计算子串的哈希值,是需要 O(k) 的时间的。所以这个算法真正的复杂度是 O(|s| * k)

我提交的数据是这样的:

Screen Shot 2020-05-30 at 11.53.37 AM.png


这个问题可以优化,我们可以使用滑动窗口的思想,每次把长度为 k 的子串所对应的整数计算出来。之后,每次窗口向前移动,子串最高位丢掉一个字符;最低位添加一个字符,使用 O(1) 的时间即可计算出新的数字。同时,哈希表中存储的是整型,复杂度才是真正的 O(1)。整体算法复杂度是 O(|s|)的。

class Solution {
public:
    bool hasAllCodes(string s, int k) {

        if(k > s.size()) return 0;

        int cur = 0;
        for(int i = 0; i < k - 1; i ++)
            cur = 2 * cur + (s[i] == '1');

        unordered_set<int> set;
        for(int i = k - 1; i < s.size(); i ++){
            cur = cur * 2 + (s[i] == '1');
            set.insert(cur);
            cur &= ~(1 << (k - 1));
        }
        return set.size() == (1 << k);
    }
};

上面的代码在 leetcode 上测试,时间快一倍,空间消耗也更少。

Screen Shot 2020-05-30 at 11.58.31 AM.png


最后,如果使用整型,我们就可以不再使用哈希表了,直接把数组当哈希表用,索引即是 key。这样,性能又能提升一倍。

class Solution {
public:
    bool hasAllCodes(string s, int k) {

        if(k > s.size()) return 0;

        int cur = 0;
        for(int i = 0; i < k - 1; i ++)
            cur = 2 * cur + (s[i] == '1');

        vector<bool> used(1 << k, false);
        for(int i = k - 1; i < s.size(); i ++){
            cur = cur * 2 + (s[i] == '1');
            used[cur] = true;
            cur &= ~(1 << (k - 1));
        }
        
        for(int e: used) if(!e) return false;
        return true;
    }
};

Screen Shot 2020-05-30 at 12.04.32 PM.png


觉得有帮助请点赞哇!

构建无障碍组件之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 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

rm Cheatsheet

Basic Syntax

Core command forms for file and directory removal.

Command Description
rm [OPTIONS] FILE... Remove one or more files
rm -r [OPTIONS] DIRECTORY... Remove directories recursively
rm -- FILE Treat argument as filename even if it starts with -
rm -i FILE Prompt before each removal

Remove Files

Common file deletion commands.

Command Description
rm file.txt Remove one file
rm file1 file2 file3 Remove multiple files
rm *.log Remove files matching pattern
rm -- -strange-filename Remove file named with leading -
rm -v file.txt Remove file with verbose output

Remove Directories

Delete directories and their contents.

Command Description
rm -r dir/ Remove directory recursively
rm -rf dir/ Force recursive removal without prompts
rm -r dir1 dir2 Remove multiple directories
rm -r -- */ Remove all directories in current path

Prompt and Safety Options

Control how aggressively rm deletes files.

Command Description
rm -i file.txt Prompt before each file removal
rm -I file1 file2 Prompt once before deleting many files
rm --interactive=always file.txt Always prompt
rm --interactive=once *.tmp Prompt once
rm -f file.txt Ignore nonexistent files, never prompt

Useful Patterns

Frequent real-world combinations.

Command Description
find . -type f -name '*.tmp' -delete Remove matching temporary files
find . -type d -empty -delete Remove empty directories
rm -rf -- build/ dist/ Remove common build directories
rm -f -- *.bak *.old Remove backup files quietly

Troubleshooting

Quick checks for common removal errors.

Issue Check
Permission denied Check ownership and permissions; use sudo only when needed
Is a directory Add -r to remove directories
No such file or directory Verify path and shell glob expansion
Cannot remove write-protected file Use -i for prompts or -f to force
File name starts with - Use rm -- filename

Related Guides

Use these references for safer file management workflows.

Guide Description
Rm Command in Linux Full rm guide with examples
How to Find Files in Linux Using the Command Line Find and target files before deleting
Chmod Command in Linux Fix permission errors before removal
Linux Commands Cheatsheet General Linux command quick reference

Linux patch Command: Apply Diff Files

The patch command applies a set of changes described in a diff file to one or more original files. It is the standard way to distribute and apply source code changes, security fixes, and configuration updates in Linux, and pairs directly with the diff command.

This guide explains how to use the patch command in Linux with practical examples.

Syntax

The general syntax of the patch command is:

txt
patch [OPTIONS] [ORIGINALFILE] [PATCHFILE]

You can pass the patch file using the -i option or pipe it through standard input:

txt
patch [OPTIONS] -i patchfile [ORIGINALFILE]
patch [OPTIONS] [ORIGINALFILE] < patchfile

patch Options

Option Long form Description
-i FILE --input=FILE Read the patch from FILE instead of stdin
-p N --strip=N Strip N leading path components from file names in the patch
-R --reverse Reverse the patch (undo a previously applied patch)
-b --backup Back up the original file before patching
--dry-run Test the patch without making any changes
-d DIR --directory=DIR Change to DIR before doing anything
-N --forward Skip patches that appear to be already applied
-l --ignore-whitespace Ignore whitespace differences when matching lines
-f --force Force apply even if the patch does not match cleanly
-u --unified Interpret the patch as a unified diff

Create and Apply a Basic Patch

The most common workflow is to use diff to generate a patch file and patch to apply it.

Start with two versions of a file. Here is the original:

hello.pytxt
print("Hello, World!")
print("Version 1")

And the updated version:

hello_new.pytxt
print("Hello, Linux!")
print("Version 2")

Use diff with the -u flag to generate a unified diff and save it to a patch file:

Terminal
diff -u hello.py hello_new.py > hello.patch

The hello.patch file will contain the following differences:

output
--- hello.py 2026-02-21 10:00:00.000000000 +0100
+++ hello_new.py 2026-02-21 10:05:00.000000000 +0100
@@ -1,2 +1,2 @@
-print("Hello, World!")
-print("Version 1")
+print("Hello, Linux!")
+print("Version 2")

To apply the patch to the original file:

Terminal
patch hello.py hello.patch
output
patching file hello.py

hello.py now contains the updated content from hello_new.py.

Dry Run Before Applying

Use --dry-run to test whether a patch will apply cleanly without actually modifying any files:

Terminal
patch --dry-run hello.py hello.patch
output
patching file hello.py

If the patch cannot be applied cleanly, patch reports the conflict without touching any files. This is useful before applying patches from external sources or when you are unsure whether a patch has already been applied.

Back Up the Original File

Use the -b option to create a backup of the original file before applying the patch. The backup is saved with a .orig extension:

Terminal
patch -b hello.py hello.patch
output
patching file hello.py

After running this command, hello.py.orig contains the original unpatched file. You can restore it manually if needed.

Strip Path Components with -p

When a patch file is generated from a different directory structure than the one where you apply it, the file paths inside the patch may not match your local paths. Use -p N to strip N leading path components from the paths recorded in the patch.

For example, a patch generated with git diff will reference files like:

output
--- a/src/utils/hello.py
+++ b/src/utils/hello.py

To apply this patch from the project root, use -p1 to strip the a/ and b/ prefixes:

Terminal
patch -p1 < hello.patch

-p1 is the standard option for patches generated by git diff and by diff -u from the root of a project directory. Without it, patch would look for a file named a/src/utils/hello.py on disk, which does not exist.

Reverse a Patch

Use the -R option to undo a previously applied patch and restore the original file:

Terminal
patch -R hello.py hello.patch
output
patching file hello.py

The file is restored to its state before the patch was applied.

Apply a Multi-File Patch

A single patch file can contain changes to multiple files. In this case, you do not specify a target file on the command line — patch reads the file names from the diff headers inside the patch:

Terminal
patch -p1 < project.patch

patch processes each file name from the diff headers and applies the corresponding changes. Use -p1 when the patch was produced by git diff or diff -u from the project root.

Quick Reference

Task Command
Apply a patch patch original.txt changes.patch
Apply from stdin patch -p1 < changes.patch
Specify patch file with -i patch -i changes.patch original.txt
Dry run (test without applying) patch --dry-run -p1 < changes.patch
Back up original before patching patch -b original.txt changes.patch
Strip one path prefix (git patches) patch -p1 < changes.patch
Reverse a patch patch -R original.txt changes.patch
Ignore whitespace differences patch -l original.txt changes.patch
Apply to a specific directory patch -d /path/to/dir -p1 < changes.patch

Troubleshooting

Hunk #1 FAILED at line N.
The patch does not match the current content of the file. This usually means the file has already been modified since the patch was created, or you are applying the patch against the wrong version. patch creates a .rej file containing the failed hunk so you can review and apply the change manually.

Reversed (or previously applied) patch detected! Assume -R?
patch has detected that the changes in the patch are already present in the file — the patch may have been applied before. Answer y to reverse the patch or n to skip it. Use -N (--forward) to automatically skip already-applied patches without prompting.

patch: **** malformed patch at line N
The patch file is corrupted or not in a recognized format. Make sure the patch was generated with diff -u (unified format). Non-unified formats require the -c (context) or specific format flag to be passed to patch.

File paths in the patch do not match files on disk.
Adjust the -p N value. Run patch --dry-run -p0 < changes.patch first, then increment N (-p1, -p2) until patch finds the correct files.

FAQ

How do I create a patch file?
Use the diff command with the -u flag: diff -u original.txt updated.txt > changes.patch. The -u flag produces a unified diff, which is the format patch works with by default. See the diff command guide for details.

What does -p1 mean?
-p1 tells patch to strip one leading path component from file names in the patch. Patches generated by git diff prefix file paths with a/ and b/, so -p1 removes that prefix before patch looks for the file on disk.

How do I undo a patch I already applied?
Run patch -R originalfile patchfile. patch reads the diff in reverse and restores the original content.

What is a .rej file?
When patch cannot apply one or more sections of a patch (called hunks), it writes the failed hunks to a file with a .rej extension alongside the original file. You can open the .rej file and apply those changes manually.

What is the difference between patch and git apply?
Both apply diff files, but git apply is designed for use inside a Git repository and integrates with the Git index. patch is a standalone POSIX tool that works on any file regardless of version control.

Conclusion

The patch command is the standard tool for applying diff files in Linux. Use --dry-run to verify a patch before applying it, -b to keep a backup of the original, and -R to reverse changes when needed.

If you have any questions, feel free to leave a comment below.

【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 #智慧城市 #大屏设计

❌