普通视图

发现新文章,点击刷新页面。
昨天 — 2025年11月6日首页

《uni-app跨平台开发完全指南》- 04 - 页面布局与样式基础

2025年11月6日 11:20

uni-app:掌握页面布局与样式

新手刚接触uni-app布局可能会遇到以下困惑:明明在模拟器上完美显示的页面,到了真机上就面目全非;iOS上对齐的元素,到Android上就错位几个像素,相信很多开发者都经历过。今天就带大家摸清了uni-app布局样式的门道,把这些经验毫无保留地分享给大家,让你少走弯路。

一、Flex布局

1.1 为什么Flex布局是移动端首选?

传统布局的痛点:

/* 传统方式实现垂直居中 */
.container {
  position: relative;
  height: 400px;
}
.center {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 200px;
  height: 100px;
  margin-top: -50px;  /* 需要计算 */
  margin-left: -100px; /* 需要计算 */
}

Flex布局:

/* Flex布局实现垂直居中 */
.container {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 400px;
}
.center {
  width: 200px;
  height: 100px;
}

从对比中不难看出,Flex布局用更少的代码、更清晰的逻辑解决了复杂的布局问题。

1.2 Flex布局的核心概念

为了更好地理解Flex布局,我们先来看一下它的基本模型:

Flex容器 (display: flex)
├─────────────────────────────────┤
│ 主轴方向 (flex-direction) →     │
│                                 │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ │ 元素1   │ │ 元素2   │ │ 元素3   │ ← Flex元素
│ └─────────┘ └─────────┘ └─────────┘
│                                 │
│ ↑                               │
│ 交叉轴方向                       │
└─────────────────────────────────┘

Flex布局的两大核心:

  • 容器:设置display: flex的元素,控制内部项目的布局
  • 元素:容器的直接子元素,受容器属性控制

1.3 容器属性

1.3.1 flex-direction:布局方向

这个属性决定了元素的排列方向,是Flex布局的基础:

.container {
  /* 水平方向,从左到右(默认) */
  flex-direction: row;
  
  /* 水平方向,从右到左 */
  flex-direction: row-reverse;
  
  /* 垂直方向,从上到下 */
  flex-direction: column;
  
  /* 垂直方向,从下到上 */
  flex-direction: column-reverse;
}

实际应用场景分析:

属性值 适用场景
row 水平导航、卡片列表
column 表单布局、设置页面
row-reverse 阿拉伯语等从右向左语言
column-reverse 聊天界面(最新消息在底部)
1.3.2 justify-content:主轴对齐

这个属性控制元素在主轴上的对齐方式,使用频率非常高:

.container {
  display: flex;
  
  /* 起始位置对齐 */
  justify-content: flex-start;
  
  /* 末尾位置对齐 */
  justify-content: flex-end;
  
  /* 居中对齐 */
  justify-content: center;
  
  /* 两端对齐,项目间隔相等 */
  justify-content: space-between;
  
  /* 每个项目两侧间隔相等 */
  justify-content: space-around;
  
  /* 均匀分布,包括两端 */
  justify-content: space-evenly;
}

空间分布对比关系:

  • start - 从头开始
  • end - 从尾开始
  • center - 居中对齐
  • between - 元素"之间"有间隔
  • around - 每个元素"周围"有空间
  • evenly - 所有空间"均匀"分布
1.3.3 align-items:交叉轴对齐

控制元素在交叉轴上的对齐方式:

.container {
  display: flex;
  height: 300rpx; /* 需要明确高度 */
  
  /* 交叉轴起点对齐 */
  align-items: flex-start;
  
  /* 交叉轴终点对齐 */
  align-items: flex-end;
  
  /* 交叉轴中点对齐 */
  align-items: center;
  
  /* 基线对齐(文本相关) */
  align-items: baseline;
  
  /* 拉伸填充(默认) */
  align-items: stretch;
}

温馨提示align-items的效果与flex-direction密切相关:

  • flex-direction: row时,交叉轴是垂直方向
  • flex-direction: column时,交叉轴是水平方向

1.4 元素属性

1.4.1 flex-grow

控制元素放大比例,默认0(不放大):

.item {
  flex-grow: <number>; /* 默认0 */
}

计算原理:

总剩余空间 = 容器宽度 - 所有元素宽度总和
每个元素分配空间 = (元素的flex-grow / 所有元素flex-grow总和) × 总剩余空间

示例分析:

.container {
  width: 750rpx;
  display: flex;
}
.item1 { width: 100rpx; flex-grow: 1; }
.item2 { width: 100rpx; flex-grow: 2; }
.item3 { width: 100rpx; flex-grow: 1; }

/* 计算过程:
剩余空间 = 750 - (100+100+100) = 450rpx
flex-grow总和 = 1+2+1 = 4
item1分配 = (1/4)×450 = 112.5rpx → 最终宽度212.5rpx
item2分配 = (2/4)×450 = 225rpx → 最终宽度325rpx  
item3分配 = (1/4)×450 = 112.5rpx → 最终宽度212.5rpx
*/
1.4.2 flex-shrink

控制元素缩小比例,默认1(空间不足时缩小):

.item {
  flex-shrink: <number>; /* 默认1 */
}

小技巧:设置flex-shrink: 0可以防止元素被压缩,常用于固定宽度的元素。

1.4.3 flex-basis

定义元素在分配多余空间之前的初始大小:

.item {
  flex-basis: auto | <length>; /* 默认auto */
}
1.4.4 flex

flexflex-growflex-shrinkflex-basis的简写:

.item {
  /* 等价于 flex: 0 1 auto */
  flex: none;
  
  /* 等价于 flex: 1 1 0% */ 
  flex: 1;
  
  /* 等价于 flex: 1 1 auto */
  flex: auto;
  
  /* 自定义 */
  flex: 2 1 200rpx;
}

1.5 完整页面布局实现

让我们用Flex布局实现一个典型的移动端页面:

<view class="page-container">
  <!-- 顶部导航 -->
  <view class="header">
    <view class="nav-back"></view>
    <view class="nav-title">商品详情</view>
    <view class="nav-actions">···</view>
  </view>
  
  <!-- 内容区域 -->
  <view class="content">
    <!-- 商品图 -->
    <view class="product-image">
      <image src="/static/product.jpg" mode="aspectFit"></image>
    </view>
    
    <!-- 商品信息 -->
    <view class="product-info">
      <view class="product-name">高端智能手机 8GB+256GB</view>
      <view class="product-price">
        <text class="current-price">¥3999</text>
        <text class="original-price">¥4999</text>
      </view>
      <view class="product-tags">
        <text class="tag">限时优惠</text>
        <text class="tag">分期免息</text>
        <text class="tag">赠品</text>
      </view>
    </view>
    
    <!-- 规格选择 -->
    <view class="spec-section">
      <view class="section-title">选择规格</view>
      <view class="spec-options">
        <view class="spec-option active">8GB+256GB</view>
        <view class="spec-option">12GB+512GB</view>
      </view>
    </view>
  </view>
  
  <!-- 底部操作栏 -->
  <view class="footer">
    <view class="footer-actions">
      <view class="action-btn cart">购物车</view>
      <view class="action-btn buy-now">立即购买</view>
    </view>
  </view>
</view>
.page-container {
  display: flex;
  flex-direction: column;
  height: 100vh;
  background-color: #f5f5f5;
}

/* 头部导航 */
.header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 88rpx;
  padding: 0 32rpx;
  background: white;
  border-bottom: 1rpx solid #eee;
}

.nav-back, .nav-actions {
  width: 60rpx;
  text-align: center;
  font-size: 36rpx;
}

.nav-title {
  flex: 1;
  text-align: center;
  font-size: 36rpx;
  font-weight: bold;
}

/* 内容区域 */
.content {
  flex: 1;
  overflow-y: auto;
}

.product-image {
  height: 750rpx;
  background: white;
}

.product-image image {
  width: 100%;
  height: 100%;
}

.product-info {
  padding: 32rpx;
  background: white;
  margin-bottom: 20rpx;
}

.product-name {
  font-size: 36rpx;
  font-weight: bold;
  margin-bottom: 20rpx;
  line-height: 1.4;
}

.product-price {
  display: flex;
  align-items: center;
  margin-bottom: 20rpx;
}

.current-price {
  font-size: 48rpx;
  color: #ff5000;
  font-weight: bold;
  margin-right: 20rpx;
}

.original-price {
  font-size: 28rpx;
  color: #999;
  text-decoration: line-through;
}

.product-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 20rpx;
}

.tag {
  padding: 8rpx 20rpx;
  background: #fff2f2;
  color: #ff5000;
  font-size: 24rpx;
  border-radius: 8rpx;
}

/* 规格选择 */
.spec-section {
  background: white;
  padding: 32rpx;
}

.section-title {
  font-size: 32rpx;
  font-weight: bold;
  margin-bottom: 24rpx;
}

.spec-options {
  display: flex;
  gap: 20rpx;
}

.spec-option {
  padding: 20rpx 40rpx;
  border: 2rpx solid #e0e0e0;
  border-radius: 12rpx;
  font-size: 28rpx;
}

.spec-option.active {
  border-color: #007AFF;
  background: #f0f8ff;
  color: #007AFF;
}

/* 底部操作栏 */
.footer {
  background: white;
  border-top: 1rpx solid #eee;
  padding: 20rpx 32rpx;
}

.footer-actions {
  display: flex;
  gap: 20rpx;
}

.action-btn {
  flex: 1;
  height: 80rpx;
  border-radius: 40rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 32rpx;
  font-weight: bold;
}

.cart {
  background: #fff2f2;
  color: #ff5000;
  border: 2rpx solid #ff5000;
}

.buy-now {
  background: #ff5000;
  color: white;
}

这个例子展示了如何用Flex布局构建复杂的页面结构,包含了水平布局、垂直布局、空间分配等各种技巧。

二、跨端适配:rpx单位系统

2.1 像素密度

要理解rpx的价值,首先要明白移动端面临的问题:

设备现状:

设备A: 4.7英寸, 750×1334像素, 326ppi
设备B: 6.1英寸, 828×1792像素, 326ppi  
设备C: 6.7英寸, 1284×2778像素, 458ppi

同样的CSS像素在不同设备上的物理尺寸不同,这就是我们需要响应式单位的原因。

2.2 rpx的工作原理

rpx的核心思想很简单:以屏幕宽度为基准的相对单位

rpx计算原理:
1rpx = (屏幕宽度 / 750) 物理像素

不同设备上的表现:

设备宽度 1rpx对应的物理像素 计算过程
750px 1px 750/750 = 1
375px 0.5px 375/750 = 0.5
1125px 1.5px 1125/750 = 1.5

2.3 rpx与其他单位的对比分析

为了更好地理解rpx,我们把它和其他常用单位做个对比:

/* 不同单位的对比示例 */
.element {
  width: 750rpx;    /* 总是占满屏幕宽度 */
  width: 100%;      /* 占满父容器宽度 */
  width: 375px;     /* 固定像素值 */
  width: 50vw;      /* 视窗宽度的50% */
}

2.4 rpx实际应用与问题排查

2.4.1 设计稿转换

情况一:750px设计稿(推荐)

设计稿测量值 = 直接写rpx值
设计稿200px → width: 200rpx

情况二:375px设计稿

rpx值 = (设计稿测量值 ÷ 375) × 750
设计稿200px → (200÷375)×750 = 400rpx

情况三:任意尺寸设计稿

// 通用转换公式
function pxToRpx(px, designWidth = 750) {
  return (px / designWidth) * 750;
}

// 使用示例
const buttonWidth = pxToRpx(200, 375); // 返回400
2.4.2 rpx常见问题

问题1:边框模糊

/* 不推荐 - 可能在不同设备上模糊 */
.element {
  border: 1rpx solid #e0e0e0;
}

/* 推荐 - 使用px保证清晰度 */
.element {
  border: 1px solid #e0e0e0;
}

问题2:大屏设备显示过大

.container {
  width: 750rpx; /* 在小屏上合适,大屏上可能太大 */
}

/* 解决方案:媒体查询限制最大宽度 */
@media (min-width: 768px) {
  .container {
    width: 100%;
    max-width: 500px;
    margin: 0 auto;
  }
}

2.5 响应式网格布局案例

<view class="product-grid">
  <view class="product-card" v-for="item in 8" :key="item">
    <image class="product-img" src="/static/product.jpg"></image>
    <view class="product-info">
      <text class="product-name">商品标题{{item}}</text>
      <text class="product-desc">商品描述信息</text>
      <view class="product-bottom">
        <text class="product-price">¥199</text>
        <text class="product-sales">销量: 1.2万</text>
      </view>
    </view>
  </view>
</view>
.product-grid {
  display: flex;
  flex-wrap: wrap;
  padding: 20rpx;
  gap: 20rpx; /* 间隙,需要确认平台支持 */
}

.product-card {
  width: calc((100% - 20rpx) / 2); /* 2列布局 */
  background: white;
  border-radius: 16rpx;
  overflow: hidden;
  box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.08);
}

/* 兼容不支持gap的方案 */
.product-grid {
  display: flex;
  flex-wrap: wrap;
  padding: 20rpx;
  justify-content: space-between;
}

.product-card {
  width: 345rpx; /* (750-20*2-20)/2 = 345 */
  margin-bottom: 20rpx;
}

.product-img {
  width: 100%;
  height: 345rpx;
  display: block;
}

.product-info {
  padding: 20rpx;
}

.product-name {
  display: block;
  font-size: 28rpx;
  font-weight: bold;
  margin-bottom: 10rpx;
  line-height: 1.4;
}

.product-desc {
  display: block;
  font-size: 24rpx;
  color: #999;
  margin-bottom: 20rpx;
  line-height: 1.4;
}

.product-bottom {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.product-price {
  font-size: 32rpx;
  color: #ff5000;
  font-weight: bold;
}

.product-sales {
  font-size: 22rpx;
  color: #999;
}

/* 平板适配 */
@media (min-width: 768px) {
  .product-card {
    width: calc((100% - 40rpx) / 3); /* 3列布局 */
  }
}

/* 大屏适配 */
@media (min-width: 1024px) {
  .product-grid {
    max-width: 1200px;
    margin: 0 auto;
  }
  
  .product-card {
    width: calc((100% - 60rpx) / 4); /* 4列布局 */
  }
}

这个网格布局会在不同设备上自动调整列数,真正实现"一次编写,到处运行"。

三、样式作用域

3.1 全局样式

全局样式是整个应用的样式基石,应该在App.vue中统一定义:

/* App.vue - 全局样式体系 */
<style>
/* CSS变量定义 */
:root {
  /* 颜色 */
  --color-primary: #007AFF;
  --color-success: #4CD964;
  --color-warning: #FF9500;
  --color-error: #FF3B30;
  --color-text-primary: #333333;
  --color-text-secondary: #666666;
  --color-text-tertiary: #999999;
  
  /* 间距 */
  --spacing-xs: 10rpx;
  --spacing-sm: 20rpx;
  --spacing-md: 30rpx;
  --spacing-lg: 40rpx;
  --spacing-xl: 60rpx;
  
  /* 圆角 */
  --border-radius-sm: 8rpx;
  --border-radius-md: 12rpx;
  --border-radius-lg: 16rpx;
  --border-radius-xl: 24rpx;
  
  /* 字体 */
  --font-size-xs: 20rpx;
  --font-size-sm: 24rpx;
  --font-size-md: 28rpx;
  --font-size-lg: 32rpx;
  --font-size-xl: 36rpx;
  
  /* 阴影 */
  --shadow-sm: 0 2rpx 8rpx rgba(0,0,0,0.1);
  --shadow-md: 0 4rpx 20rpx rgba(0,0,0,0.12);
  --shadow-lg: 0 8rpx 40rpx rgba(0,0,0,0.15);
}

/* 全局重置样式 */
page {
  font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, 
               'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 
               SimSun, sans-serif;
  background-color: #F8F8F8;
  color: var(--color-text-primary);
  font-size: var(--font-size-md);
  line-height: 1.6;
}

/* 工具类 - 原子CSS */
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }

.flex { display: flex; }
.flex-column { flex-direction: column; }
.flex-center { 
  display: flex;
  align-items: center;
  justify-content: center;
}
.flex-between {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.m-10 { margin: 10rpx; }
.m-20 { margin: 20rpx; }
.p-10 { padding: 10rpx; }
.p-20 { padding: 20rpx; }

/* 通用组件样式 */
.uni-button {
  padding: 24rpx 48rpx;
  border-radius: var(--border-radius-md);
  font-size: var(--font-size-lg);
  border: none;
  background-color: var(--color-primary);
  color: white;
  transition: all 0.3s ease;
}

.uni-button:active {
  opacity: 0.8;
  transform: scale(0.98);
}
</style>

3.2 局部样式

局部样式通过scoped属性实现样式隔离,避免样式污染:

scoped样式原理:

<!-- 编译前 -->
<template>
  <view class="container">
    <text class="title">标题</text>
  </view>
</template>

<style scoped>
.container {
  padding: 32rpx;
}
.title {
  color: #007AFF;
  font-size: 36rpx;
}
</style>

<!-- 编译后 -->
<template>
  <view class="container" data-v-f3f3eg9>
    <text class="title" data-v-f3f3eg9>标题</text>
  </view>
</template>

<style>
.container[data-v-f3f3eg9] {
  padding: 32rpx;
}
.title[data-v-f3f3eg9] {
  color: #007AFF;
  font-size: 36rpx;
}
</style>

3.3 样式穿透

当需要修改子组件样式时,使用深度选择器:

/* 修改uni-ui组件样式 */
.custom-card ::v-deep .uni-card {
  border-radius: 24rpx;
  box-shadow: var(--shadow-lg);
}

.custom-card ::v-deep .uni-card__header {
  padding: 32rpx 32rpx 0;
  border-bottom: none;
}

/* 兼容不同平台的写法 */
.custom-card /deep/ .uni-card__content {
  padding: 32rpx;
}

3.4 条件编译

uni-app的条件编译可以针对不同平台编写特定样式:

/* 通用基础样式 */
.button {
  padding: 24rpx 48rpx;
  border-radius: 12rpx;
  font-size: 32rpx;
}

/* 微信小程序特有样式 */
/* #ifdef MP-WEIXIN */
.button {
  border-radius: 8rpx;
}
/* #endif */

/* H5平台特有样式 */
/* #ifdef H5 */
.button {
  cursor: pointer;
  transition: all 0.3s ease;
}
.button:hover {
  opacity: 0.9;
  transform: translateY(-2rpx);
}
/* #endif */

/* App平台特有样式 */
/* #ifdef APP-PLUS */
.button {
  border-radius: 16rpx;
}
/* #endif */

3.5 样式架构

推荐的项目样式结构:

styles/
├── variables.css     # CSS变量定义
├── reset.css         # 重置样式
├── mixins.css        # 混合宏
├── components/       # 组件样式
│   ├── button.css
│   ├── card.css
│   └── form.css
├── pages/           # 页面样式
│   ├── home.css
│   ├── profile.css
│   └── ...
└── utils.css        # 工具类

在App.vue中导入:

<style>
/* 导入样式文件 */
@import './styles/variables.css';
@import './styles/reset.css';
@import './styles/utils.css';
@import './styles/components/button.css';
</style>

四、CSS3高级特性

4.1 渐变与阴影

4.1.1 渐变
/* 线性渐变 */
.gradient-bg {
  /* 基础渐变 */
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  
  /* 多色渐变 */
  background: linear-gradient(90deg, 
    #FF6B6B 0%, 
    #4ECDC4 33%, 
    #45B7D1 66%, 
    #96CEB4 100%);
  
  /* 透明渐变 - 遮罩效果 */
  background: linear-gradient(
    to bottom,
    rgba(0,0,0,0.8) 0%,
    rgba(0,0,0,0) 100%
  );
}

/* 文字渐变效果 */
.gradient-text {
  background: linear-gradient(135deg, #667eea, #764ba2);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
}
4.1.2 阴影
/* 基础阴影层级 */
.shadow-layer-1 {
  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}

.shadow-layer-2 {
  box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.12);
}

.shadow-layer-3 {
  box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.15);
}

/* 内阴影 */
.shadow-inner {
  box-shadow: inset 0 2rpx 4rpx rgba(0, 0, 0, 0.06);
}

/* 多重阴影 */
.shadow-multi {
  box-shadow: 
    0 2rpx 4rpx rgba(0, 0, 0, 0.1),
    0 8rpx 16rpx rgba(0, 0, 0, 0.1);
}

/* 悬浮效果 */
.card {
  transition: all 0.3s ease;
  box-shadow: var(--shadow-md);
}

.card:hover {
  box-shadow: var(--shadow-lg);
  transform: translateY(-4rpx);
}

4.2 变换与动画

4.2.1 变换
/* 2D变换 */
.transform-2d {
  /* 平移 */
  transform: translate(100rpx, 50rpx);
  
  /* 缩放 */
  transform: scale(1.1);
  
  /* 旋转 */
  transform: rotate(45deg);
  
  /* 倾斜 */
  transform: skew(15deg, 5deg);
  
  /* 组合变换 */
  transform: translateX(50rpx) rotate(15deg) scale(1.05);
}

/* 3D变换 */
.card-3d {
  perspective: 1000rpx; /* 透视点 */
}

.card-inner {
  transition: transform 0.6s;
  transform-style: preserve-3d; /* 保持3D空间 */
}

.card-3d:hover .card-inner {
  transform: rotateY(180deg);
}

.card-front, .card-back {
  backface-visibility: hidden; /* 隐藏背面 */
}

.card-back {
  transform: rotateY(180deg);
}
4.2.2 动画
/* 关键帧动画 */
@keyframes slideIn {
  0% {
    opacity: 0;
    transform: translateY(60rpx) scale(0.9);
  }
  100% {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

@keyframes bounce {
  0%, 100% {
    transform: translateY(0);
  }
  50% {
    transform: translateY(-20rpx);
  }
}

@keyframes pulse {
  0%, 100% {
    opacity: 1;
  }
  50% {
    opacity: 0.7;
  }
}

/* 动画类 */
.slide-in {
  animation: slideIn 0.6s ease-out;
}

.bounce {
  animation: bounce 0.6s ease-in-out;
}

.pulse {
  animation: pulse 2s infinite;
}

/* 交互动画 */
.interactive-btn {
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

.interactive-btn:active {
  transform: scale(0.95);
  opacity: 0.8;
}

4.3 高级交互动效

<template>
  <view class="interactive-demo">
    <!-- 悬浮操作按钮 -->
    <view class="fab" :class="{ active: menuOpen }" @click="toggleMenu">
      <text class="fab-icon">+</text>
    </view>
    
    <!-- 悬浮菜单 -->
    <view class="fab-menu" :class="{ active: menuOpen }">
      <view class="fab-item" @click="handleAction('share')" 
            :style="{ transitionDelay: '0.1s' }">
        <text class="fab-icon">📤</text>
        <text class="fab-text">分享</text>
      </view>
      <view class="fab-item" @click="handleAction('favorite')"
            :style="{ transitionDelay: '0.2s' }">
        <text class="fab-icon">❤️</text>
        <text class="fab-text">收藏</text>
      </view>
      <view class="fab-item" @click="handleAction('download')"
            :style="{ transitionDelay: '0.3s' }">
        <text class="fab-icon">📥</text>
        <text class="fab-text">下载</text>
      </view>
    </view>
    
    <!-- 动画卡片网格 -->
    <view class="animated-grid">
      <view class="grid-item" v-for="(item, index) in gridItems" 
            :key="index"
            :style="{
              animationDelay: `${index * 0.1}s`,
              background: item.color
            }"
            @click="animateItem(index)">
        <text class="item-text">{{ item.text }}</text>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      menuOpen: false,
      gridItems: [
        { text: '卡片1', color: 'linear-gradient(135deg, #667eea, #764ba2)' },
        { text: '卡片2', color: 'linear-gradient(135deg, #f093fb, #f5576c)' },
        { text: '卡片3', color: 'linear-gradient(135deg, #4facfe, #00f2fe)' },
        { text: '卡片4', color: 'linear-gradient(135deg, #43e97b, #38f9d7)' },
        { text: '卡片5', color: 'linear-gradient(135deg, #fa709a, #fee140)' },
        { text: '卡片6', color: 'linear-gradient(135deg, #a8edea, #fed6e3)' }
      ]
    }
  },
  methods: {
    toggleMenu() {
      this.menuOpen = !this.menuOpen
    },
    handleAction(action) {
      uni.showToast({
        title: `执行: ${action}`,
        icon: 'none'
      })
      this.menuOpen = false
    },
    animateItem(index) {
      // 可以添加更复杂的动画逻辑
      console.log('点击卡片:', index)
    }
  }
}
</script>

<style scoped>
.interactive-demo {
  padding: 40rpx;
  min-height: 100vh;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}

/* 悬浮操作按钮 */
.fab {
  position: fixed;
  bottom: 80rpx;
  right: 40rpx;
  width: 120rpx;
  height: 120rpx;
  background: #FF3B30;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 8rpx 32rpx rgba(255, 59, 48, 0.4);
  transition: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
  z-index: 1000;
  cursor: pointer;
}

.fab-icon {
  font-size: 48rpx;
  color: white;
  transition: transform 0.4s ease;
}

.fab.active {
  transform: rotate(135deg);
  background: #007AFF;
}

/* 悬浮菜单 */
.fab-menu {
  position: fixed;
  bottom: 220rpx;
  right: 70rpx;
  opacity: 0;
  visibility: hidden;
  transform: translateY(40rpx) scale(0.8);
  transition: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}

.fab-menu.active {
  opacity: 1;
  visibility: visible;
  transform: translateY(0) scale(1);
}

.fab-item {
  display: flex;
  align-items: center;
  background: rgba(255, 255, 255, 0.95);
  backdrop-filter: blur(20rpx);
  padding: 24rpx 32rpx;
  margin-bottom: 20rpx;
  border-radius: 50rpx;
  box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.15);
  transform: translateX(60rpx);
  opacity: 0;
  transition: all 0.4s ease;
}

.fab-menu.active .fab-item {
  transform: translateX(0);
  opacity: 1;
}

.fab-text {
  font-size: 28rpx;
  color: #333;
  margin-left: 16rpx;
  white-space: nowrap;
}

/* 动画网格 */
.animated-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 30rpx;
  margin-top: 40rpx;
}

.grid-item {
  height: 200rpx;
  border-radius: 24rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.2);
  animation: cardEntrance 0.6s ease-out both;
  transition: all 0.3s ease;
  cursor: pointer;
}

.grid-item:active {
  transform: scale(0.95);
  box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.3);
}

.item-text {
  color: white;
  font-size: 32rpx;
  font-weight: bold;
  text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
}

/* 入场动画 */
@keyframes cardEntrance {
  from {
    opacity: 0;
    transform: translateY(60rpx) scale(0.9) rotateX(45deg);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1) rotateX(0);
  }
}

/* 响应式调整 */
@media (max-width: 750px) {
  .animated-grid {
    grid-template-columns: 1fr;
  }
}

@media (min-width: 751px) and (max-width: 1200px) {
  .animated-grid {
    grid-template-columns: repeat(3, 1fr);
  }
}

@media (min-width: 1201px) {
  .animated-grid {
    grid-template-columns: repeat(4, 1fr);
    max-width: 1200px;
    margin: 40rpx auto;
  }
}
</style>

五、性能优化

5.1 样式性能优化

5.1.1 选择器性能
/* 不推荐 - 性能差 */
.container .list .item .title .text {
  color: red;
}

/* 推荐 - 性能好 */
.item-text {
  color: red;
}

/* 不推荐 - 通用选择器性能差 */
* {
  margin: 0;
  padding: 0;
}

/* 推荐 - 明确指定元素 */
view, text, image {
  margin: 0;
  padding: 0;
}
5.1.2 动画性能优化
/* 不推荐 - 触发重排的属性 */
.animate-slow {
  animation: changeWidth 1s infinite;
}

@keyframes changeWidth {
  0% { width: 100rpx; }
  100% { width: 200rpx; }
}

/* 推荐 - 只触发重绘的属性 */
.animate-fast {
  animation: changeOpacity 1s infinite;
}

@keyframes changeOpacity {
  0% { opacity: 1; }
  100% { opacity: 0.5; }
}

/* 启用GPU加速 */
.gpu-accelerated {
  transform: translateZ(0);
  will-change: transform;
}

5.2 维护性

5.2.1 BEM命名规范
/* Block - 块 */
.product-card { }

/* Element - 元素 */  
.product-card__image { }
.product-card__title { }
.product-card__price { }

/* Modifier - 修饰符 */
.product-card--featured { }
.product-card__price--discount { }
5.2.2 样式组织架构
styles/
├── base/           # 基础样式
│   ├── variables.css
│   ├── reset.css
│   └── typography.css
├── components/     # 组件样式
│   ├── buttons.css
│   ├── forms.css
│   └── cards.css
├── layouts/        # 布局样式
│   ├── header.css
│   ├── footer.css
│   └── grid.css
├── utils/          # 工具类
│   ├── spacing.css
│   ├── display.css
│   └── text.css
└── themes/         # 主题样式
    ├── light.css
    └── dark.css

通过本节的学习,我们掌握了:Flex布局rpx单位样式设计css3高级特性,欢迎在评论区留言,我会及时解答。


版权声明:本文内容基于实战经验总结,欢迎分享交流,但请注明出处。禁止商业用途转载。

昨天以前首页

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

作者 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,以及为什么组件库能提升效率。

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

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

Uniapp 速查文档

作者 玖月晴空
2025年11月4日 16:19

Uniapp 速查文档

没错,又在拓展新的框架,新的知识,这次是Uniapp

我当前只做了小程序这一端,客户端这边是支持了ios和安卓的appapp端自己打包再套到自己的壳子里的,我这边负责把一些不兼容的样式,以及登录授权相关的支持,并不是直接用uniapp直接发布app) 。其他端后续看情况再支持。


一、关于生命周期

以下是 UniApp 中完整的生命周期分类及说明,包含 应用生命周期页面生命周期 和 组件生命周期


1、应用生命周期(App.vue)

生命周期 触发时机 使用场景
onLaunch uni-app初始化完成时触发(全局只触发一次) 获取设备信息、初始化全局数据、检查登录状态
onShow uni-app启动,或从后台进入前台显示时触发 统计活跃用户、检查版本更新
onHide uni-app从前台进入后台时触发 保存应用状态、停止定时任务
onError uni-app报错时触发 错误监控和上报
onUniNViewMessage nvue页面发送消息时触发(仅App端) nvuevue页面通信

2、页面生命周期(页面级)

生命周期 触发时机 使用场景
onInit 页面初始化时触发(仅百度小程序) 百度小程序专用初始化逻辑
onLoad 页面加载时触发(一个页面只会调用一次) 接收路由参数、初始化页面数据
onShow 页面显示/切入前台时触发 刷新数据(如返回页面时刷新列表)
onReady 页面初次渲染完成时触发(一个页面只会调用一次) 操作DOM(如初始化图表)
onHide 页面隐藏/切入后台时触发(如跳转到其他页面) 暂停定时器、保存草稿
onUnload 页面卸载时触发(如关闭页面或redirectTo) 清除定时器、解绑全局事件
onResize 窗口尺寸变化时触发(仅App、微信小程序) 响应式布局调整
onPullDownRefresh 下拉刷新时触发 重新加载数据
onReachBottom 页面上拉触底时触发 加载更多数据
onTabItemTap 点击当前tab页时触发(需要是tabbar页面) 统计tab点击行为
onShareAppMessage 用户点击右上角分享时触发 自定义分享内容
onPageScroll 页面滚动时触发 实现滚动动画、吸顶效果
onNavigationBarButtonTap 点击导航栏按钮时触发(仅App、H5) 处理自定义导航栏按钮点击
onBackPress 页面返回时触发(仅App、H5) 拦截返回操作(如提示保存)

3、组件生命周期(Vue组件级)

vue生命周期官方文档

生命周期 触发时机 使用场景
beforeCreate 实例初始化之后,数据观测之前 极少使用
created 实例创建完成(可访问data/methods,但DOM未生成) 请求初始数据
beforeMount 挂载开始之前被调用 极少使用
mounted 挂载完成后调用(DOM已渲染) 操作DOM、初始化第三方库
beforeUpdate 数据更新时调用(虚拟DOM重新渲染和打补丁之前) 获取更新前的DOM状态
updated 数据更新导致虚拟DOM重新渲染后调用 执行依赖新DOM的操作
beforeUnmount 在一个组件实例被卸载之前调用,这个钩子在服务端渲染时不会被调用 当这个钩子被调用时,组件实例依然还保有全部的功能。
unmounted 在一个组件实例被卸载之后调用 可以在这个钩子中手动清理一些副作用,例如计时器、DOM 事件监听器或者与服务器的连接。

关于Vue3的其他相关知识,可以参考这篇:vue3 实战笔记,期待能帮到你。


4、完整生命周期执行顺序

Vue3 页面及组件生命周期流程图

uni-app-lifecycle-vue3.jpg

场景:首次打开页面

1. 应用生命周期
   App.onLaunch → App.onShow

2. 页面生命周期
   Page.onLoad → Page.onShow → Page.onReady

3. 组件生命周期
   组件.beforeCreate → 组件.created → 组件.beforeMount → 组件.mounted

场景:页面跳转(A → B)

A.onHideB.onLoadB.onShowB.onReady

场景:返回上一页

B.onUnloadA.onShow

4、跨平台差异说明

因为uniapp 兼容的平台较多,所以就会有关于跨平台的兼容性差异

生命周期 支持平台 特殊说明
onInit 仅百度小程序 其他平台用onLoad替代
onResize App、微信小程序 H5需监听window.resize
onBackPress App、H5 微信小程序需用wx.onAppCapture返回事件
onNavigationBarButtonTap App、H5 微信小程序需自定义导航栏

5、关于在不同生命周期中的最佳实践建议

  1. 数据请求

    • 首次加载:onLoad + created
    • 返回刷新:onShow
  2. DOM操作

    • 页面级:onReady
    • 组件级:mounted
  3. 资源释放

    • 页面级:onUnload
    • 组件级:beforeDestroy

二、项目开发

1、 配置tabBar

tabBar就是在小程序的底部菜单,也相当于一级导航,以我当前这个小程序为例子,我只需要两个tab,配置两个就可以。

4671751354772_.pic.jpg

示例代码:

  "tabBar": {
    "color": "#7A7E83",
    "selectedColor": "#2F88FF",
    "borderStyle": "black",
    "backgroundColor": "#ffffff",
    "list": [
      {
        "pagePath": "pages/index/index",
        "iconPath": "static/img/home_before2.png",
        "selectedIconPath": "static/img/home_after2.png",
        "text": "首页"
      },
      {
        "pagePath": "pages/mine/mine",
        "iconPath": "static/img/user_before2.png",
        "selectedIconPath": "static/img/user_after2.png",
        "text": "我的"
      }
    ]
  },

2、关于页面跳转

uni.navigateTo(OBJECT)

保留当前页面,跳转到应用内的某个页面,使用uni.navigateBack可以返回到原页面。 原文链接

基础使用,就是从一个页面跳转到另一个页面

 uni.navigateTo({
    url: "/pages/my-history/my-history",
  });

复杂示例: 从A页面跳转B页面,携带参数(可从url带参数,也可以用方法)

  • A页面
// 可直接通过地址栏传递参数
uni.navigateTo({
  url: `/pages/formContent/formContent?form=${permissionCode}`,
  success: (res) => {
  // 也可以在跳转成功后传递一些参数
    res.eventChannel.emit('acceptDataFromIndexPage', {
      toolDetail: tool,
    });
  },
});

  • B页面
<template>
    <view>
B 页面
    </view>
</template>

<script setup>
    import {
        ref,
        onMounted,
        getCurrentInstance
    } from 'vue'; 
    import {
        onLoad
    } from '@dcloudio/uni-app'; 
    import FormComponent from '@/components/FormComponent.vue';
    import {
            formConfigs
    } from '@/config/formConfig.js';


    onLoad((options) => {
        // 获取路由参数中的 formKey
        const formKey = `/pages/formContent/formContent?form=${options.form}`;
        console.log(formKey)
    });

    onMounted(() => {
        const instance = getCurrentInstance().proxy;
        const eventChannel = instance.getOpenerEventChannel();

        // 接收自A页面跳转成功传递的数据
        eventChannel.on('acceptDataFromIndexPage', (data) => {
            console.log('Received data:', data);
        });

    });
</script>

uni.redirectTo(OBJECT)

关闭当前页面,跳转到应用内的某个页面。原文链接

uni.reLaunch(OBJECT)

关闭所有页面,打开到应用内的某个页面。原文链接

uni.switchTab(OBJECT)

跳转到 tabBar 页面,并关闭其他所有非 tabBar 页面。原文链接

tabBar也就是你在项目里配置的底部操作区域,这个跳转就不和跳转路由一样了,需要使用特定的方法,和上面几个跳转方法一样,这个url同样是你的文件存放地址:

例如:

 uni.switchTab({
   url: '/pages/index/index',
 });

3、页面通讯

我们除了可以用vue的通讯工具,还可以用uniapp自带的通讯工具

uni.$emit(eventName,OBJECT)

触发全局的自定义事件,附加参数都会传给监听器回调函数。 原文链接

uni.$on(eventName,callback)

监听全局的自定义事件,事件由 uni.$emit 触发,回调函数会接收事件触发函数的传入参数。 原文链接


参考链接

4、判断当前的设备类型

有些时候我们是需要根据不同的设备来写一些样式或者区分请求不同接口,那就需要知道当前的设备类型

  // 存储设备信息
  let deviceInfo = {
    platform: "devtools",
    detail: {},
  };
  const appInfo = uni.getAppBaseInfo();
  deviceInfo.platform = appInfo.uniPlatform;
  deviceInfo.detail = appInfo;
  uni.setStorage({
    key: "deviceInfo",
    data: deviceInfo,
  });
  console.log("当前运行环境-登录", deviceInfo);

在chrome 浏览器运行

截屏2025-04-24 10.17.36.png

在微信小程序中运行

截屏2025-04-24 10.21.27.png

在app中运行

<Weex>[log][WXBridgeContext.mm:1323](http://WXBridgeContext.mm:1323), jsLog:
运行平台:---COMMA------BEGIN:JSON---
{
"platform":"app",
    "detail":
        {"appId":"__UNI__3DD2483",
        "appName":"uni-ui-demo",
        "appVersion":"1.0.2",
        "appVersionCode":"10020",
        "appWgtVersion":"1.0.0",
        "appLanguage":"zh-Hans",
        "enableDebug":false,
        "language":"zh-Hans-CN",
        "SDKVersion":"",
        "theme":"light",
        "version":"1.9.9.81527",
        "isUniAppX":false,
        "uniPlatform":"app",
        "uniRuntimeVersion":"4.45",
        "uniCompileVersion":"4.45",
        "uniCompilerVersion":"4.45"
     }
 }

5、条件编译

// #ifdef APP-PLUS  
plus.globalEvent.addEventListener('plusMessage', (msg) => {
        uni.showToast({
                title: 'postMessage0',
                icon: "error",
                duration: 2000
        });
        const result = msg.data.args.data;
        if (result.name == 'postMessage') {
                uni.showToast({
                        title: 'postMessage1',
                        icon: "error",
                        duration: 2000
                });
                console.log('postMessage', msg);
                uni.$emit('webviewCode', msg);
        }
});
// #endif


6、关于封装请求接口

请求实体

import { HttpResponse } from "./common";
import { useLoginStore } from "../store/loginStore";

const request = (
  baseUrl: string,
  url: string,
  method: "POST" | "GET",
  data = {},
  header = {},
): Promise<HttpResponse> => {
  return new Promise((resolve, reject) => {
    const loginStore = useLoginStore();
    uni.getNetworkType({
      success: function (res) {
        if (res.networkType === "none") {
          uni.showToast({
            title: "当前网络不可用,请检查网络设置",
            icon: "none",
            duration: 2000,
          });
          reject(new Error("网络不可用")); // 拒绝请求
        } else {
          const deviceInfo = uni.getStorageSync("deviceInfo");
          const token = uni.getStorageSync("token");
          
          // 我的自定义参数
          const otherHeader = {
            token: token?.jwt,
          };
    
          uni.request({
            url: `${baseUrl}${url}`,
            method: method,
            data: data,
            header: {
              "Content-Type": "application/json",
              ...(!baseUrl.includes("sso") ? otherHeader : {}),
              ...header,
            },

            success: (res: any) => {
            // 以下的判断根据自己的业务处理
              if (res.data.code === "0" || res.data.code === 1) {
                resolve(res.data);
              } else if (res.data.code === 401) {
                uni?.hideLoading();
                console.log("登录失效,请登录");
                // 清空本地数据
                loginStore.deleteAllStorageData();
                reject(res.data);
              } else if (res.data.code === 10006 && res.data.msg === "账号已过期") {
                uni?.hideLoading();
                console.log("账号已过期");
              }
               else if (res.data.code === undefined && res.data) {
                // 后端没有返回 code 的情况
                resolve(res.data);
                console.log(res.data);
              } else {
                // 非 2xx 状态码处理
                uni.showToast({
                  title: res?.data.msg || "请求报错,请重试",
                  icon: "none",
                  duration: 2000,
                });
                reject(res);
                console.log(res.data);
              }
            },
            fail: (err) => {
              uni.hideLoading();
              uni.showToast({
                title: "请求失败,请稍后再试",
                icon: "none",
                duration: 2000,
              });
              reject(err);
            },
          });
        }
      },
      fail: function () {
        console.error("获取网络状态失败");
        reject(new Error("获取网络状态失败"));
      },
    });
  });
};

// 封装 GET 请求
const get = (baseUrl: string, url: string, data = {}, header = {}): Promise<HttpResponse> => {
  return request(baseUrl, url, "GET", data, header);
};

// 封装 POST 请求
const post = (baseUrl: string, url: string, data = {}, header = {}): Promise<HttpResponse> => {
  return request(baseUrl, url, "POST", data, header);
};

export { get, post };

接口请求示例:

import { post } from '../request';
import { _INDEXURL } from '../config';
import { MenuVersion, HttpResponse } from '../common'


// 请求示例:
// 后端接口地址: https://xxxxx.cn/api/aigc/menu/recent/useList

// 拼接完整地址:使用历史
const useList = (params : { version : MenuVersion }) : Promise<HttpResponse<any>> => {
    const url = '/menu/recent/useList';
    return post(_INDEXURL, url, params);
};


export default useList;

上面的_INDEXURL是请求的域名前缀,用变量代替,后期好替换管理,并且我有很多不同的前缀,如下:

const _SSOURL_1 = `${config.SSOURL}/sso/1.0/sso`;
const _SSOURL_3 = `${config.SSOURL}/sso/3.0/sso`;
const _SSOURL_4 = `${config.SSOURL}/sso/4.0/sso`;
const _INDEXURL = `${config.INDEXURL}/api/aigc`;

而这个SSOURL也是根据当前的环境区分使用的是测试还是线上接口,如下这样配置:

const config: Record<string, any> = {
  test: {
    SSOURL: "https://test.xxx.cn",
    INDEXURL: "https://test.xxx.cn",
    SOCKET_URL: "wss://test.xxx.cn",
    ENVVERSION: "trial", 
    MINPRROGRAM: 2,
  },
  production: {
    SSOURL: "https://xxx.xxx.cn",
    INDEXURL: "https://xxx.xxx.cn",
    SOCKET_URL: "wss://xxx.xxx.cn",
    ENVVERSION: "release",
    MINPRROGRAM: 0,
   
  },
};

返回数据基础类型


interface HttpResponse<T = any> {
  data: { token: string };
  code: number;
  msg?: string;
  success?: boolean;
  value?: T;
  rs?: {
    [key: string]: any;
  };
}

7、注册全局组件

我的项目里有且不止一个全局的组件,我不想在每个页面都引入

把需要全局引入的组件放在一个文件里

<template>
  <!-- 公共模板,如有需要在全局添加的组件,可以在这里添加 -->
  <view>
    <!-- 登录弹框 -->
    <LoginModal />
    <!-- 购买会员弹框 -->
    <BuyMembershipDialog />
  </view>
</template>

<script setup>

import LoginModal from "@/components/login/loginModal.vue";
import BuyMembershipDialog from "@/components/modal/BuyMembershipDialog.vue";

</script>

在这里注册到全局:

import { createSSRApp } from "vue";
import App from "./App.vue";
import share from "@/utils/share.js";
import CommonTemplate from "@/components/common/common-template.vue";

export function createApp() {
  const app = createSSRApp(App);

  // 全局组件
  app.component("CommonTemplate", CommonTemplate);

  app.use(share); // 全局混入
  
  return {
    app,
  };
}

8、关于微信授权登录

微信授权流程

获取微信登录授权码 code-> 拿着授权码去换取unionIDopenID -> 通过两个id后端查询库里是否存在绑定关系,存在绑定关系则调用登录接口,不存在绑定关系则跳转到绑定手机号页面,进行手机号的绑定

截屏2025-09-26 11.09.34.png

截屏2025-07-04 16.04.58.png

使用获取手机号组件来获取微信手机号 文档地址,这个接口需要由自己的服务端来转发,服务端文档地址

每个小程序账号将有1000次体验额度,用于开发、调试和体验。该1000次的体验额度为正式版、体验版和开发版小程序共用,超额后,体验版和开发版小程序调用同正式版小程序一样,均收费。这个组件是收费的,标准单价为:每次组件调用成功,收费0.03元

<button class="btn" open-type="getPhoneNumber" @getphonenumber="getPhoneNumberFn">
 授权
</button>

具体授权

const getPhoneNumberFn = async (e) => {
  const {
    detail: { code, errMsg },
  } = e;
  if (errMsg === "getPhoneNumber:ok") {
    dialogClose();
    uni.showLoading({ title: "授权中..." });
    await getPhoneNumber({ code }).then((res) => {
      // 这里拿到手机号去绑定
      autoBindPhoneAndLoginFn(res?.value?.purePhoneNumber);
    });
  } else {
    // 用户拒绝授权
    uni.showToast({
      title: "您已拒绝授权",
      icon: "none",
    });
  }
};

获取微信登录授权的授权码

  // 获取授权码
  uni.login({
    provider: "weixin",
    onlyAuthorize: true, // 微信登录仅请求授权认证
    success: async function (event) {
      const { code } = event;

      const deviceInfo = uni.getStorageSync("deviceInfo");
       
      if (deviceInfo.platform === "mp-weixin") {
        await miniAppLogin(code);
      } else {
        // app 平台配置
        await appLoginFn(code);
      }
    },
    fail: function (err) {
      uni.showToast({
        title: "授权失败,请重试",
        icon: "none",
      });
      isLoading.value = false;
    },
  });

注意: 我现在的项目是同时运行在小程序和客户端(安卓& ios app上的),所以在拿到微信的 code 码之后,需要区分是在小程序登录还是app 登录,再调用不同的方法

小程序登录: miniAppLogin 换取openIDunionID

const miniAppLogin = async (code) => {
  await wxLogin({ code }).then((res) => {
    const unionID = res?.value?.unionid;
    const openID = res?.value?.openid;
    if (unionID) {
      unionId.value = unionID;
      openId.value = openID;
      uni.setStorageSync("openId", openID);
      // 调用接口查状态,如果是首次登录,则弹出是否同意协议,否则直接登录
      getWxBindingStatus({ unionId: unionID, openId: openID }).then((val) => {
        const status = val?.rs?.result;
        // false:未绑定  true:已绑定
        if (status) {
          handleGetticket();
        } else {
          alertDialog.value.open();
          isLoading.value = false;
        }
      });
    }
  });
};

app 的登录流程

9、关于apple授权登录

const handleAppleLogin = () => {
  uni.login({
    provider: "apple",
    success: async (loginRes) => {
      console.log("apple登录成功", loginRes);
      const token = loginRes.authResult.access_token;
      // 拿着token去请求后端,查看这个账户有没有绑定过
      await appleConnect({ access_token: token })
        .then((res) => {
        // 执行后端登录逻辑
          handleWxLoginSuccess(res);
          uni.hideLoading();
        })
        .catch((error) => {
          console.log(error, "appleConnect 失败");
          if (error.code === "5005" && JSON.parse(error.msg)) {
            // 弹绑定手机号页面,去绑定手机号
            openId.value = JSON.parse(error.msg)?.openId;
            handleLoginChangeSuccess("third");
          }
        });
    },
    fail: function (err) {
      console.log("apple授权失败", err);
      uni.showToast({
        title: "登录授权失败,请重试",
        icon: "none",
      });
    },
  });
};

如果是首次授权,会弹出一个确认框来确认是否用本apple账号登录 如果不是首次授权登录,则不会弹这个框,直接登录了

image.png


10、小程序支付

小程序调用支付还是蛮简单的,文档地址: uniapp微信小程序支付微信小程序官网支付文档

uniapp 小程序支付

参数示例(仅作为示例,非真实参数信息):

uni.requestPayment({
    provider: 'wxpay',
        timeStamp: String(Date.now()),
        nonceStr: 'A1B2C3D4E5',
        package: 'prepay_id=wx20180101abcdefg',
        signType: 'MD5',
        paySign: '',
        success: function (res) {
                console.log('success:' + JSON.stringify(res));
        },
        fail: function (err) {
                console.log('fail:' + JSON.stringify(err));
        }
});

当点击了购买以后,先调用后端接口下单

const handleOrderPay = async (openId, type) => {
    await orderPay({
      goodsId: selectedGoodsId.value,
      openId,
    }).then(({ value }) => {
      const { appId, ...res } = value;
      getPayModal(res);
    });
};

使用uniapp的方法调起支付弹框,参数参参考上面的示例或文档:

const getPayModal = (value) => {
  uni.requestPayment({
    provider: "wxpay",
    ...value,
    success: function (res) {
    
     // 支付成功后会有回调,这里是支付成功后的一些提示和操作
     // 建议:提示+跳转到指定页面
      uni.showToast({
        title: "支付成功",
        icon: "success",
      });
     uni.switchTab({url:"/pages/mine/mine"})
     
    },
    fail: function (err) {
      console.log("支付失败fail:" + JSON.stringify(err));
      uni.showToast({
        title: "支付失败",
        icon: "error",
      });
    },
  });
};

原生微信小程序接入支付

如果你用的是原生的微信小程序,文档地址,同样也是前端调用方法就可以调起支付的弹框(前提依然是要先下单),参考下面的示例:

wx.requestPayment({
  timeStamp: '',
  nonceStr: '',
  package: '',
  signType: 'MD5',
  paySign: '',
  success (res) { },
  fail (res) { }
})

最后,关于一些异常bug

关于在ios手机收到验证码后,使用自动填充时,短信验证码回填两次的问题

限制验证码输入框的内容长度,查阅了社区说这个是ios系统的bug,暂时先用maxLength来限制 代码如下:

<input
    class="input-password"
    v-model="verificationCode"
    placeholder="输入验证码"
    type="number"
    inputmode="numeric"
    maxlength="6"
  />

textarea 输入框中间有内容,从中间插入光标,一直按删除键,当删除完前面的内容后,光标会跳到末尾,导致误删末尾的内容

我的项目背景: 既需要正常的用户输入,又要拿到上次用户输入进行回填,如果不需要数据回填,就不需要v-model 就不需要数据同步,直接从@input 里拿数据就行,我猜测大概是组件数据通信延迟导致的问题。

第一种解决方案:

html:

  <textarea
        v-model="essayContent"
        @input="onInput"
      ></textarea>

js:

// 当光标已经删除到开头时,阻止默认删除行为
const onInput = (e) => {
  const selectionStart = e.detail.cursor; // 获取光标位置
  if (selectionStart === 0) {
    e.preventDefault(); // 阻止默认删除行为
    return;
  }
};

我们项目还用到了:uni-ui ,我尝试了项目中用到的 uni-easyinput 组件,同样也有这个问题,示例如下:

 <uni-easyinput
    type="textarea"
    :maxlength="4000"
    v-model="baseFormData.introduction"
    placeholder="请输入提示词"
 ></uni-easyinput>

第二种解决方案:

不用v-modelvalue绑定,然后在 @blur的时候给 value绑定的值赋值,如下:

html:

 <textarea
  :value="formData.content"
  @blur="onBlur"
></textarea>

js:

const onBlur = (e) => {
  formData.content = e.target.value;
};

这样不用v-model同样也可以在formData.content拿到数据


写到这里还没有结束,最近又收到了其他的工作安排,后面会继续更新,一方面对于自己来说可以当作一个笔记本,另一方面也给掘金的好友们提供一些思路,

祝大家开发顺顺利利。

Webpack配置魔法书:从入门到高手的通关秘籍

2025年10月30日 08:41

朋友们,我是小杨!今天咱们来聊聊Webpack配置这个话题。很多人第一次看到webpack.config.js文件时,感觉就像在看天书一样。别担心,今天我就带你从零开始,一步步解锁Webpack配置的奥秘!

初识Webpack:先来个"Hello World"

让我们从一个最简单的配置开始,就像学编程先写Hello World一样:

// 我的第一个webpack配置
const path = require('path');

module.exports = {
  // 入口:告诉Webpack从哪开始打包
  entry: './src/index.js',
  
  // 输出:打包后的文件放哪里
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  },
  
  // 模式:开发还是生产
  mode: 'development'
};

这个基础配置就像搭积木的第一步,虽然简单,但已经能完成基本的打包任务了!

核心配置详解:拆解Webpack的"五脏六腑"

1. Entry(入口):打包的起点

// 单入口(SPA应用)
entry: './src/index.js'

// 多入口(多页面应用)
entry: {
  home: './src/home.js',
  about: './src/about.js',
  contact: './src/contact.js'
}

// 动态入口
entry: () => new Promise((resolve) => {
  resolve('./src/dynamic-entry.js');
})

2. Output(输出):打包成果的归宿

output: {
  path: path.resolve(__dirname, 'dist'),
  // 使用占位符确保文件名唯一
  filename: '[name].[contenthash].js',
  // 清理输出目录
  clean: true,
  // 公共路径(CDN场景很有用)
  publicPath: 'https://cdn.example.com/'
}

3. Loader:文件转换的"翻译官"

Loader是Webpack最强大的功能之一,让我展示几个常用配置:

module: {
  rules: [
    // 处理CSS文件
    {
      test: /.css$/i,
      use: ['style-loader', 'css-loader']
    },
    
    // 处理SCSS文件
    {
      test: /.scss$/i,
      use: [
        'style-loader',
        'css-loader',
        'sass-loader'
      ]
    },
    
    // 处理图片
    {
      test: /.(png|jpg|jpeg|gif|svg)$/i,
      type: 'asset/resource',
      generator: {
        filename: 'images/[name].[hash][ext]'
      }
    },
    
    // 处理字体
    {
      test: /.(woff|woff2|eot|ttf|otf)$/i,
      type: 'asset/resource',
      generator: {
        filename: 'fonts/[name].[hash][ext]'
      }
    },
    
    // Babel转译JS
    {
      test: /.js$/,
      exclude: /node_modules/,
      use: {
        loader: 'babel-loader',
        options: {
          presets: ['@babel/preset-env']
        }
      }
    }
  ]
}

4. Plugins:增强功能的"外挂"

插件让Webpack变得更强大,来看我的常用插件组合:

const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

plugins: [
  // 自动生成HTML文件
  new HtmlWebpackPlugin({
    template: './src/index.html',
    title: '我的应用',
    minify: true
  }),
  
  // 提取CSS到单独文件
  new MiniCssExtractPlugin({
    filename: '[name].[contenthash].css'
  }),
  
  // 清理输出目录
  new CleanWebpackPlugin(),
  
  // 定义环境变量
  new webpack.DefinePlugin({
    'process.env.NODE_ENV': JSON.stringify('production')
  })
]

实战配置:搭建完整的开发环境

让我分享一个我在实际项目中使用的完整配置:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = (env, argv) => {
  const isProduction = argv.mode === 'production';
  
  return {
    entry: {
      main: './src/index.js',
      vendor: './src/vendor.js'
    },
    
    output: {
      path: path.resolve(__dirname, 'dist'),
      filename: isProduction 
        ? '[name].[contenthash].js' 
        : '[name].js',
      publicPath: '/'
    },
    
    module: {
      rules: [
        {
          test: /.js$/,
          exclude: /node_modules/,
          use: 'babel-loader'
        },
        {
          test: /.css$/,
          use: [
            isProduction 
              ? MiniCssExtractPlugin.loader 
              : 'style-loader',
            'css-loader',
            'postcss-loader'
          ]
        },
        {
          test: /.(png|jpg|gif)$/,
          type: 'asset/resource'
        }
      ]
    },
    
    plugins: [
      new HtmlWebpackPlugin({
        template: './src/index.html',
        inject: true
      }),
      ...(isProduction 
        ? [new MiniCssExtractPlugin({
            filename: '[name].[contenthash].css'
          })]
        : []
      )
    ],
    
    optimization: {
      splitChunks: {
        chunks: 'all',
        cacheGroups: {
          vendor: {
            test: /[\/]node_modules[\/]/,
            name: 'vendors',
            priority: 10
          }
        }
      }
    },
    
    devServer: {
      static: './dist',
      hot: true,
      open: true,
      port: 3000
    },
    
    devtool: isProduction ? 'source-map' : 'eval-cheap-module-source-map'
  };
};

环境特定配置:开发vs生产

开发环境配置要点

// webpack.dev.js
module.exports = {
  mode: 'development',
  devtool: 'eval-source-map',
  devServer: {
    static: './dist',
    hot: true,
    open: true,
    historyApiFallback: true
  },
  module: {
    rules: [
      {
        test: /.css$/,
        use: ['style-loader', 'css-loader']
      }
    ]
  }
};

生产环境配置要点

// webpack.prod.js
const TerserPlugin = require('terser-webpack-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

module.exports = {
  mode: 'production',
  devtool: 'source-map',
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin(),
      new CssMinimizerPlugin()
    ],
    splitChunks: {
      chunks: 'all'
    }
  },
  module: {
    rules: [
      {
        test: /.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader']
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin()
  ]
};

高级配置技巧:我的独门秘籍

1. 动态配置

module.exports = (env, argv) => {
  const isProduction = argv.mode === 'production';
  const isAnalyze = env && env.analyze;
  
  const config = {
    // 基础配置...
  };
  
  if (isAnalyze) {
    const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
    config.plugins.push(new BundleAnalyzerPlugin());
  }
  
  return config;
};

2. 多目标构建

// 同时构建多个配置
module.exports = [
  {
    name: 'client',
    target: 'web',
    entry: './src/client.js',
    output: {
      filename: 'client.bundle.js'
    }
  },
  {
    name: 'server',
    target: 'node',
    entry: './src/server.js',
    output: {
      filename: 'server.bundle.js'
    }
  }
];

常见问题排查:我踩过的那些坑

问题1:文件找不到?
检查路径配置,记得用path.resolve

问题2:Loader不生效?
检查test正则和use数组顺序

问题3:打包文件太大?
合理配置splitChunks和压缩选项

问题4:热更新不工作?
检查devServer配置和HotModuleReplacementPlugin

性能优化配置

optimization: {
  // 代码分割
  splitChunks: {
    chunks: 'all',
    cacheGroups: {
      // 第三方库单独打包
      vendor: {
        test: /[\/]node_modules[\/]/,
        name: 'vendors',
        priority: 20
      },
      // 公共代码单独打包
      common: {
        name: 'common',
        minChunks: 2,
        priority: 10,
        reuseExistingChunk: true
      }
    }
  },
  // 运行时代码单独提取
  runtimeChunk: {
    name: 'runtime'
  }
}

总结

Webpack配置就像搭积木,从简单开始,逐步添加需要的功能。记住几个关键点:

  1. 理解核心概念:Entry、Output、Loader、Plugin
  2. 区分环境:开发环境要快,生产环境要小
  3. 渐进式配置:从简单开始,按需添加功能
  4. 善用优化:代码分割、压缩、缓存一个都不能少

配置Webpack不是一蹴而就的,需要在实际项目中不断实践和调整。希望这篇指南能帮你少走弯路,快速掌握Webpack配置的精髓!

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

webpack了解吗,讲一讲原理,怎么压缩代码

2025年10月30日 08:36

大家好,我是小杨!今天我要带大家探索前端工程化的核心魔法——Webpack。很多人觉得Webpack很复杂,但其实掌握了它的原理,你就会发现它就像个智能的"代码料理机",能把各种原料加工成美味佳肴!

Webpack的核心概念:先认识这些"厨房工具"

想象一下,Webpack就是一个现代化的智能厨房:

// 这是我的webpack.config.js - 相当于"菜谱"
module.exports = {
  // 入口起点 - 告诉厨房从哪开始准备食材
  entry: './src/index.js',
  
  // 输出 - 成品要放在哪里
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  },
  
  // 加载器 - 各种食材处理工具
  module: {
    rules: [
      {
        test: /.css$/,        // 遇到CSS食材
        use: ['style-loader', 'css-loader']  // 用这两个工具处理
      },
      {
        test: /.(png|jpg)$/,  // 遇到图片食材
        use: ['file-loader']   // 用文件处理工具
      }
    ]
  },
  
  // 插件 - 高级厨房设备
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html'
    })
  ]
};

Webpack的工作原理:看看"厨房"是怎么运作的

让我用最通俗的方式解释Webpack的工作流程:

第一步:依赖收集(找食材)
Webpack从入口文件开始,像侦探一样找出所有的import和require语句,建立完整的依赖关系图。

// 假设这是我的项目结构
// src/
//   index.js (入口)
//   utils.js  
//   styles.css

// index.js
import { calculateTotal } from './utils.js';
import './styles.css';

const result = calculateTotal(100, 20);
console.log('总价:', result);

// utils.js
export function calculateTotal(price, tax) {
  return price + (price * tax);
}

第二步:模块转换(处理食材)
通过loader系统,把不同类型的文件都转换成JavaScript能理解的模块。

// 比如CSS文件,经过css-loader和style-loader处理
// 原始的styles.css
.body { color: red; }

// 被转换成JavaScript模块
const styles = ".body { color: red; }";
// 然后通过style-loader注入到页面

第三步:代码生成(装盘上菜)
把所有模块组合成一个或多个bundle文件。

代码压缩的魔法:让文件瘦身的秘密武器

说到代码压缩,这可是Webpack的拿手好戏!让我展示几种常见的压缩方式:

1. JavaScript压缩

// 压缩前的代码
function calculatePrice(originalPrice, discountRate) {
    const discountAmount = originalPrice * discountRate;
    const finalPrice = originalPrice - discountAmount;
    return finalPrice;
}

const result = calculatePrice(100, 0.2);
console.log("最终价格是:", result);

// 经过TerserWebpackPlugin压缩后
function n(n,r){return n-n*r}console.log("最终价格是:",n(100,.2));

在webpack中配置:

const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  optimization: {
    minimize: true,
    minimizer: [new TerserPlugin({
      terserOptions: {
        compress: {
          drop_console: true, // 移除console.log
          pure_funcs: ['console.log'] // 移除特定的函数调用
        }
      }
    })]
  }
};

2. CSS压缩

// 压缩前
.container {
    margin: 10px 20px 10px 20px;
    padding: 15px;
    background-color: #ff0000;
}

.title {
    font-size: 16px;
    font-weight: bold;
}

// 压缩后
.container{margin:10px 20px;padding:15px;background-color:red}.title{font-size:16px;font-weight:700}

配置方法:

const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

module.exports = {
  optimization: {
    minimizer: [
      new CssMinimizerPlugin(),
    ],
  },
};

3. 图片压缩

module.exports = {
  module: {
    rules: [
      {
        test: /.(png|jpg|jpeg|gif)$/i,
        use: [
          {
            loader: 'image-webpack-loader',
            options: {
              mozjpeg: {
                progressive: true,
                quality: 65
              },
              optipng: {
                enabled: true,
              },
              pngquant: {
                quality: [0.65, 0.90],
                speed: 4
              }
            }
          }
        ]
      }
    ]
  }
};

高级优化技巧:我的实战经验分享

1. 代码分割

module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\/]node_modules[\/]/,
          name: 'vendors',
          chunks: 'all',
        },
        common: {
          name: 'common',
          minChunks: 2,
          chunks: 'all',
          minSize: 0
        }
      }
    }
  }
};

2. Tree Shaking

// utils.js - 原始代码
export function usedFunction() {
  return '这个函数会被使用';
}

export function unusedFunction() {
  return '这个函数不会被使用,会被tree shaking掉';
}

// index.js - 只导入usedFunction
import { usedFunction } from './utils';

// 打包后,unusedFunction会被自动移除

实战案例:看我如何优化一个真实项目

让我分享一个真实的优化经历:

// 优化前的配置
module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js'
  }
};

// 优化后的配置
module.exports = {
  entry: {
    main: './src/index.js',
    admin: './src/admin.js'
  },
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
    clean: true
  },
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\/]node_modules[\/]/,
          name: 'vendors',
          priority: 10
        }
      }
    },
    usedExports: true,
    minimize: true
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash].css'
    }),
    new CompressionPlugin({
      algorithm: 'gzip'
    })
  ]
};

优化效果:

  • 文件大小减少60%
  • 加载速度提升40%
  • 缓存命中率大幅提高

常见坑点和解决方案

坑1:Tree Shaking不生效
检查:package.json中要有"sideEffects": false

坑2:压缩后代码报错
可能是ES6+语法问题,确保terser配置正确

坑3:文件太大
合理使用代码分割和动态导入

总结

Webpack就像前端开发的"瑞士军刀",掌握它的原理和优化技巧,能让你在性能优化的道路上如鱼得水。记住:好的打包策略不是一蹴而就的,需要根据项目特点不断调整优化

希望这篇分享能帮你更好地理解Webpack!如果有任何问题,欢迎在评论区交流~

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

玩转小程序生命周期:从入门到上瘾

2025年10月30日 08:30

作为前端老司机,我经常被问到:“小杨,小程序的生命周期到底怎么玩?”今天我就用最接地气的方式,带你解锁小程序生命周期的正确打开方式。准备好,我们要发车了!

先来认识下小程序的生命周期家族

想象一下,你的小程序就像一个有生命的个体,从诞生到离开,每个阶段都有专属的“人生时刻”。让我用一个简单的Page示例来演示:

// 我的第一个小程序页面
Page({
  data: {
    userInfo: {},
    isLoading: true
  },
  
  // 生命周期函数们要登场啦!
  onLoad(options) {
    console.log('页面加载啦!');
    // 我在这里初始化数据
    this.fetchUserData();
  },
  
  onShow() {
    console.log('页面显示啦!');
    // 每次进入页面都会执行
    this.updateBadgeCount();
  },
  
  onReady() {
    console.log('页面准备就绪!');
    // 页面渲染完成,可以操作DOM了
    this.initCanvas();
  },
  
  onHide() {
    console.log('页面隐藏了');
    // 页面被收起时清理一些资源
    this.clearTimer();
  },
  
  onUnload() {
    console.log('页面被卸载了');
    // 页面被关闭时彻底清理
    this.cleanup();
  }
})

全局生命周期:小程序的“大脑”

App级别的生命周期就像是整个小程序的大脑,掌控着全局的生死轮回:

App({
  onLaunch(options) {
    // 小程序启动时的初始化
    console.log('小程序诞生了!');
    this.initCloudService();
  },
  
  onShow(options) {
    // 小程序切换到前台
    console.log('小程序被唤醒了');
    this.syncData();
  },
  
  onHide() {
    // 小程序被切换到后台
    console.log('小程序去休息了');
    this.saveState();
  },
  
  onError(msg) {
    // 出错时的处理
    console.error('出问题啦:', msg);
    this.reportError(msg);
  }
})

组件生命周期:精致的“小部件”

组件的生命周期更加精细,让我用一个自定义组件来展示:

Component({
  lifetimes: {
    created() {
      // 组件实例刚被创建
      console.log('组件出生了!');
    },
    
    attached() {
      // 组件进入页面节点树
      console.log('组件安家落户了');
      this.initData();
    },
    
    ready() {
      // 组件渲染完成
      console.log('组件装修完毕');
      this.startAnimation();
    },
    
    detached() {
      // 组件被从页面移除
      console.log('组件搬家了');
      this.releaseResource();
    }
  },
  
  // 还有页面生命周期,专门为组件定制
  pageLifetimes: {
    show() {
      // 页面展示时的逻辑
      this.resumeMusic();
    },
    
    hide() {
      // 页面隐藏时的逻辑
      this.pauseMusic();
    }
  }
})

实战技巧:生命周期的最佳拍档

在我多年的开发经验中,发现这些生命周期组合使用效果最佳:

场景1:数据加载优化

Page({
  data: {
    listData: [],
    hasMore: true
  },
  
  onLoad() {
    // 初次加载数据
    this.loadInitialData();
  },
  
  onShow() {
    // 每次显示时检查更新
    if (this.shouldRefresh()) {
      this.refreshData();
    }
  },
  
  onHide() {
    // 离开时保存状态
    this.saveScrollPosition();
  }
})

场景2:资源管理

Page({
  timer: null,
  
  onShow() {
    // 开启定时器
    this.timer = setInterval(() => {
      this.updateRealTimeData();
    }, 5000);
  },
  
  onHide() {
    // 及时清理,避免内存泄漏
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
  },
  
  onUnload() {
    // 双重保险
    this.onHide();
    this.cleanupNetworkRequests();
  }
})

常见坑点与解决方案

坑1:onLoad vs onShow 傻傻分不清

  • onLoad:只在页面创建时执行一次,适合一次性初始化
  • onShow:每次页面显示都执行,适合状态更新

坑2:内存泄漏
记得在onHide或onUnload中清理定时器、事件监听等资源。

坑3:数据状态混乱
合理利用onHide保存状态,onShow恢复状态。

总结

掌握小程序生命周期,就像掌握了小程序的“呼吸节奏”。合理运用它们,能让你的小程序运行更加流畅,用户体验更加丝滑。记住:合适的生命周期做合适的事,这是写出优质小程序的秘诀!

希望这篇分享能帮你更好地理解小程序生命周期。如果有任何问题,欢迎在评论区交流讨论~

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

【uniapp】小程序体积优化,分包异步化

2025年10月29日 17:46

前言

在小程序端,分包异步化 是一个重要的减小体积的手段,下面会介绍如何在 uniapp分包异步化

跨分包自定义组件引用

在页面中正常使用: import CustomButton from "@/packageB/components/component1/index.vue";

{
  "pages": [
    {
      "path": "pages/index/index",
      "style": {
        "navigationBarTitleText": "Index",
        "componentPlaceholder": {
          "custom-button": "view"
        }
      }
    }
  ],
  "subPackages": [
    {
      "root": "packageA",
      "pages": [
        {
          "path": "index/index",
          "style": {
            "navigationBarTitleText": "分包页面",
            // 添加配置
            "componentPlaceholder": {
              "custom-button": "view"
            }
          }
        }
      ]
    },
    {
      "root": "packageB",
      "pages": [
        {
          "path": "index/index",
        }
      ],
    }
  ]
}

此特性依赖配置 componentPlaceholder,目前 uniapp 仅支持在 pages.json 中添加页面级别的配置,如果需要在某个组件或者页面中配置,可以使用 插件,支持 vue2vue3

跨分包 JS 代码引用

小程序端默认支持跨分包 JS 代码引用,需要写小程序原生支持的语法,不能使用静态引入或者动态引入。示例如下:

sub分包 定义 utils.js 文件

// sub/utils.js
export function add(a, b) {
    return a + b
}

sub分包 正常使用 utils.js 文件

// sub/index.vue
<template>
    <view>
        {{ count }}
        <button @tap="handleClick">add one</button>
    </view>
</template>

<script>
    import {
        add
    } from "./utils.js";

    export default {
        data() {
            return {
                count: 1
            }
        },
        methods: {
            handleClick() {
                this.count = add(this.count, 1)
            }
        }
    }
</script>

其他分包使用 sub分包utils.js 文件

// sub2/index.vue
<template>
    <view>
       {{ count }}
        <button @tap="handleClick">add two</button>
    </view>
</template>

<script>
    export default {
        data() {
            return {
                count: 1
            }
        },
        methods: {
            handleClick() {
                require('../sub/utils.js', sub_utils => {
                    this.count = sub_utils.add(this.count, 2);
                }, ({
                    mod,
                    errMsg
                }) => {
                    console.error(`path: ${mod}, ${errMsg}`)
                })
            }
        }
    }
</script>

注意:

  • 引用的文件必须存在
  • 使用小程序支持的原生语法

结语

如果这个库的插件帮助到了你,可以点个 star✨ 鼓励一下。

如果你有什么好的想法或者建议,欢迎在 github.com/uni-toolkit… 提 issue 或者 pr

从 0 到 1,我用小程序 + 云开发打造了一个“记忆瓶子”,记录那些重要的日子!

2025年10月29日 09:26

缘起:为什么需要一个“记忆瓶子”?

市面上有很多纪念日 APP,但它们常常伴随着各种广告弹窗、冗余功能,或者在UI设计上未能满足我个人对于“简洁与美”的追求。我希望能有一个小而美的应用,能够:

  • 纯粹地记录重要的公历或农历纪念日。
  • 能够自由设置重复提醒。
  • 界面美观,甚至可以自定义背景图。
  • 最重要的是,没有多余的干扰,安静地守护那些珍贵的回忆。

于是,“记忆瓶子”的构思便在脑海中逐渐成形。在深入技术细节之前,欢迎扫码快速体验‘记忆瓶子’! gh\_6ba58d08bd84\_344.jpg

技术选型:原生小程序 + 云开发的“双剑合璧”

作为一名开发者,选择合适的技术栈是项目成功的关键。

  • 微信小程序: 无疑是触达用户最便捷的平台之一。其轻量级、无需下载安装的特性,以及微信生态内丰富的接口能力(订阅消息、用户授权等),都让它成为首选。
  • 微信云开发: 这次我选择了“All in 云开发”的模式。云开发提供了数据库、云存储、云函数等一站式服务,大大降低了后端开发和运维的复杂度。对于个人开发者而言,免费额度友好,上手成本极低,能够让我们更专注于前端和业务逻辑的实现。

这套组合让我在短时间内能够快速迭代,将产品想法落地。

核心功能与技术亮点

1. 公农历转换与复杂日期计算的“艺术”

“纪念日”的核心就是日期。但它远非简单的加减法,公历、农历、重复、指定年数,甚至还有时区问题,都让日期计算变得异常复杂。

痛点: 用户可能想记录一个农历生日,每年自动提醒;或者一个固定公历的周年纪念日;又或者是一个只发生一次的特殊事件。如何精准地计算出这些纪念日的“下一个发生日期”,并与当前日期进行比对,是小程序的核心挑战。 解决方案: 我引入了两个强大的日期处理库:

  • solarlunar: 用于精准地进行公历和农历之间的转换。尤其是在处理闰月等复杂情况时,它的表现非常可靠。
  • dayjs 及其 utc/timezone 插件: 这两个插件对于处理跨时区(尤其是中国大陆)的日期计算至关重要。我发现,仅仅使用 new Date() 可能会在服务器和用户手机之间产生时区差异,导致“今天”的判断不准确。通过 dayjs().tz('Asia/Shanghai').startOf('day') 能够确保在云函数和前端都以统一的“上海时间”零点作为基准,从而确保倒计时和提醒的精确性。

核心逻辑片段(getList 云函数中计算 nextOccurrenceTimestamp 的部分简化版):

// 假设 item.timestamp 是事件的UTC时间戳
const eventDayjsUTC = dayjs(item.timestamp);
const eventInTimeZone = eventDayjsUTC.tz(TARGET_TIMEZONE); // TARGET_TIMEZONE = 'Asia/Shanghai'
const todayStartInTimeZone = dayjs().tz(TARGET_TIMEZONE).startOf('day');

let nextSolarYear, nextSolarMonth, nextSolarDay;

if (item.dateType === 'lunar' && item.isRepeat) {
    // 农历重复纪念日的复杂计算...
    let nextSolar = solarlunar.lunar2solar(todayStartInTimeZone.year(), item.lunarMonth, item.lunarDay, item.isLeapMonth || false);
    // ... 如果今年已过,则计算明年的 ...
    if (dayjs.tz(`${nextSolar.year}-${nextSolar.month}-${nextSolar.day}`, TARGET_TIMEZONE).valueOf() < todayStartInTimeZone.valueOf()) {
        nextSolar = solarlunar.lunar2solar(todayStartInTimeZone.year() + 1, item.lunarMonth, item.lunarDay, item.isLeapMonth || false);
    }
    nextSolarYear = nextSolar.year;
    nextSolarMonth = nextSolar.month;
    nextSolarDay = nextSolar.day;

} else if (item.dateType === 'solar' && item.isRepeat) {
    // 公历重复纪念日的计算...
    nextSolarYear = todayStartInTimeZone.year();
    nextSolarMonth = eventInTimeZone.month() + 1;
    nextSolarDay = eventInTimeZone.date();
    // ... 如果今年已过,则计算明年的 ...
    if (dayjs.tz(`${nextSolarYear}-${nextSolarMonth}-${nextSolarDay}`, TARGET_TIMEZONE).valueOf() < todayStartInTimeZone.valueOf()) {
        nextSolarYear++;
    }
} else {
    // 一次性纪念日的计算...
    nextSolarYear = eventInTimeZone.year();
    nextSolarMonth = eventInTimeZone.month() + 1;
    nextSolarDay = eventInTimeZone.date();
}

// 最终构建下一个事件的 Day.js 对象
const nextEventDateStr = `${nextSolarYear}-${String(nextSolarMonth).padStart(2,'0')}-${String(nextSolarDay).padStart(2,'0')}`;
const nextEventStartInTimeZone = dayjs.tz(nextEventDateStr, TARGET_TIMEZONE);
const nextOccurrenceTimestamp = nextEventStartInTimeZone.valueOf();

// ... 剩余的倒计时计算 ...

订阅消息:不错过每一个重要提醒

纪念日如果没有提醒,就失去了意义。“记忆瓶子”集成了微信小程序的订阅消息功能。

实现:

  • 用户在小程序内通过 wx.requestSubscribeMessage 授权接收提醒。
  • 我部署了一个定时触发的云函数 checkReminders。该云函数会每天运行,遍历所有用户的纪念日,计算每个纪念日的目标提醒日期(即“下一个发生日期”减去“提前提醒天数”)。
  • 如果目标提醒日期恰好是今天,云函数便会调用 cloud.openapi.subscribeMessage.send 发送订阅消息。
  • 踩坑提示: 在发送订阅消息时,有一个关键参数 miniprogramState初期为了调试方便,我将其设为 'developer',导致用户点击消息后跳转到开发版小程序! 正式上线后,务必将其改为 'formal',确保用户跳转到正式发布版。

checkReminders 云函数核心片段:

// 假设 nextOccurrenceTimestamp 已经计算好
const targetReminderTimestamp = nextOccurrenceTimestamp - (reminderDaysBefore * ONE_DAY_MS);

if (targetReminderTimestamp === todayTimestamp) { // todayTimestamp 是目标时区今天的0点时间戳
    // 构建消息数据
    const messageData = { /* ... */ };
    remindersToSend.push({
        touser: item._openid,
        templateId: REMINDER_TEMPLATE_ID,
        page: `pages/detail/index?id=${item._id}`,
        data: messageData,
        miniprogramState: 'formal' // 【关键】确保跳转到正式版!
    });
}

云存储与 Base64 图片上传:打造个性化背景

为了让每个纪念日卡片都能拥有独特的视觉效果,我引入了自定义背景图功能。然而,云开发免费版存储空间有限,直接上传大量图片并不是最优解。

巧妙的解决方案:

  • 用户选择图片后,小程序端会先对图片进行压缩处理(减少数据量)。
  • 接着,将压缩后的图片数据转为 Base64 编码字符串
  • 这个 Base64 字符串会被直接作为纪念日记录的一个字段,存储在云数据库中(而非云存储)。
  • 前端渲染时,直接将这个 Base64 字符串赋值给 <img> 标签的 src 属性,图片便能直接显示。

【好处】 : 这种方式巧妙地绕过了云存储的容量限制,对于图片数量不多的个人应用来说,大大节约了成本,并且部署简单,加载速度也很快。

UI/UX 优化与样式攻坚战:Vant Weapp 的“爱恨交织”

为了快速构建美观的界面,我选择了 Vant Weapp 组件库。它提供了丰富的组件和完善的文档,确实提升了开发效率。然而,在详情页的底部操作按钮布局上,我遭遇了一场漫长而痛苦的“攻坚战”!

问题描述: 在详情页,我希望“编辑”和“删除”两个按钮能够并排显示,且平分底部空间,左右留出相等的内边距。在开发者工具模拟器上,通过 display: flex; gap: 24rpx; 配合 <van-button custom-class="action-button" />.action-button { flex: 1; min-width: 0; box-sizing: border-box; } 似乎完美解决了。然而,在真机上,按钮的布局却始终是“左边有空白,右边没有”,呈现出不均衡甚至轻微溢出的情况!

排查过程:

  • 检查 flex 容器和子项的 padding, margin, box-sizing
  • 尝试使用 calc() 函数精确计算宽度。
  • 利用真机调试功能,我发现罪魁祸首竟然是微信小程序底层对原生 button 注入的默认样式wx-button:not([size=mini]) { width: 184px; ...; margin-left: auto; margin-right: auto; }。这个固定宽度和 auto 外边距在 Vant Weapp 复杂的组件结构内部,以某种难以覆盖的方式生效,破坏了我的 flex: 1 布局!

最终解决方案: 在尝试了各种 !important 覆盖、多层选择器、甚至修改 Vant 内部样式(失败告终)之后,我做出了一个决定:放弃使用 van-button 来实现底部操作按钮

我选择用两个原生的 <view> 标签来模拟按钮:

<view class="action-button-wrapper">
  <view class="custom-action-button custom-edit-button" hover-class="button-hover" bindtap="onEdit">
    <text>编 辑</text>
  </view>
  <view class="custom-action-button custom-delete-button" hover-class="button-hover" bindtap="onDelete">
    <text>删 除</text>
  </view>
</view>

并结合以下 CSS 样式:

.action-button-wrapper {
  display: flex;
  gap: 24rpx;
  padding: 40rpx 24rpx;
  box-sizing: border-box;
  width: 100%;
}
.custom-action-button {
  flex: 1;
  min-width: 0;
  box-sizing: border-box;
  height: 80rpx;
  border-radius: 40rpx;
  font-size: 28rpx;
  font-weight: bold;
  color: #FFFFFF;
  text-align: center;
  line-height: 80rpx;
}
.custom-edit-button { background-color: #C8B6B6; }
.custom-delete-button { background-color: #ee0a24; }
.button-hover { opacity: 0.85; }

这套方案简单、直接,让我对样式有了 100% 的掌控力,最终在真机上完美实现了预期布局。有时候,回归原生是最可靠的解决方案!

版本更新机制:确保用户始终使用最新功能

为了避免用户因为小程序缓存而无法体验最新功能或修复的 Bug,我在 app.js 中集成了 wx.getUpdateManager()。它能检测到小程序新版本下载完成后,弹窗提示用户重启。

关键点:

  • 监听器 onUpdateReady 必须在 onLaunch 时就设置好。
  • 【非常重要】 每次上传新版本前,务必手动修改项目根目录下 project.config.json 文件中的 "version" 字段(如从 1.0.0 改为 1.1.0),否则微信后台无法识别为新版本,更新机制也无法触发。

产品未来展望

“记忆瓶子”才刚刚起步,未来还有很多有趣的功能等待探索:

  • 共享空间: 允许多人(如情侣、家人)共同创建和维护纪念日,分享彼此的珍贵时刻。
  • 更多主题与精美分享: 提供更丰富的 UI 主题、背景素材,并支持一键生成精美的分享图片。
  • 年度总结与历史上的今天: 增加趣味性功能,回顾一年的点滴,或发现“历史上的今天”发生了什么。

总结

开发“记忆瓶子”的过程,是一个不断学习、不断解决问题的过程。它让我对小程序和云开发有了更深的理解,也再次体会到作为一名开发者,能够将自己的想法变为现实的乐趣。

如果你也对这个小程序感兴趣,欢迎扫码体验,并留下宝贵的反馈和建议!你的每一次使用和反馈,都将是“记忆瓶子”继续成长的动力。再次邀请体验和反馈

gh\_6ba58d08bd84\_344.jpg

感谢阅读!

❌
❌