普通视图
自建 React Native 热修复,让线上事故 30 秒“归零”
在迁移中学习 React 18:一份来自 React 17 的升级问题清单
前端javascript如何实现阅读位置记忆【可运行源码】
阅读位置记忆功能demo
功能说明
1. 自动保存:滚动页面时,系统会自动保存当前阅读位置(防抖处理,每秒保存一次)
2. 自动恢复:重新打开页面时,会自动跳转到上次阅读的位置
3. 手动控制:
-
手动保存当前位置
-
手动跳转到上次保存的位置
-
清除保存的记录
4. 视图反馈
-
状态指示器显示保存状态
-
恢复时高亮显示当前章节
-
显示滚动进度条
-
侧边位置标记显示当前章节和进度
使用方法
-
直接复制上面的代码到HTML文件中
-
用浏览器打开该文件
-
滚动页面阅读内容
-
刷新页面或关闭后重新打开,页面会自动跳转到上次阅读的位置
![]()
<!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>
![]()
从 “翻页书” 到 “魔术盒”:React 路由凭啥如此丝滑?
axios简易封装,适配H5开发
个人积累的一些前端问题解决方案(理论或实践,持续更新....)
🗓️ 2262年将有两个春节!作为前端的你,日历控件真的写对了吗?
HarmonyOS权限管理实战——麦克风、震动等敏感权限申请
技术栈: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 |
六、避坑指南
- 声明与申请:user_grant权限需要在配置文件声明且动态申请
- reason字段:必须提供清晰的权限使用说明,审核会检查
- 时机选择:在需要使用时再申请,不要一启动就申请所有权限
- 拒绝处理:用户拒绝后要有友好的引导,不能强制退出
- 隐私政策:应用商店要求在隐私政策中说明权限用途
七、华为应用市场审核要点
- 权限申请必须有明确的使用场景
- reason字段要清晰说明用途
- 不能申请与功能无关的权限
- 用户拒绝权限后应用仍能正常使用其他功能
总结
本文介绍了HarmonyOS权限管理的完整流程,包括配置声明、动态申请、拒绝处理等。正确的权限管理不仅是应用上架的必要条件,也是保护用户隐私的重要措施。
HarmonyOS震动反馈开发——提升用户体验的触觉交互
技术栈: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())
}
}
}
七、避坑指南
-
权限声明:必须在
module.json5中声明VIBRATE权限 - 异常处理:震动API可能失败,需要try-catch
- 用户控制:提供开关让用户控制是否启用震动
- 适度使用:过度震动会影响用户体验和电池寿命
- 设备兼容:部分设备可能不支持震动
总结
本文介绍了HarmonyOS震动API的使用方法和实际应用场景。合理使用触觉反馈可以显著提升用户体验,但要注意适度使用并提供用户控制选项。
zustand 从原理到实践 - 最佳实践
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 名称生成工具
📁 关键说明:
-
stores/index.ts- 只导出核心 Store- 导出
useStore和AppStore类型 - 不再重新导出其他内容
- 导出
-
各子目录的
index.ts- 各类型文件的统一出口-
models/index.ts- 导出所有 model 类型和初始状态 -
slices/index.ts- 导出所有 slice 类型和创建函数 -
actions/index.ts- 导出所有 action 函数 -
selectors/index.ts- 导出所有复用型 selector hooks
-
-
组件导入规则
-
useStore从@/stores导入 - actions 从
@/stores/actions导入 - selectors 从
@/stores/selectors导入 - types 从
@/stores/models导入
-
-
selectors/- 复用型 selector hooks- 封装常用的状态访问逻辑
- 内部使用
useShallow或useMemo优化性能 - 为复杂的派生状态提供独立的 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/loading→user/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?
-
多个组件需要相同的状态访问逻辑 - 如
useUserProfile、useCartSummary - 需要对状态进行复杂计算或派生 - 如计算总价、筛选列表
- 需要组合多个状态片段 - 如同时获取用户信息和登录状态
-
需要性能优化 - 使用
useShallow或useMemo避免不必要的重渲染
最佳实践:
- 将 selector hooks 统一放在
selectors/目录 - 在
selectors/index.ts中统一导出 - 在 hook 内部使用
useShallow或useMemo优化性能 - 为 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 时:
- 在
actions/xxx.ts中编写 action - 在
actions/index.ts中导出(如果使用export *,会自动导出) - 组件从
@/stores/actions导入
添加新的 model 时:
- 在
models/xxx.model.ts中定义类型和初始状态 - 在
models/index.ts中导出 - 组件从
@/stores/models导入类型
添加新的 selector hook 时:
- 在
selectors/index.ts中编写 hook - 组件从
@/stores/selectors导入
最佳实践:
- 使用
export *简化导出语句 - 为每个类型的文件创建
index.ts作为出口 -
stores/index.ts只导出useStore和AppStore - 定期检查是否有组件绕过出口文件导入
HarmonyOS纯音测听实现——专业听力检测功能开发
技术栈: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)
}
}
五、避坑指南
- 音量校准:不同设备音量输出不同,建议添加校准功能
- 测试环境:提醒用户在安静环境中使用耳机测试
- 免责声明:明确说明仅供参考,不能替代专业医学检查
- 数据保护:测试结果仅存储在本地
总结
本文实现了一个专业的纯音测听功能,包括标准频率测试、听阈记录、等级评估等。该功能可帮助用户初步了解自己的听力状况。
HarmonyOS深色模式适配实战——主题切换与WCAG对比度标准
技术栈: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);
}
}
五、对比度检查工具
推荐使用在线工具检查颜色对比度:
- WebAIM Contrast Checker: webaim.org/resources/c…
- Coolors Contrast Checker: coolors.co/contrast-ch…
六、避坑指南
- 华为审核要求:深色模式下所有文字必须清晰可见
- @StorageLink vs @StorageProp:前者双向绑定,后者只读
-
颜色透明度:使用
rgba()时注意透明度对对比度的影响 - 图标适配:深色模式下图标也需要适配
总结
本文介绍了HarmonyOS深色模式的完整实现方案,包括符合WCAG标准的颜色定义、主题管理器、页面使用方法等。正确的深色模式适配不仅能通过应用市场审核,还能提升用户体验。
HarmonyOS数据持久化最佳实践——Preferences首选项存储详解
技术栈: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);
五、避坑指南
-
初始化时机:必须在
EntryAbility.onCreate中初始化 -
flush调用:每次
put后都要调用flush() -
JSON序列化:复杂对象需要
JSON.stringify/JSON.parse - 数据量限制:Preferences适合小数据,大数据用关系型数据库
-
异步处理:所有操作都是异步的,注意使用
await
总结
本文封装了一个通用的Preferences工具类,支持基础类型和复杂对象的存储。在实际项目中,这个工具类被用于保存用户设置、测试历史、使用统计等数据。
Node.js HTTP 服务器开发
Express.js 基础
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:动态/私有端口(相对安全,适合开发使用)
-
0-1023:系统保留端口(需要管理员权限),常见的有:
-
注意事项:如果端口已被占用,会抛出
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-Type 为 application/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 的基础概念:
- Express 简介与安装:了解 Express 是什么以及如何安装
-
创建第一个 Express 应用:通过
express()创建应用实例,使用app.listen()启动服务器 -
路由基础:使用
app.get()、app.post()、app.put()、app.delete()等方法定义路由,支持路径参数、查询参数等多种匹配方式 -
请求对象(req):通过
req.params获取路径参数,通过req.query获取查询参数,通过req.body获取请求体数据 -
响应对象(res):使用
res.send()、res.json()、res.status()、res.redirect()等方法向客户端发送响应 - 完整示例:构建一个完整的 RESTful API,综合运用所学知识
参考资源
JavaScript Date 的那些事
一、时间的"长相":你看到的时间有哪些形式?
在代码中,时间通常以两种形式存在:时间戳和字符串。
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 后来也废弃了这个类)。
可能的原因:
- 数组索引思维:月份可以直接作为月份名称数组的索引
const months = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December']
const date = new Date()
console.log(months[date.getMonth()]) // 直接取月份名,无需 -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 实现等比缩放
数据大屏常用布局-等比缩放布局(Scale Laylout)-使用 CSS Transform Scale 实现等比缩放
1.介绍
1.1 典型特征和典型特点
Scale Layout 的典型特征:
1.浏览器缩小
2.整个页面一起缩
3.布局不变
4.字体可以缩到几 px
工程上意味着:
- 页面有一个固定“设计尺寸”
- 浏览器变化时 不重排(不 reflow)
- 只是 整体做 transform
为什么适合数据大屏?
数据大屏的典型特点
- 固定分辨率设计(1920×1080 / 3840×2160)
- 主要是 看,不是 点
- 运行环境可控(会议室 / 大屏电视)
1.2 效果展示
效果实现展示:
![]()
1.3 CSS 编写原则
在这种方案下,你的 CSS 编写原则是
CSS 世界里永远是 1920×1080,缩放交给 JS,使用固定 px,不要写响应式
2.实战案例-使用 CSS Transform Scale 实现等比缩放
- 固定设计尺寸
在全局样式中,#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);
}
- 动态缩放计算
使用 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 实现居中
- 在 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 前端多线程解析
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. 优缺点对照
- ✅ 优点
- 提升页面响应速度
- 适合 CPU 密集型任务
- 可作为后台“计算引擎”
- 可发送异步请求
- 可运行 fetch / WebSocket / IndexedDB
- ❌ 缺点
- 无法操作 DOM
- 通信存在数据拷贝成本
- 创建/销毁开销不小
- 调试、打包需要额外配置
7. React 中如何优雅使用 Web Worker
- ❌ 常见误区
- 每个组件 new 一个 Worker
- Worker 生命周期不清理
- 主线程/Worker 强耦合
- ✅ 推荐模式:Hook + 单例 Worker
const { post } = useWorker() // React Hook 库
const result = await post('compute', payload)
特点:
- Worker 只创建一次
- Promise 化调用
- 组件只关心结果
Web Worker 是全局资源,而不是组件资源。
8. 典型适用场景
- 🔥 强烈推荐使用
- 大数据计算 / 排序 / 过滤
- 图片处理、压缩、滤镜
- 视频转码、音频分析
- 文件解析(CSV / Excel / JSON)
- 加密、解密、哈希计算
- ⚠️ 谨慎使用
- 轻量逻辑
- 高频短任务
- 强依赖 DOM 的操作
10. Web Worker + WebSocket(工程进阶)
一个非常实用的组合:
- Worker 里维护 WebSocket
- 处理心跳 / 重连 / 解析
- 主线程只负责 UI 渲染
优势:
- UI 不受网络抖动影响
- 连接逻辑高度解耦
- 更稳定的实时通信
┌────────────┐
│ React UI │
│ │
│ 订阅数据 │
│ postMessage│
└─────▲──────┘
│
│ structured clone
▼
┌────────────┐
│ Web Worker │
│ │
│ WebSocket │
│ 心跳/重连 │
│ 限流/聚合 │
└────────────┘