普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月18日技术

前端javascript如何实现阅读位置记忆【可运行源码】

作者 DEMO派
2025年12月18日 11:28

阅读位置记忆功能demo

功能说明

1. 自动保存:滚动页面时,系统会自动保存当前阅读位置(防抖处理,每秒保存一次)
2. 自动恢复:重新打开页面时,会自动跳转到上次阅读的位置
3. 手动控制:
  1. 手动保存当前位置

  2. 手动跳转到上次保存的位置

  3. 清除保存的记录

4. 视图反馈
  1. 状态指示器显示保存状态

  2. 恢复时高亮显示当前章节

  3. 显示滚动进度条

  4. 侧边位置标记显示当前章节和进度

使用方法

  1. 直接复制上面的代码到HTML文件中

  2. 用浏览器打开该文件

  3. 滚动页面阅读内容

  4. 刷新页面或关闭后重新打开,页面会自动跳转到上次阅读的位置

示例图片

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>阅读位置记忆功能</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
            font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
        }
        
        body {
            background-color: #f5f7fa;
            color: #333;
            line-height: 1.6;
        }
        
        .container {
            max-width: 1000px;
            margin: 0 auto;
            padding: 20px;
        }
        
        header {
            text-align: center;
            padding: 30px 0;
            background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
            color: white;
            border-radius: 10px;
            margin-bottom: 30px;
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
        }
        
        h1 {
            font-size: 2.5rem;
            margin-bottom: 10px;
        }
        
        .subtitle {
            font-size: 1.1rem;
            opacity: 0.9;
            max-width: 600px;
            margin: 0 auto;
        }
        
        .control-panel {
            background-color: white;
            padding: 20px;
            border-radius: 10px;
            margin-bottom: 30px;
            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
            display: flex;
            flex-wrap: wrap;
            gap: 15px;
            justify-content: center;
        }
        
        .btn {
            padding: 12px 24px;
            border: none;
            border-radius: 5px;
            cursor: pointer;
            font-weight: 600;
            transition: all 0.3s ease;
            display: flex;
            align-items: center;
            gap: 8px;
        }
        
        .btn-primary {
            background-color: #4a6ee0;
            color: white;
        }
        
        .btn-primary:hover {
            background-color: #3a5ed0;
            transform: translateY(-2px);
        }
        
        .btn-success {
            background-color: #10b981;
            color: white;
        }
        
        .btn-success:hover {
            background-color: #0da271;
            transform: translateY(-2px);
        }
        
        .btn-warning {
            background-color: #f59e0b;
            color: white;
        }
        
        .btn-warning:hover {
            background-color: #e5900a;
            transform: translateY(-2px);
        }
        
        .status-indicator {
            display: flex;
            align-items: center;
            padding: 12px 20px;
            background-color: #f8fafc;
            border-radius: 5px;
            font-weight: 500;
        }
        
        .indicator-dot {
            width: 10px;
            height: 10px;
            border-radius: 50%;
            margin-right: 10px;
            background-color: #6b7280;
        }
        
        .indicator-dot.active {
            background-color: #10b981;
            animation: pulse 2s infinite;
        }
        
        @keyframes pulse {
            0% { opacity: 1; }
            50% { opacity: 0.5; }
            100% { opacity: 1; }
        }
        
        .content {
            background-color: white;
            padding: 30px;
            border-radius: 10px;
            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
            margin-bottom: 30px;
        }
        
        .chapter {
            margin-bottom: 40px;
            padding-bottom: 30px;
            border-bottom: 1px solid #e5e7eb;
        }
        
        .chapter:last-child {
            border-bottom: none;
            margin-bottom: 0;
            padding-bottom: 0;
        }
        
        .chapter-title {
            font-size: 1.8rem;
            color: #1f2937;
            margin-bottom: 20px;
            padding-left: 15px;
            border-left: 5px solid #4a6ee0;
        }
        
        .chapter-content {
            font-size: 1.05rem;
        }
        
        .chapter-content p {
            margin-bottom: 15px;
            text-align: justify;
        }
        
        .highlight {
            background-color: rgba(255, 255, 0, 0.3);
            transition: background-color 0.5s ease;
        }
        
        footer {
            text-align: center;
            padding: 20px;
            color: #6b7280;
            font-size: 0.9rem;
        }
        
        .position-marker {
            position: fixed;
            right: 20px;
            top: 50%;
            transform: translateY(-50%);
            background-color: rgba(74, 110, 224, 0.9);
            color: white;
            padding: 15px;
            border-radius: 8px;
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
            z-index: 100;
            width: 180px;
            text-align: center;
            display: none;
        }
        
        .position-marker h3 {
            font-size: 1rem;
            margin-bottom: 8px;
        }
        
        .position-info {
            font-size: 1.2rem;
            font-weight: bold;
        }
        
        .scroll-progress {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 4px;
            background-color: #e5e7eb;
            z-index: 1000;
        }
        
        .scroll-progress-bar {
            height: 100%;
            background: linear-gradient(90deg, #6a11cb 0%, #2575fc 100%);
            width: 0%;
            transition: width 0.2s ease;
        }
        
        @media (max-width: 768px) {
            .container {
                padding: 15px;
            }
            
            h1 {
                font-size: 2rem;
            }
            
            .control-panel {
                flex-direction: column;
                align-items: stretch;
            }
            
            .btn {
                justify-content: center;
            }
            
            .position-marker {
                display: none !important;
            }
        }
    </style>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
</head>
<body>
    <!-- 滚动进度条 -->
    <div class="scroll-progress">
        <div class="scroll-progress-bar"></div>
    </div>
    
    <!-- 位置标记 -->
    <div class="position-marker">
        <h3>上次阅读位置</h3>
        <div class="position-info"><span id="chapter-num">0</span></div>
        <div class="position-info" id="position-percent">0%</div>
    </div>
    
    <div class="container">
        <header>
            <h1><i class="fas fa-book-bookmark"></i> 阅读位置记忆功能</h1>
            <p class="subtitle">离开页面后,系统会自动保存您的阅读位置。重新打开时,会自动跳转到上次阅读的位置。</p>
        </header>
        
        <div class="control-panel">
            <div class="status-indicator">
                <span class="indicator-dot" id="status-dot"></span>
                <span id="status-text">状态:未检测到历史记录</span>
            </div>
            
            <button class="btn btn-primary" id="save-btn">
                <i class="fas fa-save"></i> 手动保存当前位置
            </button>
            
            <button class="btn btn-success" id="jump-btn">
                <i class="fas fa-arrow-right"></i> 跳转到上次位置
            </button>
            
            <button class="btn btn-warning" id="clear-btn">
                <i class="fas fa-trash-alt"></i> 清除记录
            </button>
        </div>
        
        <div class="content" id="content">
            <!-- 内容将通过JavaScript生成 -->
        </div>
        
        <footer>
            <p>© 2025 阅读位置记忆演示 | 使用 localStorage 实现位置记忆功能</p>
            <p>尝试滚动页面,然后刷新或关闭页面,重新打开时会自动跳转到上次阅读的位置。</p>
        </footer>
    </div>

    <script>
        // 生成示例内容
        const chapters = [
            {
                title: "第一章:初识前端开发",
                content: `前端开发是创建Web页面或App等前端界面呈现给用户的过程。通过HTML、CSS及JavaScript以及衍生出来的各种技术、框架、解决方案,来实现互联网产品的用户界面交互。
                
                随着互联网技术的发展,HTML5、CSS3、ES6等现代前端技术的应用,使得前端开发能够实现更丰富的交互和更好的用户体验。前端工程师需要与设计师、后端工程师协作,完成产品的前端开发工作。
                
                前端开发领域技术更新迅速,开发者需要不断学习新技术、新框架,以适应快速发展的行业需求。React、Vue、Angular等框架的出现,大大提高了前端开发的效率。`
            },
            {
                title: "第二章:JavaScript的核心概念",
                content: `JavaScript是一种具有函数优先的轻量级、解释型或即时编译型的编程语言。虽然它是作为开发Web页面的脚本语言而出名,但是它也被用到了很多非浏览器环境中。
                
                变量作用域、闭包、原型链、异步编程等是JavaScript的核心概念。理解这些概念对于编写高质量JavaScript代码至关重要。
                
                ES6引入了许多新特性,如let和const声明、箭头函数、模板字符串、解构赋值、Promise等,这些特性使得JavaScript更加强大和易用。现代前端开发几乎都基于ES6及以上版本。`
            },
            {
                title: "第三章:DOM操作与事件处理",
                content: `文档对象模型(DOM)是HTML和XML文档的编程接口。它提供了对文档的结构化表述,并定义了一种方式可以使程序对该结构进行访问,从而改变文档的结构、样式和内容。
                
                DOM将文档解析为一个由节点和对象(包含属性和方法的对象)组成的结构集合。简言之,它会将web页面和脚本或程序语言连接起来。
                
                事件处理是前端交互的核心。JavaScript通过事件监听器来响应用户的操作,如点击、悬停、滚动等。事件委托是一种常用的优化技术,它利用事件冒泡机制,将事件监听器添加到父元素上,而不是每个子元素上。`
            },
            {
                title: "第四章:现代前端框架",
                content: `React、Vue和Angular是目前最流行的三大前端框架。它们都采用了组件化的开发模式,将UI拆分为独立可复用的代码片段,并对每个片段进行独立构思。
                
                React由Facebook开发,以其虚拟DOM和单向数据流而闻名。Vue由尤雨溪创建,以其渐进式框架和易用性受到开发者喜爱。Angular由Google维护,是一个完整的企业级框架。
                
                这些框架都提供了状态管理、路由、构建工具等完整的前端开发解决方案。选择哪个框架取决于项目需求、团队技能和个人偏好。`
            },
            {
                title: "第五章:响应式设计与移动优先",
                content: `响应式Web设计是一种网页设计方法,使网站能在各种设备(从桌面电脑到移动电话)上很好地工作。其核心是使用弹性网格布局、弹性图片和媒体查询。
                
                移动优先是一种设计策略,首先为移动设备设计网站,然后逐步增强为平板电脑和桌面电脑的设计。这种策略确保网站在小屏幕上有良好的体验。
                
                随着移动设备使用量的增加,响应式设计和移动优先策略变得越来越重要。CSS框架如Bootstrap、Tailwind CSS等提供了实现响应式设计的工具。`
            },
            {
                title: "第六章:前端性能优化",
                content: `前端性能优化是提高网站加载速度和响应速度的过程。性能优化的目标包括减少页面加载时间、减少资源大小、优化渲染路径等。
                
                常见的前端性能优化技术包括:代码压缩、图片优化、懒加载、代码分割、缓存策略、减少重绘和回流等。使用Webpack、Rollup等构建工具可以自动化许多优化任务。
                
                性能直接影响用户体验和SEO排名。Google的Core Web Vitals指标已成为衡量网站用户体验的重要标准,包括LCP(最大内容绘制)、FID(首次输入延迟)和CLS(累积布局偏移)。`
            },
            {
                title: "第七章:前端工程化",
                content: `前端工程化是指将软件工程的方法和原则应用到前端开发中,以提高开发效率、代码质量和团队协作。它包括构建工具、代码规范、测试、部署等流程。
                
                现代前端工程化通常包括以下工具:包管理器(npm、yarn)、模块打包器(Webpack、Rollup)、编译器(Babel)、代码检查工具(ESLint)、样式预处理(Sass、Less)等。
                
                持续集成/持续部署(CI/CD)也是前端工程化的重要组成部分,它可以自动化测试和部署流程,确保代码质量。`
            },
            {
                title: "第八章:前端未来发展",
                content: `前端领域正在快速发展,新技术不断涌现。WebAssembly允许在浏览器中运行高性能代码;Progressive Web Apps(PWA)提供类似原生应用的体验;Web Components实现真正的组件复用。
                
                随着物联网、人工智能和5G技术的发展,前端开发将面临新的机遇和挑战。前端工程师可能需要掌握更多的跨平台开发技能,如React Native、Flutter等。
                
                前端开发的未来将是多元化、全栈化的。前端工程师不仅需要掌握前端技术,还需要了解后端、DevOps、设计等相关知识,以应对日益复杂的产品需求。`
            }
        ];
        
        // 全局变量
        let scrollTimeout;
        let lastSavedPosition = 0;
        let isRestoring = false;
        const STORAGE_KEY = 'reading_position';
        
        // DOM元素
        const contentEl = document.getElementById('content');
        const statusDot = document.getElementById('status-dot');
        const statusText = document.getElementById('status-text');
        const saveBtn = document.getElementById('save-btn');
        const jumpBtn = document.getElementById('jump-btn');
        const clearBtn = document.getElementById('clear-btn');
        const positionMarker = document.querySelector('.position-marker');
        const chapterNumEl = document.getElementById('chapter-num');
        const positionPercentEl = document.getElementById('position-percent');
        const scrollProgressBar = document.querySelector('.scroll-progress-bar');
        
        // 初始化:生成内容
        function initContent() {
            let contentHTML = '';
            chapters.forEach((chapter, index) => {
                contentHTML += `
                    <div class="chapter" id="chapter-${index + 1}">
                        <h2 class="chapter-title">${chapter.title}</h2>
                        <div class="chapter-content">
                            ${chapter.content.split('\n').map(p => `<p>${p}</p>`).join('')}
                        </div>
                    </div>
                `;
            });
            contentEl.innerHTML = contentHTML;
        }
        
        // 保存阅读位置
        function saveReadingPosition() {
            const scrollPosition = window.pageYOffset || document.documentElement.scrollTop;
            const totalHeight = document.documentElement.scrollHeight - window.innerHeight;
            const scrollPercent = totalHeight > 0 ? Math.round((scrollPosition / totalHeight) * 100) : 0;
            
            // 计算当前章节
            let currentChapter = 1;
            const chaptersElements = document.querySelectorAll('.chapter');
            for (let i = 0; i < chaptersElements.length; i++) {
                const rect = chaptersElements[i].getBoundingClientRect();
                if (rect.top <= window.innerHeight / 2) {
                    currentChapter = i + 1;
                }
            }
            
            const positionData = {
                scrollTop: scrollPosition,
                timestamp: new Date().getTime(),
                chapter: currentChapter,
                percent: scrollPercent
            };
            
            localStorage.setItem(STORAGE_KEY, JSON.stringify(positionData));
            lastSavedPosition = scrollPosition;
            
            // 更新状态
            updateStatus(true);
            
            // 显示保存提示
            showNotification('位置已保存!');
            
            console.log('位置已保存:', positionData);
        }
        
        // 恢复阅读位置
        function restoreReadingPosition() {
            const savedData = localStorage.getItem(STORAGE_KEY);
            
            if (!savedData) {
                updateStatus(false);
                return false;
            }
            
            try {
                const positionData = JSON.parse(savedData);
                isRestoring = true;
                
                // 滚动到保存的位置
                window.scrollTo({
                    top: positionData.scrollTop,
                    behavior: 'smooth'
                });
                
                // 高亮当前章节
                highlightCurrentChapter(positionData.chapter);
                
                // 更新状态
                updateStatus(true, positionData);
                
                // 显示恢复提示
                showNotification(`已恢复到上次阅读位置:第${positionData.chapter}章`);
                
                console.log('位置已恢复:', positionData);
                
                // 重置标志
                setTimeout(() => {
                    isRestoring = false;
                }, 1000);
                
                return true;
            } catch (error) {
                console.error('恢复位置时出错:', error);
                updateStatus(false);
                return false;
            }
        }
        
        // 清除保存的位置
        function clearSavedPosition() {
            localStorage.removeItem(STORAGE_KEY);
            updateStatus(false);
            positionMarker.style.display = 'none';
            showNotification('位置记录已清除');
        }
        
        // 更新状态指示器
        function updateStatus(hasData, positionData = null) {
            if (hasData) {
                statusDot.classList.add('active');
                
                if (positionData) {
                    const timeAgo = Math.round((new Date().getTime() - positionData.timestamp) / (1000 * 60));
                    statusText.textContent = `状态:已保存 (${timeAgo}分钟前,第${positionData.chapter}章)`;
                    
                    // 显示位置标记
                    positionMarker.style.display = 'block';
                    chapterNumEl.textContent = positionData.chapter;
                    positionPercentEl.textContent = `${positionData.percent}%`;
                } else {
                    statusText.textContent = '状态:已启用自动保存';
                }
            } else {
                statusDot.classList.remove('active');
                statusText.textContent = '状态:未检测到历史记录';
            }
        }
        
        // 高亮当前章节
        function highlightCurrentChapter(chapterIndex) {
            // 移除所有高亮
            document.querySelectorAll('.chapter').forEach(chapter => {
                chapter.classList.remove('highlight');
            });
            
            // 高亮当前章节
            const currentChapter = document.getElementById(`chapter-${chapterIndex}`);
            if (currentChapter) {
                currentChapter.classList.add('highlight');
                
                // 5秒后移除高亮
                setTimeout(() => {
                    currentChapter.classList.remove('highlight');
                }, 5000);
            }
        }
        
        // 显示通知
        function showNotification(message) {
            // 创建通知元素
            const notification = document.createElement('div');
            notification.style.cssText = `
                position: fixed;
                top: 20px;
                right: 20px;
                background-color: #10b981;
                color: white;
                padding: 15px 20px;
                border-radius: 5px;
                box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
                z-index: 10000;
                font-weight: 500;
                transform: translateX(120%);
                transition: transform 0.3s ease;
            `;
            notification.textContent = message;
            document.body.appendChild(notification);
            
            // 显示通知
            setTimeout(() => {
                notification.style.transform = 'translateX(0)';
            }, 10);
            
            // 3秒后隐藏并移除
            setTimeout(() => {
                notification.style.transform = 'translateX(120%)';
                setTimeout(() => {
                    document.body.removeChild(notification);
                }, 300);
            }, 3000);
        }
        
        // 更新滚动进度条
        function updateScrollProgress() {
            const winScroll = document.body.scrollTop || document.documentElement.scrollTop;
            const height = document.documentElement.scrollHeight - document.documentElement.clientHeight;
            const scrolled = (winScroll / height) * 100;
            scrollProgressBar.style.width = scrolled + "%";
        }
        
        // 初始化
        function init() {
            // 生成内容
            initContent();
            
            // 检查是否有保存的位置并尝试恢复
            const hasRestored = restoreReadingPosition();
            
            // 如果没有恢复位置,更新状态
            if (!hasRestored) {
                updateStatus(false);
            }
            
            // 事件监听
            saveBtn.addEventListener('click', saveReadingPosition);
            jumpBtn.addEventListener('click', restoreReadingPosition);
            clearBtn.addEventListener('click', clearSavedPosition);
            
            // 监听滚动事件(防抖处理)
            window.addEventListener('scroll', () => {
                // 更新滚动进度条
                updateScrollProgress();
                
                // 如果不是正在恢复位置,则保存位置
                if (!isRestoring) {
                    clearTimeout(scrollTimeout);
                    scrollTimeout = setTimeout(saveReadingPosition, 1000);
                }
                
                // 检测当前章节
                const chaptersElements = document.querySelectorAll('.chapter');
                let currentChapter = 1;
                for (let i = 0; i < chaptersElements.length; i++) {
                    const rect = chaptersElements[i].getBoundingClientRect();
                    if (rect.top <= window.innerHeight / 2) {
                        currentChapter = i + 1;
                    }
                }
                
                // 更新位置标记
                const scrollPosition = window.pageYOffset || document.documentElement.scrollTop;
                const totalHeight = document.documentElement.scrollHeight - window.innerHeight;
                const scrollPercent = totalHeight > 0 ? Math.round((scrollPosition / totalHeight) * 100) : 0;
                
                chapterNumEl.textContent = currentChapter;
                positionPercentEl.textContent = `${scrollPercent}%`;
            });
            
            // 页面卸载前保存位置
            window.addEventListener('beforeunload', () => {
                saveReadingPosition();
            });
            
            // 初始化滚动进度条
            updateScrollProgress();
        }
        
        // 页面加载完成后初始化
        document.addEventListener('DOMContentLoaded', init);
    </script>
</body>
</html>

在这里插入图片描述

HarmonyOS权限管理实战——麦克风、震动等敏感权限申请

作者 柠果
2025年12月18日 10:40

技术栈:HarmonyOS 5.0 + ArkTS + abilityAccessCtrl

适用场景:音频录制、设备控制、隐私数据访问


前言

HarmonyOS采用严格的权限管理机制,敏感权限需要在配置文件声明并动态申请。本文将介绍如何正确处理麦克风、震动等常用权限。

一、权限分类

权限类型 说明 示例
system_grant 系统自动授予 网络访问
user_grant 需用户授权 麦克风、相机

二、配置文件声明

2.1 module.json5配置

{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.MICROPHONE",
        "reason": "$string:microphone_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      },
      {
        "name": "ohos.permission.VIBRATE",
        "reason": "$string:vibrate_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      }
    ]
  }
}

2.2 字符串资源

// resources/base/element/string.json
{
  "string": [
    {
      "name": "microphone_reason",
      "value": "用于检测环境噪音分贝值,保护您的听力健康"
    },
    {
      "name": "vibrate_reason",
      "value": "用于操作反馈和手机排水功能"
    }
  ]
}

三、动态权限申请

3.1 权限工具类

import abilityAccessCtrl from '@ohos.abilityAccessCtrl';
import bundleManager from '@ohos.bundle.bundleManager';
import common from '@ohos.app.ability.common';

export class PermissionUtil {
  /**
   * 检查权限是否已授予
   */
  static async checkPermission(permission: string): Promise<boolean> {
    const atManager = abilityAccessCtrl.createAtManager();
    const bundleInfo = await bundleManager.getBundleInfoForSelf(
      bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION
    );
    const tokenId = bundleInfo.appInfo.accessTokenId;
    
    const grantStatus = await atManager.checkAccessToken(tokenId, permission);
    return grantStatus === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED;
  }

  /**
   * 请求单个权限
   */
  static async requestPermission(
    context: common.UIAbilityContext,
    permission: string
  ): Promise<boolean> {
    const atManager = abilityAccessCtrl.createAtManager();
    
    try {
      const result = await atManager.requestPermissionsFromUser(context, [permission]);
      return result.authResults[0] === 0;
    } catch (err) {
      console.error('请求权限失败:', err);
      return false;
    }
  }

  /**
   * 请求多个权限
   */
  static async requestPermissions(
    context: common.UIAbilityContext,
    permissions: string[]
  ): Promise<Map<string, boolean>> {
    const atManager = abilityAccessCtrl.createAtManager();
    const resultMap = new Map<string, boolean>();
    
    try {
      const result = await atManager.requestPermissionsFromUser(context, permissions);
      
      for (let i = 0; i < permissions.length; i++) {
        resultMap.set(permissions[i], result.authResults[i] === 0);
      }
    } catch (err) {
      console.error('请求权限失败:', err);
      permissions.forEach(p => resultMap.set(p, false));
    }
    
    return resultMap;
  }
}

3.2 麦克风权限申请

@Entry
@Component
struct DecibelMeterPage {
  @State hasPermission: boolean = false;
  @State isMonitoring: boolean = false;
  private decibelDetector: DecibelDetector | null = null;

  aboutToAppear(): void {
    this.checkAndRequestPermission();
  }

  private async checkAndRequestPermission(): Promise<void> {
    const context = getContext(this) as common.UIAbilityContext;
    
    // 先检查是否已有权限
    this.hasPermission = await PermissionUtil.checkPermission('ohos.permission.MICROPHONE');
    
    if (!this.hasPermission) {
      // 请求权限
      this.hasPermission = await PermissionUtil.requestPermission(
        context,
        'ohos.permission.MICROPHONE'
      );
    }
    
    if (this.hasPermission) {
      this.initDecibelDetector();
    }
  }

  private initDecibelDetector(): void {
    this.decibelDetector = new DecibelDetector((db: number) => {
      // 处理分贝值
    });
    this.decibelDetector.start();
    this.isMonitoring = true;
  }

  build() {
    Column() {
      if (!this.hasPermission) {
        Column() {
          Text('需要麦克风权限')
            .fontSize(18)
          Text('请授权麦克风权限以使用分贝检测功能')
            .fontSize(14)
            .fontColor('#666')
          Button('授权')
            .onClick(() => this.checkAndRequestPermission())
        }
      } else {
        // 正常功能界面
        Text(`${this.isMonitoring ? '检测中...' : '未启动'}`)
      }
    }
  }
}

四、权限被拒绝的处理

4.1 引导用户到设置页

import Want from '@ohos.app.ability.Want';

async function openAppSettings(context: common.UIAbilityContext): Promise<void> {
  const want: Want = {
    bundleName: 'com.huawei.hmos.settings',
    abilityName: 'com.huawei.hmos.settings.MainAbility',
    uri: 'application_info_entry',
    parameters: {
      pushParams: context.abilityInfo.bundleName
    }
  };
  
  try {
    await context.startAbility(want);
  } catch (err) {
    console.error('打开设置失败:', err);
  }
}

4.2 友好的权限说明弹窗

@CustomDialog
struct PermissionDialog {
  controller: CustomDialogController;
  permissionName: string = '麦克风';
  permissionReason: string = '用于检测环境噪音';
  onConfirm: () => void = () => {};
  onCancel: () => void = () => {};

  build() {
    Column() {
      Text(`需要${this.permissionName}权限`)
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
      
      Text(this.permissionReason)
        .fontSize(14)
        .fontColor('#666')
        .margin({ top: 10 })
      
      Row() {
        Button('取消')
          .onClick(() => {
            this.onCancel();
            this.controller.close();
          })
        Button('去授权')
          .onClick(() => {
            this.onConfirm();
            this.controller.close();
          })
      }
      .margin({ top: 20 })
      .justifyContent(FlexAlign.SpaceEvenly)
      .width('100%')
    }
    .padding(20)
  }
}

五、常用权限列表

权限名称 用途 类型
ohos.permission.MICROPHONE 麦克风录音 user_grant
ohos.permission.VIBRATE 设备震动 system_grant
ohos.permission.INTERNET 网络访问 system_grant
ohos.permission.CAMERA 相机拍照 user_grant
ohos.permission.READ_MEDIA 读取媒体文件 user_grant

六、避坑指南

  1. 声明与申请:user_grant权限需要在配置文件声明且动态申请
  2. reason字段:必须提供清晰的权限使用说明,审核会检查
  3. 时机选择:在需要使用时再申请,不要一启动就申请所有权限
  4. 拒绝处理:用户拒绝后要有友好的引导,不能强制退出
  5. 隐私政策:应用商店要求在隐私政策中说明权限用途

七、华为应用市场审核要点

  1. 权限申请必须有明确的使用场景
  2. reason字段要清晰说明用途
  3. 不能申请与功能无关的权限
  4. 用户拒绝权限后应用仍能正常使用其他功能

总结

本文介绍了HarmonyOS权限管理的完整流程,包括配置声明、动态申请、拒绝处理等。正确的权限管理不仅是应用上架的必要条件,也是保护用户隐私的重要措施。

HarmonyOS震动反馈开发——提升用户体验的触觉交互

作者 柠果
2025年12月18日 10:40

技术栈:HarmonyOS 5.0 + ArkTS + @ohos.vibrator

适用场景:按钮反馈、操作确认、游戏交互、手机排水


前言

触觉反馈是提升用户体验的重要手段。本文将介绍如何在HarmonyOS应用中使用震动API实现各种触觉交互效果。

一、震动API概述

HarmonyOS提供了@ohos.vibrator模块用于控制设备震动:

  • 时长震动:指定震动持续时间
  • 预设效果:使用系统预设的震动模式
  • 自定义模式:通过震动模式数组实现复杂效果

二、权限配置

// module.json5
{
  "requestPermissions": [
    {
      "name": "ohos.permission.VIBRATE",
      "reason": "$string:vibrate_reason",
      "usedScene": {
        "abilities": ["EntryAbility"],
        "when": "inuse"
      }
    }
  ]
}

三、基础使用

3.1 简单震动

import vibrator from '@ohos.vibrator';

// 震动100毫秒
async function simpleVibrate(): Promise<void> {
  try {
    await vibrator.startVibration({
      type: 'time',
      duration: 100
    }, {
      id: 0,
      usage: 'unknown'
    });
  } catch (err) {
    console.error('震动失败:', err);
  }
}

3.2 停止震动

async function stopVibrate(): Promise<void> {
  try {
    await vibrator.stopVibration(vibrator.VibratorStopMode.VIBRATOR_STOP_MODE_TIME);
  } catch (err) {
    console.error('停止震动失败:', err);
  }
}

3.3 回调方式

vibrator.startVibration({
  type: 'time',
  duration: 50
}, {
  id: 0,
  usage: 'notification'
}, (error) => {
  if (error) {
    console.error('震动失败:', error);
  }
});

四、实际应用场景

4.1 按钮点击反馈

@Component
struct VibrationButton {
  @Prop text: string = '按钮';
  onClick: () => void = () => {};

  private async vibrateFeedback(): Promise<void> {
    try {
      await vibrator.startVibration({
        type: 'time',
        duration: 30  // 短促震动
      }, { id: 0, usage: 'touch' });
    } catch (err) {}
  }

  build() {
    Button(this.text)
      .onClick(() => {
        this.vibrateFeedback();
        this.onClick();
      })
  }
}

4.2 操作成功/失败反馈

// 成功反馈 - 短促单次
async function successFeedback(): Promise<void> {
  await vibrator.startVibration({
    type: 'time',
    duration: 50
  }, { id: 0, usage: 'notification' });
}

// 失败反馈 - 连续两次
async function errorFeedback(): Promise<void> {
  await vibrator.startVibration({ type: 'time', duration: 100 }, { id: 0, usage: 'alarm' });
  await new Promise(resolve => setTimeout(resolve, 150));
  await vibrator.startVibration({ type: 'time', duration: 100 }, { id: 0, usage: 'alarm' });
}

4.3 手机排水场景

export class AudioEngine {
  private enableVibration: boolean = false;

  setVibrationEnabled(enabled: boolean): void {
    this.enableVibration = enabled;
  }

  async start(durationSeconds: number): Promise<void> {
    // 启动音频播放...
    
    // 配合震动增强排水效果
    if (this.enableVibration) {
      this.startVibration();
    }
  }

  private startVibration(): void {
    const config = getAppConfig();
    if (config.feature.vibrationPattern.length > 0) {
      try {
        vibrator.startVibration({
          type: 'time',
          duration: 100
        }, {
          id: 0,
          usage: 'unknown'
        });
      } catch (err) {
        console.error('启动震动失败:', err);
      }
    }
  }

  private stopVibration(): void {
    try {
      vibrator.stopVibration(vibrator.VibratorStopMode.VIBRATOR_STOP_MODE_TIME);
    } catch (err) {
      console.error('停止震动失败:', err);
    }
  }
}

4.4 测试记录确认

private recordThreshold(): void {
  // 记录测试结果...
  
  // 震动反馈确认
  try {
    vibrator.startVibration({
      type: 'time',
      duration: 50
    }, {
      id: 0,
      usage: 'notification'
    }, (error) => {
      if (error) {
        console.error('Vibration failed:', error);
      }
    });
  } catch (err) {
    console.error('Vibration exception:', err);
  }
}

五、震动配置管理

export interface FeatureConfig {
  enableVibration: boolean;
  vibrationPattern: number[];  // [震动时长, 间隔, 震动时长, ...]
}

// 手机排水配置
export const WaterEjectorConfig = {
  feature: {
    enableVibration: true,
    vibrationPattern: [100, 50, 100, 50],  // 震动100ms, 停50ms, 震动100ms, 停50ms
  }
};

// 听力测试配置
export const HearingTestConfig = {
  feature: {
    enableVibration: false,  // 听力测试不需要震动
    vibrationPattern: [],
  }
};

六、用户设置控制

@Entry
@Component
struct SettingsPage {
  @State vibrationEnabled: boolean = true;

  aboutToAppear(): void {
    this.loadSettings();
  }

  async loadSettings(): Promise<void> {
    this.vibrationEnabled = await PreferencesUtil.getBoolean('vibration_enabled', true);
  }

  async toggleVibration(): Promise<void> {
    this.vibrationEnabled = !this.vibrationEnabled;
    await PreferencesUtil.putBoolean('vibration_enabled', this.vibrationEnabled);
    
    // 更新音频引擎设置
    AudioEngine.getInstance().setVibrationEnabled(this.vibrationEnabled);
    
    // 反馈当前状态
    if (this.vibrationEnabled) {
      vibrator.startVibration({ type: 'time', duration: 50 }, { id: 0, usage: 'touch' });
    }
  }

  build() {
    Row() {
      Text('震动反馈')
      Toggle({ type: ToggleType.Switch, isOn: this.vibrationEnabled })
        .onChange(() => this.toggleVibration())
    }
  }
}

七、避坑指南

  1. 权限声明:必须在module.json5中声明VIBRATE权限
  2. 异常处理:震动API可能失败,需要try-catch
  3. 用户控制:提供开关让用户控制是否启用震动
  4. 适度使用:过度震动会影响用户体验和电池寿命
  5. 设备兼容:部分设备可能不支持震动

总结

本文介绍了HarmonyOS震动API的使用方法和实际应用场景。合理使用触觉反馈可以显著提升用户体验,但要注意适度使用并提供用户控制选项。

zustand 从原理到实践 - 最佳实践

作者 鲨叔
2025年12月18日 10:39

Zustand 最佳实践指南

在本指南中,我们将探讨如何使用 Zustand 来管理 React 应用的状态,同时遵循最佳实践以确保代码的可维护性和性能。

1. 设计原则

1.1 单一 Store + 多 Slice

使用单一的 store 来管理整个应用的状态,但将状态分割成多个 slice。

1.2 No-Store-Actions

将动作逻辑从 store 中分离出来,放到独立的 actions 文件中。

1.3 使用 selector 按需订阅

在 react 组件函数顶部 scope 里面,一律采用 selector 进行按需订阅。

1.4 使用 Immer 实现可变性的更新写法

在创建 store 时,通过中间件注入 Immer,以便于在 action 函数中实现可变性的更新写法。

1.5 需要考虑结合 Redux DevTools 的可调试性

调用 Zustand 的 setState 时,需要传递 action name,以便在 Redux DevTools 中清晰地追踪状态变化,而不是显示为 "anonymous"。

1.6 Type First

在编写代码之前,先定义好类型,确保类型安全。

1.7 代码组织规范

  • 将状态(model)、动作(action)和复用型 hook(selector hook)分别放在不同的文件夹中,形成清晰的文件结构。
  • 不同类型的文件创建一个 index.ts 文件作为该类型文件的统一导出出口文件

2. 目录结构

src/stores/
├── index.ts                 // 只导出 useStore 和 AppStore
├── createAppStore.ts        // 唯一 create 调用,immer 等中间件注入
├── models/                  // 初始状态形状和类型定义
│   ├── index.ts            // 统一导出所有 model 类型
│   ├── user.model.ts
│   ├── order.model.ts
│   └── cart.model.ts
├── slices/                  // 只放"数据 + 纯 set"
│   ├── index.ts            // 统一导出所有 slice 类型和创建函数
│   ├── user.ts
│   ├── order.ts
│   └── cart.ts
├── actions/                 // 所有业务动作
│   ├── index.ts            // 统一导出所有 actions
│   ├── user.ts
│   ├── order.ts
│   └── cart.ts
├── selectors/               // 复用型 selector hooks
│   └── index.ts            // 统一导出所有 selector hooks
└── utils/                   // 辅助工具
    └── actionName.ts       // Action 名称生成工具

📁 关键说明:

  1. stores/index.ts - 只导出核心 Store

    • 导出 useStoreAppStore 类型
    • 不再重新导出其他内容
  2. 各子目录的 index.ts - 各类型文件的统一出口

    • models/index.ts - 导出所有 model 类型和初始状态
    • slices/index.ts - 导出所有 slice 类型和创建函数
    • actions/index.ts - 导出所有 action 函数
    • selectors/index.ts - 导出所有复用型 selector hooks
  3. 组件导入规则

    • useStore@/stores 导入
    • actions 从 @/stores/actions 导入
    • selectors 从 @/stores/selectors 导入
    • types 从 @/stores/models 导入
  4. selectors/ - 复用型 selector hooks

    • 封装常用的状态访问逻辑
    • 内部使用 useShallowuseMemo 优化性能
    • 为复杂的派生状态提供独立的 hook

3. 编码步骤

3.1 定义模型

实现一个特定的业务领域的状态管理,推荐首先从定义该业务领域的数据模型开始:

// models/user.model.ts
export interface User {
  id: string;
  name: string;
}

export const userInitial = {
  profile: null as User | null,
  token: "" as string,
};

3.2 赋予每个 setState 一个 action name

为了让 Redux DevTools 能够清晰地显示每个 action 的名称(而不是 "anonymous"),我们需要创建一个辅助工具来自动生成 action 名称。

设计原则:

  • ✅ 使用 createActionName 辅助函数自动生成 action 名称
  • ✅ Action 名称遵循 {domain}/{action}/{status} 格式
  • ✅ 在 slice 的 setter 方法中支持可选的 actionName 参数
  • ✅ 在 actions 中调用 setter 时传递具体的 action 名称

创建辅助工具:

// utils/actionName.ts
/**
 * Action 名称辅助工具
 *
 * 用于自动生成 Redux DevTools 中显示的 action 名称
 *
 * 使用示例:
 * ```typescript
 * const actionName = createActionName("user", login);
 * actionName("loading")  // "user/login/loading"
 * actionName("success")  // "user/login/success"
 * actionName()           // "user/login"
 * ```
 */

/**
 * 创建 action 名称生成器
 *
 * @param domain - 领域名称(如 "user", "order", "cart")
 * @param fn - action 函数,用于获取函数名
 * @returns 返回一个函数,可以传入后缀生成完整的 action 名称
 */
export function createActionName(domain: string, fn: Function) {
  return (suffix?: string) => {
    const base = `${domain}/${fn.name}`;
    return suffix ? `${base}/${suffix}` : base;
  };
}

为什么需要这个工具?

  • 自动推断:利用函数 name 属性,无需手动输入 action 名称
  • 统一格式:遵循 {domain}/{action}/{status} 命名规范
  • 调试友好:在 Redux DevTools 中清晰显示,不再是 "anonymous"
  • 易于维护:修改函数名自动更新 action 名称

在 Actions 中使用:

// actions/user.ts
import { useStore } from "../createAppStore";
import { api } from "../../api/mock";
import { createActionName } from "../utils/actionName";

/**
 * 用户登录
 */
export const login = async (email: string, pwd: string) => {
  const { _setUser } = useStore.getState();
  const actionName = createActionName("user", login); // ✅ 创建 action 名称生成器

  // 开始加载
  _setUser((draft) => {
    draft.isLoading = true;
    draft.error = null;
  }, actionName("loading")); // "user/login/loading"

  try {
    const { token, profile } = await api.login(email, pwd);

    // 登录成功
    _setUser((draft) => {
      draft.token = token;
      draft.profile = profile;
      draft.isLoading = false;
      draft.error = null;
    }, actionName("success")); // "user/login/success"
  } catch (error) {
    // 登录失败
    _setUser((draft) => {
      draft.isLoading = false;
      draft.error = error instanceof Error ? error.message : "登录失败";
    }, actionName("error")); // "user/login/error"
    throw error;
  }
};

/**
 * 用户登出
 */
export const logout = () => {
  const { _setUser } = useStore.getState();
  const actionName = createActionName("user", logout);

  _setUser((draft) => {
    draft.token = "";
    draft.profile = null;
  }, actionName()); // "user/logout"
};

Action 命名规范:

场景 Action Name 示例 说明
异步操作 - 开始 user/login/loading 表示正在加载
异步操作 - 成功 user/login/success 表示操作成功
异步操作 - 失败 user/login/error 表示操作失败
同步操作 user/logout 简单的同步操作
状态更新 order/setOrderFilter 设置筛选条件
跨 slice 操作 user/logout/clearCart 登出时清空购物车

在 Redux DevTools 中的效果:

使用 createActionName 后,Redux DevTools 会清晰显示:

  • user/login/loadinguser/login/success → 登录流程清晰可见
  • cart/addToCart/success → 购物车操作一目了然
  • ❌ 不再是 anonymous → 难以追踪问题

3.3 编写 Slice(仅数据 + 纯 set)

✅ Good Case: Slice 只包含数据和纯 set 方法(使用 immer)

// slices/user.ts
import type { StateCreator } from "zustand";
import type { WritableDraft } from "immer";
import { userInitial, type UserState } from "../models/user.model";

export interface UserSlice {
  user: UserState;
  _setUser: (
    updater: (draft: WritableDraft<UserState>) => void,
    actionName?: string // ✅ 添加可选的 action 名称参数
  ) => void;
}

export const createUserSlice: StateCreator<
  UserSlice,
  [["zustand/immer", never], ["zustand/devtools", never]], // ✅ 添加 devtools 类型
  [],
  UserSlice
> = (set) => ({
  user: userInitial,

  /**
   * 纯 setter 方法(由 immer 支持)
   *
   * @param updater - 状态更新函数
   * @param actionName - 可选的 action 名称,用于 Redux DevTools
   *
   * 使用示例:
   * _setUser((draft) => {
   *   draft.token = "new_token";     // ✅ 直接修改
   *   draft.profile = { ... };       // ✅ 直接赋值
   * }, "user/login");                // ✅ 传递 action 名称(可选)
   */
  _setUser: (updater, actionName) =>
    set(
      (state) => {
        updater(state.user);
        // immer 会自动处理不可变更新
      },
      false, // ✅ replace 参数
      actionName || "user/_setUser" // ✅ 传递 action 名称给 devtools
    ),
});

❌ Bad Case: 在 Slice 中混入副作用代码(业务逻辑)

// ❌ 不推荐:slice 中包含异步逻辑和业务规则
export const createUserSlice: StateCreator<UserSlice> = (set, get) => ({
  user: userInitial,

  // ❌ 问题 1: 业务逻辑耦合在 slice 中,难以复用和测试
  login: async (email: string, pwd: string) => {
    const { token, profile } = await api.login({ email, pwd });
    set({ user: { token, profile } });
  },

  // ❌ 问题 2: 跨 slice 访问逻辑散落各处
  loginAndLoadOrders: async (email: string, pwd: string) => {
    const { token, profile } = await api.login({ email, pwd });
    set({ user: { token, profile } });
    // 访问其他 slice 的方法
    get().loadOrders(profile.id);
  },
});

为什么不好?

  • 业务逻辑与状态定义耦合,违反单一职责原则
  • 难以测试异步逻辑
  • 跨 slice 调用会产生复杂的依赖关系
  • 无法在组件外(如路由守卫)复用这些逻辑

3.4 单一 Store 组合所有 Slice 并注入 Immer

✅ Good Case: 单一 Store + 中间件统一注入

// createAppStore.ts
import { create } from "zustand";
import { devtools, subscribeWithSelector } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
import { createUserSlice, type UserSlice } from "./slices/user";
import { createOrderSlice, type OrderSlice } from "./slices/order";

export type AppStore = UserSlice & OrderSlice;

export const useStore = create<AppStore>()(
  devtools(
    subscribeWithSelector(
      immer((...a) => ({
        ...createUserSlice(...a),
        ...createOrderSlice(...a),
      }))
    ),
    { name: "app" }
  )
);

❌ Bad Case 1: 每个 Slice 单独注入 Immer

// ❌ 不推荐:在每个 slice 内部单独使用 immer
import { immer } from "zustand/middleware/immer";

export const createUserSlice: StateCreator<UserSlice> = immer((set) => ({
  user: userInitial,
  setUser: (data) =>
    set((state) => {
      state.user = data; // immer 语法
    }),
}));

// 问题:
// 1. 重复注入 middleware,性能浪费
// 2. 组合多个 slice 时类型推导复杂
// 3. 无法统一控制 middleware 配置

❌ Bad Case 2: 多个独立的 Store

// ❌ 不推荐:为每个领域创建独立 store
export const useUserStore = create<UserSlice>()(/* ... */);
export const useOrderStore = create<OrderSlice>()(/* ... */);
export const useCartStore = create<CartSlice>()(/* ... */);

// 组件中使用
function MyComponent() {
  const user = useUserStore((s) => s.user);
  const orders = useOrderStore((s) => s.orders);
  const cart = useCartStore((s) => s.cart);

  // ❌ 问题:跨 store 的状态协调很困难
  useEffect(() => {
    if (user.profile) {
      // 如何让 orderStore 知道 user 已登录?
      // 只能通过订阅或手动调用,容易出错
    }
  }, [user.profile]);
}

为什么推荐单一 Store?

  • 中间件(devtools/persist)只需配置一次
  • 跨领域的业务逻辑编排更简单
  • 类型推导更准确,开发体验更好
  • DevTools 可以看到完整的状态树

何时可以考虑多 Store?

  • 完全独立的子应用(微前端)
  • 需要动态加载/卸载的模块
  • 跨窗口/Worker 通信的场景

3.5 动作外置(No-Store-Actions)

✅ Good Case: 将业务逻辑抽离到独立的 actions 文件

// actions/user.ts
import { useStore } from "../createAppStore";
import { api } from "../../api/mock";
import { createActionName } from "../utils/actionName";

/**
 * 用户登录
 *
 * 最佳实践:
 * 1. 使用 useStore.getState() 获取状态和 setter
 * 2. 包含完整的错误处理和 loading 状态管理
 * 3. 使用 createActionName 生成 action 名称(见 3.1)
 */
export const login = async (email: string, pwd: string) => {
  const { _setUser } = useStore.getState();
  const actionName = createActionName("user", login);

  _setUser((draft) => {
    draft.isLoading = true;
    draft.error = null;
  }, actionName("loading"));

  try {
    const { token, profile } = await api.login(email, pwd);
    _setUser((draft) => {
      draft.token = token;
      draft.profile = profile;
      draft.isLoading = false;
    }, actionName("success"));
  } catch (error) {
    _setUser((draft) => {
      draft.isLoading = false;
      draft.error = error instanceof Error ? error.message : "登录失败";
    }, actionName("error"));
    throw error;
  }
};

/**
 * 跨 slice 编排:登出时清空所有用户相关数据
 */
export const logout = () => {
  const { _setUser, _setOrder, _setCart } = useStore.getState();
  const actionName = createActionName("user", logout);

  _setUser((draft) => {
    draft.token = "";
    draft.profile = null;
  }, actionName());

  // 跨 slice 协作:清空订单和购物车
  _setOrder((draft) => {
    draft.list = [];
  }, actionName("clearOrders"));

  _setCart((draft) => {
    draft.items = [];
  }, actionName("clearCart"));
};
// actions/index.ts - 统一导出所有 actions
export * from "./user";
export * from "./order";
export * from "./cart";

为什么要动作外置?

  • 关注点分离:Store 只负责状态定义,Actions 负责业务逻辑
  • 易于测试:Actions 是纯函数,可以独立测试
  • 便于复用:Actions 可以在组件外调用(如路由守卫、中间件)
  • 跨 slice 编排:轻松协调多个 slice 的状态更新
  • 代码组织:按业务功能划分文件,而不是混在一起

❌ Bad Case: 在 Store 中定义 Actions

// ❌ 不推荐:actions 定义在 store 内部
export const useStore = create<AppStore>()((set, get) => ({
  user: userInitial,

  // ❌ 问题 1: store 文件臃肿,难以维护
  login: async (email: string, pwd: string) => {
    const { token, profile } = await api.login({ email, pwd });
    set((state) => ({
      user: { ...state.user, token, profile },
    }));
  },

  // ❌ 问题 2: 无法在组件外使用(如 Router Guard)
  logout: () => {
    set({ user: userInitial });
  },

  // ❌ 问题 3: 测试时必须 mock 整个 store
  checkAuth: () => {
    return !!get().user.token;
  },
}));

为什么不好?

  • Store 文件会随着业务增长变得非常庞大
  • Actions 无法在非组件环境中调用(需要通过 useStore.getState() 绕过)
  • 测试困难,必须创建完整的 store 实例
  • 类型定义复杂,actions 和 state 混在一起

3.6 组件使用:精确订阅 + 调用 Actions

✅ Good Case: 从各自的出口文件导入,使用 selector 精确订阅 + 调用独立 actions

// ✅ 推荐:从各自的出口文件导入
import { useStore } from "@/stores";
import { login } from "@/stores/actions";

function LoginBtn() {
  // ✅ 只订阅需要的状态
  const profile = useStore((s) => s.user.profile);

  return (
    <button onClick={() => login("ok@mail.com", "123456")}>
      {profile ? profile.name : "登录"}
    </button>
  );
}

❌ Bad Case 0: 从具体文件导入,而非出口文件

// ❌ 不推荐:绕过出口文件,直接从具体文件导入
import { useStore } from "@/stores/createAppStore";
import { login } from "@/stores/actions/user";
import { useUserProfile } from "@/stores/selectors/user";

function LoginBtn() {
  const profile = useStore((s) => s.user.profile);
  return (
    <button onClick={() => login("ok@mail.com", "123456")}>
      {profile ? profile.name : "登录"}
    </button>
  );
}

// 问题:
// 1. 导入路径不一致,难以维护
// 2. 破坏了出口文件的设计原则
// 3. 如果目录结构调整,需要修改多处导入

❌ Bad Case 1: 组件内直接操作 State

import { useStore } from "@/stores";

function LoginBtn() {
  const profile = useStore((s) => s.user.profile);

  const handleLogin = async () => {
    // ❌ 问题:业务逻辑散落在组件中,无法复用
    const { token, profile } = await api.login("ok@mail.com", "123456");
    useStore.getState()._setUser((draft) => {
      draft.token = token;
      draft.profile = profile;
    });
  };

  return <button onClick={handleLogin}>{profile?.name ?? "登录"}</button>;
}

❌ Bad Case 2: 订阅整个 Store 或过多字段

import { useStore } from "@/stores";

function LoginBtn() {
  // ❌ 问题 1: 订阅了整个 store,任何状态变化都会导致重渲染
  const store = useStore();

  // ❌ 问题 2: 订阅了不需要的字段
  const { user, order, cart } = useStore((s) => ({
    user: s.user,
    order: s.order,
    cart: s.cart,
  }));

  return (
    <button onClick={() => login("ok@mail.com", "123456")}>
      {store.user.profile?.name ?? "登录"}
    </button>
  );
}

❌ Bad Case 3: 在 Render 中调用 getState()

function LoginBtn() {
  // ❌ 严重问题:不会响应状态变化!
  const profile = useStore.getState().user.profile;

  return (
    <button onClick={() => login("ok@mail.com", "123456")}>
      {profile?.name ?? "登录"}
    </button>
  );
}

为什么不好?

  • Bad Case 0: 导入路径不一致,破坏出口文件设计原则
  • Bad Case 1: 业务逻辑重复,难以测试和维护
  • Bad Case 2: 不必要的重渲染,影响性能
  • Bad Case 3: 组件不会随状态更新而更新,出现 UI 不同步

3.7 复用型 Selector Hooks

✅ Good Case: 将常用的状态访问逻辑封装成独立的 selector hook

// selectors/index.ts
import { useStore } from "../createAppStore";
import { useShallow } from "zustand/react/shallow";
import { useMemo } from "react";
import type { OrderStatus } from "../models";

/**
 * 获取用户信息的复用 hook
 */
export function useUserProfile() {
  return useStore(
    useShallow((s) => ({
      profile: s.user.profile,
      isLoggedIn: s.user.isLoggedIn,
    }))
  );
}

/**
 * 获取购物车摘要信息的复用 hook
 */
export function useCartSummary() {
  const items = useStore((s) => s.cart.items);

  return useMemo(() => {
    const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
    const totalPrice = items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );

    return { itemCount, totalPrice };
  }, [items]);
}

/**
 * 获取订单列表的复用 hook(支持筛选)
 */
export function useOrderList(status: OrderStatus | "all" = "all") {
  const list = useStore((s) => s.order.list);

  return useMemo(() => {
    if (status === "all") {
      return list;
    }
    return list.filter((o) => o.status === status);
  }, [list, status]);
}

组件中使用:

// ✅ 从 selectors 出口文件导入复用型 selector hooks
import { useUserProfile, useCartSummary } from "@/stores/selectors";

function UserDashboard() {
  const { profile, isLoggedIn } = useUserProfile();
  const { itemCount, totalPrice } = useCartSummary();

  if (!isLoggedIn) {
    return <div>请先登录</div>;
  }

  return (
    <div>
      <h1>欢迎,{profile?.name}</h1>
      <p>
        购物车:{itemCount} 件商品,总价 ¥{totalPrice}
      </p>
    </div>
  );
}

❌ Bad Case: 在每个组件中重复编写相同的 selector 逻辑

// ❌ 不推荐:在多个组件中重复相同的逻辑
function ComponentA() {
  const items = useStore((s) => s.cart.items);
  const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
  const totalPrice = items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );

  return <div>总价:¥{totalPrice}</div>;
}

function ComponentB() {
  // ❌ 重复的逻辑
  const items = useStore((s) => s.cart.items);
  const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
  const totalPrice = items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );

  return <div>商品数:{itemCount}</div>;
}

// 问题:
// 1. 代码重复,难以维护
// 2. 如果计算逻辑需要修改,要改多处
// 3. 没有性能优化(useMemo)

何时应该创建复用型 Selector Hook?

  1. 多个组件需要相同的状态访问逻辑 - 如 useUserProfileuseCartSummary
  2. 需要对状态进行复杂计算或派生 - 如计算总价、筛选列表
  3. 需要组合多个状态片段 - 如同时获取用户信息和登录状态
  4. 需要性能优化 - 使用 useShallowuseMemo 避免不必要的重渲染

最佳实践:

  • 将 selector hooks 统一放在 selectors/ 目录
  • selectors/index.ts 中统一导出
  • 在 hook 内部使用 useShallowuseMemo 优化性能
  • 为 selector hook 添加清晰的注释和类型定义

4. 性能与陷阱

4.1 引用类型订阅必须使用 useShallow

✅ Good Case: 使用 useShallow 避免不必要的重渲染

import { useShallow } from "zustand/react/shallow";

function UserCard() {
  // ✅ 使用 shallow 比较,只有 profile 或 token 真正变化时才 re-render
  const { profile, token } = useStore(
    useShallow((s) => ({
      profile: s.user.profile,
      token: s.user.token,
    }))
  );

  return <div>{profile?.name}</div>;
}

❌ Bad Case: 不使用 useShallow 导致每次都重新渲染

function UserCard() {
  // ❌ 问题:每次 useStore 调用都会返回新的对象引用
  // 即使 profile 和 token 的值没变,组件也会 re-render
  const { profile, token } = useStore((s) => ({
    profile: s.user.profile,
    token: s.user.token,
  }));

  return <div>{profile?.name}</div>;
}

// 验证问题:
function ParentComponent() {
  const unrelatedState = useStore((s) => s.cart.items); // 购物车变化

  return (
    <div>
      {/* ❌ UserCard 也会重渲染,即使 user 数据没变 */}
      <UserCard />
    </div>
  );
}

为什么不好?

  • Selector 返回的对象每次都是新引用,React 默认使用 Object.is 比较
  • 导致大量不必要的重渲染
  • 在列表渲染时性能问题尤为明显

规则:

  • 返回单个原始值(string/number/boolean)→ 无需 useShallow
  • 返回对象/数组(即使内容相同)→ 必须使用 useShallow

4.2 合并多次更新到单个 setState

✅ Good Case: 在单次 setState 中更新多个字段

export const syncUserData = async (userId: string) => {
  const [profile, settings, orders] = await Promise.all([
    api.getProfile(userId),
    api.getSettings(userId),
    api.getOrders(userId),
  ]);

  // ✅ 方案 1: 合并到单个 setState(推荐)
  useStore.setState((state) => ({
    user: { ...state.user, profile },
    settings: { ...state.settings, data: settings },
    orders: { ...state.orders, list: orders },
  }));
};

❌ Bad Case: 连续多次 setState 可能影响性能

export const syncUserData = async (userId: string) => {
  const [profile, settings, orders] = await Promise.all([
    api.getProfile(userId),
    api.getSettings(userId),
    api.getOrders(userId),
  ]);

  // ❌ 不推荐:虽然 React 18+ 会自动批处理,但不够优雅
  useStore.getState()._setUser((draft) => {
    draft.profile = profile;
  });
  useStore.getState()._setSettings((draft) => {
    draft.data = settings;
  });
  useStore.getState()._setOrders((draft) => {
    draft.list = orders;
  });
};

说明:

  • React 18+ 已经自动批处理同步事件和异步回调中的更新
  • 最佳实践仍然是合并到单个 setState,原因:
    • 代码更清晰,意图更明确("这是一次原子操作")
    • 避免中间状态(即使很短暂)
    • 在某些边缘场景下更可靠

如果必须分开调用怎么办?(React 18+)

// React 18+ 会自动批处理,无需担心
export const updateMultiple = async () => {
  await someAsyncOp();

  // ✅ 这些更新会被自动合并
  useStore.getState()._setUser((draft) => {
    draft.profile = newProfile;
  });
  useStore.getState()._setOrders((draft) => {
    draft.list = newOrders;
  });
};

5. 出口文件模式详解

5.1 为什么需要出口文件?

问题场景:没有出口文件时的混乱

// 组件 A
import { useStore } from "@/stores/createAppStore";
import { login } from "@/stores/actions/user";
import type { UserState } from "@/stores/models/user.model";

// 组件 B
import { useStore } from "../stores/createAppStore";
import { login } from "../stores/actions/user";
import type { UserState } from "../stores/models/user.model";

// 组件 C
import { useStore } from "../../stores/createAppStore";
import { login } from "../../stores/actions/user";
import type { UserState } from "../../stores/models/user.model";

// 问题:
// 1. 需要记住每个具体文件的位置
// 2. 目录结构调整时,需要修改多处导入
// 3. 导入路径冗长,难以维护

解决方案:出口文件模式

// 从各自的出口文件导入
import { useStore } from "@/stores";
import { login } from "@/stores/actions";
import type { UserState } from "@/stores/models";

// 优势:
// 1. 导入路径简洁统一
// 2. 不需要关心具体文件名
// 3. 目录重构时只需修改对应的 index.ts
// 4. 便于代码审查和规范检查

5.2 如何实现出口文件?

为每个类型的文件创建 index.ts

// stores/models/index.ts
export * from "./user.model";
export * from "./order.model";
export * from "./cart.model";
// stores/slices/index.ts
export * from "./user";
export * from "./order";
export * from "./cart";
// stores/actions/index.ts
export * from "./user";
export * from "./order";
export * from "./cart";
// stores/selectors/index.ts
// 在这个文件中直接定义和导出 selector hooks
import { useStore } from "../createAppStore";
import { useShallow } from "zustand/react/shallow";

export function useUserProfile() {
  return useStore(
    useShallow((s) => ({
      profile: s.user.profile,
      isLoggedIn: !!s.user.profile,
    }))
  );
}
// ...

stores/index.ts 只导出核心 Store

// stores/index.ts
/**
 * Store 核心导出文件
 *
 * 只导出核心的 Store Hook 和类型
 */

export { useStore } from "./createAppStore";
export type { AppStore } from "./createAppStore";

5.3 使用出口文件

✅ Good Case: 从各自的出口文件导入

// ✅ 推荐:从各自的出口文件导入
import { useStore } from "@/stores";
import { login, logout, loadOrders, addToCart } from "@/stores/actions";
import { useUserProfile, useCartSummary } from "@/stores/selectors";
import type { UserState, Order, CartItem } from "@/stores/models";

function MyComponent() {
  const { profile } = useUserProfile();
  const { itemCount } = useCartSummary();

  return (
    <div>
      <h1>{profile?.name}</h1>
      <p>购物车:{itemCount} 件</p>
      <button onClick={() => login("user@example.com", "123456")}>登录</button>
    </div>
  );
}

❌ Bad Case: 直接从具体文件导入

// ❌ 不推荐:绕过出口文件
import { useStore } from "@/stores/createAppStore";
import { login, logout } from "@/stores/actions/user";
import { loadOrders } from "@/stores/actions/order";
import { useUserProfile } from "@/stores/selectors/useUserProfile";
import type { UserState } from "@/stores/models/user.model";
import type { Order } from "@/stores/models/order.model";

// 问题:
// 1. 导入语句冗长,难以维护
// 2. 需要记住每个具体文件的位置
// 3. 破坏了出口文件的设计

5.4 TypeScript 类型导入

类型导入也应该从出口文件

// ✅ 推荐:类型从 models/index.ts 导入
import type { UserState, Order, CartItem } from "@/stores/models";

// ❌ 不推荐:直接从具体 model 文件导入类型
import type { UserState } from "@/stores/models/user.model";
import type { Order } from "@/stores/models/order.model";

使用 import type 语法

// ✅ 推荐:使用 import type 明确标识类型导入
import { useStore } from "@/stores";
import { login } from "@/stores/actions";
import type { UserState, Order } from "@/stores/models";

// 或者混合导入
import { login } from "@/stores/actions";
import { type UserState, type Order } from "@/stores/models";

5.5 内部文件之间的导入

内部文件(stores 目录内)可以直接相互导入

// stores/actions/user.ts
// ✅ 内部文件可以直接导入其他内部文件
import { useStore } from "../createAppStore";
import type { UserState } from "../models/user.model";

export const login = async (email: string, pwd: string) => {
  // ...
};
// stores/createAppStore.ts
// ✅ 内部文件可以直接导入 slices
import { createUserSlice } from "./slices/user";
import { createOrderSlice } from "./slices/order";
import { createCartSlice } from "./slices/cart";

规则:

  • 外部使用(组件、页面等)→ 从各自的出口文件导入
    • useStore@/stores
    • actions → @/stores/actions
    • selectors → @/stores/selectors
    • types → @/stores/models
  • 内部使用(stores 目录内的文件)→ 可以直接相对导入

5.6 出口文件的维护

添加新的 action 时:

  1. actions/xxx.ts 中编写 action
  2. actions/index.ts 中导出(如果使用 export *,会自动导出)
  3. 组件从 @/stores/actions 导入

添加新的 model 时:

  1. models/xxx.model.ts 中定义类型和初始状态
  2. models/index.ts 中导出
  3. 组件从 @/stores/models 导入类型

添加新的 selector hook 时:

  1. selectors/index.ts 中编写 hook
  2. 组件从 @/stores/selectors 导入

最佳实践:

  • 使用 export * 简化导出语句
  • 为每个类型的文件创建 index.ts 作为出口
  • stores/index.ts 只导出 useStoreAppStore
  • 定期检查是否有组件绕过出口文件导入

HarmonyOS纯音测听实现——专业听力检测功能开发

作者 柠果
2025年12月18日 10:37

技术栈:HarmonyOS 5.0 + ArkTS + AudioRenderer

适用场景:听力健康应用、医疗辅助工具


前言

纯音测听(Pure Tone Audiometry)是临床上最常用的听力检测方法。本文将介绍如何在HarmonyOS应用中实现一个专业的纯音测听功能。

一、纯音测听原理

1.1 测试频率

标准纯音测听使用以下频率:

  • 125Hz, 250Hz, 500Hz:低频
  • 1000Hz, 2000Hz:中频(语音频率)
  • 4000Hz, 8000Hz:高频

1.2 听阈分级(WHO标准)

听阈 (dB) 听力等级
≤25 正常
26-40 轻度损失
41-55 中度损失
56-70 中重度损失
71-90 重度损失
>90 极重度损失

二、数据结构设计

export interface PureToneFrequencyResult {
  frequency: number;        // 测试频率 Hz
  leftEarThreshold: number; // 左耳听阈 dB,-1表示未测试
  rightEarThreshold: number;// 右耳听阈 dB
}

export interface PureToneTestResult {
  id: string;
  timestamp: number;
  results: PureToneFrequencyResult[];
  leftEarAverage: number;
  rightEarAverage: number;
  overallLevel: string;
  leftEarLevel: string;
  rightEarLevel: string;
}

三、测试页面实现

3.1 页面状态管理

@Entry
@Component
struct PureToneTestPage {
  @StorageLink('appDarkMode') isDarkMode: boolean = true;
  
  @State currentEar: string = 'left';
  @State currentFrequencyIndex: number = 0;
  @State currentVolume: number = 30;
  @State isPlaying: boolean = false;
  @State testResults: PureToneFrequencyResult[] = [];
  @State testPhase: string = 'intro';

  private testFrequencies: number[] = [125, 250, 500, 1000, 2000, 4000, 8000];
  private audioEngine: AudioEngine = AudioEngine.getInstance();

  aboutToAppear(): void {
    this.initTestResults();
    this.audioEngine.init();
  }

  private initTestResults(): void {
    this.testResults = this.testFrequencies.map((freq: number) => ({
      frequency: freq,
      leftEarThreshold: -1,
      rightEarThreshold: -1
    }));
  }
}

3.2 播放测试音

private async playTestTone(): Promise<void> {
  if (this.isPlaying) {
    await this.audioEngine.stop();
    this.isPlaying = false;
    return;
  }

  this.isPlaying = true;
  const frequency = this.testFrequencies[this.currentFrequencyIndex];
  const safeVolume = Math.max(0.1, this.currentVolume / 100);
  
  this.audioEngine.setAudioType('tone');
  this.audioEngine.setWaveformType('sine');
  this.audioEngine.setFrequency(frequency);
  this.audioEngine.setVolume(safeVolume);
  
  await this.audioEngine.start(3);
  
  setTimeout(() => { this.isPlaying = false; }, 3000);
}

3.3 记录听阈

private recordThreshold(): void {
  const result = this.testResults[this.currentFrequencyIndex];
  
  if (this.currentEar === 'left') {
    result.leftEarThreshold = this.currentVolume;
  } else {
    result.rightEarThreshold = this.currentVolume;
  }

  // 震动反馈
  vibrator.startVibration({ type: 'time', duration: 50 }, { id: 0, usage: 'notification' });

  this.nextTest();
}

private nextTest(): void {
  if (this.currentEar === 'left') {
    this.currentEar = 'right';
    this.currentVolume = 30;
  } else {
    this.currentEar = 'left';
    this.currentFrequencyIndex++;
    this.currentVolume = 30;
    
    if (this.currentFrequencyIndex >= this.testFrequencies.length) {
      this.testPhase = 'completed';
      this.saveResults();
    }
  }
}

3.4 计算平均听阈

static async savePureToneTestResult(results: PureToneFrequencyResult[]): Promise<PureToneTestResult> {
  const speechFrequencies = [500, 1000, 2000, 4000];
  let leftSum = 0, rightSum = 0, leftCount = 0, rightCount = 0;
  
  for (const r of results) {
    if (speechFrequencies.includes(r.frequency)) {
      if (r.leftEarThreshold >= 0) {
        leftSum += r.leftEarThreshold;
        leftCount++;
      }
      if (r.rightEarThreshold >= 0) {
        rightSum += r.rightEarThreshold;
        rightCount++;
      }
    }
  }
  
  const leftAvg = leftCount > 0 ? Math.round(leftSum / leftCount) : -1;
  const rightAvg = rightCount > 0 ? Math.round(rightSum / rightCount) : -1;
  
  return {
    id: `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
    timestamp: Date.now(),
    results: results,
    leftEarAverage: leftAvg,
    rightEarAverage: rightAvg,
    overallLevel: getHearingLevelByThreshold(Math.max(leftAvg, rightAvg)),
    leftEarLevel: getHearingLevelByThreshold(leftAvg),
    rightEarLevel: getHearingLevelByThreshold(rightAvg)
  };
}

static getHearingLevelByThreshold(threshold: number): string {
  if (threshold < 0) return '未测试';
  if (threshold <= 25) return '正常';
  if (threshold <= 40) return '轻度损失';
  if (threshold <= 55) return '中度损失';
  if (threshold <= 70) return '中重度损失';
  if (threshold <= 90) return '重度损失';
  return '极重度损失';
}

四、UI界面示例

build() {
  Column() {
    // 进度指示
    Row() {
      ForEach(this.testFrequencies, (freq: number, index: number) => {
        Circle()
          .width(12).height(12)
          .fill(index < this.currentFrequencyIndex ? '#2D7FF9' : 
                index === this.currentFrequencyIndex ? '#34C759' : '#333')
      })
    }.justifyContent(FlexAlign.SpaceEvenly).width('100%')

    Text(`${this.testFrequencies[this.currentFrequencyIndex]} Hz`)
      .fontSize(48).fontWeight(FontWeight.Bold)
    
    Text(`${this.currentEar === 'left' ? '左耳' : '右耳'}`)
      .fontSize(20)

    Slider({ value: this.currentVolume, min: 0, max: 100, step: 5 })
      .onChange((value: number) => { this.currentVolume = value; })

    Row() {
      Button('播放').onClick(() => this.playTestTone())
      Button('听到了').onClick(() => this.recordThreshold())
    }.justifyContent(FlexAlign.SpaceEvenly)
  }
}

五、避坑指南

  1. 音量校准:不同设备音量输出不同,建议添加校准功能
  2. 测试环境:提醒用户在安静环境中使用耳机测试
  3. 免责声明:明确说明仅供参考,不能替代专业医学检查
  4. 数据保护:测试结果仅存储在本地

总结

本文实现了一个专业的纯音测听功能,包括标准频率测试、听阈记录、等级评估等。该功能可帮助用户初步了解自己的听力状况。

HarmonyOS深色模式适配实战——主题切换与WCAG对比度标准

作者 柠果
2025年12月18日 10:37

技术栈:HarmonyOS 5.0 + ArkTS + AppStorage

适用场景:应用主题切换、无障碍适配、华为应用市场审核


前言

深色模式已成为现代应用的标配功能。华为应用市场审核对深色模式有严格要求,所有文字必须清晰可见。本文将介绍如何实现符合WCAG标准的深色模式适配。

一、WCAG对比度标准

WCAG(Web Content Accessibility Guidelines)定义了文字对比度标准:

级别 正常文本 大文本
AA级 ≥ 4.5:1 ≥ 3:1
AAA级 ≥ 7:1 ≥ 4.5:1

二、主题颜色定义

2.1 浅色主题

export const LightTheme: ThemeColors = {
  pageBg: '#F8F9FA',
  cardBg: '#FFFFFF',
  cardBgSecondary: '#F5F5F5',
  
  // 文字色 - 符合WCAG标准
  textPrimary: '#1A1A1A',      // 对比度 16.1:1
  textSecondary: '#595959',    // 对比度 7.0:1
  textTertiary: '#737373',     // 对比度 4.6:1
  
  divider: '#EEEEEE',
  border: '#E5E5E5',
  buttonBg: '#F0F0F0',
  shadowColor: 'rgba(0, 0, 0, 0.08)',
};

2.2 深色主题

export const DarkTheme: ThemeColors = {
  pageBg: '#121212',
  cardBg: '#1E1E1E',
  cardBgSecondary: '#2A2A2A',
  
  // 文字色 - 符合WCAG标准
  textPrimary: '#FFFFFF',      // 对比度 15.3:1
  textSecondary: '#CCCCCC',    // 对比度 9.7:1
  textTertiary: '#999999',     // 对比度 5.1:1
  
  divider: '#333333',
  border: '#404040',
  buttonBg: '#2A2A2A',
  shadowColor: 'rgba(0, 0, 0, 0.3)',
};

2.3 主题接口定义

export interface ThemeColors {
  pageBg: string;
  cardBg: string;
  cardBgSecondary: string;
  textPrimary: string;
  textSecondary: string;
  textTertiary: string;
  divider: string;
  border: string;
  buttonBg: string;
  shadowColor: string;
}

三、主题管理器

export class ThemeManager {
  private static isDarkMode: boolean = false;
  private static listeners: Array<(isDark: boolean) => void> = [];

  static setDarkMode(isDark: boolean): void {
    ThemeManager.isDarkMode = isDark;
    ThemeManager.listeners.forEach(listener => listener(isDark));
  }

  static getIsDarkMode(): boolean {
    return ThemeManager.isDarkMode;
  }

  static getTheme(): ThemeColors {
    return ThemeManager.isDarkMode ? DarkTheme : LightTheme;
  }

  static addListener(listener: (isDark: boolean) => void): void {
    ThemeManager.listeners.push(listener);
  }

  static removeListener(listener: (isDark: boolean) => void): void {
    const index = ThemeManager.listeners.indexOf(listener);
    if (index > -1) ThemeManager.listeners.splice(index, 1);
  }
}

四、页面中使用主题

4.1 使用AppStorage实现全局状态

@Entry
@Component
struct MainPage {
  @StorageLink('appDarkMode') isDarkMode: boolean = true;
  
  getBgColor(): string { 
    return this.isDarkMode ? '#0D1117' : '#F5F5F5'; 
  }
  
  getTextPrimary(): string { 
    return this.isDarkMode ? '#FFFFFF' : '#1A1A1A'; 
  }
  
  getTextSecondary(): string { 
    return this.isDarkMode ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.6)'; 
  }

  private toggleTheme(): void {
    this.isDarkMode = !this.isDarkMode;
    AppStorage.setOrCreate('appDarkMode', this.isDarkMode);
    PreferencesUtil.putBoolean('app_dark_mode', this.isDarkMode);
  }

  build() {
    Column() {
      Text('标题')
        .fontSize(24)
        .fontColor(this.getTextPrimary())
      
      Text('副标题')
        .fontSize(14)
        .fontColor(this.getTextSecondary())
      
      Button('切换主题')
        .onClick(() => this.toggleTheme())
    }
    .backgroundColor(this.getBgColor())
  }
}

4.2 跟随系统主题

import ConfigurationConstant from '@ohos.app.ability.ConfigurationConstant';

@Entry
@Component
struct SettingsPage {
  @StorageProp('currentColorMode') @Watch('onColorModeChange') 
  currentColorMode: number = ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT;
  
  @State themeMode: string = 'system'; // 'system' | 'light' | 'dark'

  onColorModeChange(): void {
    if (this.themeMode === 'system') {
      const isDark = this.currentColorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
      AppStorage.setOrCreate('appDarkMode', isDark);
    }
  }

  private applyThemeMode(): void {
    let isDark: boolean;
    if (this.themeMode === 'system') {
      isDark = this.currentColorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
    } else {
      isDark = this.themeMode === 'dark';
    }
    AppStorage.setOrCreate('appDarkMode', isDark);
    PreferencesUtil.putString('theme_mode', this.themeMode);
  }
}

五、对比度检查工具

推荐使用在线工具检查颜色对比度:

六、避坑指南

  1. 华为审核要求:深色模式下所有文字必须清晰可见
  2. @StorageLink vs @StorageProp:前者双向绑定,后者只读
  3. 颜色透明度:使用rgba()时注意透明度对对比度的影响
  4. 图标适配:深色模式下图标也需要适配

总结

本文介绍了HarmonyOS深色模式的完整实现方案,包括符合WCAG标准的颜色定义、主题管理器、页面使用方法等。正确的深色模式适配不仅能通过应用市场审核,还能提升用户体验。

HarmonyOS数据持久化最佳实践——Preferences首选项存储详解

作者 柠果
2025年12月18日 10:35

技术栈:HarmonyOS 5.0 + ArkTS + @ohos.data.preferences

适用场景:用户设置、历史记录、应用状态保存


前言

在应用开发中,数据持久化是必不可少的功能。HarmonyOS提供了@ohos.data.preferences模块用于轻量级数据存储。本文将介绍如何封装一个通用的首选项工具类。

一、Preferences vs 关系型数据库

特性 Preferences 关系型数据库
数据量 小(KB级) 大(MB级)
数据结构 键值对 表结构
查询能力 简单 复杂SQL
适用场景 用户设置 业务数据

二、工具类封装

2.1 基础工具类

import dataPreferences from '@ohos.data.preferences';
import common from '@ohos.app.ability.common';

export class PreferencesUtil {
  private static readonly PREFERENCES_NAME = 'app_preferences';
  private static preferences: dataPreferences.Preferences | null = null;

  /**
   * 初始化(必须在EntryAbility.onCreate中调用)
   */
  static async init(context: common.UIAbilityContext): Promise<void> {
    try {
      PreferencesUtil.preferences = await dataPreferences.getPreferences(
        context,
        PreferencesUtil.PREFERENCES_NAME
      );
      console.info('PreferencesUtil: 初始化成功');
    } catch (err) {
      console.error('PreferencesUtil: 初始化失败', err);
    }
  }

  static async putString(key: string, value: string): Promise<void> {
    if (!PreferencesUtil.preferences) return;
    await PreferencesUtil.preferences.put(key, value);
    await PreferencesUtil.preferences.flush();
  }

  static async getString(key: string, defaultValue: string = ''): Promise<string> {
    if (!PreferencesUtil.preferences) return defaultValue;
    return await PreferencesUtil.preferences.get(key, defaultValue) as string;
  }

  static async putNumber(key: string, value: number): Promise<void> {
    if (!PreferencesUtil.preferences) return;
    await PreferencesUtil.preferences.put(key, value);
    await PreferencesUtil.preferences.flush();
  }

  static async getNumber(key: string, defaultValue: number = 0): Promise<number> {
    if (!PreferencesUtil.preferences) return defaultValue;
    return await PreferencesUtil.preferences.get(key, defaultValue) as number;
  }

  static async putBoolean(key: string, value: boolean): Promise<void> {
    if (!PreferencesUtil.preferences) return;
    await PreferencesUtil.preferences.put(key, value);
    await PreferencesUtil.preferences.flush();
  }

  static async getBoolean(key: string, defaultValue: boolean = false): Promise<boolean> {
    if (!PreferencesUtil.preferences) return defaultValue;
    return await PreferencesUtil.preferences.get(key, defaultValue) as boolean;
  }

  static async delete(key: string): Promise<void> {
    if (!PreferencesUtil.preferences) return;
    await PreferencesUtil.preferences.delete(key);
    await PreferencesUtil.preferences.flush();
  }
}

2.2 复杂对象存储

export interface HearingTestResult {
  id: string;
  timestamp: number;
  maxFrequency: number;
  hearingAge: number;
  level: string;
}

export class TestHistoryManager {
  private static readonly KEY = 'hearing_test_history';

  static async saveResult(result: HearingTestResult): Promise<void> {
    const history = await TestHistoryManager.getHistory();
    history.push(result);
    // 只保留最近20条
    const trimmed = history.slice(-20);
    await PreferencesUtil.putString(this.KEY, JSON.stringify(trimmed));
  }

  static async getHistory(): Promise<HearingTestResult[]> {
    const str = await PreferencesUtil.getString(this.KEY, '[]');
    try {
      return JSON.parse(str) as HearingTestResult[];
    } catch {
      return [];
    }
  }
}

2.3 键名常量管理

export const PreferencesKeys = {
  VIBRATION_ENABLED: 'vibration_enabled',
  VOLUME_LEVEL: 'volume_level',
  THEME_MODE: 'theme_mode',
  USE_COUNT: 'use_count',
  TOTAL_DURATION: 'total_duration',
  ONBOARDING_COMPLETED: 'onboarding_completed',
};

三、在EntryAbility中初始化

import { UIAbility } from '@ohos.app.ability.UIAbility';
import { PreferencesUtil } from 'common';

export default class EntryAbility extends UIAbility {
  async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise<void> {
    await PreferencesUtil.init(this.context);
  }
}

四、使用示例

// 保存用户设置
await PreferencesUtil.putBoolean(PreferencesKeys.VIBRATION_ENABLED, true);
await PreferencesUtil.putNumber(PreferencesKeys.VOLUME_LEVEL, 80);

// 读取用户设置
const vibrationEnabled = await PreferencesUtil.getBoolean(PreferencesKeys.VIBRATION_ENABLED, true);
const volumeLevel = await PreferencesUtil.getNumber(PreferencesKeys.VOLUME_LEVEL, 80);

// 保存复杂对象
const result: HearingTestResult = {
  id: Date.now().toString(),
  timestamp: Date.now(),
  maxFrequency: 16000,
  hearingAge: 25,
  level: '正常'
};
await TestHistoryManager.saveResult(result);

五、避坑指南

  1. 初始化时机:必须在EntryAbility.onCreate中初始化
  2. flush调用:每次put后都要调用flush()
  3. JSON序列化:复杂对象需要JSON.stringify/JSON.parse
  4. 数据量限制:Preferences适合小数据,大数据用关系型数据库
  5. 异步处理:所有操作都是异步的,注意使用await

总结

本文封装了一个通用的Preferences工具类,支持基础类型和复杂对象的存储。在实际项目中,这个工具类被用于保存用户设置、测试历史、使用统计等数据。

Express.js 基础

作者 梨子同志
2025年12月18日 10:31

Express.js 是 Node.js 生态系统中最流行、最成熟的 Web 应用框架。它提供了极简且灵活的 API,使得构建 Web 服务器和 RESTful API 变得快速而简单。


一、Express 简介与安装

什么是 Express

Express 是一个基于 Node.js 的极简且灵活的 Web 应用程序框架,提供了一组强大的功能,适用于 Web 和移动应用程序的开发。它构建在 Node.js 的 HTTP 模块之上,简化了服务器端应用程序的开发过程。

安装 Express

在使用 Express 之前,需要先安装它。Express 通过 npm(Node Package Manager)进行安装和管理。

# 创建项目目录
mkdir my-express-app
cd my-express-app

# 初始化项目(创建 package.json)
npm init -y

# 安装 Express
npm install express

二、创建第一个 Express 应用

创建一个 Express 应用非常简单,只需要调用 express() 函数即可。这个函数返回一个 Express 应用实例,它是整个应用的核心。

最简单的 Express 应用

// 引入 Express 模块
const express = require('express');

// 创建 Express 应用实例
const app = express();

// 定义路由
app.get('/', (req, res) => {
  res.send('Hello World!');
});

// 启动服务器,监听指定端口
const PORT = 3000;
app.listen(PORT, () => {
  console.log(`服务器运行在 http://localhost:${PORT}`);
});

express() 函数

express() 是一个工厂函数,用于创建 Express 应用实例。这个实例包含了所有 Express 的功能,包括路由、中间件、模板引擎等。

const express = require('express');
const app = express();  // 创建应用实例

// app 对象现在包含了所有 Express 的方法和属性
console.log(typeof app);  // 'function'(实际上是一个函数对象)

app.listen() 方法

app.listen() 方法用于启动服务器并开始监听指定端口的 HTTP 请求。它是 Express 应用启动的关键方法。

基本语法:

app.listen(port, callback)

参数说明:

  • port: 要监听的端口号(数字)
    • 端口号范围:有效端口号为 0-65535
    • 推荐端口:开发环境常用 3000、3001、8000、8080、5000 等
    • 不能使用的端口
      • 0-1023:系统保留端口(需要管理员权限),常见的有:
        • 80:HTTP(通常需要 root 权限)
        • 443:HTTPS(通常需要 root 权限)
        • 22:SSH
        • 21:FTP
        • 25:SMTP
        • 3306:MySQL
        • 5432:PostgreSQL
      • 1024-49151:注册端口(IANA 注册),应避免与已知服务冲突
      • 49152-65535:动态/私有端口(相对安全,适合开发使用)
    • 注意事项:如果端口已被占用,会抛出 EADDRINUSE 错误
  • callback: 服务器启动后执行的回调函数(可选)

示例:

const express = require('express');
const app = express();

// 方式一:只指定端口
app.listen(3000);

// 方式二:指定端口和回调函数(推荐)
app.listen(3000, () => {
  console.log('服务器运行在 3000 端口');
});

// 方式三:使用环境变量配置端口
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`服务器运行在 ${PORT} 端口`);
});

使用 Node.js HTTP 模块(底层方式):

Express 的 app.listen() 实际上是对 Node.js http.createServer() 的封装。你也可以直接使用 HTTP 模块:

const express = require('express');
const http = require('http');

const app = express();
const server = http.createServer(app);

server.listen(3000, () => {
  console.log('服务器运行在 3000 端口');
});

三、路由基础

路由是 Express 应用的核心功能之一。它定义了应用如何响应客户端对不同 URL 路径和 HTTP 方法的请求。

什么是路由

路由是指确定应用程序如何响应客户端对特定端点的请求,该端点是 URI(或路径)和特定的 HTTP 请求方法(GET、POST、PUT、DELETE 等)的组合。

路由的基本结构

每个路由都有一个或多个处理函数,当路由匹配时执行:

app.METHOD(path, handler)
  • app: Express 应用实例
  • METHOD: HTTP 请求方法(get、post、put、delete 等)
  • path: 服务器上的路径
  • handler: 路由匹配时执行的函数

HTTP 方法路由

Express 支持所有 HTTP 方法,最常用的是 GET、POST、PUT 和 DELETE。

app.get() - GET 请求

GET 请求用于获取资源,是最常用的 HTTP 方法。

// 获取所有用户
app.get('/users', (req, res) => {
  res.json([
    { id: 1, name: 'John' },
    { id: 2, name: 'Jane' }
  ]);
});

// 获取单个用户
app.get('/users/:id', (req, res) => {
  const userId = req.params.id;
  res.json({ id: userId, name: 'John' });
});

app.post() - POST 请求

POST 请求用于创建新资源。

// 需要配置中间件解析请求体
app.use(express.json());

// 创建用户
app.post('/users', (req, res) => {
  const { name, email } = req.body;
  
  // 创建用户的逻辑...
  
  res.status(201).json({
    id: 1,
    name: name,
    email: email,
    message: '用户创建成功'
  });
});

app.put() - PUT 请求

PUT 请求用于更新整个资源。

app.use(express.json());

// 更新用户(完整更新)
app.put('/users/:id', (req, res) => {
  const userId = req.params.id;
  const { name, email } = req.body;
  
  // 更新用户的逻辑...
  
  res.json({
    id: userId,
    name: name,
    email: email,
    message: '用户更新成功'
  });
});

app.delete() - DELETE 请求

DELETE 请求用于删除资源。

// 删除用户
app.delete('/users/:id', (req, res) => {
  const userId = req.params.id;
  
  // 删除用户的逻辑...
  
  res.status(204).send();  // 204 No Content
});

其他 HTTP 方法

Express 还支持其他 HTTP 方法:

// PATCH - 部分更新
app.patch('/users/:id', (req, res) => {
  res.json({ message: '部分更新用户' });
});

// OPTIONS - 预检请求
// 使用场景:主要用于 CORS(跨域资源共享)场景
// - 当前端应用(如 http://localhost:3000)请求后端 API(如 http://localhost:8080)时
// - 当请求使用了 PUT、DELETE、PATCH 等非简单方法,或包含自定义请求头时
// 解决的问题:
// - 浏览器安全策略:浏览器会阻止跨域请求,除非服务器明确允许
// - 告知客户端服务器支持哪些 HTTP 方法和请求头
app.options('/users', (req, res) => {
  res.set('Allow', 'GET, POST, OPTIONS');
  res.send();
});

// HEAD - 获取响应头
app.head('/users', (req, res) => {
  res.end();
});

路由路径匹配

Express 支持多种路由路径匹配方式,包括字符串匹配、正则表达式匹配和参数匹配。

字符串路径匹配

最简单的路由路径是字符串匹配:

// 精确匹配
app.get('/about', (req, res) => {
  res.send('关于页面');
});

// 匹配根路径
app.get('/', (req, res) => {
  res.send('首页');
});

路径参数匹配

使用 : 定义路径参数:

// 单个参数
app.get('/users/:id', (req, res) => {
  res.json({ userId: req.params.id });
});

// 多个参数
app.get('/users/:userId/posts/:postId', (req, res) => {
  res.json({
    userId: req.params.userId,
    postId: req.params.postId
  });
});

可选参数

使用 ? 定义可选参数:

// /users 和 /users/:id 都可以匹配
app.get('/users/:id?', (req, res) => {
  if (req.params.id) {
    res.json({ userId: req.params.id });
  } else {
    res.json({ message: '所有用户' });
  }
});

通配符匹配

使用 * 进行通配符匹配:

// 匹配 /users/ 后面的所有路径
app.get('/users/*', (req, res) => {
  res.send('用户相关页面');
});

正则表达式匹配

使用正则表达式进行复杂匹配:

// 只匹配数字 ID
app.get('/users/:id(\\d+)', (req, res) => {
  res.json({ userId: req.params.id });
});

// 匹配特定格式
app.get('/files/:filename(.*\\.(jpg|png|gif))', (req, res) => {
  res.send(`图片文件: ${req.params.filename}`);
});

多个处理函数

一个路由可以有多个处理函数(中间件),按顺序执行:

// 验证函数
function validateUser(req, res, next) {
  const userId = req.params.id;
  if (!userId || isNaN(userId)) {
    return res.status(400).json({ error: '无效的用户 ID' });
  }
  next();  // 继续执行下一个处理函数
}

// 获取用户函数
function getUser(req, res) {
  const userId = req.params.id;
  res.json({ id: userId, name: 'John' });
}

// 使用多个处理函数
app.get('/users/:id', validateUser, getUser);

四、请求对象(req):获取请求数据

在 Express 中,每个路由处理函数都会接收请求对象(req),它包含了客户端发送的所有信息,包括请求头、请求参数、请求体等。

req.params - 路径参数

路径参数是 URL 路径中的动态部分,使用 : 定义,通过 req.params 对象访问。

基本用法

// 定义路径参数
app.get('/users/:id', (req, res) => {
  console.log(req.params);  // { id: '123' }
  console.log(req.params.id);  // '123'
  
  res.json({
    userId: req.params.id,
    message: '获取用户信息'
  });
});

// 访问 /users/123
// req.params = { id: '123' }

多个路径参数

一个路由可以定义多个路径参数:

app.get('/users/:userId/posts/:postId', (req, res) => {
  console.log(req.params);
  // 访问 /users/123/posts/456
  // req.params = { userId: '123', postId: '456' }
  
  res.json({
    userId: req.params.userId,
    postId: req.params.postId
  });
});

路径参数命名规则

路径参数的名称可以是字母、数字和下划线的组合:

// 有效
app.get('/users/:id', handler);
app.get('/users/:userId', handler);
app.get('/users/:user_id', handler);

// 无效(不能包含特殊字符)
// app.get('/users/:user-id', handler);  // 错误

req.query - 查询参数

查询参数是 URL 中 ? 后面的键值对,通过 req.query 对象访问。查询参数用于过滤、排序、分页等操作。

基本用法

// 访问 /search?q=express&page=1
app.get('/search', (req, res) => {
  console.log(req.query);  // { q: 'express', page: '1' }
  console.log(req.query.q);  // 'express'
  console.log(req.query.page);  // '1'
  
  res.json({
    query: req.query.q,
    page: req.query.page
  });
});

多个查询参数

// 访问 /products?category=electronics&minPrice=100&maxPrice=500&sort=price
app.get('/products', (req, res) => {
  const { category, minPrice, maxPrice, sort } = req.query;
  
  console.log('分类:', category);
  console.log('最低价格:', minPrice);
  console.log('最高价格:', maxPrice);
  console.log('排序:', sort);
  
  // 使用查询参数进行过滤和排序...
  
  res.json({
    category: category,
    minPrice: minPrice,
    maxPrice: maxPrice,
    sort: sort
  });
});

数组查询参数

查询参数可以是数组:

// 访问 /products?tags=javascript&tags=nodejs&tags=express
app.get('/products', (req, res) => {
  console.log(req.query.tags);  // ['javascript', 'nodejs', 'express']
  
  res.json({
    tags: req.query.tags
  });
});

查询参数的默认值

为查询参数提供默认值:

app.get('/products', (req, res) => {
  const page = parseInt(req.query.page) || 1;  // 默认第 1 页
  const limit = parseInt(req.query.limit) || 10;  // 默认每页 10 条
  const sort = req.query.sort || 'id';  // 默认按 id 排序
  
  res.json({
    page: page,
    limit: limit,
    sort: sort
  });
});

req.body - 请求体

请求体包含 POST、PUT 等请求中发送的数据。需要中间件来解析(如 express.json())。

// 需要先配置中间件
app.use(express.json());

app.post('/users', (req, res) => {
  console.log(req.body);  // { name: 'John', email: 'john@example.com' }
  console.log(req.body.name);  // 'John'
  
  res.json({
    id: 1,
    name: req.body.name,
    email: req.body.email
  });
});

req 的其他常用属性

req.headers - 请求头

请求头包含了客户端发送的所有 HTTP 头信息。

app.get('/', (req, res) => {
  console.log(req.headers);
  console.log(req.headers['user-agent']);  // 浏览器信息
  console.log(req.headers['content-type']);  // 内容类型
});

req.method - HTTP 方法

获取请求的 HTTP 方法(GET、POST、PUT、DELETE 等)。

app.use((req, res, next) => {
  console.log(`请求方法: ${req.method}`);
  console.log(`请求路径: ${req.path}`);
  next();
});

req.path - 请求路径

获取请求的路径部分(不包含查询字符串)。

// 访问 /users/123?page=1
app.get('/users/:id', (req, res) => {
  console.log(req.path);  // '/users/123'
});

req.url - 完整 URL

获取请求的完整 URL(包含查询字符串)。

// 访问 /users/123?page=1
app.get('/users/:id', (req, res) => {
  console.log(req.url);  // '/users/123?page=1'
});

req.ip - 客户端 IP 地址

获取客户端的 IP 地址。

app.get('/', (req, res) => {
  console.log('客户端 IP:', req.ip);
  res.send(`你的 IP 地址是: ${req.ip}`);
});

路径参数 vs 查询参数

理解路径参数和查询参数的区别和使用场景很重要:

路径参数(req.params):

  • 用于标识资源(如用户 ID、文章 ID)
  • URL 的一部分,必需
  • 示例:/users/123(123 是用户 ID)

查询参数(req.query):

  • 用于过滤、排序、分页等操作
  • URL 的可选部分
  • 示例:/users?page=1&limit=10(page 和 limit 用于分页)

五、响应对象(res):发送响应

响应对象(res)代表 HTTP 响应,用于向客户端发送数据、设置响应头、设置状态码等。

res.send() - 通用响应方法

res.send() 是最灵活的响应方法,可以发送各种类型的数据。Express 会根据数据类型自动设置适当的 Content-Type

app.get('/', (req, res) => {
  res.send('Hello World!');  // 发送字符串,Content-Type: text/html
});

app.get('/json', (req, res) => {
  res.send({ message: 'Hello' });  // 发送对象,Content-Type: application/json
});

app.get('/buffer', (req, res) => {
  res.send(Buffer.from('Hello'));  // 发送 Buffer,Content-Type: application/octet-stream
});

app.get('/api/users', (req, res) => {
  res.send([
    { id: 1, name: 'John' },
    { id: 2, name: 'Jane' }
  ]);  // 发送数组,Content-Type: application/json
});

res.json() - JSON 响应

res.json() 专门用于发送 JSON 响应,会自动设置 Content-Typeapplication/json,并调用 JSON.stringify()

app.get('/api/user', (req, res) => {
  res.json({
    id: 1,
    name: 'John Doe',
    email: 'john@example.com'
  });
});

app.get('/api/users', (req, res) => {
  res.json([
    { id: 1, name: 'John' },
    { id: 2, name: 'Jane' }
  ]);
});

app.get('/api/user/:id', (req, res) => {
  res.json({
    id: req.params.id,
    name: 'John Doe',
    profile: {
      age: 30,
      city: 'New York'
    },
    tags: ['developer', 'nodejs']
  });
});

res.status() - 设置状态码

res.status() 用于设置 HTTP 响应状态码,通常与其他响应方法链式调用。

基本用法

// 成功响应(200)
app.get('/success', (req, res) => {
  res.status(200).json({ message: '成功' });
});

// 创建成功(201)
app.post('/users', (req, res) => {
  res.status(201).json({ id: 1, name: 'John' });
});

// 未找到(404)
app.get('/not-found', (req, res) => {
  res.status(404).json({ error: '资源未找到' });
});

// 服务器错误(500)
app.get('/error', (req, res) => {
  res.status(500).json({ error: '服务器内部错误' });
});

常用 HTTP 状态码

// 2xx - 成功
res.status(200).json({ message: 'OK' });           // 成功
res.status(201).json({ message: 'Created' });       // 创建成功
res.status(204).send();                             // 无内容(常用于 DELETE)

// 4xx - 客户端错误
res.status(400).json({ error: 'Bad Request' });     // 请求错误
res.status(401).json({ error: 'Unauthorized' });   // 未授权
res.status(403).json({ error: 'Forbidden' });       // 禁止访问
res.status(404).json({ error: 'Not Found' });       // 未找到
res.status(409).json({ error: 'Conflict' });       // 冲突

// 5xx - 服务器错误
res.status(500).json({ error: 'Internal Server Error' });  // 服务器错误
res.status(503).json({ error: 'Service Unavailable' });     // 服务不可用

res.redirect() - 重定向

res.redirect() 用于将客户端重定向到另一个 URL。这对于页面跳转、URL 重写等场景非常有用。

// 临时重定向(302)
app.get('/old-page', (req, res) => {
  res.redirect('/new-page');
});

// 永久重定向(301)
app.get('/old-url', (req, res) => {
  res.redirect(301, '/new-url');
});

// 重定向到外部 URL
app.get('/external', (req, res) => {
  res.redirect('https://www.example.com');
});

// 重定向到相对路径
app.get('/login', (req, res) => {
  // 登录成功后重定向到首页
  res.redirect('/');
});

app.get('/users/:id/edit', (req, res) => {
  // 编辑完成后重定向到用户详情页
  const userId = req.params.id;
  res.redirect(`/users/${userId}`);
});

res 的其他常用方法

res.set() / res.header() - 设置响应头

设置 HTTP 响应头。

app.get('/', (req, res) => {
  res.set('Content-Type', 'text/html');
  res.set('X-Custom-Header', 'custom-value');
  res.send('<h1>Hello</h1>');
});

// 或者使用 res.header()
app.get('/api', (req, res) => {
  res.header('Content-Type', 'application/json');
  res.json({ message: 'Hello' });
});

res.get() - 获取响应头

获取已设置的响应头值。

app.get('/', (req, res) => {
  res.set('X-Custom-Header', 'custom-value');
  console.log(res.get('X-Custom-Header'));  // 'custom-value'
  res.send('Hello');
});

res.cookie() - 设置 Cookie

设置 Cookie。

app.get('/set-cookie', (req, res) => {
  res.cookie('username', 'john', { maxAge: 900000, httpOnly: true });
  res.send('Cookie 已设置');
});

res.write() 和 res.end() - 分块写入和结束响应

res.write():用于分块写入响应数据,可以多次调用 res.end():用于结束响应

app.get('/chunked', (req, res) => {
  res.write('Hello');
  res.write(' ');
  res.write('World');
  res.end('!');  // 结束响应
});

注意事项:

  • res.write() 可以多次调用,但必须最后调用 res.end() 来结束响应
  • 如果使用 res.send()res.json(),它们会自动结束响应,不需要手动调用 res.end()

res.render() - 渲染模板

// 需要配置模板引擎(如 EJS)
app.set('view engine', 'ejs');

app.get('/', (req, res) => {
  res.render('index', { title: '首页', users: [] });
});

res.download() - 下载文件

res.download() 用于将文件作为附件下载发送给客户端,会自动设置适当的响应头。客户端访问后,浏览器会弹出下载对话框,用户可以选择保存文件。

app.get('/download', (req, res) => {
  res.download('/path/to/file.pdf', 'document.pdf');  // 第一个参数是文件路径,第二个参数是下载时的文件名(可选)
});

res.sendFile() - 发送文件

const path = require('path');

app.get('/file', (req, res) => {
  res.sendFile(path.join(__dirname, 'public', 'index.html'));
});

响应方法链式调用

多个响应方法可以链式调用:

app.get('/api/user', (req, res) => {
  res
    .status(200)
    .set('X-Custom-Header', 'custom-value')
    .json({
      id: 1,
      name: 'John Doe'
    });
});

六、完整示例:RESTful API

下面是一个完整的 RESTful API 示例,展示了 Express 的各种功能的使用:

const express = require('express');
const app = express();

// 配置中间件解析 JSON 请求体
app.use(express.json());

// 模拟数据存储
let users = [
  { id: 1, name: 'John Doe', email: 'john@example.com' },
  { id: 2, name: 'Jane Smith', email: 'jane@example.com' }
];

// GET /users - 获取所有用户(支持分页和过滤)
app.get('/users', (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const limit = parseInt(req.query.limit) || 10;
  const role = req.query.role;  // 可选的角色过滤
  
  res.status(200).json({
    success: true,
    page: page,
    limit: limit,
    count: users.length,
    data: users
  });
});

// GET /users/:id - 获取单个用户
app.get('/users/:id', (req, res) => {
  const userId = parseInt(req.params.id);
  const user = users.find(u => u.id === userId);
  
  if (!user) {
    return res.status(404).json({
      success: false,
      error: '用户未找到'
    });
  }
  
  res.status(200).json({
    success: true,
    data: user
  });
});

// POST /users - 创建用户
app.post('/users', (req, res) => {
  const { name, email } = req.body;
  
  if (!name || !email) {
    return res.status(400).json({
      success: false,
      error: '缺少必要字段:name 和 email'
    });
  }
  
  const newUser = {
    id: users.length + 1,
    name: name,
    email: email
  };
  
  users.push(newUser);
  
  res.status(201).json({
    success: true,
    message: '用户创建成功',
    data: newUser
  });
});

// PUT /users/:id - 更新用户(完整更新)
app.put('/users/:id', (req, res) => {
  const userId = parseInt(req.params.id);
  const userIndex = users.findIndex(u => u.id === userId);
  
  if (userIndex === -1) {
    return res.status(404).json({
      success: false,
      error: '用户未找到'
    });
  }
  
  const { name, email } = req.body;
  users[userIndex] = { ...users[userIndex], name, email };
  
  res.status(200).json({
    success: true,
    message: '用户更新成功',
    data: users[userIndex]
  });
});

// DELETE /users/:id - 删除用户
app.delete('/users/:id', (req, res) => {
  const userId = parseInt(req.params.id);
  const userIndex = users.findIndex(u => u.id === userId);
  
  if (userIndex === -1) {
    return res.status(404).json({
      success: false,
      error: '用户未找到'
    });
  }
  
  users.splice(userIndex, 1);
  
  res.status(204).send();  // 204 No Content
});

// 获取用户的文章列表(路径参数 + 查询参数)
app.get('/users/:userId/posts', (req, res) => {
  const userId = req.params.userId;  // 路径参数
  const page = parseInt(req.query.page) || 1;  // 查询参数
  const limit = parseInt(req.query.limit) || 10;  // 查询参数
  const sort = req.query.sort || 'createdAt';  // 查询参数
  
  res.json({
    userId: userId,
    page: page,
    limit: limit,
    sort: sort,
    posts: []
  });
});

// 重定向示例
app.get('/home', (req, res) => {
  res.redirect('/');
});

app.get('/', (req, res) => {
  res.send('欢迎访问用户管理 API');
});

// 启动服务器
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`服务器运行在 http://localhost:${PORT}`);
});

总结

Express.js 作为 Node.js 最流行的 Web 框架,提供了简洁而强大的 API 来构建 Web 应用和 RESTful API。本文深入探讨了 Express 的基础概念:

  1. Express 简介与安装:了解 Express 是什么以及如何安装
  2. 创建第一个 Express 应用:通过 express() 创建应用实例,使用 app.listen() 启动服务器
  3. 路由基础:使用 app.get()app.post()app.put()app.delete() 等方法定义路由,支持路径参数、查询参数等多种匹配方式
  4. 请求对象(req):通过 req.params 获取路径参数,通过 req.query 获取查询参数,通过 req.body 获取请求体数据
  5. 响应对象(res):使用 res.send()res.json()res.status()res.redirect() 等方法向客户端发送响应
  6. 完整示例:构建一个完整的 RESTful API,综合运用所学知识

参考资源

JavaScript Date 的那些事

作者 parade岁月
2025年12月18日 10:11

一、时间的"长相":你看到的时间有哪些形式?

在代码中,时间通常以两种形式存在:时间戳字符串

1.1 时间戳 (Timestamp)

时间戳是一个数字,表示从 1970 年 1 月 1 日 00:00:00 UTC(称为 Unix 纪元)到某个时刻经过的时间。

秒级 vs 毫秒级

  • 秒级时间戳:Unix/Linux 系统常用,如 1734345000
  • 毫秒级时间戳:JavaScript 使用的是这种,如 1734345000000

💡 提示:两者差 1000 倍,位数相差 3 位。秒级 10 位数,毫秒级 13 位数。

获取时间戳的方式

// 获取当前时间的毫秒级时间戳
Date.now() // ✅ 推荐,简洁高效
new Date().getTime() // 等价,但多创建了一个 Date 对象
+new Date() // 隐式转换,不推荐(可读性差)

// 示例
console.log(Date.now()) // 1734345000000

1.2 字符串形式

时间字符串有多种格式标准,了解它们能帮你避免很多解析问题。

ISO 8601 标准(推荐)

国际标准化组织制定的格式,跨平台、跨语言通用

2025-12-16T10:30:00.000Z
│    │  │ │ │  │  │   └── Z 表示 UTC 时区(也可以是 +08:00)
│    │  │ │ │  │  └────── 毫秒
│    │  │ │ │  └───────── 秒
│    │  │ │ └──────────── 分
│    │  │ └────────────── 时
│    │  └──────────────── 日
│    └─────────────────── 月
└──────────────────────── 年
// ISO 字符串示例
'2025-12-16' // 只有日期部分
'2025-12-16T10:30:00' // 不带时区(会被当作本地时间)
'2025-12-16T10:30:00Z' // UTC 时间
'2025-12-16T10:30:00+08:00' // 带时区偏移(东八区)

RFC 2822 标准

常见于邮件头、HTTP 响应头。

'Mon, 16 Dec 2025 10:30:00 GMT'
'Tue, 16 Dec 2025 18:30:00 +0800'

本地化字符串

因地区、语言而异,不建议用于数据传输,仅用于展示。

'2025年12月16日' // 中文
'12/16/2025' // 美式(月/日/年)
'16/12/2025' // 欧式(日/月/年)
'December 16, 2025' // 英文

各格式适用场景

格式 适用场景 备注
时间戳 存储、计算、接口传输 最通用,无歧义
ISO 8601 接口传输、日志、数据库 标准格式,强烈推荐
RFC 2822 邮件、HTTP 头 特定协议使用
本地化字符串 仅用于 UI 展示 展示友好,但不能用于传输

二、创建 Date 对象的几种方式

2.1 无参构造:获取当前时间

const now = new Date()
console.log(now) // Mon Dec 16 2024 18:30:00 GMT+0800 (中国标准时间)

2.2 时间戳构造

// 毫秒级时间戳
const date1 = new Date(1734345000000)

// ⚠️ 如果后端返回秒级时间戳,记得乘 1000
const backendTimestamp = 1734345000 // 秒级
const date2 = new Date(backendTimestamp * 1000)

💡 常见错误:忘记转换秒级时间戳,导致日期显示为 1970 年。

2.3 字符串构造(重点:可靠性问题)

✅ ISO 格式最可靠

// 这些在所有现代浏览器中表现一致
new Date('2025-12-16T10:30:00.000Z') // UTC 时间
new Date('2025-12-16T10:30:00+08:00') // 带时区
new Date('2025-12-16T10:30:00') // 本地时间

⚠️ 不可靠的字符串格式

// 1. 纯日期字符串 - 时区行为不一致!
new Date('2025-12-16')
// Chrome/Firefox: 当作 UTC 00:00:00,转本地时间是 08:00:00
// Safari/iOS Safari: 当作本地时间 00:00:00
// 结果: 同一个字符串,不同浏览器可能相差 8 小时!

// 2. 斜杠分隔 - 部分浏览器不支持
new Date('2025/12/16') // 大部分浏览器 OK,但不是标准
new Date('12/16/2025') // 美式格式,依赖浏览器实现

// 3. 其他格式 - 结果不可预测
new Date('16-12-2025') // ❌ 可能返回 Invalid Date
new Date('12-16-2025') // ❌ 不同浏览器解析不同
new Date('December 16, 2025') // ⚠️ 能用,但依赖英文环境

// 4. 带中文 - 完全不支持
new Date('2025年12月16日') // ❌ Invalid Date

💡 最佳实践

// 如果拿到非标准格式,先转成 ISO 或时间戳
const dateStr = '16/12/2025' // 欧式格式
const [day, month, year] = dateStr.split('/')
const safeDate = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`)

// 或者使用日期库(如 Day.js)处理

2.4 时间分量构造(月份坑点预告)

// new Date(year, monthIndex, day, hours, minutes, seconds, ms)
const date = new Date(2025, 11, 16, 10, 30, 0)
//                         ↑
//                    注意: 11 表示 12 月!

这就引出了下一章的重点——月份从 0 开始的问题。


三、月份从 0 开始:到底是哪里的坑?

这是 JavaScript Date 最臭名昭著的设计之一。但很多人对它有误解,让我们来澄清。

3.1 澄清误区:字符串构造不受影响

// ✅ 字符串中的 12 就是 12 月,没有任何问题
new Date('2025-12-16') // 12 月 16 日
new Date('2025-12-16T10:30') // 12 月 16 日 10:30

// ✅ ISO 字符串中的月份是正常的 1-12
new Date('2025-01-01') // 1 月 1 日
new Date('2025-12-31') // 12 月 31 日

3.2 真正的坑点:时间分量构造和 getter/setter

坑点 1:时间分量构造

// ❌ 常见错误: 第二个参数以为是月份(1-12)
new Date(2025, 12, 16) // 错误! 这是 2026 年 1 月 16 日
new Date(2025, 1, 1)   // 错误! 这是 2 月 1 日,不是 1 月

// ✅ 正确写法: 第二个参数是 monthIndex(0-11)
new Date(2025, 11, 16) // 2025 年 12 月 16 日
new Date(2025, 0, 1)   // 2025 年 1 月 1 日

坑点 2:getMonth() 返回 0-11

const date = new Date('2025-12-16')
console.log(date.getMonth()) // 11,不是 12!

// ✅ 想要得到正常月份,需要 +1
const month = date.getMonth() + 1 // 12

// ❌ 常见错误: 忘记 +1
const wrongMonth = date.getMonth() // 11 (错误!)
console.log(`当前月份是 ${wrongMonth} 月`) // "当前月份是 11 月"(实际是 12 月)

坑点 3:setMonth() 同样是 0-11

const date = new Date('2025-06-16')

// ❌ 错误: 以为是设置为 12 月
date.setMonth(12) // 实际设置为下一年 1 月!

// ✅ 正确: 设置为 12 月
date.setMonth(11)

3.3 为什么设计成这样?

这是历史遗留问题。JavaScript 的 Date 对象设计借鉴了 Java 的 java.util.Date(Java 后来也废弃了这个类)。

可能的原因:

  1. 数组索引思维:月份可以直接作为月份名称数组的索引
const months = ['January', 'February', 'March', 'April', 'May', 'June',
                'July', 'August', 'September', 'October', 'November', 'December']
const date = new Date()
console.log(months[date.getMonth()]) // 直接取月份名,无需 -1
  1. 早期设计仓促:JavaScript 只用了 10 天设计出来,很多决策没有深思熟虑

3.4 记忆口诀

字符串月份正常写,分量构造和 getter 要减一(或从 0 开始)。


四、Date 的其他常见坑

4.1 时区问题:本地时间 vs UTC 时间

const date = new Date('2025-12-16T00:00:00Z') // UTC 时间午夜

// 本地时间方法(受时区影响)
date.getHours() // 8 (北京时间 +8 小时)
date.getDate() // 16
date.getDay() // 2 (周二)

// UTC 时间方法(不受时区影响)
date.getUTCHours() // 0
date.getUTCDate() // 16
date.getUTCDay() // 2

// toString 也不同
date.toString() 
// "Tue Dec 16 2025 08:00:00 GMT+0800 (中国标准时间)"

date.toISOString() 
// "2025-12-16T00:00:00.000Z"

date.toUTCString() 
// "Tue, 16 Dec 2025 00:00:00 GMT"

💡 提示:涉及跨时区场景时,统一使用 UTC 时间,避免混乱。

4.2 月末溢出:自动进位

// Date 会自动处理溢出,这有时是 feature,有时是 bug
new Date(2025, 0, 32) // 1 月 32 日 → 2 月 1 日
new Date(2025, 1, 30) // 2 月 30 日 → 3 月 2 日(2025 非闰年)
new Date(2025, 11, 32) // 12 月 32 日 → 2026 年 1 月 1 日

// ✅ 利用这个特性获取某月最后一天
function getLastDayOfMonth(year, month) {
  // month 是 1-12,所以 month 作为 monthIndex 就是下个月
  // day 传 0 表示上个月最后一天
  return new Date(year, month, 0).getDate()
}

getLastDayOfMonth(2025, 2) // 28 (2 月最后一天)
getLastDayOfMonth(2024, 2) // 29 (2024 是闰年)
getLastDayOfMonth(2025, 12) // 31 (12 月最后一天)

更多特殊参数用法

// day 传 0: 上个月最后一天
new Date(2025, 2, 0) // 2025-02-28 (2 月最后一天)

// day 传负数: 往前推
new Date(2025, 2, -1) // 2025-02-27 (2 月倒数第二天)
new Date(2025, 0, 0) // 2024-12-31 (去年最后一天)

// 获取上个月同一天
const today = new Date(2025, 2, 15) // 3 月 15 日
const lastMonth = new Date(2025, 1, 15) // 2 月 15 日

// 获取下个月第一天
const nextMonthFirst = new Date(2025, 3, 1) // 4 月 1 日

// ✅ 判断是否为闰年
function isLeapYear(year) {
  // 2 月 29 日如果存在,就是闰年
  return new Date(year, 1, 29).getDate() === 29
}

isLeapYear(2024) // true
isLeapYear(2025) // false

4.3 Invalid Date:如何判断日期是否有效

const valid = new Date('2025-12-16')
const invalid = new Date('not a date')

// ✅ 方法 1: 检查 getTime() 是否为 NaN (推荐)
isNaN(valid.getTime()) // false
isNaN(invalid.getTime()) // true

// 方法 2: 转字符串检查
invalid.toString() // "Invalid Date"

// 方法 3: 使用 valueOf()
isNaN(invalid.valueOf()) // true

// ✅ 封装成函数
function isValidDate(date) {
  return date instanceof Date && !isNaN(date.getTime())
}

// 测试
isValidDate(new Date()) // true
isValidDate(new Date('invalid')) // false
isValidDate('2025-12-16') // false (不是 Date 对象)

4.4 Date 对象比较的陷阱

const date1 = new Date('2025-12-16')
const date2 = new Date('2025-12-16')

// ❌ 错误: 直接比较会比较引用,而非值
date1 == date2 // false
date1 === date2 // false

// ✅ 正确: 转成时间戳比较
date1.getTime() === date2.getTime() // true
+date1 === +date2 // true (隐式转换)

// ✅ 比较大小(可以直接比较,会自动转时间戳)
date1 > date2 // false
date1 < date2 // false
date1 >= date2 // true

// 实际应用示例
const deadline = new Date('2025-12-31')
const today = new Date()
if (today > deadline) {
  console.log('已过期')
}

4.5 时间戳精度问题

// JavaScript 的 Number 类型是 64 位浮点数
// 安全整数范围: -(2^53 - 1) 到 (2^53 - 1)
Number.MAX_SAFE_INTEGER // 9007199254740991

// 对于时间戳来说:
// 毫秒级时间戳在 2287 年之前都是安全的
const year2287 = 9999999999999
new Date(year2287) // Sat Nov 20 2286 17:46:39 GMT+0800

// ⚠️ 但如果后端返回微秒级或纳秒级时间戳,可能超出安全范围
const microTimestamp = 1734345000000000 // 微秒级(16位,超出安全范围)
// 这种情况需要用 BigInt 或字符串处理

// 解决方案示例
const microStr = '1734345000000000'
const millisTimestamp = Math.floor(Number(microStr) / 1000)
new Date(millisTimestamp)

4.6 格式化困难:没有内置 format 方法

const date = new Date('2025-12-16T10:30:00')

// 想要 "2025-12-16" 格式? 只能手动拼接
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0') // 别忘了 +1!
const day = String(date.getDate()).padStart(2, '0')
const formatted = `${year}-${month}-${day}` // "2025-12-16"

// 想要 "2025-12-16 10:30:00"? 继续拼...
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
const fullFormatted = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`

// 😫 每次都要写这么多代码

// ✅ 封装成工具函数
function formatDate(date, format = 'YYYY-MM-DD HH:mm:ss') {
  const year = date.getFullYear()
  const month = String(date.getMonth() + 1).padStart(2, '0')
  const day = String(date.getDate()).padStart(2, '0')
  const hours = String(date.getHours()).padStart(2, '0')
  const minutes = String(date.getMinutes()).padStart(2, '0')
  const seconds = String(date.getSeconds()).padStart(2, '0')
  
  return format
    .replace('YYYY', year)
    .replace('MM', month)
    .replace('DD', day)
    .replace('HH', hours)
    .replace('mm', minutes)
    .replace('ss', seconds)
}

// 使用
formatDate(new Date()) // "2025-12-16 10:30:00"
formatDate(new Date(), 'YYYY/MM/DD') // "2025/12/16"

4.7 Date 对象是可变的

// ⚠️ Date 对象的 setter 方法会修改原对象
const date = new Date('2025-12-16')
console.log(date.toString()) // "Mon Dec 16 2025..."

date.setMonth(0) // 修改为 1 月
console.log(date.toString()) // "Thu Jan 16 2025..." (原对象被改变!)

// 这在函数传参时容易产生副作用
function addOneDay(date) {
  date.setDate(date.getDate() + 1)
  return date // ⚠️ 返回的是修改后的原对象
}

const original = new Date('2025-12-16')
const next = addOneDay(original)
console.log(original.toString()) // 原对象也变了!

// ✅ 正确做法: 先复制再修改
function addOneDaySafe(date) {
  const newDate = new Date(date.getTime()) // 复制
  newDate.setDate(newDate.getDate() + 1)
  return newDate
}

五、Day.js 解决了哪些痛点? (对比原生 Date)

Day.js 是一个轻量级的日期处理库(仅 2KB gzip),API 设计借鉴了 Moment.js,但更加现代和轻便。

5.1 痛点对比表

痛点 原生 Date Day.js
月份从 0 开始 getMonth() 返回 0-11,需要手动 +1 month() 也是 0-11,但 format('M') 自动输出 1-12
格式化日期 无内置方法,需手动拼接十几行代码 format('YYYY-MM-DD HH:mm:ss') 一行搞定
字符串解析不一致 不同浏览器结果不同,非 ISO 格式不可靠 统一解析,customParseFormat 插件支持任意格式
日期加减 需手动计算毫秒或用 setDate() 等方法 add(7, 'day')subtract(1, 'month') 语义清晰
日期比较 需转时间戳比较,代码冗长 isBefore()isAfter()isSame() 直观易读
不可变性 setMonth() 等方法会修改原对象,易产生 bug 所有操作返回新对象,原对象不变,避免副作用
时区处理 只有本地和 UTC,切换麻烦 timezone 插件轻松处理任意时区
相对时间 无内置支持 fromNow() 直接输出"3 天前"、"2 小时后"
体积 内置,0 成本 ~2KB gzip,极轻量

5.2 Day.js 基本使用

import dayjs from 'dayjs'

// 创建日期对象
dayjs() // 当前时间
dayjs('2025-12-16') // 从字符串
dayjs(1734345000000) // 从时间戳
dayjs(new Date()) // 从 Date 对象

// 格式化 (最常用!)
dayjs().format('YYYY-MM-DD') // "2025-12-16"
dayjs().format('YYYY-MM-DD HH:mm:ss') // "2025-12-16 10:30:00"
dayjs().format('YYYY年MM月DD日') // "2025年12月16日"

// 日期加减
dayjs().add(7, 'day') // 7 天后
dayjs().subtract(1, 'month') // 1 个月前
dayjs().add(1, 'year') // 1 年后

// 日期比较
dayjs('2025-12-16').isBefore('2025-12-17') // true
dayjs('2025-12-16').isAfter('2025-12-15') // true
dayjs('2025-12-16').isSame('2025-12-16') // true

// 相对时间 (需要 relativeTime 插件)
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn'
dayjs.extend(relativeTime)
dayjs.locale('zh-cn')

dayjs().fromNow() // "几秒前"
dayjs().add(3, 'day').fromNow() // "3 天后"

数据大屏常用布局-等比缩放布局(Scale Laylout)-使用 CSS Transform Scale 实现等比缩放

作者 码途潇潇
2025年12月18日 10:10

数据大屏常用布局-等比缩放布局(Scale Laylout)-使用 CSS Transform Scale 实现等比缩放

1.介绍

1.1 典型特征和典型特点

Scale Layout 的典型特征:

1.浏览器缩小

2.整个页面一起缩

3.布局不变

4.字体可以缩到几 px

工程上意味着:

  • 页面有一个固定“设计尺寸”
  • 浏览器变化时 不重排(不 reflow)
  • 只是 整体做 transform

为什么适合数据大屏?

数据大屏的典型特点

  • 固定分辨率设计(1920×1080 / 3840×2160)
  • 主要是 看,不是 点
  • 运行环境可控(会议室 / 大屏电视)

1.2 效果展示

效果实现展示: 录屏2025-12-17 09.44.00 (online-video-cutter.com) - Compressed with FlexClip (2)

1.3 CSS 编写原则

在这种方案下,你的 CSS 编写原则是

CSS 世界里永远是 1920×1080,缩放交给 JS,使用固定 px,不要写响应式

2.实战案例-使用 CSS Transform Scale 实现等比缩放

  1. 固定设计尺寸

在全局样式中,#app 被设置为固定的设计尺寸:

Index.css

#app {
  @apply inline-block absolute left-1/2;
  width: 1920px;
  height: 1080px;
  transform-origin: 0 0;
  color: rgba(255, 255, 255, 0.9);
}
  1. 动态缩放计算

使用 useScreenScale hook 计算缩放比例:

import { onMounted, onUnmounted } from 'vue'

const useScreenScale = () => {
/**
 * Scales the screen based on the design dimensions.
 * window resize is not usually triggered, so no need to use debounce
 * @return {void} This function does not return a value.
 */
const handleScreenScale = () => {
const designDreftWidth = 1920
const designDreftHeight = 1080

const scale =
document.documentElement.clientWidth / document.documentElement.clientHeight <
designDreftWidth / designDreftHeight
? document.documentElement.clientWidth / designDreftWidth
: document.documentElement.clientHeight / designDreftHeight

if (document.querySelector('#app') !== null) {
;(document.querySelector('#app') as HTMLElement).style.transform =
`scale(${scale}) translate(-50%)`
}
}
onMounted(() => {
handleScreenScale()
window.onresize = () => handleScreenScale()
})

onUnmounted(() => {
window.onresize = null
})
}

export default useScreenScale

缩放逻辑:

  • 比较当前屏幕宽高比与设计稿宽高比(16:9)

  • 如果屏幕更窄:按宽度缩放 scale = 屏幕宽度 / 1920

  • 如果屏幕更高:按高度缩放 scale = 屏幕高度 / 1080

  • 应用变换:transform: scale(scale) translate(-50%)

    • translate(-50%)和left(50%)配合,实现水平居中
  • scale() 缩放,scale就是缩放比例

  • translate(-50%) 配合 left-1/2 实现居中

  1. 在 App.vue 中启用
<script setup lang="ts">
useScreenScale()
</script>

页面如何实现等比缩放?

页面无需额外处理,因为:

所有页面都在 #app 内部

#app 整体缩放,内部元素自动跟随

使用固定像素值即可,例如:

 <div class="w-[1114px] h-full">
        <HistoryEventsList
          ref="historyEventsListRef"
          @update:queryParams="handleTrendQuery"
          @open:warningEventDetail="handleOpenWarningEventDetail"
        />
      </div>
      <div class="w-[738px] h-full">

这些固定像素值(如 1114px、738px)会随 #app 的缩放比例自动缩放。

3.注意坑点- Element Plus 弹出层的默认行为

Element Plus 弹出层的默认行为,Element Plus 的弹出层组件(el-select、el-cascader、el-date-picker 等)默认会将下拉菜单挂载到 body 上,而不是组件所在的 DOM 树中。

DOM 结构示意:

<body>
  <div id="app">  ← 这里应用了 transform: scale(0.75)
    <HistoryEventsList>
      <el-select>  ← 输入框在这里
      </el-select>
    </HistoryEventsList>
  </div>
  
  <!-- 默认情况下,下拉菜单会挂载到这里 -->
  <div class="el-select-dropdown">  ← 下拉菜单在 body 下,不在 #app 内!
    <el-option>...</el-option>
  </div>
</body>

由于缩放是写在app上的,但是弹出层挂在了body上,所以应该将弹出层挂到app上,使用append-to="#app"

<el-date-picker
            v-model="currentDateRange"
            :shortcuts="EVENT_SHORTCUTS"
            append-to="#app"
            type="daterange"
            range-separator="至"
            start-placeholder="开始日期"
            end-placeholder="结束日期"
            size="small"
            :style="{ width: '100%', height: '32px' }"
          />

Web Worker 前端多线程解析

作者 二二四一
2025年12月18日 10:05

1. Web Worker 定义

Web Worker 是浏览器提供的多线程能力,允许在主线程之外运行 JavaScript。

它的目标只有一个: 👉 把耗时任务从 UI 线程中挪走,让页面保持流畅。

2. Web Worker 作用

解决主线程的现实困境,主线程同时负责:

  • JS 执行
  • 页面渲染
  • 用户交互
  • 事件响应

一旦遇到👇

  • 大量计算
  • JSON 解析
  • 文件处理
  • 图片 / 视频处理
  • 高频轮询 / 长时间任务

👉 UI 就会卡,页面就会“假死”。

3. Web Worker 的工作原理

1️⃣ 线程模型

主线程(UI / React)
   │
   ├── postMessage
   ▼
Worker 线程(独立事件循环)
   │
   └── postMessage
  • Worker 运行在 独立线程
  • 拥有自己的事件循环
  • 不会阻塞 UI

2️⃣ 通信机制

  • 使用 postMessage 传递消息
  • 默认采用 structured clone(深拷贝)
  • 可使用 Transferable(如 ArrayBuffer)转移所有权,避免拷贝

⚠️ Worker 不能访问 DOM / window / document,主线程的上下文

4. Web Worker 的类型

类型 名称 特点 使用场景
Dedicated Worker 专用 Worker 一对一、最常用 计算、解析、后台任务
Shared Worker 共享 Worker 多页面共享 多标签页共享状态
Service Worker service Worker 拦截网络请求 PWA / 离线缓存
Worklet —— 低延迟 音频 / 渲染

👉 日常业务中 90% 用 Dedicated Worker

5. Web Worker 的特点

  • ✅ 真正的并行执行
  • ✅ 避免主线程阻塞
  • ❌ 不能操作 DOM
  • ❌ 创建和通信有成本
  • ⚠️ 数据传输需要考虑性能

6. 优缺点对照

  1. ✅ 优点
  • 提升页面响应速度
  • 适合 CPU 密集型任务
  • 可作为后台“计算引擎”
  • 可发送异步请求
  • 可运行 fetch / WebSocket / IndexedDB
  1. ❌ 缺点
  • 无法操作 DOM
  • 通信存在数据拷贝成本
  • 创建/销毁开销不小
  • 调试、打包需要额外配置

7. React 中如何优雅使用 Web Worker

  1. ❌ 常见误区
  • 每个组件 new 一个 Worker
  • Worker 生命周期不清理
  • 主线程/Worker 强耦合
  1. ✅ 推荐模式:Hook + 单例 Worker
const { post } = useWorker() // React Hook 库
const result = await post('compute', payload)

特点:

  • Worker 只创建一次
  • Promise 化调用
  • 组件只关心结果

Web Worker 是全局资源,而不是组件资源。

8. 典型适用场景

  1. 🔥 强烈推荐使用
  • 大数据计算 / 排序 / 过滤
  • 图片处理、压缩、滤镜
  • 视频转码、音频分析
  • 文件解析(CSV / Excel / JSON)
  • 加密、解密、哈希计算
  1. ⚠️ 谨慎使用
  • 轻量逻辑
  • 高频短任务
  • 强依赖 DOM 的操作

10. Web Worker + WebSocket(工程进阶)

一个非常实用的组合:

  • Worker 里维护 WebSocket
  • 处理心跳 / 重连 / 解析
  • 主线程只负责 UI 渲染

优势:

  • UI 不受网络抖动影响
  • 连接逻辑高度解耦
  • 更稳定的实时通信
┌────────────┐
│ React UI   │
│            │
│ 订阅数据   │
│ postMessage│
└─────▲──────┘
      │
      │ structured clone
      ▼
┌────────────┐
│ Web Worker │
│            │
│ WebSocket  │
│ 心跳/重连  │
│ 限流/聚合  │
└────────────┘
❌
❌