普通视图

发现新文章,点击刷新页面。
今天 — 2025年11月5日掘金 前端

鸿蒙组件分级指南:从细胞到思维的系统化角色解析

2025年11月5日 14:44

初级组件(基础单元层)

作用:搭建界面“细胞结构”,承载最基础的交互与展示功能 包含内容

  • 系统组件Text(文字)、Button(按钮)、Image(图片)
  • 布局方式Column(纵向布局)、Row(横向布局)、Stack(层叠布局)
  • 装饰器@Component(定义组件)、@Entry(入口组件)、@State(组件内部状态)

开发者角色

  • 负责搭建基础页面框架,类似“细胞合成师”
  • 通过组合基础组件实现简单功能(如静态页面、点击计数)
  • 需掌握链式调用配置属性(如.fontSize(20).width('100%')

类比

  • Text组件 → 细胞的DNA,承载信息传递功能
  • Column布局 → 骨骼系统,支撑内容垂直排列
  • @State装饰器 → 短期记忆,记录组件内部临时状态

示例场景

@Entry @Component  
struct BasicPage {  
  @State count: number = 0;  
  build() {  
    Column() {  
      Text(`点击次数: ${this.count}`)  
      Button('+1')  
        .onClick(() => { this.count++ })  
    }  
  }  
}  

中级组件(功能器官层)

作用:构建复杂功能模块,实现数据流动与UI复用 包含内容

  • 布局扩展Grid(网格布局)、List(列表)、Flex(弹性布局)
  • 自定义组件:通过@Builder封装可复用UI块
  • 装饰器@Prop(单向数据传递)、@Link(双向数据绑定)、@Styles(样式复用)

开发者角色

  • 负责模块化开发,类似“器官工程师”
  • 设计父子组件通信逻辑(如表单数据传递)
  • 需掌握响应式编程思想(数据驱动UI更新)

类比

  • List布局 → 消化系统,有序处理列表数据流
  • @Builder函数 → 肌肉组织,重复调用完成特定动作
  • @Link装饰器 → 神经系统,实现父子组件双向控制

示例场景

// 父组件  
@Component  
struct ParentComp {  
  @State total: number = 0;  
  build() {  
    Column() {  
      ChildComp({ childCount: $total })  
      Text(`总和: ${this.total}`)  
    }  
  }  
}  

// 子组件  
@Component  
struct ChildComp {  
  @Link childCount: number;  
  build() {  
    Button('子组件+1')  
      .onClick(() => { this.childCount++ })  
  }  
}  

高级组件(系统协调层) 作用:统筹全局架构,实现跨组件通信与性能优化 包含内容

  • 高级布局RelativeContainer(相对布局)、WaterFlow(瀑布流)
  • 状态管理@Provide/@Consume(跨层级共享)、@Observed(深度监听对象)
  • 能力扩展:自定义弹层、分布式组件、原子化服务

开发者角色

  • 负责系统架构设计,类似“大脑指挥官”
  • 制定全局状态管理策略(如用户登录态同步)
  • 需优化组件渲染性能(如使用LazyForEach加载长列表)

类比

  • @Provide/@Consume → 血液循环系统,跨层级传递养分(数据)
  • RelativeContainer → 关节系统,灵活协调组件相对位置
  • 原子化服务 → 思维逻辑,独立运行且可组合

实战策略

  1. 状态分层

    • 局部状态用@State,跨页面用PersistentStorage
    • 全局共享数据使用AppStorage
  2. 性能优化

    • 复杂布局层级不超过5层
    • 列表滑动场景启用cachedCount预加载
  3. 工程化实践

    • 使用HAR包封装通用组件库
    • 通过ViewModel分离UI与业务逻辑

总结对比表

层级 组件类型 开发者角色 类比模型 核心目标
初级 基础系统组件 细胞合成师 细胞与骨骼 搭建静态框架
中级 自定义功能模块 器官工程师 肌肉与神经 实现数据流动
高级 分布式服务 大脑指挥官 思维与血液循环 统筹全局性能

通过类比与融合将知识点串联起来,以应用为主体、以函数比行为、以布局为框架、以通信为神经、以组件为血肉,共同搭建软件生命体。

next框架打包.next文件夹部署

2025年11月5日 14:26

前端小小白,刚开始使用AI辅助编程前端项目,使用的是React框架,让AI实现功能的时候它使用到了动态路由,我目前的理解就是将一个单传的前端项目变成了全栈前后端一体的项目,导致npm build执行完之后并没有生成期望的dist文件夹或output文件夹,而是将所有内容打包到了.next文件夹中,并且不只是单纯的路径变化,这种打包方式没有生成静态页面入口,而是打出来了一个server.js文件作为入口,连静态页面都没部署过的我,真的被难死了。还好,claude大法好,我不断提问和测试,终究还是解决了,记录一下解决方案。

部署方案:使用 standalone 模式

第一步:本地构建

在你的本地项目目录执行:

npm run build

构建完成后,你会看到:

Compiled successfully
✓ Linting and checking validity of types
✓ Collecting page data
✓ Generating static pages
✓ Finalizing page optimization

并且项目路径下会有.next文件夹

第二步:准备部署文件

构建成功后,需要准备以下文件:

部署包结构:
├── server.js              # 来自 .next/standalone/server.js
├── package.json           # 来自 .next/standalone/package.json  
├── .next/
│   └── server/           # 来自 .next/server/
│   └── static/           # 来自 .next/static/
│   └── xxx               # 来自 .next下其他文件
└── public/               # 来自根目录的 public/

第三步:打包上传

使用 PowerShell 脚本

创建 package-deploy.ps1

Write-Host "正在打包部署文件..." -ForegroundColor Green

# 创建临时目录
if (Test-Path deploy-temp) { Remove-Item -Recurse -Force deploy-temp }
New-Item -ItemType Directory -Path deploy-temp | Out-Null

# 复制文件
Write-Host "复制 standalone 文件..." -ForegroundColor Yellow
Copy-Item -Path ".next/standalone/*" -Destination "deploy-temp/" -Recurse -Force

Write-Host "复制 static 文件..." -ForegroundColor Yellow
New-Item -ItemType Directory -Path "deploy-temp/.next" -Force | Out-Null
Copy-Item -Path ".next/static" -Destination "deploy-temp/.next/" -Recurse -Force

Write-Host "复制 public 文件..." -ForegroundColor Yellow
Copy-Item -Path "public" -Destination "deploy-temp/" -Recurse -Force

# 创建压缩包
Write-Host "创建 deploy.zip..." -ForegroundColor Yellow
Compress-Archive -Path "deploy-temp/*" -DestinationPath "deploy.zip" -Force

# 清理
Remove-Item -Recurse -Force deploy-temp

Write-Host "打包完成!文件: deploy.zip" -ForegroundColor Green
Write-Host "文件大小: $((Get-Item deploy.zip).Length / 1MB) MB" -ForegroundColor Cyan

运行:

powershell -ExecutionPolicy Bypass -File package-deploy.ps1

第四步:服务器部署

1. 上传文件到服务器
# 使用 scp 上传(Linux/Mac)
scp deploy.zip user@your-server:/path/to/app/

# 或使用 WinSCP、FileZilla 等工具
2. 在服务器上解压并运行
# SSH 登录服务器
ssh user@your-server

# 进入应用目录
cd /path/to/app

# 解压文件
unzip deploy.zip

# 查看文件结构(确认正确)
ls -la
# 应该看到:server.js, package.json, .next/, public/

# 直接运行(测试)
HOSTNAME=0.0.0.0 PORT=3889 node server.js

# 或使用 PM2(生产环境推荐)
HOSTNAME=0.0.0.0 PORT=3889 pm2 start server.js --name your-app
pm2 save
pm2 startup

直接运行成功显示如下: 在这里插入图片描述 使用 PM2 运行成功显示如下: 在这里插入图片描述 成功后可以使用pm2 status查看状态: 在这里插入图片描述

PM命令

  • 停止并删除错误的进程
pm2 stop your-app
pm2 delete your-app
  • 查看 PM2 状态
pm2 status
  • 查看错误日志
pm2 logs your-app --err --lines 50
  • 查看所有日志
pm2 logs wps-app --lines 50
  • 查看进程详情
pm2 show your-app

全自动,解放双手

按照目前的部署方式,每次都有一步步手动操作,麻烦且容易出错,所以编写一个自动化脚本,保证不出错情况下解放双手!

  • 创建部署应用相关配置文件deploy-config.json
    {
      "server": {
        "host": "192.168.41.11",
        "user": "root",
        "port": 22,
        "deployPath": "/opt/module/wps/test3"
      },
      "app": {
        "name": "your-app",
        "port": 3889,
        "pm2Name": "your-app"
      },
      "build": {
        "command": "npm run build",
        "outputDir": ".next"
      }
    }
    
  • 打包部署脚本 该脚本是powershell脚本,在windows环境运行,xxx.ps1,脚本执行需要依赖deploy-config.json
    # ====================================
    # Next.js Standalone Auto Deploy Script (Windows PowerShell)
    # ====================================
    
    # Stop on error
    $ErrorActionPreference = "Stop"
    
    # Read configuration file
    Write-Host "=====================================" -ForegroundColor Cyan
    Write-Host "  Next.js Auto Deploy Script" -ForegroundColor Cyan
    Write-Host "=====================================" -ForegroundColor Cyan
    Write-Host ""
    
    if (-not (Test-Path "deploy-config.json")) {
        Write-Host "Error: deploy-config.json not found" -ForegroundColor Red
        Write-Host "Please create configuration file first" -ForegroundColor Yellow
        exit 1
    }
    
    $config = Get-Content "deploy-config.json" | ConvertFrom-Json
    
    Write-Host "Deploy Configuration:" -ForegroundColor Green
    Write-Host "  Server: $($config.server.user)@$($config.server.host)" -ForegroundColor White
    Write-Host "  Path: $($config.server.deployPath)" -ForegroundColor White
    Write-Host "  Port: $($config.app.port)" -ForegroundColor White
    Write-Host ""
    
    # Confirm deployment
    $confirm = Read-Host "Continue with deployment? (y/n)"
    if ($confirm -ne "y" -and $confirm -ne "Y") {
        Write-Host "Deployment cancelled" -ForegroundColor Yellow
        exit 0
    }
    
    Write-Host ""
    
    # ====================================
    # Step 1: Build Project
    # ====================================
    Write-Host "=====================================" -ForegroundColor Cyan
    Write-Host "Step 1/5: Build Project" -ForegroundColor Cyan
    Write-Host "=====================================" -ForegroundColor Cyan
    
    try {
        Write-Host "Executing: $($config.build.command)" -ForegroundColor Yellow
        Invoke-Expression $config.build.command
        
        if ($LASTEXITCODE -ne 0) {
            throw "Build failed"
        }
        
        Write-Host "Build successful" -ForegroundColor Green
        Write-Host ""
    } catch {
        Write-Host "Build failed: $_" -ForegroundColor Red
        exit 1
    }
    
    # ====================================
    # Step 2: Package Files
    # ====================================
    Write-Host "=====================================" -ForegroundColor Cyan
    Write-Host "Step 2/5: Package Files" -ForegroundColor Cyan
    Write-Host "=====================================" -ForegroundColor Cyan
    
    try {
        # Create temp directory
        $tempDir = "deploy-temp"
        if (Test-Path $tempDir) {
            Remove-Item -Recurse -Force $tempDir
        }
        New-Item -ItemType Directory -Path $tempDir | Out-Null
        
        # Copy standalone files
        Write-Host "Copying standalone files..." -ForegroundColor Yellow
        $standalonePath = ".next/standalone"
        if (-not (Test-Path $standalonePath)) {
            throw "Cannot find .next/standalone directory. Please ensure next.config.ts has output: 'standalone'"
        }
        Copy-Item -Path "$standalonePath/*" -Destination $tempDir -Recurse -Force
        
        # Copy static files
        Write-Host "Copying static files..." -ForegroundColor Yellow
        $staticPath = ".next/static"
        if (Test-Path $staticPath) {
            $nextDir = Join-Path $tempDir ".next"
            if (-not (Test-Path $nextDir)) {
                New-Item -ItemType Directory -Path $nextDir | Out-Null
            }
            Copy-Item -Path $staticPath -Destination $nextDir -Recurse -Force
        }
        
        # Copy public files
        Write-Host "Copying public files..." -ForegroundColor Yellow
        if (Test-Path "public") {
            Copy-Item -Path "public" -Destination $tempDir -Recurse -Force
        }
        
        # Create archive
        Write-Host "Creating deploy.zip..." -ForegroundColor Yellow
        $zipPath = "deploy.zip"
        if (Test-Path $zipPath) {
            Remove-Item -Force $zipPath
        }
        Compress-Archive -Path "$tempDir/*" -DestinationPath $zipPath -Force
        
        $zipSize = [math]::Round((Get-Item $zipPath).Length / 1MB, 2)
        Write-Host "Package successful (Size: $zipSize MB)" -ForegroundColor Green
        Write-Host ""
        
        # Cleanup temp directory
        Remove-Item -Recurse -Force $tempDir
    } catch {
        Write-Host "Package failed: $_" -ForegroundColor Red
        if (Test-Path $tempDir) {
            Remove-Item -Recurse -Force $tempDir
        }
        exit 1
    }
    
    # ====================================
    # Step 3: Upload to Server
    # ====================================
    Write-Host "=====================================" -ForegroundColor Cyan
    Write-Host "Step 3/5: Upload to Server" -ForegroundColor Cyan
    Write-Host "=====================================" -ForegroundColor Cyan
    
    # Check if scp is available
    $scpTest = Get-Command scp -ErrorAction SilentlyContinue
    if (-not $scpTest) {
        Write-Host "Error: scp command not found. Please install OpenSSH client." -ForegroundColor Red
        exit 1
    }
    
    $uploadSuccess = $false
    $maxRetries = 3
    $retryCount = 0
    
    while (-not $uploadSuccess -and $retryCount -lt $maxRetries) {
        try {
            if ($retryCount -gt 0) {
                Write-Host ""
                Write-Host "Retry attempt $retryCount of $($maxRetries - 1)..." -ForegroundColor Yellow
            }
            
            Write-Host "Uploading to $($config.server.host)..." -ForegroundColor Yellow
            
            $scpTarget = "$($config.server.user)@$($config.server.host):$($config.server.deployPath)/deploy.zip"
            
            # Upload file
            & scp -P $config.server.port deploy.zip $scpTarget
            
            if ($LASTEXITCODE -eq 0) {
                $uploadSuccess = $true
                Write-Host "Upload successful" -ForegroundColor Green
                Write-Host ""
            } else {
                $retryCount++
                if ($retryCount -lt $maxRetries) {
                    Write-Host "Upload failed. Please check password and try again." -ForegroundColor Yellow
                }
            }
        } catch {
            $retryCount++
            if ($retryCount -lt $maxRetries) {
                Write-Host "Upload failed: $_" -ForegroundColor Red
                Write-Host "Please try again..." -ForegroundColor Yellow
            }
        }
    }
    
    if (-not $uploadSuccess) {
        Write-Host ""
        Write-Host "Upload failed after $maxRetries attempts" -ForegroundColor Red
        Write-Host ""
        Write-Host "Tips:" -ForegroundColor Yellow
        Write-Host "  1. Check if server address and port are correct" -ForegroundColor Yellow
        Write-Host "  2. Verify your password" -ForegroundColor Yellow
        Write-Host "  3. Consider setting up SSH key authentication" -ForegroundColor Yellow
        exit 1
    }
    
    # ====================================
    # Step 4: Extract Files
    # ====================================
    Write-Host "=====================================" -ForegroundColor Cyan
    Write-Host "Step 4/5: Extract Files" -ForegroundColor Cyan
    Write-Host "=====================================" -ForegroundColor Cyan
    
    Write-Host "Extracting files on server..." -ForegroundColor Yellow
    
    $sshTarget = "$($config.server.user)@$($config.server.host)"
    $deployPath = $config.server.deployPath
    $pm2Name = $config.app.pm2Name
    $port = $config.app.port
    
    # Step 1: Extract files
    & ssh -p $config.server.port $sshTarget "cd $deployPath && unzip -o deploy.zip && rm deploy.zip"
    Write-Host "Files extracted" -ForegroundColor Green
    Write-Host ""
    
    # ====================================
    # Step 5: Start Application
    # ====================================
    Write-Host "=====================================" -ForegroundColor Cyan
    Write-Host "Step 5/5: Start Application" -ForegroundColor Cyan
    Write-Host "=====================================" -ForegroundColor Cyan
    
    Write-Host "Starting application with PM2..." -ForegroundColor Yellow
    
    # Step 2: Stop old process
    & ssh -p $config.server.port $sshTarget "pm2 stop $pm2Name 2>/dev/null; pm2 delete $pm2Name 2>/dev/null; true"
    
    # Step 3: Start new process with HOSTNAME=0.0.0.0 to fix IPv6 listening issue
    & ssh -p $config.server.port $sshTarget "cd $deployPath && HOSTNAME=0.0.0.0 PORT=$port pm2 start server.js --name $pm2Name"
    
    # Step 4: Save PM2 config
    & ssh -p $config.server.port $sshTarget "pm2 save"
    
    Write-Host ""
    Write-Host "Verifying application status..." -ForegroundColor Yellow
    
    # Verify deployment
    & ssh -p $config.server.port $sshTarget "pm2 list"
    
    Write-Host ""
    Write-Host "Application started" -ForegroundColor Green
    Write-Host ""
    
    # ====================================
    # Complete
    # ====================================
    Write-Host "=====================================" -ForegroundColor Green
    Write-Host "  Deployment Successful!" -ForegroundColor Green
    Write-Host "=====================================" -ForegroundColor Green
    Write-Host ""
    Write-Host "Application Info:" -ForegroundColor Cyan
    Write-Host "  URL: http://$($config.server.host):$($config.app.port)" -ForegroundColor White
    Write-Host "  PM2 Name: $($config.app.pm2Name)" -ForegroundColor White
    Write-Host ""
    Write-Host "Common Commands:" -ForegroundColor Cyan
    Write-Host "  View logs: ssh $($config.server.user)@$($config.server.host) 'pm2 logs $($config.app.pm2Name)'" -ForegroundColor White
    Write-Host "  View status: ssh $($config.server.user)@$($config.server.host) 'pm2 status'" -ForegroundColor White
    Write-Host "  Restart app: ssh $($config.server.user)@$($config.server.host) 'pm2 restart $($config.app.pm2Name)'" -ForegroundColor White
    Write-Host ""
    
    # Cleanup local files
    if (Test-Path "deploy.zip") {
        Remove-Item -Force "deploy.zip"
    }
    
    Write-Host "Press any key to exit..."
    $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')
    
    

圆满,大功告成!

从零搭建小程序首页:新手也能看懂的结构解析与实战指南

作者 ouma_syu
2025年11月5日 14:21

一、从全局到页面:小程序是怎么组织起来的?

小程序和普通网页不太一样,它有一套自己的结构规则。

你会看到三个关键的“全局文件”:

文件 用来做什么
app.json 小程序的配置中心 —— 你所有页面、导航栏、tabBar 都在这里统一管理
app.js 应用入口 —— 小程序启动时做的事都在这里(比如登录、缓存)
app.wxss 全局样式文件 —— 所有页面都会继承它

理解了这部分,你就知道“小程序不是一个页面,而是一个完整 App”。


二、页面是如何工作的?(Page 架构)

每个页面都由四个文件组成:
wxml(结构) + wxss(样式) + js(逻辑) + json(配置)

你写的逻辑主要集中在:

Page({
  data: { /** 页面数据 */ },
  methods: { /** 事件方法 */ }
})

只要 data 改变,界面就会自动更新 —— 这就是小程序的数据驱动

对于新手来说,它的好处是:

  • 写界面 → 只关心数据
  • 事件逻辑 → 写在 Page 里
  • 页面跳转 → 用 wx.navigateTo

看起来简单,其实内部做了很多“自动更新”的事情。


三、首页长什么样?我们拆成几个模块就不难了

一个典型的小程序首页通常包含这些部分:

1. 搜索框(入口)

你使用了 Vant 的 van-search,样式统一、免手搓组件,对新手非常友好。

2. 分类网格(8 个菜单)

使用 wx:for 循环,通过数据自动渲染:

<block wx:for="{{menus}}">
  <navigator>
    <image src="{{item.typePic}}" />
    <view>{{item.typeName}}</view>
  </navigator>
</block>

这种结构非常适合新手入门:
数据驱动,改数据比改结构简单得多。

3. Swiper 轮播图

用于展示广告或主推内容。

4. 文章列表(信息流)

这部分是首页内容的主体,通过图片 + 文案组合,让用户快速浏览你的小程序内容价值。


四、样式如何适配?rpx 是你必须掌握的基础能力

小程序中的 rpx 是“响应式单位”:
不管手机宽窄,小程序都会帮你等比例适配

也就是说:

  • 设计图标注多少,你就写多少
  • 不会在不同手机上变形
  • 新手不用考虑复杂的适配方案

例如:

.article-column__img {
  width: 100%;
  height: 290rpx;
}

不用想太多,就是等比例缩放。


五、事件与交互(非常关键)

你在搜索框外层包了一个 bindtap,点击时跳转到搜索页:

<view bindtap="toSearch">
  <van-search />
</view>

对应 Page 里的方法:

toSearch(){
  wx.navigateTo({
    url:'/pages/search/search'
  })
}

对新手来说,这里要理解的重点只有一个:

  • wxml 里绑定事件
  • js 里写方法
  • 页面就能跳过去了

总结:从结构到交互,让你真正理解一个小程序首页是怎么跑起来的

本文从小程序的整体结构开始,带你一步步拆解首页是如何“跑”起来的。我们先了解了全局配置如何决定应用的基本框架,再看到页面文件如何各司其职:

  • WXML 负责结构
  • WXSS 负责样式
  • JS 管理数据与事件
  • JSON 做页面级配置

接着,我们将这些知识落地到真实的首页场景里,包括轮播图、分类宫格和内容信息流等常见模块。你不仅看到“写出来是什么样”,也理解了“为什么这样写”——比如为什么要数据驱动页面、为什么要用 wx:for,以及为什么组件库能提升效率。

对于一个正在学习小程序的新手来说,这些能力其实已经足够让你搭建出一个可上线、可扩展、也容易维护的首页。更重要的是,你现在能把“看别人代码”变成“自己能写出来”,这是学习前端道路上非常关键的一步。

如果你希望进一步提升,我们可以继续深入:做搜索页、做详情页、做全局状态管理、做接口数据联调……一步一步,你就能构建出一个完整的小程序产品。

深入解析 JavaScript 引擎与作用域机制

2025年11月5日 13:47

JavaScript 引擎概述

JavaScript 作为一门流行的编程语言,其执行依赖于 JavaScript 引擎。常见的 JavaScript 引擎主要有两类:

  1. 浏览器内置引擎:如 Chrome 的 V8 引擎、Firefox 的 SpiderMonkey 等
  2. Node.js 环境:基于 V8 引擎构建的服务器端 JavaScript 运行环境

以 V8 引擎为例,它本质上是一段庞大的程序,能够读取并执行 JavaScript 代码,是 JavaScript 能够在不同环境中运行的核心动力。

JavaScript 代码的执行过程

JavaScript 代码的执行并非简单的逐行读取运行,而是会先经过编译阶段(代码梳理过程),主要包含三个步骤:

  1. 分词 / 词法分析:将源代码分解成有意义的词法单元(如变量名、关键字、运算符等)
  2. 解析 / 语法分析:将词法单元转换为抽象语法树(AST),验证代码语法的正确性,识别有效标识符
  3. 代码生成:将 AST 转换为可执行的机器码

函数的本质与作用

在 JavaScript 中,函数是代码组织的重要方式,形如function fn() {}的结构就是一个函数体。函数存在的核心意义在于:

  • 封装特定逻辑代码块
  • 实现代码的复用与模块化
  • 延迟代码执行(只有调用函数时,内部代码才会运行)

作用域详解

作用域决定了变量和函数的可访问范围,JavaScript 中主要有三种作用域类型:

1. 全局作用域

在所有函数和代码块之外声明的变量,具有全局作用域,在程序的任何地方都能访问。

2. 函数作用域

在函数内部声明的变量(包括函数参数)属于函数作用域,仅在函数内部可访问。

var a = 10
function foo(b) { // 形参b属于foo函数作用域
    var a = 20 // 函数内部的a属于foo函数作用域
    function bar() {
        console.log(a + b)
    }
    bar()
}
foo(2) // 实参传递,输出22

3. 块级作用域

{}配合letconst声明的变量,形成块级作用域,变量仅在当前代码块内可访问。

{
    const a = 100 // 块级作用域内的常量
    var b = 200 // 函数作用域变量(穿透代码块)
}
console.log(b); // 输出200,var声明的变量可访问
// console.log(a); // 报错,a在块级作用域外不可访问

作用域查找规则

作用域遵循由内往外查找的原则,外层作用域不能访问内层作用域的变量。当在当前作用域找不到变量时,会向上级作用域查找,直到全局作用域。

let 关键字与暂时性死区

let关键字带来了块级作用域的特性,同时引入了 "暂时性死区"(TDZ)概念:

当一个{}代码块中存在let x声明时,在该代码块中访问x时:

  • 只能访问当前块内部声明的x
  • 在声明之前访问会触发错误(暂时性死区)
  • 无法访问外部作用域的同名变量
let a = 1
if(true){ 
    console.log(a); // 报错:Cannot access 'a' before initialization
    let a = 2 // 块级作用域内的声明,形成暂时性死区
}

变量声明的差异

var 声明的特性(函数作用域)

// 变量提升示例(scope/1.js)
var a
console.log(a) // 输出undefined
a = 1

// 代码块穿透示例(scope/4.js)
if(true){
    var a = 1
}
console.log(a); // 输出1,var声明穿透代码块

const和let 声明的特性(块级作用域,常量)

// scope/3.js

//let a = 1
//a = 2
//console.log(a);//a 为2,let 可以修改

const a = 3
a = 4 // 报错:Assignment to constant variable
console.log(a);//const 不可被修改

总结

理解 JavaScript 的引擎工作原理和作用域机制,是写出高质量代码的基础:

  • 代码执行前会经过编译阶段的词法分析、语法分析和代码生成
  • 作用域控制着变量的可见性和生命周期
  • 合理使用letconstvar,理解它们在作用域上的差异
  • 掌握作用域链的查找规则和暂时性死区特性,可有效避免常见的变量访问错误

##小练习

试着自己判断一下输出结果吧 D16297D9-5A60-49D2-9EFF-2D3BE0B41B83.png

4F30EFE6-45FA-4C2F-9D1C-5777870B9FC2.png

565893EB-E13C-4739-AFA2-C8ADF4592C25.png

85DB301D-AE67-4F93-860A-487E2FF75886.png

深入 V8 引擎:JavaScript 执行机制与作用域模型的底层逻辑解析

2025年11月5日 13:09

前言

在 Web 开发与后端服务的广阔领域中,JavaScript 凭借其跨平台特性成为无可替代的核心语言。而支撑这门语言高效运行的核心,正是 JavaScript 引擎 —— 它如同一位隐形的 “翻译官”,将人类可读的 JS 代码转化为计算机可执行的指令。其中,V8 引擎作为谷歌开源的高性能引擎,不仅驱动着 Chrome 浏览器的 JS 执行,更成为 Node.js 的底层动力,彻底打破了 JavaScript 仅能运行于浏览器的边界。从本质而言,V8 引擎本身就是一段经过极致优化的庞大函数集合,其核心使命便是精准 “读懂” JavaScript 语法规则,并高效执行代码逻辑。

一、JavaScript 执行的前置流程:从代码到指令的编译之旅

当 V8 引擎读取到 JavaScript 代码的瞬间,并不会立即执行。为了确保执行效率与语法正确性,它会先启动一套严谨的编译(梳理)流程,将原始代码逐步转化为可执行指令,这一过程如同建筑施工前的图纸设计,是后续高效执行的基础。

1. 分词 / 词法分析:拆解代码的 “原子单元”

词法分析阶段的核心任务是将连续的字符流拆解为一个个具有独立语义的 “词法单元”(Token)。这些单元是 JavaScript 语法的最小组成单位,包括关键字(如 var、function、let)、标识符(变量名、函数名)、运算符(+、=、&&)、字面量(数字、字符串)等。

例如对于代码var a; console.log(a);,词法分析后会拆解为:[var, a, ;, console, ., log, (, a, ), ;]这一步骤会忽略代码中的空格、换行等无意义分隔符,仅聚焦于具有语法意义的字符组合,为后续的语法分析铺平道路。

2. 解析 / 语法分析:构建抽象语法树(AST)

语法分析阶段会基于词法单元序列,依据 JavaScript 语法规则进行结构化验证与重组,最终生成抽象语法树(Abstract Syntax Tree,简称 AST)。AST 是对代码语法结构的结构化表示,它剔除了冗余的语法符号(如分号、括号),仅保留代码的逻辑层次与语义关联,同时筛选出所有有效标识符(变量名、函数名等)。

var a; console.log(a);为例,其对应的 AST 会清晰呈现:

  • 顶级包含两个语句:变量声明语句(声明标识符 a)和函数调用语句(调用 console 对象的 log 方法,参数为标识符 a)
  • 每个语句的层级关系、从属对象均被明确标注

AST 的生成是语法校验的关键环节:如果代码存在语法错误(如缺少括号、关键字拼写错误),引擎会在此阶段抛出语法错误,终止后续流程。同时,AST 也是连接代码与最终可执行指令的核心桥梁。

3. 生成代码:从 AST 到可执行指令

在获得合法的 AST 后,V8 引擎会将其转化为计算机可直接执行的机器码(或中间代码,再通过即时编译 JIT 优化为机器码)。早期的 JS 引擎采用解释执行模式(逐行解析、逐行执行),效率较低;而 V8 引擎通过 JIT 编译技术,将频繁执行的代码(热点代码)提前编译为优化后的机器码,大幅提升执行效率。

至此,从原始代码到编译执行的完整链路正式完成,这一过程看似复杂,却在 V8 引擎中以微秒级的速度持续运转,支撑着亿万 Web 应用与 Node.js 服务的稳定运行。

二、函数:JavaScript 的逻辑封装与执行载体

在 JavaScript 中,函数是代码逻辑的核心封装单元,形如function foo() {}的结构,本质上是将一段具有特定功能的代码块 “包裹” 起来,形成一个可复用、可调用的独立模块。函数的存在,不仅让代码结构更清晰、复用性更强,更定义了 JavaScript 中 “何时执行” 的核心规则 —— 只有当函数被主动调用时,其内部包裹的代码才会进入执行流程。

函数的执行依赖于调用栈(Call Stack)的支撑:当函数被调用时,V8 引擎会为其创建一个独立的执行上下文(Execution Context),并压入调用栈;函数执行完毕后,该执行上下文会被弹出栈,释放资源。这种机制确保了函数执行的顺序性与独立性,同时也为后续作用域的划分奠定了基础。

值得注意的是,函数的参数在执行时会被视为该函数内部的有效标识符,与函数内部声明的变量享有同等的作用域权限,这一特性在作用域查找规则中有着重要体现。

三、作用域:JavaScript 的变量访问规则与边界划分

作用域是 JavaScript 中定义变量可访问范围的核心机制,它如同一个 “变量容器”,规定了不同位置的代码对变量的访问权限。合理的作用域划分不仅能避免变量命名冲突,更能保障代码的安全性与可维护性。JavaScript 的作用域主要分为三大类,且遵循 “由内向外查找” 的核心访问规则。

1. 全局作用域:代码的 “公共区域”

全局作用域是最顶层的作用域,在浏览器环境中,全局作用域由window对象(或globalThis)代表;在 Node.js 环境中,则由global对象(或globalThis)代表。所有未在函数或块级结构中声明的变量(或通过var声明的顶层变量),都会成为全局作用域的属性,可在代码的任何位置被访问。

例如:

var globalVar = "全局变量"; 
function foo() { 
console.log(globalVar); // 可访问全局作用域的变量,输出"全局变量" 
} 
foo(); 
console.log(globalVar); // 直接访问全局变量,输出"全局变量"

image.png 全局作用域的生命周期与应用程序一致,但其缺点也十分明显:过多的全局变量会导致命名冲突,增加代码维护难度,因此在实际开发中应尽量减少全局变量的使用。

2. 函数作用域:函数内部的 “私有空间”

函数作用域是指函数内部声明的变量仅能在该函数内部访问,外部作用域无法直接访问。当函数被创建时,其内部便形成了一个独立的作用域,函数的参数也属于该作用域的有效标识符。

例如:

function foo() { 
var funcVar = "函数内部变量"; console.log(funcVar); // 函数内部可访问,输出"函数内部变量" 
} 
foo(); 
console.log(funcVar); // 外部作用域无法访问,抛出ReferenceError

image.png

函数作用域的隔离性使得函数内部的变量不会与外部变量冲突,同时也保障了函数内部逻辑的私密性。作用域的查找规则在此体现为:函数内部访问变量时,会先在自身作用域中查找;若未找到,则向上层作用域(可能是另一个函数作用域或全局作用域)查找,直至找到目标变量或抵达全局作用域(仍未找到则抛出错误)。

3. 块级作用域:{} 包裹的 “局部范围”

块级作用域是 ES6(ECMAScript 2015)引入的新特性,由letconst关键字与{}语法配合创建。凡是被{}包裹的代码块(如if语句、for循环、普通代码块),若内部通过letconst声明变量,则这些变量的作用域被限制在该代码块内部,外部无法访问。

例如:

if (true) { 
let blockVar = "块级变量"; 
const blockConst = "块级常量";
console.log(blockVar); // 块内部可访问,输出"块级变量"
} 
console.log(blockVar); // 外部无法访问,抛出ReferenceError 
console.log(blockConst); // 外部无法访问,抛出ReferenceError

块级作用域的出现,弥补了var声明变量无块级隔离的缺陷(var声明的变量仅受函数作用域和全局作用域限制),进一步提升了代码的安全性与灵活性。

核心规则:作用域的查找顺序与暂时性死区

1. 查找顺序:由内向外,不可逆

JavaScript 的作用域查找遵循 “由内向外” 的原则:当代码在某个作用域中访问变量时,会优先在当前作用域中查找该变量;若未找到,则向上一层父作用域查找;依次类推,直至找到目标变量或抵达全局作用域(若全局作用域仍未找到,则抛出ReferenceError)。

需要特别注意的是:外层作用域永远无法访问内层作用域的变量。这种单向查找机制确保了内层作用域的变量不会被外层随意修改,保障了代码的封装性。

例如:

var outerVar = "外层变量"; 
function outer() { 
var middleVar = "中层变量";
function inner() { 
var innerVar = "内层变量"; 
console.log(innerVar); // 内层作用域查找,输出"内层变量" 
console.log(middleVar); // 向上查找中层作用域,输出"中层变量"
console.log(outerVar); // 向上查找全局作用域,输出"外层变量"
} 
inner(); 
console.log(innerVar); // 外层无法访问内层变量,抛出ReferenceError }
outer();

2. 暂时性死区:let/const 的 “作用域绑定” 特性

当一个{}代码块中存在let xconst x声明时,会触发 JavaScript 的 “暂时性死区”(Temporal Dead Zone,简称 TDZ)规则。其核心表现为:在该{}代码块内部,任何访问x的操作,都只能指向块内部通过let/const声明的x;即使块内部的x声明在访问之后,也不会向上层作用域查找x,而是直接抛出ReferenceError

简单来说,暂时性死区本质上是let/const与块级作用域的强绑定:块级作用域会 “提前锁定”let/const声明的标识符,在该标识符被正式声明前,其所在的块级作用域内无法通过任何方式访问该标识符(包括上层作用域的同名标识符)。

例如:

var x = "全局x"; 
if (true) { 
console.log(x); // 此处处于x的暂时性死区,抛出ReferenceError 
let x = "块级x"; // 正式声明块级作用域的x 
}

image.png 再如:

let x = "外部x"; 
{ 
let x = x; // 左侧x处于暂时性死区,右侧x无法访问外部x,抛出ReferenceError
}

暂时性死区的设计,旨在避免var声明变量时的 “变量提升” 带来的意外问题(var声明的变量会被提升至作用域顶部,未赋值时为undefined),强制开发者按照 “先声明、后使用” 的逻辑编写代码,提升代码的可读性与稳定性。

总结

JavaScript 的执行与作用域机制,是理解这门语言核心特性的关键。V8 引擎通过 “分词 - 解析 - 生成代码” 的编译流程,为 JS 代码的高效执行提供了底层支撑;函数作为逻辑封装单元,定义了代码的执行时机与独立上下文;而作用域(全局、函数、块级)则通过 “由内向外查找” 与 “暂时性死区” 等规则,明确了变量的访问边界与权限。

深入理解这些底层逻辑,不仅能帮助我们规避开发中的常见错误(如变量命名冲突、作用域污染、暂时性死区报错),更能让我们写出更高效、更安全、更具可维护性的 JavaScript 代码,为前端框架开发、Node.js 后端服务等高级应用场景奠定坚实基础。

新人第一篇,谢谢大家的支持

加http和https访问的网站不同?

2025年11月5日 13:06

一、一个生动的比喻:寄明信片 vs 寄加密信件

为了更好地理解,我们可以用一个简单的比喻:

  • HTTP:就像寄送一张明信片。你写在明信片上的所有内容(账号、密码、聊天记录),从你手上送到邮局,再分拣、运输,最终到达收件人手中,这整个过程中的任何人(快递员、分拣员)都可以轻易地看到上面的内容。信息是完全透明的。

  • HTTPS:就像寄送一封用密码锁锁住的机密文件。首先,你和收件人先通过安全方式确认了彼此的身份(确保他不是骗子),然后商定了一个只有你们俩知道的复杂密码。之后,你把文件放进保险箱,用这个密码锁好再寄出。即使中途被人截获,他们也无法打开箱子看到里面的内容。信息是加密的、安全的。

二、核心区别在哪里?

1. 安全性:天壤之别 这是最根本、最重要的区别。

  • HTTP明文传输。你在网站上输入的任何信息,包括密码、银行卡号、搜索记录、身份证号等,在网络上都是以未加密的形式“裸奔”。黑客很容易在中间环节截获并窃取这些信息。
  • HTTPS加密传输。它通过SSL/TLS协议对你浏览器和网站服务器之间的所有通信数据进行加密。即使数据被截获,黑客看到的也只是一堆毫无意义的乱码,无法破解。

SSL证书申请入口

直接访问JoySSL,注册一个个账号记得填写注册码230931获取大额优惠。

7.22上午.jpg

2. 数据完整性:是否被“调包”

  • 使用 HTTP,传输的数据很容易被中间人篡改。比如,你本来想下载一个正版软件,但可能被恶意替换成带病毒的版本。或者,你看的网页被强行插入了垃圾广告。
  • 使用 HTTPS,数据在传输过程中一旦被篡改,加密连接会立刻中断,浏览器会向你发出警告,确保了数据的完整性和真实性

3. 身份验证:你访问的是“真网站”吗?

  • HTTP 无法验证网站的真实身份。你访问的 www.你的银行.com 可能是一个高仿的钓鱼网站,专门用来套取你的账号密码。
  • HTTPS 要求网站必须从一个全球公认的权威机构获取 “SSL证书” 。当你的浏览器看到这个证书时,就能确认“对,这就是我要访问的那个官方网站,不是假冒的”。这为你验证了网站的身份。

三、如何识别与选择?

现代浏览器已经为我们做了非常直观的提示:

  • 当你访问 HTTPS 网站时:

    • 地址栏开头会显示一个小锁图标 🟢。
    • 网址以 https:// 开头(浏览器有时会隐藏,但本质不变)。
    • 整个体验是安全、受保护的。
  • 当你访问 HTTP 网站时:

    • 地址栏开头会明确标记 “不安全” 或显示一个感叹号图标 ⚠️。
    • 浏览器可能会在你输入密码等敏感信息时弹出警告。

总结

简单来说:

  • HTTP = 不安全。像是在大街上公开喊话,谁都能听见。
  • HTTPS = 安全。像是在保密房间里私密对话,外人无法偷听和干扰。

结论:在今天,请务必养成习惯,尤其是在进行登录、支付或填写任何个人敏感信息时,只信任和使用带有“小锁”标志和 https:// 开头的网站。 为了你的隐私和财产安全,这一个小小的“s”至关重要。

简易横向导航制作指南

2025年11月5日 12:46

简易横向导航

概念:

横向导航就是网页顶部水平排列的菜单栏,用户通过它可以在网站的不同页面间跳转。就像书店里的指示牌,告诉你哪个区域有什么书籍。

基本制作方法

HTML+CSS

HTML 结构

<nav class="main-nav">
    <ul>
        <li><a href="#home">首页</a></li>
        <li><a href="#about">关于</a></li>
        <li><a href="#services">服务</a></li>
        <li><a href="#contact">联系</a></li>
    </ul>
</nav>

CSS 样式

.main-nav {
    background: #333;
    padding: 0 20px;
}

.main-nav ul {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
}

.main-nav li {
    margin: 0;
}

.main-nav a {
    display: block;
    color: white;
    text-decoration: none;
    padding: 15px 20px;
    transition: background 0.3s;
}

.main-nav a:hover {
    background: #555;
}

完整代码示例:

<!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;
        }

        body {
            font-family: Arial, sans-serif;
            line-height: 1.6;
        }

        /* 导航样式 */
        .main-nav {
            background: #2c3e50;
            position: sticky;
            top: 0;
        }

        .nav-container {
            max-width: 1200px;
            margin: 0 auto;
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 0 20px;
        }

        .logo {
            color: white;
            font-size: 24px;
            font-weight: bold;
            text-decoration: none;
        }

        .nav-menu {
            display: flex;
            list-style: none;
        }

        .nav-menu li {
            margin: 0;
        }

        .nav-menu a {
            color: white;
            text-decoration: none;
            padding: 20px 25px;
            display: block;
            transition: all 0.3s;
        }

        .nav-menu a:hover {
            background: #34495e;
        }

        .nav-menu a.active {
            background: #3498db;
        }

        /* 页面内容 */
        .content {
            max-width: 1200px;
            margin: 0 auto;
            padding: 40px 20px;
        }

        section {
            margin-bottom: 60px;
            min-height: 400px;
        }

        h1 {
            margin-bottom: 20px;
            color: #2c3e50;
        }
    </style>
</head>
<body>
    <nav class="main-nav">
        <div class="nav-container">
            <a href="#" class="logo">我的网站</a>
            <ul class="nav-menu">
                <li><a href="#home" class="active">首页</a></li>
                <li><a href="#about">关于我们</a></li>
                <li><a href="#services">服务项目</a></li>
                <li><a href="#products">产品中心</a></li>
                <li><a href="#contact">联系我们</a></li>
            </ul>
        </div>
    </nav>

    <main class="content">
        <section id="home">
            <h1>欢迎来到首页</h1>
            <p>这是一个简单的横向导航示例。</p>
        </section>
        
        <section id="about">
            <h1>关于我们</h1>
            <p>这里可以介绍公司或个人的相关信息。</p>
        </section>
        
        <section id="services">
            <h1>服务项目</h1>
            <p>展示提供的各种服务内容。</p>
        </section>
        
        <section id="products">
            <h1>产品中心</h1>
            <p>介绍主要产品和特色。</p>
        </section>
        
        <section id="contact">
            <h1>联系我们</h1>
            <p>提供联系方式和其他信息。</p>
        </section>
    </main>
</body>
</html>
运行结果如下:

屏幕截图 2025-11-04 215319.png

常用 CSS 属性

属性 作用 常用值
display 显示方式 flex
justify-content 水平对齐 space-between
list-style 列表样式 none
text-decoration 文字装饰 none
padding 内边距 10px 20px

移动端适配

@media (max-width: 768px) {
    .nav-menu {
        flex-direction: column;
        width: 100%;
    }
    
    .nav-menu a {
        padding: 15px;
        text-align: center;
        border-bottom: 1px solid #555;
    }
}

注意事项:

  1. 保持简洁:导航项不要太多,5-7个最合适
  2. 明确标识:让用户清楚知道当前所在位置
  3. 响应式设计:考虑手机等小屏幕设备的显示
  4. 颜色对比:确保文字和背景颜色有足够对比度
  5. 加载速度:避免使用过大的图片或复杂效果

总结

横向导航是网站的基础组件,好的导航应该具备以下几点:

  1. 结构清晰简单
  2. 视觉效果明确
  3. 操作方便直观
  4. 适应各种设备

深入理解CSS定位叠放次序:z-index完全指南

2025年11月5日 12:45

在网页布局中,当多个元素重叠时,如何控制它们的显示顺序?z-index就是解决这个问题的关键属性!

z-index

概念:

在CSS中,当多个定位元素(position不是static)在页面上重叠时,浏览器需要决定哪个元素显示在前面,哪个在后面。这个前后顺序就是叠放次序,而z-index属性正是用来控制这个顺序的魔法工具。

基本语法:

selector {
    z-index: auto | <integer> | inherit;
}

属性值说明:

描述 示例
auto 默认值,元素不会建立新的堆叠上下文 z-index: auto;
<integer> 整数值,可以是正数、负数或0 z-index: 1;
inherit 继承父元素的z-index值 z-index: inherit;

示例1:基础z-index使用

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>z-index基础示例</title>
    <style>
        .container {
            position: relative;
            width: 300px;
            height: 300px;
            margin: 50px auto;
            border: 2px solid #333;
        }

        .box {
            position: absolute;
            width: 200px;
            height: 200px;
            opacity: 0.8;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 20px;
            font-weight: bold;
            color: white;
        }

        .box1 {
            background-color: #ff6b6b;
            top: 50px;
            left: 50px;
            z-index: 1;
        }

        .box2 {
            background-color: #4ecdc4;
            top: 100px;
            left: 100px;
            z-index: 2;
        }

        .box3 {
            background-color: #45b7d1;
            top: 150px;
            left: 150px;
            z-index: 3;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="box box1">z-index: 1</div>
        <div class="box box2">z-index: 2</div>
        <div class="box box3">z-index: 3</div>
    </div>
</body>
</html>
运行结果如下:

屏幕截图 2025-11-04 194025.png

示例2:负z-index的使用

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>负z-index示例</title>
    <style>
        .background-container {
            position: relative;
            width: 400px;
            height: 400px;
            margin: 50px auto;
            background: linear-gradient(45deg, #667eea, #764ba2);
            border-radius: 10px;
            overflow: hidden;
        }

        .background-text {
            position: absolute;
            font-size: 120px;
            font-weight: 900;
            color: rgba(255, 255, 255, 0.1);
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            z-index: 1;
        }

        .content {
            position: relative;
            z-index: 2;
            padding: 40px;
            color: white;
            text-align: center;
        }

        .background-pattern {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: repeating-linear-gradient(
                45deg,
                transparent,
                transparent 10px,
                rgba(255, 255, 255, 0.05) 10px,
                rgba(255, 255, 255, 0.05) 20px
            );
            z-index: -1;
        }
    </style>
</head>
<body>
    <div class="background-container">
        <div class="background-text">CSS</div>
        <div class="background-pattern"></div>
        <div class="content">
            <h2>负z-index效果</h2>
            <p>背景图案使用z-index: -1,位于内容后面</p>
        </div>
    </div>
</body>
</html>
运行结果如下:

屏幕截图 2025-11-04 194239.png

示例3:堆叠上下文的影响

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-wide, initial-scale=1.0">
    <title>堆叠上下文示例</title>
    <style>
        .outer-container {
            position: relative;
            width: 500px;
            height: 400px;
            margin: 50px auto;
            background-color: #f8f9fa;
            border: 2px solid #dee2e6;
            padding: 20px;
        }

        .parent {
            position: relative;
            width: 400px;
            height: 300px;
            background-color: #e9ecef;
            padding: 20px;
            /* 创建新的堆叠上下文 */
            z-index: 1;
        }

        .child-high {
            position: absolute;
            top: 50px;
            left: 50px;
            width: 200px;
            height: 200px;
            background-color: #ff6b6b;
            z-index: 100;
        }

        .sibling {
            position: absolute;
            top: 100px;
            left: 100px;
            width: 200px;
            height: 200px;
            background-color: #4ecdc4;
            z-index: 2;
        }

        .outside-element {
            position: absolute;
            top: 150px;
            left: 150px;
            width: 200px;
            height: 200px;
            background-color: #45b7d1;
            z-index: 3;
        }
    </style>
</head>
<body>
    <div class="outer-container">
        <div class="parent">
            <div class="child-high">子元素 z-index: 100</div>
        </div>
        <div class="sibling">兄弟元素 z-index: 2</div>
        <div class="outside-element">外部元素 z-index: 3</div>
    </div>
</body>
</html>
运行结果如下:

屏幕截图 2025-11-04 194423.png

重要注意事项

1. 定位要求

z-index只对定位元素生效,包括:

  1. position: relative
  2. position: absolute
  3. position: fixed
  4. position: sticky
  5. 对于position: static(默认值)的元素,z-index无效。

2. 堆叠上下文创建

以下属性会创建新的堆叠上下文:

  1. position: relative/absolute/fixed/sticky z-index 不为 auto
  2. position: fixed(始终创建)
  3. opacity 值小于 1
  4. transform 值不为 none
  5. filter 值不为 none

3. 默认堆叠顺序

z-index未设置或相同时,元素按以下顺序堆叠(从后到前):

  1. 根元素的背景和边框
  2. 普通流中非定位块级元素(按HTML顺序)
  3. 普通流中非定位行内元素
  4. 定位元素(按HTML顺序)

4. 堆叠上下文的限制

  • 子元素的z-index只在父元素的堆叠上下文中有效,无法与父堆叠上下文外的元素比较。

5. 整数值比较

  1. z-index比较的是整数值大小,不是十进制比较
  2. z-index: 5 会在 z-index: 4 前面
  3. z-index: 10 会在 z-index: 9 前面

总结

掌握z-index的关键要点:

  1. 定位是前提:确保元素设置了非staticposition
  2. 理解三维概念:z-index控制的是垂直于屏幕的方向
  3. 注意堆叠上下文:父元素的堆叠上下文会影响子元素的显示
  4. 合理规划数值:使用有间隔的数值便于后续调整
  5. 避免过度使用:复杂的z-index结构会增加维护难度

UniappX不会运行到鸿蒙?超超超保姆级鸿蒙开发生成证书以及配置证书步骤

2025年11月5日 12:17

前言

记录一下UniappX运行到鸿蒙的全过程,超超超细生成证书以及配置证书步骤

image.png

1.开发环境要求

  • 注册鸿蒙开发者账号,地址

  • HBuilderX 4.24+ 下载地址

  • DevEco Studio 下载地址

    • HBuilderX 4.24+ 要求 DevEco Studio 5.0.3.400+
    • HBuilderX 4.31+ 要求 DevEco Studio 5.0.3.800+。
    • HBuilderX 4.61+ 针对 uni-app x 项目要求 DevEco Studio 5.0.7.100+。
    • uni-app 项目要求鸿蒙系统版本 API 12 以上,uni-app x 项目要求鸿蒙系统版本 API 14 以上(DevEco Studio 有内置鸿蒙模拟器)
  • 点击下载 DevEco5.1.1Beta 版本,下载 API19 模拟器即可运行 uni-app 鸿蒙项目和元服务,除此之外的模拟器暂不支持

2.在 HBuilderX 中设置鸿蒙运行配置

HBuilderX 依赖于 DevEco Studio 里面带的鸿蒙工具链,所以需要电脑已经安装了符合版本要求的 DevEco Studio。

打开 HBuilderX,点击上方菜单 - 工具 - 设置,再点击 运行配置,在鸿蒙运行配置中设置 DevEco Studio 的安装路径

image.png

3.鸿蒙开发证书生成和配置

证书资料文件

需要设置的文件总共有三个:

  • 私钥库文件(.p12):里面保存着数字签名用的私钥,由开发者自己手动生成或者 DevEco Studio 自动申请调试证书时自动生成。有两层密码保护(私钥库密码和私钥密码),须妥善保管,尤其是发布证书的私钥一定不能泄露。
  • 证书文件(.cer):由华为签署颁发,用于证明开发者的身份。在 AppGallery Connect 手动申请并下载获得,或者在自动生成调试证书时自动下载。
  • 签名描述文件(.p7b):由华为颁发,里面包含了跟应用相关的签名信息,如包名、ACL 权限等,调试证书还包括可用于调试运行的设备列表。在 AppGallery Connect 手动添加并下载获得,或者在自动生成调试证书时自动下载。

image.png

话不多说,直接开始生成步骤:

1.第一步在 DevEco Studio 找到 Build 倒数第二个 Generate Key and CSR ,中文如下图所示:

1.png

2.点击后弹出这个生成密钥的小窗口如图所示:

2.png

3.点击新增证书New按钮后会弹出小窗口,设置生成的文件名和存放位置和密码,密码要记住,可以用txt文本记住哦

密码规则:密码必须包含至少8个字符,并且必须包含以下任意两种字符中的任意一种: 密码必须包含至少8个字符,并且必须包含以下任意两种字符中的任意一种:

  • 1.小写字母:a-z
  • 2.大写字母:A-Z
  • 3.数字:0-9
  • 4.特殊字符:"-!@#$%6^&"0-_=+0;";"<>?

3.png

4.png

4.设置Alias别名,完成后点击下一步

5.png

5.上面就设置好了p12证书的地址和名称,下面设置好csr证书的文件存放地址和名称(可以都放在同一个文件夹下并名字相同,方便使用)如图:

6.png

6.设置好csr证书的文件存放地址和名称后,点击完成,可以在文件夹查看是否生成文件,至此p12文件csr文件已经生成好了,如下图所示:

7.png

8.png

7.打开AppGallery Connect平台,需要登陆华为账号。登录成功后,点击 证书、APP ID和Proflie

image.png

8.点击新增证书

9.png

9.设置证书名称和证书类型,这里需要用到上面生成的csr文件

10.png

11.png

12.png

10.然后点击提交后并下载刚生成的 cer证书

13.png

11.点击开发与服务,选择自己的项目,没有项目的可以先添加项目,如图所示:

14.png

12.创建新项目(已有项目的可跳过)

image.png

13.点击添加应用,选择鸿蒙OS,点击创建APP ID

image.png

image.png

14.填写应用名称应用包名,HbuilderX运行的时候需要用到哦

image.png

15.先添加自己的设备,用 DevEco Studio模拟器的同学们,如果不知道UDID,可以直接在HbuliderX运行到鸿蒙模拟器,点击查看调试证书,即可看到模拟器UDID

16-1.png

16.添加好设备后,来创建Profile,点击添加

15.png

16.,Profile名称自己命名,类型选择调试,选择设备 里面全选,申请权限选择受限权限(根据个人需要)然后选择右上角添加

16.png

17.png

17.生成Profile后,点击下载保存到刚才其他证书的文件里,可查看文件夹 p7b文件

18.png

18.现在证书都已经生成好了,然后再去把对应项目关联一下证书,点击 开发与服务,选择对应的项目

19.png

19.然后找到SHA256证书/公钥指纹:点击添加添加公钥指纹 (HarmonyOS API 9及以上),添加刚才创建的证书即可

20.png

21.png

20.最后就是使用HbuilderX运行了,点击运行到鸿蒙,点击配置调试证书
  • 输入前面生成项目的包名
  • 点击自动申请调试证书,即可一键写入

22.png

21.嘿嘿,可看到已成功运行到DevEco Studio模拟器

image.png

23.png

本文纯属记录,也希望能够给第一次用uniappX开发鸿蒙的小伙伴们一点点方向,有问题地方望大佬指正(认真学习脸)

image.png

下次再见!🌈

Snipaste_2025-04-27_15-18-02.png

vue 组件实现 、background-hover随鼠标丝滑移动~

作者 头疼846
2025年11月5日 11:50

轻录屏-2025-11-05-11-34-00.gif

今天看到了一个博主的鼠标跟随背景丝滑移动,但是他是用纯css 写的。 组建的可复用不高,而且是放在整个 app 的style 标签外面。感觉组建多次使用会造成污染。

下面是我根据二次改进的

这是HTML片段

<div class="container" ref="containerRef" @mouseover="onContainerMouseOver">
    <!-- 使用事件委托 -->
    <div class="item" v-for="index in itemCount" :key="index" :data-index="index">
      <slot name="content" :index="index"></slot>
    </div>
  </div>

<style lang="scss" scoped>
$surface-2: #767676;
$item-height: 151px;
$border-radius: 0.4rem;
$transition-timing: cubic-bezier(0.2, 1, 0.2, 1);
$transition-duration: 0.5s;

.container {
position: relative;
}

.item {
--height: #{$item-height};
--surface-2: #{$surface-2};
cursor: pointer;
padding: 30px 16px;
border-bottom: 1px #ddd solid;
box-sizing: border-box;

// 使用 transform 替代 top,性能更好
&:last-child {
  --y: 0;
  --h: 0;

  &::before {
    content: "";
    display: block;
    position: absolute;
    background: var(--surface-2);
    opacity: 0;
    width: 100%;
    top: 0;
    left: 0;
    height: var(--h);
    border-radius: $border-radius;
    pointer-events: none;
    transition: all $transition-duration $transition-timing;
    transform: translateY(var(--y)); // 使用 transform 优化性能
    will-change: transform; // 提示浏览器优化
  }
}

// 减少选择器复杂度
&:hover ~ .item:last-child::before,
&:last-child:hover::before {
  opacity: 0.06;
}
}

// 减少重绘范围
@media (prefers-reduced-motion: reduce) {
.item:last-child::before {
  transition: opacity 0.1s ease;
}
}
</style>

js 开始

移动背景 的是绑定在最后一个列表的 item 的 before 上 首先我们坐的是插槽,先把 移动背景设置好高度-初始化设置高度,和防抖节约性能


// 缓存计算值
let itemHeight = 0
let itemPositions = []


// 防抖重计算
let resizeObserver
const recalculatePositions = () => {
  if (!containerRef.value) return

  const items = containerRef.value.querySelectorAll(".item")
  itemHeight = items[0]?.offsetHeight || 0
  lastItemRef.value?.style.setProperty("--h", `${itemHeight}px`)

  // 预计算所有位置-到时候鼠标在每个item 移动会用到
  itemPositions = Array.from(items).map(item => item.offsetTop)
}

onMounted(() => {
  lastItemRef.value = containerRef.value?.querySelector(".item:last-child")
  recalculatePositions()

  // 监听容器尺寸变化
  resizeObserver = new ResizeObserver(() => {
    recalculatePositions()
  })

  if (containerRef.value) {
    resizeObserver.observe(containerRef.value)
  }
})

接下来就处理每次进入如何,把背景移动到当前地方了


// 使用事件委托,减少事件监听器数量
const onContainerMouseOver = e => {
  const target = e.target.closest(".item")
  if (!target || !lastItemRef.value) return

  const index = parseInt(target.dataset.index) - 1
  if (index >= 0 && index < itemPositions.length) {
    lastItemRef.value.style.setProperty("--y", `${itemPositions[index]}px`)
  }
}

全部代码

<template>
  <div class="container" ref="containerRef" @mouseover="onContainerMouseOver">
    <!-- 使用事件委托 -->
    <div class="item" v-for="index in itemCount" :key="index" :data-index="index">
      <slot name="content" :index="index"></slot>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from "vue"

const itemCount = 15
const containerRef = ref(null)
const lastItemRef = ref(null) // 单独引用最后一个item

// 缓存计算值
let itemHeight = 0
let itemPositions = []

// 使用事件委托,减少事件监听器数量
const onContainerMouseOver = e => {
  const target = e.target.closest(".item")
  if (!target || !lastItemRef.value) return

  const index = parseInt(target.dataset.index) - 1
  if (index >= 0 && index < itemPositions.length) {
    lastItemRef.value.style.setProperty("--y", `${itemPositions[index]}px`)
  }
}

// 防抖重计算
let resizeObserver
const recalculatePositions = () => {
  if (!containerRef.value) return

  const items = containerRef.value.querySelectorAll(".item")
  itemHeight = items[0]?.offsetHeight || 0
  lastItemRef.value?.style.setProperty("--h", `${itemHeight}px`)

  // 预计算所有位置
  itemPositions = Array.from(items).map(item => item.offsetTop)
}

onMounted(() => {
  lastItemRef.value = containerRef.value?.querySelector(".item:last-child")
  recalculatePositions()

  // 监听容器尺寸变化
  resizeObserver = new ResizeObserver(() => {
    recalculatePositions()
  })

  if (containerRef.value) {
    resizeObserver.observe(containerRef.value)
  }
})

onUnmounted(() => {
  resizeObserver?.disconnect()
})
</script>

<style lang="scss" scoped>
$surface-2: #767676;
$item-height: 151px;
$border-radius: 0.4rem;
$transition-timing: cubic-bezier(0.2, 1, 0.2, 1);
$transition-duration: 0.5s;

.container {
  position: relative;
}

.item {
  --height: #{$item-height};
  --surface-2: #{$surface-2};
  cursor: pointer;
  padding: 30px 16px;
  border-bottom: 1px #ddd solid;
  box-sizing: border-box;

  // 使用 transform 替代 top,性能更好
  &:last-child {
    --y: 0;
    --h: 0;

    &::before {
      content: "";
      display: block;
      position: absolute;
      background: var(--surface-2);
      opacity: 0;
      width: 100%;
      top: 0;
      left: 0;
      height: var(--h);
      border-radius: $border-radius;
      pointer-events: none;
      transition: all $transition-duration $transition-timing;
      transform: translateY(var(--y)); // 使用 transform 优化性能
      will-change: transform; // 提示浏览器优化
    }
  }

  // 减少选择器复杂度
  &:hover ~ .item:last-child::before,
  &:last-child:hover::before {
    opacity: 0.06;
  }
}

// 减少重绘范围
@media (prefers-reduced-motion: reduce) {
  .item:last-child::before {
    transition: opacity 0.1s ease;
  }
}
</style>

 <SmoothMovement>
      <template #content="{ index }">
        <div class="item_contnent">
          {{ index }}
        </div>
      </template>
    </SmoothMovement>

uni.scanCode vs MpaasScan:支付宝扫码识别赢麻了,保姆级教程来咯~

2025年11月5日 11:44

uniapp 开发的朋友,对uni.scanCode肯定不陌生 —— 基础二维码扫描场景里,它上手快,不用额外折腾,确实省心。对于简单扫码来说也够用了,但业务一深入,各种扫不上,扫描时间过长的糟心事就来了,模糊的二维码直接识别失败,想扫条形码却毫无反应,偶尔等半天扫不出来东西,而是一串莫名其妙的数字,甚至扫空白区域都能跳出 “10320155” 这种编码……

先跟大家唠唠我踩过的这些坑,你们有没有遇到过同款?评论区来吐槽一波~

模糊码识别不出:用户传的二维码印刷不清楚,或者被污渍遮挡一点,uni.scanCode就直接罢工,完全读不出内容—— 总不能让用户重新做二维码吧?太不现实了!
条形码不兼容:扫商品条形码,对条形码的支持度低,大部分时候扫了没反应
识别慢 + 错乱:扫描时要等好几秒才出结果,更糟的是偶尔会出现明明是 URL 二维码,扫出来却是一串无意义的数值;甚至对着空白处扫,也会弹出 “10320155” 这类奇怪编码,让人心里焦躁.....
用户体验拉胯:我自己开发测试的时候都觉得烦,更别说用户了!总不能每次都让用户 离近点,再扫一次,换个角度吧?一次两次还行,次数多了用户直接炸毛.....
进入正题吧,免费的 MpaasScan支付宝扫码,真香,很久没有发博客文章了,因为这个MpaasScan支付宝扫码我已经用过三次了,因为每次用的时候间隔时间长了,每次都会忘记步骤,使用频繁,写篇笔记记录一下哈哈

先讲讲使用 MpaasScan后的核心优势

识别速度快:在相同距离、光源条件下,对二维码、条形码的识别速度很快
识别能力强:扫不出的模糊、破损二维码也能读取
服务很好:虽然是免费插件,但服务体验拉满,并不是我吹捧他哈~是事实,这个服务uni需要学起来吧,响应速度也是基本秒回,1v1 专属对接,不管是配置阶段,还是后期调用时的问题,都能精准帮你解决

教程来咯

1.注册登录 阿里云 mPaaS 我用的是uniapp
链接附上:mpaas.console.aliyun.com/ image.png2.创建应用 输入应用名称,logo不是必填,可以上传也可以不上传 单击创建,应用创建成功,并跳转到创建页面
image.png

image.png

3.uniapp打包一个apk上传成功后,下载对应配置文件

选择对应的系统 iOS / Android / 鸿蒙,我这边用的是Android

注意:打包时,这个配置的packageName包名必须和打包包名保持一致,且必须为小写,我问了官方的技术,说是后面才改的

image.png

image.png4.选择云端插件,在代码配置文件中填入阿里云下载的配置文件对应的值

image.png

5.打自定义包调试,一定得是自定义调试包哦,运行到手机模拟器调试就ok了 修改代码不需要再次制定自定义基座~

注意:如果出现这种提示,需要当前应用创建者在Dcloud插件市场里配置包名,如果你是应用协作者,联系应用创建者添加即可,保持和阿里云Package Name保持一致就OK了

2efa6374615cb2a8fedecc354d67d73d.png

其实他的步骤也没有那么标准,没有必须按部就班的来,比如你也可以直接在 Dcloud 插件市场导个云端插件,打包个 APK 出来,再把 APK 传到阿里云,自动生成配置文件,最后把参数填进去就行!最关键就一个事儿:Dcloud 插件市场里填的包名,和阿里云那边的 Package Name 得一模一样~ 只要这俩对得上,就会顺顺利利,没啥坑!这些都是我踩坑踩出的经验呜呜呜~

还有啥没说到的问题,后面慢慢补呀,嘻嘻 (●'◡'●)~

PS:MpaasScan:提一嘴小注意事项哈!mpaas 基线会依赖些外部三方库,可能会让打包速度变慢哦~

好啦~ MpaasScan支付宝扫码的配置 + 调用已经全搞定!直接拿去用,愉快开发玩耍起来呀~

实现在 UnoCSS 中使用任意深度颜色的预设

作者 叉歪
2025年11月5日 11:43

unocss中提供的调色板有很多种颜色跟深度,如c-red-100、c-blue-800等等,但基本都是固定的50~950的11种深度颜色,如果需要添加新的深度颜色,就需要在theme中添加配置,如c-wine-red-453。

image.png

但是如果想任意使用50~950之间的任何深度颜色呢?在theme中把这近千种颜色都定义出来?🤔感觉不太合适,所以决定尝试创建一个预设库来实现下。

unocss-preset-magicolor

使用

pnpm add unocss-preset-magicolor -D

与其他unocss预设库一样,需要在使用前将该预设添加到unocss配置文件中。

import { defineConfig } from 'unocss';
import { presetMagicolor } from 'unocss-preset-magicolor';

export default defineConfig({ presets: [presetMagicolor()] });

基础用法

你可以使用50到950之间任何深度的任何颜色。如果你想添加一个新的主题颜色,也只需要在theme中配置默认颜色。颜色的不同深度会通过 magic-color生成,所有颜色格式都会转换为oklch类型.

import { defineConfig, presetWind4 } from 'unocss';
import { presetMagicolor } from '../packages/presets/src';

export default defineConfig({
  presets: [
    presetWind4(),
    presetMagicolor(),
  ],
  theme: { colors: { wine: { red: '#9c1d1e' } } },
});
<template>
  <button class="px-8 py-4 bg-mc-rose-445 hover:bg-mc-wine-red-575">
    <span class="c-mc-[#789411]-430">
      Hello World!
    </span>
  </button>
</template>

image.png

使用class来定义颜色

现在,除了在theme中定义颜色,你还可以直接在class中定义颜色。它对于需要动态修改颜色的组件非常有用。

<template>
  <button class="px-8 py-4 mc-btn_[#9c1d1e] hover:mc-btn_blue bg-mc-btn-450">
    <span class="c-mc-btn-610">
      Hello World!
    </span>
  </button>
</template>

2025-11-05-11-22-54.gif

预设的实现逻辑

如果你对该预设的实现有兴趣,或者想了解下如何自定义unocss的预设,可以往下看看。

获取颜色更多的深度选择

unocss提供的调色板颜色,在50~950的11种深度颜色是固定的,没有什么特殊规律,也就无法从一个深度颜色值直接推算出其他的深度颜色值,所以这里如果想要获取red453这个深度的颜色值,思路就是先获取red400与red500的颜色值,53作为过渡比例进行计算,也就是red453 = red400 + (red500 - red400) * 0.53

在计算前我们需要先获取到颜色,主题色我们可以通过rules中的Theme获取得到,但并不一定所有主题颜色都有50~950的这11个深度,所以这里我们使用大佬写的一个专门处理颜色的库Magic Color,这个库不仅可以转化颜色的格式,其中提供theme方法还可以直接生成任何颜色的11个不同深度颜色值,为后面实现自定义颜色奠定基础。

最后这里以color样式为例,先不考虑透明度这些样式,实现计算颜色深度的代码如下,其中使用mc则作为颜色的前缀,避免与其他预设冲突。

实现代码:

import type { Preset, RuleContext } from 'unocss';
import type { PresetOptions } from './types';
import { mc } from 'magic-color';
import { parseColor, type Theme } from 'unocss/preset-mini';

export function presetMagicolor(_options?: PresetOptions): Preset<Theme> {
  return {
    name: 'unocss-preset-magicolor',
    rules: [
      [/^c-mc-(.+)$/, ([, str]: string[], ctx: RuleContext<Theme>) => {
        const [color] = str.split(/[:/]/);
        const depth = color.match(/.*-(\d+)/)?.[1];
        const { theme } = ctx;

        // 该颜色可直接解析获取到,直接返回
        const parsedColor = parseColor(color, theme);
        if (parsedColor?.color) {
          return { color: parsedColor.color };
        }

        if (!depth) { return; }

        const originColor = color.split(/-\d+-?/)[0];

        if (!originColor || !Number.isNaN(Number(originColor))) { return; } // 无效的颜色
        // 原深度值
        const originDepth = Number(depth);
        // 前深度值,不小于50
        let beforeDepth = Math.floor(originDepth / 100) * 100;
        beforeDepth = beforeDepth <= 50 ? 50 : beforeDepth;
        // 后深度值,不大于950
        let afterDepth = Math.floor((originDepth + 100) / 100) * 100;
        afterDepth = afterDepth >= 950 ? 950 : afterDepth;

        let beforeParsedColor = parseColor(`${originColor}-${beforeDepth}`, theme)?.color;
        let afterParsedColor = parseColor(`${originColor}-${afterDepth}`, theme)?.color;
        // 未能获取到对应深度的颜色时,通过mc.theme获取,用于自定义颜色
        if (!beforeParsedColor || !afterParsedColor) {
          const customColor = parseColor(originColor, theme)?.color || originColor;
          if (!mc.valid(customColor)) { return; }
          const themeColor = mc.theme(customColor);
          if (!beforeParsedColor) {
            beforeParsedColor = themeColor[beforeDepth];
          }
          if (!afterParsedColor) {
            afterParsedColor = themeColor[afterDepth];
          }
        }
    
        if (!mc.valid(beforeParsedColor) || !mc.valid(afterParsedColor)) { return; }
        
        // 获取前后深度的HSL格式颜色
        const beforeDepthColor = mc(beforeParsedColor).toHsl().values;
        const afterDepthColor = mc(afterParsedColor).toHsl().values;
        const transitionRatio = (originDepth - beforeDepth) / 100;
        // 计算获得当前深度的颜色
        const resultColor = Array.from({ length: 3 }).map((_, i) => {
          const value = beforeDepthColor[i] + (afterDepthColor[i] - beforeDepthColor[i]) * transitionRatio;
          return Math.round(value * 100) / 100;
        });

        return { color: `hsl(${resultColor[0]}deg ${resultColor[1]}% ${resultColor[2]}%)` };
      }],
    ],
  };
};

这时我们就可以给color使用50~950之间的任意深度颜色了。

<template>
  <div class="c-mc-blue-600">
    Hello World!
  </div>
  <div class="c-mc-blue-620">
    Hello World!
  </div>
  <div class="c-mc-blue-650">
    Hello World!
  </div>
  <div class="c-mc-blue-680">
    Hello World!
  </div>
  <div class="c-mc-blue-700">
    Hello World!
  </div>
</template>

并且当我们需要扩展更多颜色时,也仅需要在主题配置默认颜色,或者直接在class中自定义颜色即可,预设会通过代码中的mc.theme获取不同深度的颜色。

import { defineConfig, presetMini } from 'unocss';
import { presetMagicolor } from '../packages/presets/src';

export default defineConfig({
  presets: [presetMini(), presetMagicolor()],
  theme: { colors: { wine: { red: '#990B0A'} }, // 仅需配置默认颜色
});

使用时颜色会自动获取对应的深度颜色,也可自定义颜色,且同时支持不同深度配置。

<template>
  <!-- 使用自定义的主题 -->
  <div class="c-mc-wine-red-666">
    Hello World!
  </div>
  <!-- 在自定义的颜色后面加深度即可 -->
  <div class="c-mc-[#465422]-345">
    Hello World!
  </div>
</template>

更多样式支持

由于是自定义的预设规则,以上的代码也是只是作用于color属性,对于bg-colorborder-color这些样式,我们同样也是需要手动添加规则去实现。

在样式规则上,我们以Wind4 preset版本的样式规则为主,可以通过引入其colorCSSGenerator方法来兼容c-opbg-op这些功能,以达到最终的效果。

完整实现代码:https://github.com/nixwai/unocss-preset-magicolor/pull/1/files

<template>
  <div class="w-100vw h-100vh flex items-center justify-center">
    <button class="px-8 py-4 bg-mc-blue-450 hover:bg-mc-blue-630 b-5 b-mc-yellow-860">
      <span class="c-mc-[#798322]-420">
        Hello World!
      </span>
    </button>
  </div>
</template>

image.png

使用class给颜色定义名称

当前的预设已经满足我们在样式上任意使用颜色,但如果想要定义新颜色名依然只能在theme上定义,且并不支持动态更改该颜色,因为其深度的颜色是通过计算后直接赋值到对应的样式的。

image.png

因此,要想让颜色除了在theme上定义,这里想到的就是用特殊的前缀、名称、颜色值3者拼接的方式来定义一个class,之后在其元素以及子元素便可使用该颜色名代替颜色,格式为mc-颜色名_颜色值,类似于js的变量定义:var 颜色名=颜色值,只不过这里我们的mc代替了var_代替了=,且定义后还可以通过切换class的颜色值方式来动态切换所有使用该颜色名的颜色。如:

<template>
  <div class="w-100vw h-100vh flex items-center justify-center">
    <button class="px-8 py-4 mc-btn_[#104CB3] hover:mc-btn_[#0B3782] bg-mc-btn-450">
      <span class="c-mc-btn-120">
        Hello World!
      </span>
    </button>
  </div>
</template>

但是要实现这个功能并不容易,在unocss自定义rules时,每个rules的运行都是独立的,意味着使用mc-btn_[#104CB3]定义的颜色,在rules中的bg-mc-btnc-mt-btn无法直接获取到btn具体的颜色值,因此这里需要使用的css变量来保存定义的颜色,使用时再通过css变量间接获取到颜色。例如:

.mc-btn_[#104CB3] { 
  --mc-btn-color: #104CB3; 
}

.bg-mc-btn { 
  background: var(--mc-btn-color);
}

.c-mc-btn {
  color: var(--mc-btn-color);
}

实现代码:

import type { Preset } from 'unocss';
import type { PresetOptions } from './types';
import { parseColor } from '@unocss/preset-wind4/utils';

export function presetMagicolor(_options?: PresetOptions): Preset {
  return {
    name: 'unocss-preset-magicolor',
    rules: [
      [/^mc-(.+)$/, ([, str], { theme }) => {
        const [name, hue] = str.split('_');
        const color = hue?.split(/[:/]/)?.[0];
        if (!color) {
          return {};
        }
        const parsedColor = parseColor(color, theme);
        if (!parsedColor?.color) {
          return {};
        }
        return { [`--mc-${name}-color`]: parsedColor.color };
      }],
      [/^c-mc-(.+)$/, ([, str]) => {
        const [bodyColor] = str.split(/[:/]/);
        return { color: `var(--mc-${bodyColor}-color)` };
      }],
      [/^bg-mc-(.+)$/, ([, str]) => {
        const [bodyColor] = str.split(/[:/]/);
        return { background: `var(--mc-${bodyColor}-color)` };
      }],
    ],
  };
};

当然这只是单一颜色的获取,定义后也只能使用对应名称的颜色,要想在使用时也能随意获取到不同深度的颜色,除了需要保存定义的颜色,还要获取颜色的那11个深度颜色值并保存下来,之后使用时便可以利用公式计算出来。如:

.mc-wine-red_[#9c1d1e] { 
  --mc-wine-red-color: #9c1d1e; 
  --mc-wine-red-50-l: 0.971;
  --mc-wine-red-50-c: 0.013;
  --mc-wine-red-50-h: 17.38;
  --mc-wine-red-100-l: 0.936;
  --mc-wine-red-100-c: 0.031;
  --mc-wine-red-100-h: 17.717;
  --mc-wine-red-200-l: 0.886;
  --mc-wine-red-200-c: 0.057;
  --mc....
  --mc-wine-red-950-l: 0.257;
  --mc-wine-red-950-c: 0.086;
  --mc-wine-red-950-h: 25.723;
}

.bg-wine-red-450 { 
     background-color: oklch(var(--mc-wine-red-450-l) var(--mc-wine-red-450-c) var(--mc-wine-red-450-h));
    --mc-wine-red-450-l: calc(var(--mc-wine-red-400-l) + 0.5 * (var(--mc-wine-red-500-l) - var(--mc-wine-red-400-l)));
    --mc-wine-red-450-c: calc(var(--mc-wine-red-400-c) + 0.5 * (var(--mc-wine-red-500-c) - var(--mc-wine-red-400-c)));
    --mc-wine-red-450-h: calc(var(--mc-wine-red-400-h) + 0.5 * (var(--mc-wine-red-500-h) - var(--mc-wine-red-400-h)));
}

.c-wine-red-600 {
  color: oklch(var(--mc-wine-red-600-l) var(--mc-wine-red-600-c) var(--mc-wine-red-600-h));
}

完整实现代码:https://github.com/nixwai/unocss-preset-magicolor/pull/2/files

更多功能

目前预设的开发基本完成,但可能还有些问题需要在后续优化,比如在使用class定义颜色后会产生数十个css变量,但并不是每个变量都会使用到,目前想到的解决办法是将他们合并成一个,再通过模运算拆分出来,以减少css变量。如:

// 拼接起来,每个值占用数字的6.mc-wine-red_[#9c1d1e] { 
  --mc-wine-red-color: #9c1d1e;
  --mc-wine-red-50-200-l: 882000932000.97;
  --mc-wine-red-300-500-l: 623000707000.809;
  --mc-wine-red-600-800-l: 199000488000.546;
  --mc-wind-red-900-950-l: 282000.379;
  --mc-wine-red-50-200-c: ...
  --mc-wine-red-300-500-c: ...
  ...
}

// 使用时通过模运算取值
.bg-wine-red-450 { 
     background-color: oklch(var(--mc-wine-red-450-l) var(--mc-wine-red-450-c) var(--mc-wine-red-450-h));
    --mc-wine-red-450-l: calc(
       mod(var(--mc-wine-red-300-500-l), 1000000000) / 1000000 + 
       0.5 * (
         mod(var(--mc-wine-red-300-500-l), 1000000000000000) / 1000000000000 - 
         mod(var(--mc-wine-red-300-500-l), 1000000000) / 1000000
       )
     );
    ...
}

虽然css变量减少了,但在计算复杂了不少,并不是个特别好的解决办法,所以并未实现出来,如果你有更好的解决办法或者思路,欢迎评论提出来。

如果你喜欢这个预设,欢迎star,也欢迎提出你的看法。

vue多页项目如何在每次版本更新时做提示

作者 hxmmm
2025年11月5日 11:39

一、遇到的问题

项目中使用懒加载方式加载组件,在新部署镜像后,由于浏览器缓存又去加载旧的js chunk,但是之时旧的js chunk已经不存在,加载不出来造成bug

image.png

二、解决方式

在每次部署后更改版本号,在页面做提示,当前版本又更新,提示用户刷新页面

(1)可以使用的方案有哪些

  • 使用轮训查询最新的版本号做对比
  • 使用websocket
  • 使用service worker

(2)最终采用了什么方案

最终使用了方案1;原因是配置简单方便;缺点是会加大服务器压力!~ (1)在public中创建一个version.json文件,写清楚各个模块的版本, 我这里项目vue多页的,每个项目都要单独版本管理

{
"A项目": {
  "version": "1.18.0",
  "description": ""
},
"B项目": {
  "version": "1.18.0",
  "description": ""
},
"C项目": {
  "version": "1.18.0",
  "description": ""
},
}

(2)创建一个全局的versionUpdate方法,来检测版本是否更新

import 'element-plus/dist/index.css'
import { ElMessageBox } from 'element-plus'

/**
 * 版本信息接口
 */
type TVersionInfo = {
  [moduleName: string]: TModuleInfo
}

/**
 * 模块版本存储信息
 */
type TModuleInfo = {
  version: string
  description?: string
}

/**
 * 基于version.json的版本检测和更新提示工具
 */
export class VersionUpdateService {
  private versionCheckInterval: number | null = null
  private readonly CHECK_INTERVAL = 5 * 60 * 1000 // 5分钟检查一次
  private moduleName: string
  private storageKey: string

  constructor(moduleName: string = 'home') {
    this.moduleName = moduleName
    this.storageKey = `module-version-${moduleName}`
  }

  /**
   * 获取模块版本信息
   */
  private getModuleVersionInfo(): TModuleInfo | null {
    const stored = localStorage.getItem(this.storageKey)
    return stored ? JSON.parse(stored) : null
  }

  /**
   * 保存模块版本信息
   */
  private saveModuleVersionInfo(info: TModuleInfo): void {
    localStorage.setItem(this.storageKey, JSON.stringify(info))
  }

  /**
   * 从version.json获取版本信息(统一从 public/version.json 中按模块名读取)
   */
  private async fetchVersionInfo(): Promise<TModuleInfo | null> {
    try {
        const fullUrl = `${window.location.origin}/version.json?t=${Date.now()}`
        console.log(`[${this.moduleName}] 正在获取version.json: ${fullUrl}`)
  
        const response = await fetch(fullUrl, {
            method: 'GET',
            cache: 'no-cache',
            headers: { 'Content-Type': 'application/json' }
        })
  
        if (!response.ok) {
            console.warn(`[${this.moduleName}] 无法获取version.json: ${response.status} ${response.statusText}`)
            return null
        }
  
        // 期望 public/version.json 结构为:{ "A项目": { ... }, "B项目": { ... }, "C项目": { ... }, ... }
        const indexData = await response.json() as TVersionInfo

        console.log(`[${this.moduleName}] 获取到版本信息:`, indexData)
        return indexData[this.moduleName]
    } catch (error) {
        console.warn(`[${this.moduleName}] 获取version.json失败:`, error)
        return null
    }
  }

  /**
   * 检查是否有新版本
   */
  private async checkForUpdate(): Promise<boolean> {
    const currentVersionInfo = await this.fetchVersionInfo()
    if (!currentVersionInfo) {
      console.warn(`[${this.moduleName}] 无法获取当前版本信息,跳过检测`)
      return false
    }
    
    const storedInfo = this.getModuleVersionInfo()
    
    if (!storedInfo) {
      // 第一次检查,保存当前版本信息
      this.saveModuleVersionInfo(currentVersionInfo)
      console.log(`[${this.moduleName}] 首次检查,保存版本信息`)
      return false
    }
    
    const versionUpdated = currentVersionInfo.version !== storedInfo.version
    
    if (versionUpdated) {
      console.log(`[${this.moduleName}] 检测到版本更新:`, currentVersionInfo)
      return true
    }
    
    console.log(`[${this.moduleName}] 当前为最新版本:`, currentVersionInfo)
    return false
  }

  /**
   * 显示更新提示
   */
  private showUpdateNotification(currentVersionInfo: TModuleInfo): void {
    const moduleTitle = this.getModuleTitle(this.moduleName)
    const currentModuleInfo = currentVersionInfo
    const message = `有新版本可用:${currentModuleInfo.version}\n${currentModuleInfo.description}`

    ElMessageBox.confirm(
      message,
      `${moduleTitle}版本更新`,
      {
        confirmButtonText: '立即刷新',
        cancelButtonText: '稍后提醒',
        type: 'info',
        center: true
      }
    ).then(() => {
      this.updateVersionInfo(currentVersionInfo)
      this.reloadPage()
    }).catch(() => {
      console.log(`[${this.moduleName}] 用户选择稍后更新`)
    })
  }

  /**
   * 获取模块标题
   */
  private getModuleTitle(moduleName: string): string {
    const titles: Record<string, string> = {
      'A项目': 'A项目名称'
      ...
    }
    return titles[moduleName] || moduleName
  }

  /**
   * 更新版本信息
   */
  private async updateVersionInfo(currentVersionInfo: TModuleInfo): Promise<void> {
    this.saveModuleVersionInfo(currentVersionInfo)
    console.log(`[${this.moduleName}] 版本信息已更新:`, currentVersionInfo.version)
  }

  /**
   * 刷新页面
   */
  private reloadPage(): void {
    if ('caches' in window) {
      caches.keys().then(names => {
        names.forEach(name => {
          caches.delete(name)
        })
      })
    }
    
    setTimeout(() => {
      window.location.reload()
    }, 100)
  }

  /**
   * 开始定期检查
   */
  public startVersionCheck(): void {
    this.performVersionCheck()
    
    this.versionCheckInterval = window.setInterval(() => {
      this.performVersionCheck()
    }, this.CHECK_INTERVAL)
  }

  /**
   * 执行版本检查
   */
  private async performVersionCheck(): Promise<void> {
    const currentVersionInfo = await this.fetchVersionInfo()
    if (!currentVersionInfo) return
    
    const hasUpdate = await this.checkForUpdate()
    if (hasUpdate) {
      this.showUpdateNotification(currentVersionInfo)
    }
  }

  /**
   * 停止版本检查
   */
  public stopVersionCheck(): void {
    if (this.versionCheckInterval) {
      clearInterval(this.versionCheckInterval)
      this.versionCheckInterval = null
    }
  }

  /**
   * 获取所有模块版本信息(调试用)
   */
  public static getAllModuleVersions(): Record<string, TModuleInfo | null> {
    const modules = ['A项目'...]
    const result: Record<string, TModuleInfo | null> = {}
    
    modules.forEach(module => {
      const key = `module-version-${module}`
      const stored = localStorage.getItem(key)
      result[module] = stored ? JSON.parse(stored) : null
    })
    
    return result
  }

  /**
   * 清除指定模块的版本信息
   */
  public static clearModuleVersion(moduleName: string): void {
    const key = `module-version-${moduleName}`
    localStorage.removeItem(key)
    console.log(`已清除模块 [${moduleName}] 的版本信息`)
  }

  /**
   * 初始化版本更新检测
   */
  public static init(moduleName: string = 'home'): VersionUpdateService {
    const service = new VersionUpdateService(moduleName)
    service.startVersionCheck()
    return service
  }
}

/**
 * 初始化版本更新检测
 */
export const initVersionUpdateJson = (moduleName?: string) => {
  return VersionUpdateService.init(moduleName || 'home')
}

/**
 * 兼容旧版本的导出
 */
export const initVersionUpdate = initVersionUpdateJson

3、在每个模块中的main.ts中引入使用这个方法

import { initVersionUpdateJson } from '@/utils/VersionUpdate'
// 初始化版本检测
initVersionUpdateJson('chess') // 这里传入的是项目名称

如果要做优化,CSS提高性能的方法有哪些?

作者 Giant100
2025年11月5日 11:28

写给小白的 CSS 性能优化指南:让你的页面飞起来

如果你刚开始学前端,可能会觉得 CSS 就是 “写样式”—— 把文字弄大、给按钮上色、排个版就行。但其实,CSS 不仅影响页面好不好看,还直接决定了页面 “跑” 得快不快。

想象一下:用户点进你的网页,半天加载不出来,或者滑动的时候卡顿掉帧,大概率会直接关掉。而很多时候,这些问题可能就藏在你写的 CSS 里。

今天就用大白话讲讲,怎么优化 CSS 让页面更流畅,哪怕是刚入门也能看懂~

为什么要优化 CSS?

简单说:CSS 会 “卡” 住页面渲染

浏览器加载网页时,要先下载 HTML、CSS,再根据它们计算出页面长什么样(这个过程叫 “渲染”)。如果 CSS 写得不好,比如文件太大、规则太复杂,浏览器就会花更多时间处理,用户看到的就是 “加载中” 或者卡顿的页面。

优化 CSS,本质上就是帮浏览器 “减负”,让它更快地把页面呈现给用户。

6 个新手也能学会的 CSS 优化技巧

1. 关键 CSS 直接写在 HTML 里(内联)

你可能习惯把 CSS 写到单独的 .css 文件里,再用 <link> 引入 —— 这没错,但有个小问题:浏览器得先下载完这个 CSS 文件,才能开始渲染页面。

如果是首屏必须的样式(比如导航栏、头部 Banner),可以直接写到 HTML 的 <style> 标签里(这叫 “内联”)。这样浏览器下载完 HTML 就能立刻渲染,不用等外部 CSS 文件,首屏加载速度会快很多。

举个例子:

<!DOCTYPE html>
<html>
<head>
  <!-- 内联首屏关键 CSS -->
  <style>
    .header { height: 60px; background: #fff; }
    .banner { width: 100%; height: 200px; }
  </style>
  <!-- 非关键 CSS 还是外部引入 -->
  <link rel="stylesheet" href="other-styles.css">
</head>
<body>...</body>
</html>

⚠️ 注意:别把所有 CSS 都内联!太大的 CSS 会让 HTML 文件变胖,反而变慢。只内联首屏必须的那部分就好~

2. 给 CSS “瘦个身”(压缩)

写 CSS 时,我们会换行、加注释,方便自己看,但这些 “多余内容” 会让文件变大,下载变慢。

比如你写:

/* 这是导航栏样式 */
.nav {
  width: 100%;
  height: 60px;
  background: #333;
}

压缩后会变成这样(去掉空格、换行、注释):

.nav{width:100%;height:60px;background:#333}

文件变小了,浏览器下载就更快。新手不用自己手动删,用 Webpack、Vite 这些工具打包时,会自动帮你压缩 CSS,记得开这个功能就行~

3. 别写 “绕弯子” 的选择器

浏览器读 CSS 选择器的方式很特别:从右往左看

比如你写 #box .list li,浏览器会先找所有 <li>,再筛选出在 .list 里的,最后挑出在 #box 里的。如果选择器嵌套太多层(比如 div .container .list .item span),浏览器就得做很多 “筛选工作”,会变慢。

给新手的小建议:

  • 别嵌套超过 3 层,比如 a:hover 就够了,别写成 div .nav ul li a:hover
  • 能用 ID 选择器(#box)就别嵌套,比如 #box { ... } 比 .container #box { ... } 快;
  • 少用通配符 *(比如 * { margin: 0; })和属性选择器(比如 input[type="text"]),它们会遍历所有元素,很费时间。

4. 少用 “费性能” 的属性

有些 CSS 属性看起来很酷,但浏览器渲染它们的时候要 “加班”。比如:

  • box-shadow(阴影)、border-radius(圆角):需要计算额外的图形;
  • filter(滤镜,比如模糊、变色):会让浏览器反复处理像素;
  • opacity(透明度):改变时可能触发页面重新渲染。

这些属性用多了,页面滚动或动画时容易卡顿。新手可以尽量少用,或者用更简单的方式替代(比如用图片代替复杂阴影)。

5. 别用 @import 引入 CSS

引入 CSS 有两种方式:

<!-- 方式1:link 标签 -->
<link rel="stylesheet" href="style.css">

<!-- 方式2:@import(不推荐!) -->
<style>
  @import url("style.css");
</style>

为什么不推荐 @import?因为浏览器处理它时,得先下载完当前 CSS 文件,才能知道还要下载 style.css,相当于 “排队下载”;而 link 标签可以让多个 CSS 文件 “同时下载”,速度更快。

记住:引入 CSS 优先用 <link>,别用 @import

6. 非关键 CSS 让它 “悄悄加载”(异步)

前面说过,CSS 会 “阻塞” 页面渲染 —— 浏览器没下载完 CSS 时,页面可能是空白的。但有些 CSS 不是首屏必须的(比如打印样式、隐藏模块的样式),可以让它们 “异步加载”,不耽误页面渲染。

怎么做?简单来说,就是告诉浏览器:“这个 CSS 不急,你慢慢下,先渲染页面”。比如:

<!-- 异步加载非关键 CSS -->
<link rel="stylesheet" href="print.css" media="print" onload="this.media='all'">

这里的 media="print" 告诉浏览器 “这是打印时用的,现在不用管”,等加载完后再通过 onload 改成正常样式,既不阻塞渲染,又能加载完整样式。

总结一下

CSS 优化没那么复杂,核心就是:让浏览器少干活、快干活

新手刚开始不用追求完美,先记住这几点:关键 CSS 内联、压缩文件、选择器别太复杂、少用费性能的属性。慢慢在实际写代码时注意这些细节,你的页面就会越来越流畅~

最后想说:好的前端不只是 “实现效果”,更要让用户用得舒服。优化 CSS,就是让用户离你的网站更近一步呀~

使用 SSE 与 Streamdown 实现 Markdown 流式渲染

作者 kuxku
2025年11月5日 11:15

首发链接:icodex.me/sse-streamd…

1. 技术背景介绍

  • SSE 原理与优势:

    • SSE(Server-Sent Events)基于持久化的 HTTP 连接,服务端以 text/event-stream 连续推送事件,客户端通过 EventSource 被动接收,无需轮询。
    • 单向、轻量、天然跨浏览器支持良好(Chrome、Firefox、Safari 等),适合日志、状态、增量文本等实时场景。
    • 连接语义清晰:重试、心跳、事件名、数据体等均有规范;服务端易实现,客户端简单。
  • Markdown 流式传输的应用场景:

    • AI 生成内容的渐进式输出(思维链、长答案、代码块)可边生成边渲染,提高交互体验。
    • 长文档编排与协作写作中,减少等待时间;支持分块解析,避免“大块未闭合”导致的整体阻塞。
    • 实时文档、增量日志、直播笔记、滚动公告等。
  • Streamdown 组件与核心特性:

    • 来自 Vercel,专为“未完成 Markdown”与“AI 流式内容”设计的 React 组件,API 与 react-markdown 类似,支持增量块解析。

    • 关键能力:

      • parseIncompleteMarkdown:容忍未闭合代码块、列表、表格,边输入边渲染。
      • components:自定义渲染标签(如代码、链接、图片)与安全前缀限制。
      • remarkPlugins / rehypePlugins:扩展 Markdown 语法与 HTML 处理。
      • shikiTheme:Shiki 语法高亮主题;支持 math(KaTeX)与 mermaid 图表。
      • controlsisAnimating:渲染控制、结尾动画等体验优化的配置项。

提示:本文所有示例均使用原生 EventSource 与 React(Streamdown),也可替换为其他框架,只要能消费 SSE 文本即可。

2. Node.js 服务端实现

目标:从 Markdown 文件或数据源流式读取并按块通过 SSE 推送到浏览器,保证连接生命周期管理、心跳与错误处理。

2.1 最小可运行示例(Express)

 import express from "express";
 import fs from "fs";
 import path from "path";
 
 const app = express();
 const PORT = Number(process.env.PORT || 8787);
 
 // SSE 路由:客户端通过 /sse 订阅增量 Markdown
 app.get("/sse", (req, res) => {
   // 1) 基础响应头:保持连接与禁止缓存
   res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
   res.setHeader("Cache-Control", "no-cache, no-transform");
   res.setHeader("Connection", "keep-alive");
   // 若需要跨域:res.setHeader('Access-Control-Allow-Origin', '*');
 
   // 2) 立即发送一个注释提高兼容性(某些代理需首字节刷新)
   res.write(": ok\n\n");
 
   // 3) 心跳:每 15s 发送一次注释,防止中间设备断开
   const heartBeat = setInterval(() => {
     res.write(": heartbeat\n\n");
   }, 15000);
 
   // 4) 将 Markdown 文件按行/块读取并逐块发送
   const mdPath = path.join(__dirname, "content.md");
   const stream = fs.createReadStream(mdPath, { encoding: "utf-8" });
 
   stream.on("data", (chunk) => {
     // 规范事件格式:event + data + 空行
     // 这里使用事件名 "message";你也可以使用自定义事件
     res.write(`event: message\n`);
     // 为避免多行破碎,可将 chunk 按行安全封装到 data
     const safe = chunk.replace(/\r?\n/g, "\n");
     res.write(`data: ${safe}\n\n`);
   });
 
   stream.on("end", () => {
     // 发送结束信号(可选):客户端据此停止拼接
     res.write(`event: end\n`);
     res.write(`data: [DONE]\n\n`);
     // 延迟关闭以确保客户端收到尾包
     setTimeout(() => res.end(), 500);
     clearInterval(heartBeat);
   });
 
   stream.on("error", (err) => {
     res.write(`event: error\n`);
     res.write(`data: ${JSON.stringify({ message: err.message })}\n\n`);
     clearInterval(heartBeat);
     res.end();
   });
 
   // 5) 连接关闭处理(客户端主动关闭或网络中断)
   req.on("close", () => {
     clearInterval(heartBeat);
     stream.destroy();
   });
 });
 
 app.listen(PORT, () => {
   console.log(`SSE server at http://localhost:${PORT}/sse`);
 });

2.2 响应头与代理注意事项

  • Content-Type: text/event-stream 必须,且编码设为 utf-8
  • 禁止压缩:许多代理会对长连接压缩,可能导致帧边界破坏;可通过 Cache-Control: no-transform 与关闭中间层压缩。
  • 反向代理(Nginx/Cloudflare)需开启传输刷写与禁用缓冲;例如 Nginx 可使用 proxy_buffering off;

2.3 心跳与断线重试

  • SSE 允许客户端在网络异常时自动重试。服务端应周期性发送注释(以 : 开头)维持连接活性。
  • 如果需要定制重试间隔,可发送 retry: <ms> 字段,如 retry: 3000 表示 3s 重试。

2.4 分块策略与数据格式

  • 尽量保证每个 data: 对应一个语义片段(行、句、代码块的一部分),使前端更好地增量渲染与滚动。
  • 如需多事件类型,可使用不同 event: 名称(如 metamessageend)。

3. 前端实现

目标:通过 EventSource 建立 SSE 连接,增量拼接 Markdown 文本,并使用 Streamdown 容忍未完成块进行渲染,同时处理错误与重连。

3.1 React + Streamdown 基本用法

 import { useEffect, useRef, useState } from "react";
 import Streamdown from "streamdown";
 
 export default function App() {
   const [md, setMd] = useState<string>("");
   const esRef = useRef<EventSource | null>(null);
   const [connected, setConnected] = useState(false);
 
   useEffect(() => {
     // 连接到后端接口地址,类似 http 接口
     const es = new EventSource("http://localhost:8787/sse");
     esRef.current = es;
 
     es.onopen = () => setConnected(true);
     es.onerror = (e) => {
       console.warn("SSE error", e);
       setConnected(false);
       // 由浏览器自动重试;也可在此关闭并手动重连
     };
     es.addEventListener("message", (ev) => {
       try {
         // 这里服务端直接推送原始文本;如果是 JSON,需解析后取字段
         const text = String(ev.data);
         // 增量拼接:避免频繁渲染,可批量缓冲
         setMd((prev) => prev + text);
       } catch (err) {
         console.error(err);
       }
     });
     es.addEventListener("end", () => {
       // 收到结束事件,可做滚动、动画或解锁 UI
       console.info("stream end");
       es.close();
       setConnected(false);
     });
 
     return () => {
       es.close();
       setConnected(false);
     };
   }, []);
 
   return (
     <div style={{ padding: 16 }}>
       <h3>SSE + Streamdown Markdown Streaming</h3>
       <p>连接状态:{connected ? "已连接" : "未连接"}</p>
       <Streamdown
         // 核心容忍未完成 Markdown边到边渲染
         parseIncompleteMarkdown
         shikiTheme="github-dark"
         // 安全前缀限制链接与图片前缀避免 XSS
         components={{
           a: {
             allowedPrefix: ["https://", "http://"],
           },
           img: {
             allowedPrefix: ["https://", "http://"],
           },
         }}
       >
         {md}
       </Streamdown>
     </div>
   );
 }

3.2 流式拼接与渲染优化

  • 批量缓冲:在 message 事件中将增量文本先写入缓冲区,每 50–100ms 合并一次,减少 React 重渲染频次。
  • 大块代码:若服务端能识别代码块边界(如以 ```),可在边界处再触发一次强制渲染,提升用户感知。
  • 滚动粘连:当内容增长时保持视窗跟随底部;Streamdown 的 controlsisAnimating 可用于结尾动画与滚动体验优化。
  • 错误处理:网络断连时提示用户并自动重试;如需手动重连,可通过按钮触发重新建立 EventSource

3.3 简易重连机制(可选)

 import { useEffect, useRef, useState } from "react";
 
 export function useSSE(url: string, onMessage: (data: string) => void) {
   const [connected, setConnected] = useState(false);
   const esRef = useRef<EventSource | null>(null);
   const retryRef = useRef<number>(2000); // 初始重试间隔
 
   useEffect(() => {
     function connect() {
       const es = new EventSource(url);
       esRef.current = es;
       es.onopen = () => {
         setConnected(true);
         retryRef.current = 2000; // 连接成功,重置重试间隔
       };
       es.onerror = () => {
         setConnected(false);
         es.close();
         // 指数退避重试,最多 20s
         retryRef.current = Math.min(retryRef.current * 2, 20000);
         setTimeout(connect, retryRef.current);
       };
       es.addEventListener("message", (ev) => onMessage(String(ev.data)));
     }
     connect();
     return () => esRef.current?.close();
   }, [url, onMessage]);
 
   return { connected };
 }

4. 示例代码

使用 Nextjs 实现,参考 stream-markdown-demo

screenshot.gif

5. 总结

  • 优点:

    • SSE 简洁稳定,浏览器原生支持,无需双向通道即可实现“服务端推送”。
    • Streamdown 适配未完成 Markdown,显著提升 AI/长文场景的渲染质量与体验。
    • 端到端实现成本低,利于快速落地与迭代。
  • 局限:

    • SSE 为单向推送;如需双向交互可结合 WebSocket 或使用双通道(SSE 下行 + Fetch/POST 上行)。
    • 中间代理与缓冲需谨慎配置,否则可能造成延迟与断流。
    • 对于复杂内容块,例如视频,图片,代码块等自定义渲染模块,非常依赖 Markdown 组件自身的能力;如果在项目技术选型阶段需要格外注意这一点,避免后续随着业务复杂性的增加,导致代码无法继续维护开发。
  • 改进方向:

    • 更细粒度的分块与服务端增量语义(代码块、列表边界识别)。
    • 引入内容安全策略(CSP)与资源前缀白名单,更好抵御 XSS。
    • 增加离线缓存与断点续传,提升弱网体验。

🛠️ Service Worker API深度解析 - 生命周期、缓存与离线实战

2025年11月5日 11:00

🎯 学习目标:掌握 Service Worker 的生命周期、缓存策略与请求拦截,并能为 Web 应用实现稳定的离线与更新机制。

📊 难度等级:中级
🏷️ 技术标签#ServiceWorker #PWA #CacheStorage #离线
⏱️ 阅读时间:约9分钟


🌟 引言

在日常的 Web 开发中,你是否遇到过这样的困扰:

  • 用户在弱网或断网下无法使用你的应用;
  • 新版本上线后,用户缓存未更新,看到旧资源;
  • 资源请求策略混乱,缓存越来越大且不可控;
  • 对 Service Worker 的生命周期与作用域理解不清,调试成本高。

今天分享6个「Service Worker API」的核心实战技巧,帮助你构建离线可用、可控更新、性能更稳的现代 Web 应用!


💡 核心技巧详解

📝 使用说明:本文从基础到实战,围绕注册、生命周期、缓存、拦截、更新与通信展开,示例均采用箭头函数与 JSDoc 注释,代码简短易读。

1. 注册与作用域控制:让 SW 管控正确的路径

🔍 应用场景

在单页或多页应用中,确保 Service Worker 的作用域覆盖到需要离线与拦截的目录,并避免影响不相关的区域。

❌ 常见问题

sw.js 放在错误目录导致作用域过大或过小,或忘记使用 localhost/HTTPS 导致无法注册。

/**
 * 注册 Service Worker(作用域为当前目录)
 * @returns {Promise<ServiceWorkerRegistration|undefined>} 注册结果
 */
const registerServiceWorker = async () => {
  if (!('serviceWorker' in navigator)) return undefined;
  // 作用域以 sw.js 所在路径为准,建议放在要管控的子目录根部
  return navigator.serviceWorker.register('./sw.js');
};

// 页面加载时注册
void registerServiceWorker();

💡 核心要点

  • 作用域 = sw.js 所在目录及其子路径;
  • localhost 视为安全上下文,可直接注册;生产环境需 HTTPS
  • 建议按子系统或页面组划分多个 SW,避免过度管控。

🎯 实际应用

sw.js 放在 app/ 目录即可仅管控 app/* 路径;管理后台与用户端可各自独立。


2. 生命周期:安装/激活/更新与强制切换

🔍 应用场景

新版本上线时,控制旧 SW 与新 SW 的切换节奏,保障用户体验与兼容性。

✅ 推荐方案

install 做预缓存,在 activate 清理旧缓存。用 skipWaiting() 加速新版本进入等待状态,用 clients.claim() 让新版本接管已有页面。

/**
 * 安装阶段:预缓存核心资源
 * @param {ExtendableEvent} event
 */
self.addEventListener('install', (event) => {
  const precache = async () => {
    const cache = await caches.open('sw-cache-v1');
    await cache.addAll(['./', './index.html', './ping.txt']);
  };
  event.waitUntil(precache());
  self.skipWaiting(); // 加速激活新版本
});

/**
 * 激活阶段:清理旧缓存并接管页面
 * @param {ExtendableEvent} event
 */
self.addEventListener('activate', (event) => {
  const cleanup = async () => {
    const keys = await caches.keys();
    await Promise.all(keys.filter((k) => k !== 'sw-cache-v1').map((k) => caches.delete(k)));
    await self.clients.claim(); // 接管现有客户端
  };
  event.waitUntil(cleanup());
});

💡 核心要点

  • 新 SW 下载后进入「等待」状态,待旧 SW释放页面才激活;
  • skipWaiting() 可加速切换,但需评估对未保存状态的影响;
  • clients.claim() 让新 SW 立即控制已有页面,避免「刷新后才生效」。

3. 缓存策略:预缓存 + 运行时缓存(Cache First)

🔍 应用场景

静态资源预缓存,接口或动态资源按需缓存,常用策略:Cache First + 后台更新或 Stale-While-Revalidate

/**
 * 运行时缓存:同源 GET 请求采用 Cache First
 * @param {FetchEvent} event
 */
self.addEventListener('fetch', (event) => {
  const handle = async () => {
    const req = event.request;
    const url = new URL(req.url);
    const isGetSameOrigin = req.method === 'GET' && url.origin === self.location.origin;
    if (!isGetSameOrigin) return fetch(req);

    const cached = await caches.match(req);
    if (cached) return cached; // 命中缓存直接返回

    const res = await fetch(req);
    const cache = await caches.open('sw-cache-v1');
    // 克隆响应再入缓存,避免流耗尽
    void cache.put(req, res.clone());
    return res;
  };
  event.respondWith(handle());
});

💡 核心要点

  • 只缓存同源 GET 请求,避免跨域与非幂等请求带来风险;
  • cache.put(req, res.clone()) 保持流可读;
  • 大型资源建议分层缓存与过期清理。

4. 请求拦截与降级:离线兜底与错误处理

🔍 应用场景

网络不可用或请求失败时,友好降级与兜底响应。

/**
 * 兜底策略:失败时返回离线页面或提示文案
 * @param {FetchEvent} event
 */
self.addEventListener('fetch', (event) => {
  const fallback = async () => {
    try {
      return await fetch(event.request);
    } catch (_) {
      const offline = new Response('当前离线,稍后重试。', { status: 200, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
      return offline;
    }
  };
  event.respondWith(fallback());
});

💡 核心要点

  • 将兜底逻辑限定在关键路径与纯文本提示;
  • 更复杂场景可返回离线 HTML 页面;
  • 结合本地数据(IndexedDB)实现离线阅读或表单缓存。

5. 版本管理与缓存清理:控制可预测的更新

🔍 应用场景

频繁迭代时,确保缓存不会无限增长,且用户能及时获得新版本。

/**
 * 简洁的版本化命名与清理
 * @returns {Promise<void>} 完成清理
 */
const purgeOldCaches = async () => {
  const keep = 'sw-cache-v2';
  const keys = await caches.keys();
  await Promise.all(keys.filter((k) => k !== keep).map((k) => caches.delete(k)));
};

💡 核心要点

  • 缓存名带版本号,更新时切换并清理旧版本;
  • 资源 URL 带 hash/版本号,避免命名冲突;
  • 定期在激活阶段执行清理。

6. 与页面通信:状态提示与精准控制

🔍 应用场景

通知页面 SW 的安装/更新状态,或接收页面指令(清理缓存、强制更新等)。

/**
 * 页面向 SW 发送消息(如请求清理缓存)
 * @param {any} payload - 发送的数据
 * @returns {void}
 */
const postToSW = (payload) => {
  if (!navigator.serviceWorker.controller) return;
  navigator.serviceWorker.controller.postMessage(payload);
};

// SW 内接收消息
self.addEventListener('message', (event) => {
  /** @type {{type: string}} */
  const data = event.data || {};
  if (data.type === 'PURGE') void caches.keys().then((keys) => keys.forEach((k) => caches.delete(k)));
});

💡 核心要点

  • 通过 postMessage 双向通信;
  • 结合 UI 提示用户是否有新版本可用;
  • 更新策略透明,保障用户体验。

📊 技巧对比总结

技巧 使用场景 优势 注意事项
注册与作用域 控制 SW 管控范围 覆盖精准、易维护 路径决定作用域;需 HTTPS/localhost
生命周期 安装/激活/更新 版本切换可控 skipWaiting/claim 需评估风险
运行时缓存 动态资源加速 性能稳定、离线可用 谨慎缓存非幂等请求
离线兜底 弱网/断网场景 体验友好 离线页面需轻量且易识别
版本与清理 持续迭代 可预测更新 版本化命名 + 激活清理
通信 更新提示/控制 可视化与可操作 注意安全与消息协议

🎯 实战应用建议

  1. 对不同子系统使用独立 SW,降低影响面。
  2. 预缓存仅包含核心静态资源,运行时缓存针对关键接口与静态文件。
  3. 同源 GET 才进入缓存,其他请求原样透传。
  4. 使用 skipWaiting + clients.claim 组合时,加入“新版本可用”提示与用户确认。
  5. 缓存名版本化,激活阶段统一清理旧缓存。
  6. 通过 postMessage 汇报状态与接收页面指令,形成可观察的更新流程。

性能考虑

  • 限制缓存体积并分层管理,避免无限增长;
  • 为接口使用 Stale-While-Revalidate 或带超时时间的 Cache First
  • 对跨域与非幂等请求不做缓存,降低风险。

💡 总结

这6个 Service Worker 实战技巧覆盖从注册到更新的完整闭环,掌握它们能让你的 Web 应用:

  1. 在断网与弱网中保持基本可用;
  2. 版本更新可控、缓存不膨胀;
  3. 请求策略清晰、性能稳定;
  4. 调试成本降低、维护更可预测。

🔗 相关资源


💡 今日收获:掌握了 Service Worker 的核心用法与最佳实践,这些能力能显著提升 Web 应用的可用性与性能。

如果这篇文章对你有帮助,欢迎点赞、收藏和分享!有任何问题也欢迎在评论区讨论。 🚀

前端测试不再难:Vite+React+Vitest单元测试完整手册

作者 蓝瑟
2025年11月5日 10:56

适用项目框架:Vite + React + Vitest

前言

在现代前端开发中,单元测试是提升代码质量、减少回归 bug、增强重构信心的关键一环。尤其在 Vite + React 的技术栈中,Vitest 作为官方推荐的测试工具,凭借其原生支持 Vite、极速启动、与 Jest 高度兼容等特性,已成为 React 项目单元测试的首选。

本文将带你从零搭建一套完整、高效、可维护的单元测试体系,覆盖:

  • 依赖安装与 IDE 配置
  • Vitest 核心配置
  • 工具库核心概念
  • 四大核心测试场景(utils、hooks、components、redux
  • 常见踩坑与解决方案
  • 最佳实践与团队落地建议

适合人群:有一定 React 基础,想系统性引入单元测试的前端开发者

1. 安装依赖和 VSCode 插件

npm install --save-dev vitest jsdom @vitest/coverage-v8 @testing-library/dom @testing-library/jest-dom @testing-library/react  @testing-library/user-event @testing-library/react-hooks

依赖说明

库名称 简介
vitest 由 Vite 提供原生支持的高性能测试框架,支持 ESM、TypeScript、快照、mock 等
jsdom 在 Node.js 中模拟浏览器 DOM 环境,实现了 DOM 和 HTML 标准
@vitest/coverage-v8 Vitest 的 V8 代码覆盖率插件,用于收集测试覆盖率数据
@testing-library/dom 核心 DOM 查询与交互工具,强调“用户视角”测试
@testing-library/jest-dom 提供 toBeInTheDocumenttoHaveClass 等语义化断言
@testing-library/react 在 DOM Testing Library 基础上构建,提供测试 React 组件的轻量级工具
@testing-library/user-event 模拟真实用户行为(如点击、输入、hover)
@testing-library/react-hooks 用于测试 React hooks 的工具库

VSCode 插件推荐

  1. Vitest
    提供测试文件高亮、点击运行、调试支持。

    Vitest Plugin

  2. Vitest Runner
    在编辑器侧边栏显示测试树,支持单测运行、断点调试。

    Vitest Runner


2. 增加 test 配置

仅供参考,可根据项目结构灵活调整

vite.config.ts

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    // 使用 jsdom 模拟浏览器环境
    environment: 'jsdom',
    // 全局 setup 文件
    setupFiles: './src/tests/setup.ts',
    // 覆盖率配置
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'clover'],
      include: ['src/**/*.{ts,tsx}'],
      exclude: [
        'src/assets/**',
        'src/constants/**',
        'src/enums/**',
        'src/i18n/**',
        'src/styles/**',
        'src/apis/**',
        'src/main.tsx',
        'src/vite-env.d.ts'
      ],
    },
  },
})

src/tests/setup.ts

// 引入 jest-dom 匹配器, 确保 `toBeInTheDocument` 以及其他来自 `@testing-library/jest-dom` 的匹配器在 Vitest 中正常使用。
import '@testing-library/jest-dom/vitest'

// 自动清理 DOM,防止测试间污染
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'

afterEach(() => {
  cleanup()
})

// 可选:全局 mock console.error 避免干扰
// vi.spyOn(console, 'error').mockImplementation(() => {})

3. 重要概念

概念 说明 最佳实践
AAA 模式 Arrange(准备)→ Act(操作)→ Assert(断言) 结构清晰,避免测试逻辑混乱
用户视角测试 不关心实现细节,只关心“用户能否看到/操作” 使用 screen.getByRole 而非 getByTestId
快照测试 捕获 UI 结构,防止意外变更 仅用于稳定 UI,动态内容慎用
Mock 隔离外部依赖(API、网络、定时器) 使用 vi.mockmsw
集成测试 vs 单元测试 单元:单函数/组件;集成:多模块协作 Redux 推荐集成测试
act() 确保状态更新完成后再断言 涉及状态变更时必须使用

4. 一些基础的测试示例

1. utils 函数测试

何时需要测试

  • 纯逻辑函数(无副作用)
  • 业务复杂、易出错
  • 被多个组件复用

何时不要测试

  • 简单 getter/setter
  • 仅做类型转换
  • 已被组件测试覆盖

源代码 src/utils/sumDemo.ts

export function sum(a: number, b: number) {
  return a + b
}

测试代码 sumDemo.test.ts

import { expect, test } from 'vitest'
import { sum } from './sumDemo'

test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3)
})

test('adds 10 + 2', () => {
  expect(sum(10, 2)).toBe(12)
})

2. 自定义 Hooks 测试

何时需要测试

  • 独立封装的可复用 Hook
  • 包含复杂状态逻辑
  • 难以通过组件测试覆盖

何时不要测试

  • 组件内部定义的 Hook
  • 简单 useState 包装
  • 已有组件集成测试覆盖

源代码 useCounter.ts

import { useCallback, useState } from 'react'

function useCounter(initial = 0) {
  const [count, setCount] = useState<number>(initial)
  const increment = useCallback(() => setCount(x => x + 1), [])
  const decrement = useCallback(() => setCount(x => x - 1), [])
  const reset = useCallback(() => setCount(initial), [initial])

  return { count, increment, decrement, reset }
}

export default useCounter

测试代码 useCounter.test.ts

import { act, renderHook } from '@testing-library/react'
import { expect, test } from 'vitest'
import useCounter from './useCounter'

test('should increment counter', () => {
  const { result } = renderHook(() => useCounter())

  act(() => {
    result.current.increment()
  })

  expect(result.current.count).toBe(1)
})

test('should support initial value', () => {
  const { result } = renderHook(() => useCounter(10))
  expect(result.current.count).toBe(10)
})

test('should reset to initial value', () => {
  const { result } = renderHook(() => useCounter(5))

  act(() => result.current.increment())
  act(() => result.current.reset())

  expect(result.current.count).toBe(5)
})

3. 组件测试

何时需要测试

  • 核心 UI 组件
  • 包含交互逻辑
  • 状态变化影响渲染

何时不要测试

  • 纯展示组件(无状态)
  • 样式细节(用 Storybook)
  • 第三方组件包装(除非有自定义逻辑)

源代码 MyButton.tsx

import type { ButtonProps } from 'antd'
import { Button } from 'antd'

interface MyButtonProps extends ButtonProps {
  text: string
}

const MyButton = ({ text, onClick, ...rest }: MyButtonProps) => {
  return (
    <Button {...rest} onClick={onClick}>
      {text}
    </Button>
  )
}

export default MyButton

测试代码 MyButton.test.tsx

import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, test, vi } from 'vitest'
import MyButton from './MyButton'

describe('MyButton', () => {
  test('renders with correct text', () => {
    render(<MyButton text="Submit" />)
    expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument()
  })

  test('calls onClick when clicked', async () => {
    const handleClick = vi.fn()
    render(<MyButton text="Click me" onClick={handleClick} />)

    await userEvent.click(screen.getByRole('button', { name: 'Click me' }))
    expect(handleClick).toHaveBeenCalledTimes(1)
  })

  test('applies disabled state', () => {
    render(<MyButton text="Disabled" disabled />)
    expect(screen.getByRole('button')).toBeDisabled()
  })
})

推荐使用 getByRole 而非 getByText,更符合无障碍规范


4. Redux Store 测试

参考官方原则:Redux Testing

何时需要测试

  • 复杂 reducer 逻辑
  • 集成测试验证状态流

何时不要测试

  • 模拟 useSelector/useDispatch
  • 测试 React-Redux 内部实现
  • 简单 action creator

工具函数 renderWithProviders.ts

import { configureStore } from '@reduxjs/toolkit'
import type { RenderOptions } from '@testing-library/react'
import { render } from '@testing-library/react'
import type { PropsWithChildren } from 'react'
import { Provider } from 'react-redux'
import type { AppStore, RootState } from '@/store'
import rootReducer from '@/store'

export const setupStore = (preloadedState?: Partial<RootState>): AppStore => {
  return configureStore({
    reducer: rootReducer,
    preloadedState,
  })
}

interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
  preloadedState?: Partial<RootState>
  store?: AppStore
}

export function renderWithProviders(
  ui: React.ReactElement,
  {
    preloadedState = {},
    store = setupStore(preloadedState),
    ...renderOptions
  }: ExtendedRenderOptions = {}
) {
  const Wrapper = ({ children }: PropsWithChildren) => (
    <Provider store={store}>{children}</Provider>
  )
  return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) }
}

集成测试示例 UserDisplay.tsx

import { Button } from 'antd'
import { useAppDispatch, useAppSelector } from '@/store'
import { setAccount } from '@/store/accountSlice'

export default function UserDisplay() {
  const userName = useAppSelector(state => state.account.name)
  const dispatch = useAppDispatch()

  return (
    <div>
      <div data-testid="username">{userName}-test</div>
      <Button onClick={() => dispatch(setAccount('new user'))}>
        Switch User
      </Button>
    </div>
  )
}
// UserDisplay.test.tsx
import { screen } from '@testing-library/react'
import { describe, expect, test } from 'vitest'
import { renderWithProviders } from '@/tests/renderWithProviders'
import UserDisplay from './UserDisplay'

describe('UserDisplay Integration', () => {
  test('renders initial username', () => {
    renderWithProviders(<UserDisplay />, {
      preloadedState: { account: { name: 'John' } }
    })
    expect(screen.getByTestId('username')).toHaveTextContent('John-test')
  })

  test('updates username on button click', () => {
    renderWithProviders(<UserDisplay />)
    screen.getByText('Switch User').click()
    expect(screen.getByTestId('username')).toHaveTextContent('new user-test')
  })
})

Reducer 单元测试 todosSlice.ts

// slice
const todosSlice = createSlice({
  name: 'todos',
  initialState: [] as Todo[],
  reducers: {
    todoAdded(state, action: PayloadAction<string>) {
      const id = state.length ? Math.max(...state.map(t => t.id)) + 1 : 0
      state.push({ id, text: action.payload, completed: false })
    }
  }
})
// todosSlice.test.ts
test('should add todo with correct id', () => {
  const state = [{ id: 5, text: 'Existing', completed: false }]
  const newState = todosReducer(state, todoAdded('New'))
  expect(newState).toHaveLength(2)
  expect(newState[1]).toEqual({ id: 6, text: 'New', completed: false })
})

5. 踩坑记录

问题 原因 解决方案
toBeInTheDocument is not a function 未引入 @testing-library/jest-dom setup.tsimport '@testing-library/jest-dom/vitest'
测试间状态污染 未清理 DOM afterEach(cleanup)
act 警告 状态更新未包裹 使用 act(() => { ... })await userEvent
覆盖率包含 node_modules 配置错误 使用 exclude 排除
vi.mock 不生效 ESM 模块提升 使用 vi.mock('./module', { partial: true })
异步组件未渲染 缺少 findBy 使用 await screen.findByText(...)

以下是一些更细节的踩坑记录📝

1. vitest的模块模拟机制提升问题

1. 报错信息:

There was an error when mocking a module. If you are using “vi.mock” factory, make sure thre are no top level variables inside, since this call is hoisted to the top of th file.

about vitest ---- record error1:

2.错误写法:

// 获取 selector 函数的 mock 引用

const mockSelectSolutionFinishedSelection = vi.fn();
const mockSelectSolutionValidateErrorSubSelections = vi.fn();

// Mock store 模块
vi.mock("@/store", async (importOriginal) => {
  const actual = await importOriginal<typeof storeHooks>();
  return {
    ...actual,
    useAppSelector: vi.fn((selector) => selector()), // 动态调用 selector 函数
    selectSolution_finishedSelection: mockSelectSolutionFinishedSelection,
    selectSolution_validateErrorSubSelections: mockSelectSolutionValidateErrorSubSelections,
  }
})
3.错误解析:

Vitest的模块模拟机制会将vi.mock提升到文件顶部,导致这些变量在初始化之前被访问,从而引发错误。

4.解决措施:

// 将 mock 变量提升到模块顶层

const mockSelectSolutionFinishedSelection = vi.fn()
const mockSelectSolutionValidateErrorSubSelections = vi.fn()

// Mock store 模块
vi.mock("@/store", async (importOriginal) => {
    const actual = await importOriginal<typeof storeHooks>()
    return {
        ...actual,
        // 关键修改:让 useAppSelector 根据 selector 动态返回 mock 值
        useAppSelector: vi.fn((selector) => {
            switch (selector) {
                case actual.selectSolution_finishedSelection:
                    return mockSelectSolutionFinishedSelection()
                case actual.selectSolution_validateErrorSubSelections:
                    return mockSelectSolutionValidateErrorSubSelections()
                default:
                    return selector()
            }
        }),
    }
})
5.小结:

Vitest 的模块模拟机制会 提升(hoist)所有 vi.mock 调用到文件顶部,导致: 1.这些变量会在模块初始化前被访问 2.不同测试用例之间会共享同一个 mock 引用

最佳实践原则

  1. 永远不要在 vi.mock 工厂函数内部定义新变量
  2. 需要 mock 的变量应该提升到模块顶层
  3. 通过函数引用而非字符串匹配来识别 selector
  4. 如果需要保留原模块的某些功能,使用 importOriginal 获取原始引用

2. 谨慎使用 vi.spyOn

问题现象
// ❌ 无法工作
const mockUseSolutionType = vi.spyOn(require('../solutionBasehooks'), 'useSolutionType');

// ✅ 可以工作
import { useSolutionType } from '../solutionBasehooks';
vi.mocked(useSolutionType).mockReturnValue(...);
核心原因

模块系统差异ESM/CJS 兼容性导致:

  1. vi.spyOn 的局限性

    • 仅适用于 对象上的方法(如 obj.method
    • 无法直接监视 ES 模块的默认导出函数(因为 ES 模块导出是静态的、不可变的)
    • 当使用 require() 导入时,可能获取到未 mock 的原始模块引用
  2. vi.mocked 的优势

    • 专为 TypeScript 设计,能与 vi.mock 的模块替换机制完美配合
    • 通过 import 语句获取的是已经被 Vitest 处理过的 mock 引用
    • 自动继承 TypeScript 类型提示
正确做法
// ✅ 推荐方式:使用 vi.mock + vi.mocked
vi.mock('../solutionBasehooks', () => ({
  useSolutionType: vi.fn()
}));

import { useSolutionType } from '../solutionBasehooks';

// 类型安全且可靠的 mock
vi.mocked(useSolutionType).mockReturnValue(SolutionEnum.Haipick3);

6. 一些心得与最佳实践

测试金字塔

     👑  E2E (少量)
   🏃  集成测试 (适量)
 👤  单元测试 (大量)

推荐策略

  1. 80% 单元测试:utils、hooks、纯逻辑
  2. 15% 集成测试:组件 + Redux 状态流
  3. 5% E2E:核心用户流程

单元测试原则

1.单一职责:每个测试应该只测试一个功能点。

不推荐:一个测试测试多个功能点

test('Button renders correctly and handles click', () => {
  // 测试渲染
  // 测试点击事件
});

推荐:拆分为多个测试

test('Button renders correctly', () => {
  // 测试渲染
});

test('Button handles click event', () => {
  // 测试点击事件
});
2.独立性:测试应该相互独立,不依赖于其他测试的执行顺序或结果。
3.可读性:测试代码应该清晰易读,测试名称应描述被测试的行为。

不推荐:测试名称不清晰

test('test button', () => {
 // ...
});

推荐:测试名称清晰描述行为

test('Button should be disabled when isDisabled prop is true', () => {
 // ...
});
4.快速:单元测试应该执行迅速,以便频繁运行。

命名规范

sum.test.ts
useCounter.test.ts
MyButton.render.test.tsx
MyButton.interaction.test.tsx

CI/CD 集成

# .github/workflows/test.yml
- name: Run Tests
  run: npm run test:cov
- name: Upload Coverage
  uses: codecov/codecov-action@v3

团队落地建议

  • 可以尝试先从 utils 和 hooks 开始写测试
  • 新功能必须带测试
  • 重构时补充测试
  • 考虑使用 vitest --watch 本地开发

结语:测试不仅仅是验证代码正确性,更是推动我们写出更好代码的设计工具。如果你还没有在项目中系统引入单元测试,现在就是最好的时机。本文提供了从环境搭建到实战示例的完整路径,覆盖了前端测试最常见的场景。不要被"完美测试"所束缚——从为一个工具函数写测试开始,从为关键业务组件添加防护开始吧~

Vue中如何实现可切换显示/隐藏侧边栏的按钮

2025年11月5日 10:52

Vue中如何实现可切换显示/隐藏侧边栏的按钮

需求如下:
image.png

image.png

在Vue应用中实现可切换显示/隐藏侧边栏的按钮,以下是如何实现这一功能的详细步骤:

1. 基本结构设计

需要三个主要元素:

  • app-sidebar: 侧边栏容器
  • sidebar-toggle-btn: 切换按钮
  • app-main: 主内容区域
<template>
  <div class="app-layout">
    <!-- 侧边栏 -->
    <aside 
      class="app-sidebar" 
      :class="{ 'sidebar-hidden': !isSidebarVisible }"
    >
      <!-- 侧边栏内容 -->
    </aside>
    
    <!-- 切换按钮 -->
    <button
      class="sidebar-toggle-btn"
      @click="toggleSidebar"
      :class="{ 'sidebar-hidden': !isSidebarVisible }"
    >
      <span class="toggle-btn-arrow" :class="{ 'rotated': !isSidebarVisible }">
        ‹
      </span>
    </button>
    
    <!-- 主内容区域 -->
    <main class="app-main">
      <div class="main-content">
        <router-view />
      </div>
    </main>
  </div>
</template>

2. 数据状态管理

在Vue组件的 data 中定义侧边栏显示状态:

export default {
  data() {
    return {
      isSidebarVisible: true // 默认显示侧边栏
    }
  },
  methods: {
    toggleSidebar() {
      this.isSidebarVisible = !this.isSidebarVisible
    }
  }
}

3. CSS样式实现

侧边栏样式

.app-sidebar {
  width: 250px;
  height: calc(100vh - 60px);
  position: fixed;
  top: 60px;
  left: 0;
  transition: transform 0.3s ease;
}

.sidebar-hidden {
  transform: translateX(-100%);
}

切换按钮样式

.sidebar-toggle-btn {
  position: fixed;
  left: 250px;
  top: 50%;
  transform: translateY(-50%);
  transition: all 0.3s ease;
  z-index: 100;
}

.sidebar-toggle-btn.sidebar-hidden {
  left: 0;
}

.toggle-btn-arrow.rotated {
  transform: rotate(180deg);
}

4. 核心实现原理

状态切换逻辑

  • 通过 isSidebarVisible 响应式数据控制侧边栏显示/隐藏
  • 使用CSS transform: translateX() 实现平滑过渡动画
  • 切换按钮根据侧边栏状态调整位置和箭头方向

响应式布局

  • 侧边栏隐藏时主内容区域自动扩展占据空间
  • 使用CSS选择器 :not(.sidebar-hidden) 控制主内容区域的margin

5. 交互体验优化

视觉反馈

  • 按钮悬停效果
  • 箭头方向变化动画
  • 阴影和圆角设计提升视觉层次

动画过渡

  • 使用CSS transition 实现平滑动画
  • 位置变化和样式变化同步进行
  • 300ms过渡时间提供流畅体验

封装axios实现全局loading,在一定程度上减少重复请求的发生

作者 apollo_qwe
2025年11月5日 10:51
解决了 “在每个页面 / 按钮上手动绑定 loading” 的问题,大幅减少了重复代码
核心代码如下:
import axios from 'axios';
import { Loading } from 'element-ui';
// 定义不同API类型的超时时间(单位:毫秒)
const TIMEOUT_CONFIG = {
  default: 3000, // 默认超时时间
  fast: 1000,     // 快速API,简单查询等
  normal: 5000,  // 普通API,大多数业务接口
  slow: 10000,    // 慢速API,文件上传、大数据量处理
  critical: 15000 // 关键API,支付、重要业务处理
}

const service = axios.create({
  baseURL: process.env.VUE_APP_BASE_API, 
  timeout: TIMEOUT_CONFIG.default 
});

/**
 *  loading 计数
 *  loadingCount //请求次数,为0时,结束loading
 **/
 let loadingCount = 0
 let isLoading = false
 let loadingInstance = null
 const noLoadingApi = ['a','b','c'] // 禁止触发全局loading的路由
 const addLoading = (url) => {
 const result = noLoadingApi.includes(url)
  if(result){
    return;
  }
   loadingCount++
   if (!isLoading){
    loadingInstance = Loading.service({
      lock: true,
      background: 'rgba(0, 0, 0, 0.9)'
    })
    isLoading = true
   }
 }
 
 const closeLoading = (url) => {
 const result = url && noLoadingApi.includes(url)
  if(result){
    return;
  }
   loadingCount--
   if (loadingCount <= 0) {
    loadingInstance && loadingInstance.close()
    isLoading = false
   }
 }

service.interceptors.request.use(
  config => {
    // 定义关键API路径数组
    const CRITICAL_API_PATHS = ['a','b','c'];
    
    // 检查是否是关键API
    if (CRITICAL_API_PATHS.some(path => config.url.includes(path))) {
      config.timeout = TIMEOUT_CONFIG.critical
    }
    addLoading(config.url)
    //....根据需求自定义封装请求头
  
    return config;
  },
  error => {
    return Promise.reject(error);
  }
);

service.interceptors.response.use(
  response => {
    closeLoading(response.config.url)
    //...根据需求自定义封装响应头
  },
  error => {
    closeLoading()
    return Promise.reject(error);
  }
);

export default service;

1. 全局统一管理 loading,无需页面 / 按钮单独处理

代码通过 axios 的请求 / 响应拦截器,对所有经过 service 实例的请求进行统一拦截:

  • 请求发起时:自动调用 addLoading 显示全局 loading(除非接口在 noLoadingApi 白名单中);
  • 请求完成时(成功 / 失败):自动调用 closeLoading 关闭全局 loading(当所有并发请求都完成时)。

这种方式下,无论哪个页面、哪个按钮触发的请求,只要使用了这个 service 实例,都不需要在页面中手动写 loading.show() 或 loading.hide(),完全由拦截器自动处理。

2. 避免了 “重复编写 loading 控制逻辑” 的冗余

如果没有这段代码,通常的做法是:

  • 在每个按钮点击事件中,先手动显示 loading;
  • 在请求的 then/catch 中手动隐藏 loading;
  • 还要处理多个请求并发时,loading 被提前关闭的问题(比如两个请求同时发起,第一个完成就关 loading,导致第二个请求无 loading)。

而这段代码通过 loadingCount 计数和拦截器统一控制,一次性解决了 “显示 / 隐藏时机”“并发请求 loading 管理” 等问题,所有页面 / 按钮都能复用这套逻辑,无需重复编写。

3. 特殊场景通过配置排除,灵活性兼顾

代码中通过 noLoadingApi 数组定义了 “不需要 loading 的接口”,对于这些特殊接口,无需在页面中单独处理,只需在全局配置中维护这个数组即可,进一步减少了页面级的重复配置。

总结

这段代码通过 “拦截器 + 全局配置” 的方式,实现了 loading 的 “一次编写,全项目复用”,彻底避免了在每个页面、每个按钮中重复编写 loading 控制逻辑的工作,显著减少了冗余代码,同时还解决了并发请求下的 loading 显示问题,是一种高效的全局状态管理方案。

❌
❌