普通视图

发现新文章,点击刷新页面。
昨天 — 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高级特性,欢迎在评论区留言,我会及时解答。


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

昨天以前首页

《uni-app跨平台开发完全指南》- 03 - Vue.js基础入门

2025年11月5日 16:23

Vue.js 基础

本系列是《uni-app跨平台开发完全指南》系列教程,旨在帮助开发者从零开始掌握uni-app开发。本章将深入讲解Vue.js的核心概念,为你后续的uni-app开发打下坚实基础。

为什么学习Vue.js对uni-app开发如此重要?

很多初学者可能会问:"我直接学uni-app不行吗?为什么要先学Vue.js?"

这里有个很重要的概念需要理解:uni-app的本质是基于Vue.js的跨端实现框架。更形象一点,如果说uni-app是整车制造,那么Vue.js就属于发动机。如果你不懂发动机原理,虽然也能开车,但一旦出现故障,就束手无策了。同样,不掌握Vue.js基础,在uni-app开发中遇到复杂问题时,你会很难找到根本解决方案。

一、Vue.js 简介与开发环境搭建

1.1 Vue.js 是什么?

简单来说,Vue.js是一个用于构建用户界面的渐进式JavaScript框架。所谓"渐进式",意味着你可以根据项目需求,逐步采用Vue.js的特性:

  • 可以在老项目中局部使用Vue.js增强交互
  • 也可以使用Vue.js全家桶开发完整的前端应用
  • 还可以用Vue.js开发原生移动应用(如uni-app)

1.2 环境准备:第一个Vue应用

让我们从最简单的HTML页面开始:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>我的第一个Vue应用</title>
    <!-- 引入Vue.js -->
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
</head>
<body>
    <!-- Vue实例挂载点 -->
    <div id="app">
        <h1>{{ message }}</h1>
        <button @click="reverseMessage">反转消息</button>
    </div>

    <script>
        // 创建Vue实例
        var app = new Vue({
            el: '#app', // 指定挂载元素
            data: {     // 定义数据
                message: 'Hello Vue!'
            },
            methods: {  // 定义方法
                reverseMessage: function() {
                    this.message = this.message.split('').reverse().join('');
                }
            }
        });
    </script>
</body>
</html>

代码解析

  • el: '#app':告诉Vue这个实例要控制页面中id为app的元素
  • data:定义这个Vue实例的数据,可以在模板中使用
  • {{ message }}:模板语法,将data中的message值渲染到页面
  • @click:事件绑定,点击时执行reverseMessage方法

二、Vue 核心概念

2.1 数据绑定

数据绑定是Vue最核心的特性之一,它建立了数据DOM之间的自动同步关系。

2.1.1 文本插值:{{ }}
<div id="app">
    <!-- 基本文本插值 -->
    <p>消息:{{ message }}</p>
    
    <!-- JS表达式 -->
    <p>计算:{{ number + 1 }}</p>
    <p>三元表达式:{{ isActive ? '激活' : '未激活' }}</p>
    <p>反转:{{ message.split('').reverse().join('') }}</p>
</div>

<script>
new Vue({
    el: '#app',
    data: {
        message: 'Hello Vue!',
        number: 10,
        isActive: true
    }
});
</script>

重要提示{{ }}中支持JavaScript表达式,但不支持语句(如iffor等)。

2.1.2 属性绑定:v-bind
<div id="app">
    <!-- 绑定HTML属性 -->
    <div v-bind:title="tooltip">鼠标悬停查看提示</div>
    
    <!-- 绑定CSS类 -->
    <div v-bind:class="{ active: isActive, 'text-danger': hasError }">
        动态类名
    </div>
    
    <!-- 绑定样式 -->
    <div v-bind:style="{ color: activeColor, fontSize: fontSize + 'px' }">
        动态样式
    </div>
    
    <!-- 简写 -->
    <img :src="imageSrc" :alt="imageAlt">
</div>

<script>
new Vue({
    el: '#app',
    data: {
        tooltip: '这是一个提示信息',
        isActive: true,
        hasError: false,
        activeColor: 'red',
        fontSize: 20,
        imageSrc: 'path/to/image.jpg',
        imageAlt: '示例图片'
    }
});
</script>

v-bind原理:当数据变化时,Vue会自动更新对应的DOM属性。

2.2 指令系统

指令是带有v-前缀的特殊属性,它们为HTML添加了动态行为。

2.2.1 条件渲染:v-if vs v-show
<div id="app">
    <!-- v-if:条件性地渲染一块内容 -->
    <p v-if="score >= 90">优秀!</p>
    <p v-else-if="score >= 60">及格</p>
    <p v-else>不及格</p>
    
    <!-- v-show:总是渲染,只是切换display -->
    <p v-show="isVisible">这个元素会显示/隐藏</p>
    
    <button @click="toggle">切换显示</button>
    <button @click="changeScore">改变分数</button>
</div>

<script>
new Vue({
    el: '#app',
    data: {
        score: 85,
        isVisible: true
    },
    methods: {
        toggle: function() {
            this.isVisible = !this.isVisible;
        },
        changeScore: function() {
            this.score = Math.floor(Math.random() * 100);
        }
    }
});
</script>

v-if 与 v-show 的区别

特性 v-if v-show
渲染方式 条件为false时不渲染DOM元素 总是渲染,只是切换display属性
切换开销 更高的切换开销(创建/销毁组件) 更高的初始渲染开销
适用场景 运行时条件很少改变 需要非常频繁地切换
2.2.2 列表渲染:v-for
<div id="app">
    <!-- 遍历数组 -->
    <ul>
        <li v-for="(item, index) in items" :key="item.id">
            {{ index + 1 }}. {{ item.name }} - ¥{{ item.price }}
        </li>
    </ul>
    
    <!-- 遍历对象 -->
    <ul>
        <li v-for="(value, key) in userInfo" :key="key">
            {{ key }}: {{ value }}
        </li>
    </ul>
    
    <!-- 遍历数字范围 -->
    <span v-for="n in 5" :key="n">{{ n }} </span>
</div>

<script>
new Vue({
    el: '#app',
    data: {
        items: [
            { id: 1, name: '苹果', price: 5 },
            { id: 2, name: '香蕉', price: 3 },
            { id: 3, name: '橙子', price: 4 }
        ],
        userInfo: {
            name: '张三',
            age: 25,
            city: '北京'
        }
    }
});
</script>

关键点

  • :key的重要性:为每个节点提供唯一标识,优化列表渲染性能
  • 可以使用(item, index)(value, key, index)语法
2.2.3 事件处理:v-on
<div id="app">
    <!-- 基本事件处理 -->
    <button v-on:click="counter += 1">点击次数: {{ counter }}</button>
    
    <!-- 方法事件处理器 -->
    <button @click="sayHello">打招呼</button>
    
    <!-- 内联处理器中的方法 -->
    <button @click="say('Hello', $event)">带参数的事件</button>
    
    <!-- 事件修饰符 -->
    <form @submit.prevent="onSubmit">
        <input type="text">
        <button type="submit">提交</button>
    </form>
    
    <!-- 按键修饰符 -->
    <input @keyup.enter="onEnter" placeholder="按回车键触发">
</div>

<script>
new Vue({
    el: '#app',
    data: {
        counter: 0
    },
    methods: {
        sayHello: function(event) {
            alert('Hello!');
            console.log(event); // 原生事件对象
        },
        say: function(message, event) {
            alert(message);
            if (event) {
                event.preventDefault();
            }
        },
        onSubmit: function() {
            alert('表单提交被阻止了!');
        },
        onEnter: function() {
            alert('你按了回车键!');
        }
    }
});
</script>

常用事件修饰符

  • .stop:阻止事件冒泡
  • .prevent:阻止默认行为
  • .capture:使用事件捕获模式
  • .self:只当事件是从侦听器绑定的元素本身触发时才触发回调
  • .once:只触发一次
  • .passive:告诉浏览器你不想阻止事件的默认行为
2.2.4 双向数据绑定:v-model
<div id="app">
    <!-- 文本输入 -->
    <input v-model="message" placeholder="编辑我">
    <p>消息是: {{ message }}</p>
    
    <!-- 多行文本 -->
    <textarea v-model="multilineText"></textarea>
    <p style="white-space: pre-line;">{{ multilineText }}</p>
    
    <!-- 复选框 -->
    <input type="checkbox" id="checkbox" v-model="checked">
    <label for="checkbox">{{ checked ? '已选中' : '未选中' }}</label>
    
    <!-- 多个复选框 -->
    <div>
        <input type="checkbox" id="jack" value="Jack" v-model="checkedNames">
        <label for="jack">Jack</label>
        <input type="checkbox" id="john" value="John" v-model="checkedNames">
        <label for="john">John</label>
        <input type="checkbox" id="mike" value="Mike" v-model="checkedNames">
        <label for="mike">Mike</label>
        <br>
        <span>选中的名字: {{ checkedNames }}</span>
    </div>
    
    <!-- 单选按钮 -->
    <div>
        <input type="radio" id="one" value="One" v-model="picked">
        <label for="one">One</label>
        <input type="radio" id="two" value="Two" v-model="picked">
        <label for="two">Two</label>
        <br>
        <span>选中的值: {{ picked }}</span>
    </div>
    
    <!-- 选择框 -->
    <select v-model="selected">
        <option disabled value="">请选择</option>
        <option>A</option>
        <option>B</option>
        <option>C</option>
    </select>
    <span>选中的值: {{ selected }}</span>
</div>

<script>
new Vue({
    el: '#app',
    data: {
        message: '',
        multilineText: '',
        checked: false,
        checkedNames: [],
        picked: '',
        selected: ''
    }
});
</script>

v-model原理:本质上是语法糖,它负责监听用户的输入事件以更新数据。

// v-model 相当于:
<input 
  :value="message" 
  @input="message = $event.target.value">

2.3 计算属性与监听器

2.3.1 计算属性:computed
<div id="app">
    <input v-model="firstName" placeholder="姓">
    <input v-model="lastName" placeholder="名">
    
    <!-- 使用计算属性 -->
    <p>全名(计算属性): {{ fullName }}</p>
    
    <!-- 使用方法 -->
    <p>全名(方法): {{ getFullName() }}</p>
    
    <!-- 示例代码 -->
    <div>
        <h3>购物车</h3>
        <div v-for="item in cart" :key="item.id">
            {{ item.name }} - ¥{{ item.price }} × {{ item.quantity }}
        </div>
        <p>总价: {{ totalPrice }}</p>
        <p>打折后: {{ discountedTotal }}</p>
    </div>
</div>

<script>
new Vue({
    el: '#app',
    data: {
        firstName: '张',
        lastName: '三',
        cart: [
            { id: 1, name: '商品A', price: 100, quantity: 2 },
            { id: 2, name: '商品B', price: 200, quantity: 1 }
        ],
        discount: 0.8 // 8折
    },
    computed: {
        // 计算属性:基于依赖进行缓存
        fullName: function() {
            console.log('计算属性 fullName 被调用了');
            return this.firstName + ' ' + this.lastName;
        },
        // 计算总价
        totalPrice: function() {
            return this.cart.reduce((total, item) => {
                return total + (item.price * item.quantity);
            }, 0);
        },
        // 基于其他计算属性的计算属性
        discountedTotal: function() {
            return this.totalPrice * this.discount;
        }
    },
    methods: {
        // 方法:每次重新渲染都会调用
        getFullName: function() {
            console.log('方法 getFullName 被调用了');
            return this.firstName + ' ' + this.lastName;
        }
    }
});
</script>

计算属性的依赖追踪流程:

graph TD
    A[访问计算属性] --> B{脏数据?}
    B -->|是| C[重新计算值]
    B -->|否| D[返回缓存值]
    C --> E[标记为干净数据]
    E --> D
    F[依赖数据变化] --> G[标记为脏数据]
    G --> A

计算属性特点

  • 基于它们的响应式依赖进行缓存
  • 只在相关响应式依赖发生改变时才会重新求值
  • 多次访问计算属性会立即返回之前的计算结果,而不必再次执行函数
2.3.2 监听器:watch
<div id="app">
    <input v-model="question" placeholder="输入问题">
    <p>答案: {{ answer }}</p>
    
    <!-- 示例 -->
    <input v-model="user.name" placeholder="用户名">
    <input v-model="user.age" type="number" placeholder="年龄">
    <p>用户信息变化次数: {{ changeCount }}</p>
</div>

<script>
new Vue({
    el: '#app',
    data: {
        question: '',
        answer: '我无法给你答案直到你提问!',
        user: {
            name: '',
            age: 0
        },
        changeCount: 0
    },
    watch: {
        // 简单监听:question发生变化时执行
        question: function(newQuestion, oldQuestion) {
            this.answer = '等待你停止输入...';
            this.getAnswer();
        },
        // 深度监听:对象内部属性的变化
        user: {
            handler: function(newVal, oldVal) {
                this.changeCount++;
                console.log('用户信息发生变化:', newVal);
            },
            deep: true, 
            immediate: true 
        }
    },
    methods: {
        getAnswer: function() {
            // 模拟异步操作
            setTimeout(() => {
                this.answer = '这是对你问题的回答';
            }, 1000);
        }
    }
});
</script>

计算属性 vs 监听器

场景 使用计算属性 使用监听器
数据派生 适用于现有数据计算新数据 不适用
异步操作 不支持异步 支持异步
性能优化 自动缓存 无缓存
复杂逻辑 声明式 命令式

三、组件化开发

组件化就像搭积木一样,把复杂的界面拆分成独立、可复用的部分。

3.1 组件注册与使用

3.1.1 全局组件
<div id="app">
    <!-- 使用全局组件 -->
    <my-button></my-button>
    <user-card 
        name="张三" 
        :age="25" 
        avatar="path/to/avatar.jpg">
    </user-card>
</div>

<script>
// 全局组件注册
Vue.component('my-button', {
    template: `
        <button class="my-btn" @click="onClick">
            <slot>默认按钮</slot>
        </button>
    `,
    methods: {
        onClick: function() {
            this.$emit('btn-click'); // 触发自定义事件
        }
    }
});

// 另一个全局组件
Vue.component('user-card', {
    props: ['name', 'age', 'avatar'], // 定义组件属性
    template: `
        <div class="user-card">
            <img :src="avatar" :alt="name" class="avatar">
            <div class="info">
                <h3>{{ name }}</h3>
                <p>年龄: {{ age }}</p>
            </div>
        </div>
    `
});

new Vue({
    el: '#app'
});
</script>

<style>
.my-btn {
    padding: 10px 20px;
    background: #4CAF50;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
}

.user-card {
    border: 1px solid #ddd;
    padding: 15px;
    margin: 10px 0;
    border-radius: 8px;
    display: flex;
    align-items: center;
}

.avatar {
    width: 50px;
    height: 50px;
    border-radius: 50%;
    margin-right: 15px;
}

.info h3 {
    margin: 0 0 5px 0;
}
</style>
3.1.2 局部组件
<div id="app">
    <product-list></product-list>
</div>

<script>
// 定义局部组件
var ProductList = {
    template: `
        <div class="product-list">
            <h2>商品列表</h2>
            <product-item 
                v-for="product in products" 
                :key="product.id"
                :product="product"
                @add-to-cart="onAddToCart">
            </product-item>
        </div>
    `,
    data: function() {
        return {
            products: [
                { id: 1, name: 'iPhone', price: 5999, stock: 10 },
                { id: 2, name: 'MacBook', price: 9999, stock: 5 },
                { id: 3, name: 'iPad', price: 3299, stock: 8 }
            ]
        };
    },
    methods: {
        onAddToCart: function(product) {
            console.log('添加到购物车:', product.name);
            // 这里可以调用Vuex或触发全局事件
        }
    }
};

// 子组件
var ProductItem = {
    props: ['product'],
    template: `
        <div class="product-item">
            <h3>{{ product.name }}</h3>
            <p>价格: ¥{{ product.price }}</p>
            <p>库存: {{ product.stock }}</p>
            <button 
                @click="addToCart" 
                :disabled="product.stock === 0">
                {{ product.stock === 0 ? '缺货' : '加入购物车' }}
            </button>
        </div>
    `,
    methods: {
        addToCart: function() {
            this.$emit('add-to-cart', this.product);
        }
    }
};

new Vue({
    el: '#app',
    components: {
        'product-list': ProductList,
        'product-item': ProductItem
    }
});
</script>

<style>
.product-list {
    max-width: 600px;
    margin: 0 auto;
}

.product-item {
    border: 1px solid #eee;
    padding: 15px;
    margin: 10px 0;
    border-radius: 5px;
}

.product-item h3 {
    color: #333;
    margin-top: 0;
}
</style>

3.2 组件通信

组件通信是组件化开发的核心,Vue提供了多种通信方式,用一张图来看下:

graph TB
    A[组件通信] --> B[父子通信]
    A --> C[兄弟通信]
    A --> D[跨级通信]
    A --> E[全局通信]
    
    B --> B1[Props Down]
    B --> B2[Events Up]
    B --> B3[v-model]
    B --> B4[refs]
    
    C --> C1[Event Bus]
    C --> C2[共同父级]
    
    D --> D1[Provide/Inject]
    D --> D2[attrs/listeners]
    
    E --> E1[Vuex]
    E --> E2[全局事件]

下面结合一段具体代码示例,带大家了解下组件间是如何通信的:

<div id="app">
    <h2>组件通信示例</h2>
    
    <!-- 1. 父子组件 -->
    <parent-component></parent-component>
    
    <!-- 2. 事件总线 -->
    <component-a></component-a>
    <component-b></component-b>
</div>

<script>
// 事件总线(用于非父子组件通信)
var eventBus = new Vue();

// 组件A
Vue.component('component-a', {
    template: `
        <div class="component">
            <h3>组件A</h3>
            <button @click="sendMessage">发送消息给组件B</button>
        </div>
    `,
    methods: {
        sendMessage: function() {
            eventBus.$emit('message-from-a', '你好,这是来自组件A的消息!');
        }
    }
});

// 组件B
Vue.component('component-b', {
    template: `
        <div class="component">
            <h3>组件B</h3>
            <p>收到消息: {{ receivedMessage }}</p>
        </div>
    `,
    data: function() {
        return {
            receivedMessage: ''
        };
    },
    mounted: function() {
        var self = this;
        eventBus.$on('message-from-a', function(message) {
            self.receivedMessage = message;
        });
    }
});

// 父组件
Vue.component('parent-component', {
    template: `
        <div class="parent">
            <h3>父组件</h3>
            <p>父组件数据: {{ parentData }}</p>
            
            <!-- 父传子:通过props -->
            <child-component 
                :message="parentData"
                @child-event="onChildEvent">
            </child-component>
            
            <!-- 子传父:通过自定义事件 -->
            <p>子组件消息: {{ childMessage }}</p>
        </div>
    `,
    data: function() {
        return {
            parentData: '来自父组件的数据',
            childMessage: ''
        };
    },
    methods: {
        onChildEvent: function(message) {
            this.childMessage = message;
        }
    }
});

// 子组件
Vue.component('child-component', {
    props: ['message'], // 接收父组件数据
    template: `
        <div class="child">
            <h4>子组件</h4>
            <p>收到父组件的消息: {{ message }}</p>
            <button @click="sendToParent">发送消息给父组件</button>
        </div>
    `,
    methods: {
        sendToParent: function() {
            this.$emit('child-event', '来自子组件的问候!');
        }
    }
});

new Vue({
    el: '#app'
});
</script>

<style>
.component, .parent, .child {
    border: 1px solid #ccc;
    padding: 15px;
    margin: 10px;
    border-radius: 5px;
}

.parent {
    background: #f0f8ff;
}

.child {
    background: #f9f9f9;
    margin-left: 30px;
}
</style>

四、生命周期函数

4.1 生命周期

Vue实例有一个完整的生命周期,包括创建、挂载、更新、销毁等阶段。每个阶段都提供了相应的生命周期钩子,让我们可以在特定阶段执行自定义逻辑。

sequenceDiagram
    participant P as Parent Component
    participant C as Child Component
    participant VD as Virtual DOM
    participant RD as Real DOM
    
    Note over P: 1. 父组件创建
    P->>C: 2. 创建子组件实例
    Note over C: 3. beforeCreate
    Note over C: 4. 初始化注入
    Note over C: 5. created
    C->>VD: 6. 编译模板为渲染函数
    Note over C: 7. beforeMount
    C->>RD: 8. 创建$el并挂载
    Note over C: 9. mounted
    Note over C: 10. 等待数据变化
    C->>C: 11. 数据变化
    Note over C: 12. beforeUpdate
    C->>VD: 13. 重新渲染
    VD->>RD: 14. 打补丁
    Note over C: 15. updated
    P->>C: 16. 销毁子组件
    Note over C: 17. beforeDestroy
    C->>C: 18. 清理工作
    Note over C: 19. destroyed

其实生命周期钩子函数不用刻意去记忆,实在不知道直接控制台打印看日志结果就行了,当然能记住最好~~~

4.2 生命周期钩子

<div id="app">
    <h2>用计时器来演示生命周期狗子函数</h2>
    <p>计数器: {{ count }}</p>
    <button @click="count++">增加</button>
    <button @click="destroy">销毁实例</button>
    
    <div v-if="showChild">
        <lifecycle-demo :count="count"></lifecycle-demo>
    </div>
    <button @click="showChild = !showChild">切换子组件</button>
</div>

<script>
Vue.component('lifecycle-demo', {
    props: ['count'],
    template: `
        <div class="lifecycle-demo">
            <h3>子组件 - 计数: {{ count }}</h3>
            <p>生命周期调用记录:</p>
            <ul>
                <li v-for="log in logs" :key="log.id">{{ log.message }}</li>
            </ul>
        </div>
    `,
    data: function() {
        return {
            logs: [],
            logId: 0
        };
    },
    
    // 生命周期钩子
    beforeCreate: function() {
        this.addLog('beforeCreate: 实例刚被创建,data和methods还未初始化');
    },
    
    created: function() {
        this.addLog('created: 实例创建完成,data和methods已初始化');
        // 这里可以调用API获取初始数据
        this.fetchData();
    },
    
    beforeMount: function() {
        this.addLog('beforeMount: 模板编译完成,但尚未挂载到页面');
    },
    
    mounted: function() {
        this.addLog('mounted: 实例已挂载到DOM元素,可以访问$el');
        // 这里可以操作DOM或初始化第三方库
        this.initializeThirdPartyLib();
    },
    
    beforeUpdate: function() {
        this.addLog('beforeUpdate: 数据更新前,虚拟DOM重新渲染之前');
    },
    
    updated: function() {
        this.addLog('updated: 数据更新完成,DOM已重新渲染');
        // 这里可以执行依赖于DOM更新的操作
    },
    
    beforeDestroy: function() {
        this.addLog('beforeDestroy: 实例销毁前,此时实例仍然完全可用');
        // 这里可以清理定时器、取消订阅等
        this.cleanup();
    },
    
    destroyed: function() {
        // 注意:在destroyed钩子中无法添加日志,因为组件已销毁
        console.log('destroyed: 实例已销毁,所有绑定和监听器已被移除');
    },
    
    methods: {
        addLog: function(message) {
            this.logs.push({
                id: this.logId++,
                message: message + ' - ' + new Date().toLocaleTimeString()
            });
        },
        
        fetchData: function() {
            // 模拟接口请求
            setTimeout(() => {
                this.addLog('数据获取完成');
            }, 100);
        },
        
        initializeThirdPartyLib: function() {
            this.addLog('三方库初始化完成');
        },
        
        cleanup: function() {
            this.addLog('清理工作完成');
        }
    }
});

new Vue({
    el: '#app',
    data: {
        count: 0,
        showChild: true
    },
    methods: {
        destroy: function() {
            this.$destroy();
            alert('Vue实例被销毁');
        }
    }
});
</script>

<style>
.lifecycle-demo {
    border: 2px solid #4CAF50;
    padding: 15px;
    margin: 10px 0;
    background: #f9fff9;
}

.lifecycle-demo ul {
    max-height: 200px;
    overflow-y: auto;
    background: white;
    padding: 10px;
    border: 1px solid #ddd;
}
</style>

4.3 生命周期使用场景总结

生命周期钩子 常见使用场景
created - API数据请求
- 事件监听器初始化
- 定时器设置
mounted - DOM操作
- 三方库初始化(如图表库)
- 插件初始化
updated - DOM依赖的操作
- 基于新状态的操作
beforeDestroy - 清除定时器
- 取消事件监听
- 清理三方库实例

五、响应式原理

响应式就是当数据发生变化时,视图会自动更新。这听起来很简单,但底层原理有着巧妙的设计。

5.1 原理

Vue的响应式系统基于三个核心概念:

5.1.1 数据劫持(Object.defineProperty)
// 简化的响应式原理
function defineReactive(obj, key, val) {
    // 递归处理嵌套对象
    observe(val);
    
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function() {
            console.log(`读取 ${key}: ${val}`);
            // 这里会进行依赖收集
            return val;
        },
        set: function(newVal) {
            if (newVal === val) return;
            console.log(`设置 ${key}: ${newVal}`);
            val = newVal;
            // 这里会通知依赖更新
            updateView();
        }
    });
}

function observe(obj) {
    if (!obj || typeof obj !== 'object') return;
    
    Object.keys(obj).forEach(key => {
        defineReactive(obj, key, obj[key]);
    });
}

// 测试
const data = { message: 'Hello', count: 0 };
observe(data);

// 现在data是响应式的
data.message = 'Hello Vue!';  // 控制台会输出:设置 message: Hello Vue!
console.log(data.message);    // 控制台会输出:读取 message: Hello Vue!
5.1.2 依赖收集与派发更新

Vue的响应式系统实际更为复杂,包含依赖收集和派发更新机制:

// 简化的Dep(依赖)类
class Dep {
    constructor() {
        this.subscribers = new Set();
    }
    
    depend() {
        if (activeUpdate) {
            this.subscribers.add(activeUpdate);
        }
    }
    
    notify() {
        this.subscribers.forEach(sub => sub());
    }
}

let activeUpdate = null;

function autorun(update) {
    function wrappedUpdate() {
        activeUpdate = wrappedUpdate;
        update();
        activeUpdate = null;
    }
    wrappedUpdate();
}

// 使用示例
const dep = new Dep();

autorun(() => {
    dep.depend();
    console.log('更新视图');
});

// 当数据变化时
dep.notify(); // 输出:更新视图

5.2 注意事项

5.2.1 数组更新检测
<div id="app">
    <h3>数组响应式注意事项</h3>
    <ul>
        <li v-for="item in items" :key="item.id">{{ item.name }}</li>
    </ul>
    
    <button @click="addItem">添加项目successed</button>
    <button @click="addItemWrong">添加项目error</button>
    <button @click="changeItemProperty">修改项目属性</button>
</div>

<script>
new Vue({
    el: '#app',
    data: {
        items: [
            { id: 1, name: '项目1' },
            { id: 2, name: '项目2' }
        ]
    },
    methods: {
        // 推荐使用数组变异方法
        addItem: function() {
            this.items.push({
                id: this.items.length + 1,
                name: '项目' + (this.items.length + 1)
            });
        },
        
        // 不推荐直接通过索引设置
        addItemWrong: function() {
            // 这种方式不会触发视图更新!
            this.items[this.items.length] = {
                id: this.items.length + 1,
                name: '项目' + (this.items.length + 1)
            };
            console.log('数组已修改,但视图不会更新');
        },
        
        // 对象属性的响应式
        changeItemProperty: function() {
            // Vue.set 或 this.$set 确保响应式
            this.$set(this.items[0], 'newProperty', '新属性值');
        }
    }
});
</script>
5.2.2 响应式API
// 响应式API
new Vue({
    data: {
        user: {
            name: '张三'
        },
        list: [1, 2, 3]
    },
    created() {
        // 添加响应式属性
        this.$set(this.user, 'age', 25);
        
        // 删除响应式属性
        this.$delete(this.user, 'name');
        
        // 数组响应式方法
        this.list = this.$set(this.list, 0, 100); // 替换第一个元素
        
        // 或者使用Vue.set全局方法
        Vue.set(this.list, 1, 200);
    }
});

六、项目实战:TodoList应用

用一个完整的TodoList应用来综合运用以上所学知识:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Vue.js TodoList应用</title>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            padding: 20px;
        }
        .todo-app {
            max-width: 500px;
            margin: 0 auto;
            background: white;
            border-radius: 10px;
            box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
            overflow: hidden;
        }
        .header {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            padding: 30px 20px;
            text-align: center;
        }
        .header h1 {
            margin-bottom: 10px;
            font-size: 2.5em;
        }
        .input-section {
            padding: 20px;
            border-bottom: 1px solid #eee;
        }
        .todo-input {
            width: 100%;
            padding: 15px;
            border: 2px solid #e1e1e1;
            border-radius: 8px;
            font-size: 16px;
            transition: border-color 0.3s;
        }
        .todo-input:focus {
            outline: none;
            border-color: #667eea;
        }
        .add-btn {
            width: 100%;
            padding: 15px;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            border: none;
            border-radius: 8px;
            font-size: 16px;
            cursor: pointer;
            margin-top: 10px;
            transition: transform 0.2s;
        }
        .add-btn:hover {
            transform: translateY(-2px);
        }
        .filters {
            display: flex;
            padding: 15px 20px;
            border-bottom: 1px solid #eee;
        }
        .filter-btn {
            flex: 1;
            padding: 10px;
            background: none;
            border: none;
            cursor: pointer;
            transition: all 0.3s;
            border-radius: 5px;
            margin: 0 5px;
        }
        .filter-btn.active {
            background: #667eea;
            color: white;
        }
        .todo-list {
            max-height: 400px;
            overflow-y: auto;
        }
        .todo-item {
            display: flex;
            align-items: center;
            padding: 15px 20px;
            border-bottom: 1px solid #f1f1f1;
            transition: background-color 0.3s;
        }
        .todo-item:hover {
            background-color: #f9f9f9;
        }
        .todo-item.completed {
            opacity: 0.6;
        }
        .todo-item.completed .todo-text {
            text-decoration: line-through;
        }
        .todo-checkbox {
            margin-right: 15px;
            transform: scale(1.2);
        }
        .todo-text {
            flex: 1;
            font-size: 16px;
        }
        .delete-btn {
            background: #ff4757;
            color: white;
            border: none;
            padding: 5px 10px;
            border-radius: 5px;
            cursor: pointer;
            transition: background-color 0.3s;
        }
        .delete-btn:hover {
            background: #ff3742;
        }
        .stats {
            padding: 15px 20px;
            text-align: center;
            color: #666;
            border-top: 1px solid #eee;
        }
        .empty-state {
            text-align: center;
            padding: 40px 20px;
            color: #999;
        }
    </style>
</head>
<body>
    <div id="app">
        <div class="todo-app">
            <!-- 头部 -->
            <div class="header">
                <h1>TodoList</h1>
                <p>Vue.js应用</p>
            </div>
            
            <div class="input-section">
                <input 
                    v-model="newTodo" 
                    @keyup.enter="addTodo"
                    placeholder="添加新任务..."
                    class="todo-input">
                <button @click="addTodo" class="add-btn">
                    添加任务
                </button>
            </div>
            
            <!-- 过滤器 -->
            <div class="filters">
                <button 
                    @click="filter = 'all'"
                    :class="['filter-btn', { active: filter === 'all' }]">
                    全部 ({{ totalTodos }})
                </button>
                <button 
                    @click="filter = 'active'"
                    :class="['filter-btn', { active: filter === 'active' }]">
                    待完成 ({{ activeTodos }})
                </button>
                <button 
                    @click="filter = 'completed'"
                    :class="['filter-btn', { active: filter === 'completed' }]">
                    已完成 ({{ completedTodos }})
                </button>
            </div>
            
            <!-- Todo列表 -->
            <div class="todo-list">
                <div v-if="filteredTodos.length === 0" class="empty-state">
                    {{ emptyMessage }}
                </div>
                
                <div 
                    v-for="todo in filteredTodos" 
                    :key="todo.id"
                    :class="['todo-item', { completed: todo.completed }]">
                    
                    <input 
                        type="checkbox" 
                        v-model="todo.completed"
                        class="todo-checkbox">
                    
                    <span class="todo-text">{{ todo.text }}</span>
                    
                    <button 
                        @click="removeTodo(todo.id)"
                        class="delete-btn">
                        删除
                    </button>
                </div>
            </div>
            
            <!-- 统计信息 -->
            <div class="stats">
                <span v-if="totalTodos > 0">
                    进度: {{ completionRate }}% ({{ completedTodos }}/{{ totalTodos }})
                </span>
                <span v-else>还没有任务,添加一个吧!</span>
            </div>
        </div>
    </div>

    <script>
        new Vue({
            el: '#app',
            data: {
                newTodo: '',           // 新任务输入
                todos: [],             // 任务列表
                filter: 'all',         // 当前过滤器
                nextId: 1              // 下一个任务ID
            },
            
            // 计算属性
            computed: {
                // 总任务数
                totalTodos() {
                    return this.todos.length;
                },
                
                // 活跃任务数
                activeTodos() {
                    return this.todos.filter(todo => !todo.completed).length;
                },
                
                // 已完成任务数
                completedTodos() {
                    return this.todos.filter(todo => todo.completed).length;
                },
                
                // 完成率
                completionRate() {
                    if (this.totalTodos === 0) return 0;
                    return Math.round((this.completedTodos / this.totalTodos) * 100);
                },
                
                // 过滤后的任务列表
                filteredTodos() {
                    switch (this.filter) {
                        case 'active':
                            return this.todos.filter(todo => !todo.completed);
                        case 'completed':
                            return this.todos.filter(todo => todo.completed);
                        default:
                            return this.todos;
                    }
                },
                
                // 空状态消息
                emptyMessage() {
                    switch (this.filter) {
                        case 'active':
                            return '没有待完成的任务!';
                        case 'completed':
                            return '还没有完成的任务!';
                        default:
                            return '还没有任务,添加一个吧!';
                    }
                }
            },
            
            methods: {
                // 添加新任务
                addTodo() {
                    if (this.newTodo.trim() === '') return;
                    
                    this.todos.push({
                        id: this.nextId++,
                        text: this.newTodo.trim(),
                        completed: false,
                        createdAt: new Date()
                    });
                    
                    this.newTodo = ''; 
                },
                
                // 删除任务
                removeTodo(id) {
                    this.todos = this.todos.filter(todo => todo.id !== id);
                }
            },
            
            // 生命周期钩子
            created() {
                console.log('TodoList应用已创建');
                // 加载本地存储的数据。。。
            },
            
            mounted() {
                console.log('TodoList应用已挂载');
            }
        });
    </script>
</body>
</html>

这个TodoList应用综合运用了:v-model@click@keyupv-ifv-forcomputed:class生命周期钩子

七、总结

7.1 核心概念

  1. 数据驱动:Vue的核心思想,数据变化自动更新视图
  2. 指令系统:v-bind, v-model, v-for, v-if等指令的强大功能
  3. 组件化:将UI拆分为独立可复用的组件
  4. 生命周期:理解组件从创建到销毁的完整过程
  5. 响应式原理:理解数据变化的侦测机制

7.2 组件设计原则

// 好的组件设计
Vue.component('user-profile', {
    props: {
        user: {
            type: Object,
            required: true,
            validator: function(value) {
                return value.name && value.email;
            }
        }
    },
    template: `
        <div class="user-profile">
            <img :src="user.avatar" :alt="user.name">
            <h3>{{ user.name }}</h3>
            <p>{{ user.email }}</p>
        </div>
    `
});

// 不好的组件设计(props验证不足,模板混乱)
Vue.component('bad-component', {
    props: ['user'],
    template: '<div>...</div>' // 模板过长,难以维护
});

7.3 状态管理建议

// 对于复杂应用,考虑使用Vuex
// 对于简单应用,合理组织组件间通信

// 好的状态组织
new Vue({
    data: {
        // 相关状态分组
        user: {
            profile: {},
            preferences: {}
        },
        ui: {
            loading: false,
            sidebarOpen: true
        }
    }
});

7.4 常见问题

常见问题 错误做法 正确做法
数组更新 this.items[0] = newValue this.$set(this.items, 0, newValue)
对象属性 this.obj.newProp = value this.$set(this.obj, 'newProp', value)
异步更新 直接操作DOM 使用this.$nextTick()
事件监听 不清理事件监听器 beforeDestroy中清理

结语

至此Vue.js基础就学习完了,想要掌握更多的Vue.js知识可去官网深入学习,掌握好Vue.js,uni-app学习就会事半功倍。


如果觉得本文对你有帮助,请一键三连(点赞、关注、收藏)支持一下!有任何问题欢迎在评论区留言讨论。

《Flutter全栈开发实战指南:从零到高级》- 08 -导航与路由管理

2025年10月30日 09:33
在移动应用开发中,页面跳转和导航是必不可少的功能。想象一下,如果微信不能从聊天列表跳转到具体聊天窗口,或者淘宝不能从商品列表进入商品详情,那该有多么糟糕!本教程将带你深入理解路由与导航的底层原理!!!

《Flutter全栈开发实战指南:从零到高级》- 05 - 基础组件实战:构建登录界面

2025年10月23日 17:48

手把手教你实现一个Flutter登录页面

嗨,各位Flutter爱好者!今天我要和大家分享一个超级实用的功能——用Flutter构建一个功能完整的登录界面。说实话,第一次接触Flutter时,看着那些组件列表也是一头雾水,但当真正动手做出第一个登录页面后,才发现原来一切都这么有趣!

登录界面就像餐厅的门面,直接影响用户的第一印象。今天,我们就一起来打造一个既美观又实用的"门面"!

我们要实现什么?

先来看看我们的目标——一个支持多种登录方式的登录界面:

含以下功能点:

  • 双登录方式:账号密码 + 手机验证码
  • 实时表单验证
  • 记住密码和自动登录
  • 验证码倒计时
  • 第三方登录(微信&QQ&微博)
  • 交互动画

是不是已经迫不及待了?别急,工欲善其事,必先利其器!!! 在开始搭建之前,我们先来熟悉一下Flutter的基础组件,这些组件就像乐高积木,每个都有独特的用途,组合起来就能创造奇迹!

一、Flutter基础组件

1.1 Text组件:不只是显示文字

Text组件就像聊天时的文字消息,不同的样式能传达不同的情感。让我给你展示几个实用的例子:

// 基础文本 - 就像普通的聊天消息
Text('你好,Flutter!')

// 带样式的文本 - 像加了特效的消息
Text(
  '欢迎回来!',
  style: TextStyle(
    fontSize: 24.0,              // 字体大小
    fontWeight: FontWeight.bold, // 字体粗细
    color: Colors.blue[800],     // 字体颜色
    letterSpacing: 1.2,          // 字母间距
  ),
)

// 富文本 - 像一条消息中有不同样式的部分
Text.rich(
  TextSpan(
    children: [
      TextSpan(
        text: '已有账号?',
        style: TextStyle(color: Colors.grey[600]),
      ),
      TextSpan(
        text: '立即登录',
        style: TextStyle(
          color: Colors.blue,
          fontWeight: FontWeight.bold,
        ),
      ),
    ],
  ),
)

实用技巧:

  • 文字超出时显示省略号:overflow: TextOverflow.ellipsis
  • 限制最多显示行数:maxLines: 2
  • 文字居中显示:textAlign: TextAlign.center

1.2 TextField组件:用户输入

TextField就像餐厅的点菜单,用户在上面写下需求,我们负责处理。来看看如何打造一个贴心的输入体验:

// 基础输入框
TextField(
  decoration: InputDecoration(
    labelText: '用户名',             // 标签文字
    hintText: '请输入用户名',        // 提示文字
    prefixIcon: Icon(Icons.person), // 前缀图标
  ),
)

// 密码输入框 - 带显示/隐藏切换
TextField(
  obscureText: true,  // 隐藏输入内容
  decoration: InputDecoration(
    labelText: '密码',
    prefixIcon: Icon(Icons.lock),
    suffixIcon: IconButton(    // 后缀图标按钮
      icon: Icon(Icons.visibility),
      onPressed: () {
        // 切换密码显示/隐藏
      },
    ),
  ),
)

// 带验证的输入框
TextFormField(
  validator: (value) {
    if (value == null || value.isEmpty) {
      return '请输入内容';  // 验证失败时的提示
    }
    return null;  // 验证成功
  },
)

TextField的核心技能:

  • controller:管理输入内容
  • focusNode:跟踪输入焦点
  • keyboardType:为不同场景准备合适的键盘
  • onChanged:实时监听用户的每个输入

1.3 按钮组件:触发事件的开关

按钮就像电梯的按键,按下它就会带你到达想去的楼层。Flutter提供了多种类型按钮,每种都有其独有的特性:

// 1. ElevatedButton - 主要操作按钮(有立体感)
ElevatedButton(
  onPressed: () {
    print('按钮被点击了!');
  },
  style: ElevatedButton.styleFrom(
    backgroundColor: Colors.blue,      // 背景色
    foregroundColor: Colors.white,     // 文字颜色
    padding: EdgeInsets.all(16),       // 内边距
    shape: RoundedRectangleBorder(     // 形状
      borderRadius: BorderRadius.circular(12),
    ),
  ),
  child: Text('登录'),
)

// 2. TextButton - 次要操作按钮
TextButton(
  onPressed: () {
    print('忘记密码');
  },
  child: Text('忘记密码?'),
)

// 3. OutlinedButton - 边框按钮
OutlinedButton(
  onPressed: () {},
  child: Text('取消'),
  style: OutlinedButton.styleFrom(
    side: BorderSide(color: Colors.grey),
  ),
)

// 4. IconButton - 图标按钮
IconButton(
  onPressed: () {},
  icon: Icon(Icons.close),
  color: Colors.grey,
)

按钮状态管理很重要:

  • 加载时禁用按钮,防止重复提交
  • 根据表单验证结果控制按钮可用性
  • 提供视觉反馈,让用户知道操作已被接收

1.4 布局组件

布局组件就像房子的承重墙,它们决定了界面元素的排列方式。掌握它们,你就能轻松构建各种复杂布局:

// Container - 万能的容器
Container(
  width: 200,
  height: 100,
  margin: EdgeInsets.all(16),    // 外边距
  padding: EdgeInsets.all(20),   // 内边距
  decoration: BoxDecoration(
    color: Colors.white,
    borderRadius: BorderRadius.circular(16),
    boxShadow: [                 // 阴影效果
      BoxShadow(
        color: Colors.black12,
        blurRadius: 10,
      ),
    ],
  ),
  child: Text('内容'),
)

// Row - 水平排列
Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween,
  children: [
    Text('左边'),
    Text('右边'),
  ],
)

// Column - 垂直排列
Column(
  children: [
    Text('第一行'),
    SizedBox(height: 16),  // 间距组件
    Text('第二行'),
  ],
)

现在我们已经熟悉了基础组件,是时候开始真正的功能实战了!

二、功能实战:构建多功能登录页面

2.1 项目目录结构

在开始编码前,我们先规划好项目结构,就像建房子前先画好房体图纸一样:

lib/
├── main.dart                    # 应用入口
├── models/                      # 数据模型
│   ├── user_model.dart          # 用户模型
│   └── login_type.dart          # 登录类型
├── pages/                       # 页面文件
│   ├── login_page.dart          # 登录页面
│   ├── home_page.dart           # 首页
│   └── register_page.dart       # 注册页面
├── widgets/                     # 自定义组件
│   ├── login_tab_bar.dart       # 登录选项卡
│   ├── auth_text_field.dart     # 认证输入框
│   └── third_party_login.dart   # 第三方登录
├── services/                    # 服务层
│   └── auth_service.dart        # 认证服务
├── utils/                       # 工具类
│   └── validators.dart          # 表单验证
└── theme/                       # 主题配置
    └── app_theme.dart           # 应用主题

2.2 数据模型定义

我们先定义需要用到的数据模型:

// 登录类型枚举
enum LoginType {
  account,  // 账号密码登录
  phone,    // 手机验证码登录
}

// 用户数据模型
class User {
  final String id;
  final String name;
  final String email;
  final String phone;
  
  User({
    required this.id,
    required this.name,
    required this.email,
    required this.phone,
  });
}

2.3 实现登录页面

下面我将会带你一步步构建登录页面。

第一步:状态管理

首先,我们需要管理页面的各种状态,就像我们平时开车时要关注各项指标:

class _LoginPageState extends State<LoginPage> {
  // 登录方式状态
  LoginType _loginType = LoginType.account;
  
  // 文本控制器
  final TextEditingController _accountController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();
  final TextEditingController _phoneController = TextEditingController();
  final TextEditingController _smsController = TextEditingController();
  
  // 焦点管理
  final FocusNode _accountFocus = FocusNode();
  final FocusNode _passwordFocus = FocusNode();
  final FocusNode _phoneFocus = FocusNode();
  final FocusNode _smsFocus = FocusNode();
  
  // 状态变量
  bool _isLoading = false;
  bool _rememberPassword = true;
  bool _autoLogin = false;
  bool _isPasswordVisible = false;
  bool _isSmsLoading = false;
  int _smsCountdown = 0;
  
  // 错误信息
  String? _accountError;
  String? _passwordError;
  String? _phoneError;
  String? _smsError;
  
  // 表单Key
  final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
  
  @override
  void initState() {
    super.initState();
    _loadSavedData();
  }
  
  void _loadSavedData() {
    // 从本地存储加载保存的账号
    if (_rememberPassword) {
      _accountController.text = 'user@example.com';
    }
  }
}
第二步:构建页面

接下来,我们构建页面的整体结构:

@override
Widget build(BuildContext context) {
  return Scaffold(
    backgroundColor: Colors.grey[50],
    body: SafeArea(
      child: SingleChildScrollView(
        physics: BouncingScrollPhysics(),
        child: Container(
          padding: EdgeInsets.all(24),
          child: Form(
            key: _formKey,
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                _buildBackButton(),        // 返回按钮
                SizedBox(height: 20),
                _buildHeader(),            // 页面标题
                SizedBox(height: 40),
                _buildLoginTypeTab(),      // 登录方式切换
                SizedBox(height: 32),
                _buildDynamicForm(),       // 动态表单
                SizedBox(height: 24),
                _buildRememberSection(),   // 记住密码选项
                SizedBox(height: 32),
                _buildLoginButton(),       // 登录按钮
                SizedBox(height: 40),
                _buildThirdPartyLogin(),   // 第三方登录
                SizedBox(height: 24),
                _buildRegisterPrompt(),    // 注册提示
              ],
            ),
          ),
        ),
      ),
    ),
  );
}
第三步:构建各个组件

现在我们来逐一实现每个功能组件:

登录方式切换选项卡:

Widget _buildLoginTypeTab() {
  return Container(
    height: 48,
    decoration: BoxDecoration(
      color: Colors.grey[100],
      borderRadius: BorderRadius.circular(12),
    ),
    child: Row(
      children: [
        // 账号登录选项卡
        _buildTabItem(
          title: '账号登录',
          isSelected: _loginType == LoginType.account,
          onTap: () {
            setState(() {
              _loginType = LoginType.account;
            });
          },
        ),
        // 手机登录选项卡
        _buildTabItem(
          title: '手机登录',
          isSelected: _loginType == LoginType.phone,
          onTap: () {
            setState(() {
              _loginType = LoginType.phone;
            });
          },
        ),
      ],
    ),
  );
}

动态表单区域:

Widget _buildDynamicForm() {
  return AnimatedSwitcher(
    duration: Duration(milliseconds: 300),
    child: _loginType == LoginType.account
        ? _buildAccountForm()   // 账号登录表单
        : _buildPhoneForm(),    // 手机登录表单
  );
}

账号输入框组件:

Widget _buildAccountField() {
  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Text('邮箱/用户名'),
      SizedBox(height: 8),
      TextFormField(
        controller: _accountController,
        focusNode: _accountFocus,
        decoration: InputDecoration(
          hintText: '请输入邮箱或用户名',
          prefixIcon: Icon(Icons.person_outline),
          border: OutlineInputBorder(
            borderRadius: BorderRadius.circular(12),
          ),
          errorText: _accountError,
        ),
        onChanged: (value) {
          setState(() {
            _accountError = _validateAccount(value);
          });
        },
      ),
    ],
  );
}

登录按钮组件:

Widget _buildLoginButton() {
  bool isFormValid = _loginType == LoginType.account
      ? _accountError == null && _passwordError == null
      : _phoneError == null && _smsError == null;

  return SizedBox(
    width: double.infinity,
    height: 52,
    child: ElevatedButton(
      onPressed: isFormValid && !_isLoading ? _handleLogin : null,
      child: _isLoading
          ? CircularProgressIndicator()
          : Text('立即登录'),
    ),
  );
}
第四步:实现业务逻辑

表单验证:

String? _validateAccount(String? value) {
  if (value == null || value.isEmpty) {
    return '请输入账号';
  }
  final emailRegex = RegExp(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$');
  if (!emailRegex.hasMatch(value)) {
    return '请输入有效的邮箱';
  }
  return null;
}

登录逻辑:

Future<void> _handleLogin() async {
  if (_isLoading) return;
  
  if (_formKey.currentState!.validate()) {
    setState(() {
      _isLoading = true;
    });
    
    try {
      User user;
      if (_loginType == LoginType.account) {
        user = await AuthService.loginWithAccount(
          account: _accountController.text,
          password: _passwordController.text,
        );
      } else {
        user = await AuthService.loginWithPhone(
          phone: _phoneController.text,
          smsCode: _smsController.text,
        );
      }
      await _handleLoginSuccess(user);
    } catch (error) {
      _handleLoginError(error);
    } finally {
      setState(() {
        _isLoading = false;
      });
    }
  }
}

效果展示与总结

f1.png

f2.png 至此我们终于完成了一个功能完整的登录页面!让我们总结一下实现的功能:

实现功能点

  1. 双登录方式:用户可以在账号密码和手机验证码之间无缝切换
  2. 智能验证:实时表单验证,即时错误提示
  3. 用户体验:加载状态、错误提示、流畅动画
  4. 第三方登录:支持微信、QQ、微博登录
  5. 状态记忆:记住密码和自动登录选项

学到了什么?

通过这个项目,我们掌握了:

  • 组件使用:Text、TextField、Button等基础组件的深度使用
  • 状态管理:使用setState管理复杂的页面状态
  • 表单处理:实时验证和用户交互
  • 布局技巧:创建响应式和美观的界面布局
  • 业务逻辑:处理用户输入和API调用

最后的话

看到这里,你已经成功构建了一个完整的登录界面!这个登录页面只是开始,期待你能创造出更多更好的应用!

有什么问题或想法?欢迎在评论区留言讨论~, Happy Coding!✨

《Flutter全栈开发实战指南:从零到高级》- 04 - Widget核心概念与生命周期

2025年10月21日 08:58

Flutter Widget核心概念与生命周期

掌握Flutter UI构建的基石,告别"面向谷歌编程"

前言:为什么Widget如此重要?

还记得我刚开始学Flutter的时候,最让我困惑的就是那句"Everything is a Widget"。当时我想,这怎么可能呢?按钮是Widget,文字是Widget,连整个页面都是Widget,这也太抽象了吧!

经过几个实际项目的打磨,我才真正明白Widget设计的精妙之处。今天我就用最通俗易懂的方式,把我踩过的坑和总结的经验都分享给大家。

1. StatelessWidget vs StatefulWidget:静态与动态的艺术

1.1 StatelessWidget:一次成型的雕塑

通俗理解:就像一张照片,拍好之后内容就固定不变了。

// 用户信息卡片 - 典型的StatelessWidget
class UserCard extends StatelessWidget {
  // 这些final字段就像雕塑的原材料,一旦设定就不能改变
  final String name;
  final String email;
  final String avatarUrl;
  
  // const构造函数让Widget可以被Flutter优化
  const UserCard({
    required this.name,
    required this.email,
    required this.avatarUrl,
  });
  
  @override
  Widget build(BuildContext context) {
    // build方法描述这个Widget长什么样
    return Card(
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Row(
          children: [
            CircleAvatar(backgroundImage: NetworkImage(avatarUrl)),
            SizedBox(width: 16),
            Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(name, style: TextStyle(fontWeight: FontWeight.bold)),
                Text(email, style: TextStyle(color: Colors.grey)),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

使用场景总结

  • ✅ 显示静态内容(文字、图片)
  • ✅ 布局容器(Row、Column、Container)
  • ✅ 数据完全来自父组件的展示型组件
  • ✅ 不需要内部状态的纯UI组件

1.2 StatefulWidget:有记忆的智能助手

举个例子:就像一个智能闹钟,它能记住你设置的时间,响应用户操作。

// 计数器组件 - 典型的StatefulWidget
class Counter extends StatefulWidget {
  @override
  _CounterState createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _count = 0; // 状态数据,可以变化
  
  void _increment() {
    // setState告诉Flutter:状态变了,请重新构建UI
    setState(() {
      _count++;
    });
  }
  
  void _decrement() {
    setState(() {
      _count--;
    });
  }
  
  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Text('当前计数: $_count', style: TextStyle(fontSize: 24)),
        SizedBox(height: 20),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(onPressed: _decrement, child: Text('减少')),
            SizedBox(width: 20),
            ElevatedButton(onPressed: _increment, child: Text('增加')),
          ],
        ),
      ],
    );
  }
}

使用场景总结

  • ✅ 需要用户交互(按钮、输入框)
  • ✅ 有内部状态需要管理
  • ✅ 需要执行初始化或清理操作
  • ✅ 需要响应数据变化

1.3 选择指南:我的实用判断方法

刚开始我经常纠结该用哪种,后来总结了一个简单的方法:

问自己三个问题

  1. 这个组件需要记住用户的操作吗?
  2. 组件的数据会自己变化吗?
  3. 需要执行初始化或清理操作吗?

如果答案都是"否",用StatelessWidget;如果有一个"是",就用StatefulWidget。

2. Widget生命周期:从出生到退休的完整旅程

2.1 生命周期全景图

我把Widget的生命周期比作人的职业生涯,这样更容易理解:

class LifecycleExample extends StatefulWidget {
  @override
  _LifecycleExampleState createState() => _LifecycleExampleState();
}

class _LifecycleExampleState extends State<LifecycleExample> {
  // 1. 构造函数 - 准备简历
  _LifecycleExampleState() {
    print('📝 构造函数:创建State对象');
  }
  
  // 2. initState - 办理入职
  @override
  void initState() {
    super.initState();
    print('🎯 initState:组件初始化完成');
    // 在这里初始化数据、注册监听器
  }
  
  // 3. didChangeDependencies - 熟悉环境
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    print('🔄 didChangeDependencies:依赖发生变化');
    // 当父组件或全局数据变化时调用
  }
  
  // 4. build - 开始工作
  @override
  Widget build(BuildContext context) {
    print('🎨 build:构建UI界面');
    return Container(child: Text('生命周期演示'));
  }
  
  // 5. didUpdateWidget - 岗位调整
  @override
  void didUpdateWidget(LifecycleExample oldWidget) {
    super.didUpdateWidget(oldWidget);
    print('📝 didUpdateWidget:组件配置更新');
    // 比较新旧配置,决定是否需要更新状态
  }
  
  // 6. deactivate - 办理离职
  @override
  void deactivate() {
    print('👋 deactivate:组件从树中移除');
    super.deactivate();
  }
  
  // 7. dispose - 彻底退休
  @override
  void dispose() {
    print('💀 dispose:组件永久销毁');
    // 清理资源:取消订阅、关闭控制器等
    super.dispose();
  }
}

2.2 生命周期流程图

创建阶段:
createState() → initState() → didChangeDependencies() → build()

更新阶段:
setState() → build()  或  didUpdateWidget() → build()

销毁阶段:
deactivate() → dispose()

2.3 实战经验:我踩过的那些坑

坑1:在initState中访问Context

// ❌ 错误做法
@override
void initState() {
  super.initState();
  Theme.of(context); // Context可能还没准备好!
}

// ✅ 正确做法  
@override
void didChangeDependencies() {
  super.didChangeDependencies();
  Theme.of(context); // 这里才是安全的
}

坑2:忘记清理资源

@override
void initState() {
  super.initState();
  _timer = Timer.periodic(Duration(seconds: 1), _onTick);
}

// ❌ 忘记在dispose中取消定时器
// ✅ 一定要在dispose中清理
@override
void dispose() {
  _timer?.cancel(); // 重要!
  super.dispose();
}

坑3:异步操作中的setState

Future<void> fetchData() async {
  final data = await api.getData();
  
  // ❌ 直接调用setState
  // setState(() { _data = data; });
  
  // ✅ 先检查组件是否还在
  if (mounted) {
    setState(() {
      _data = data;
    });
  }
}

当然还有很多其他的坑,这里就不一一介绍了,感兴趣的朋友可以留言,看到一定会回复~

3. BuildContext:组件的身份证和通信证

3.1 BuildContext的本质

简单来说,BuildContext就是组件在组件树中的"身份证"。它告诉我们:

  • 这个组件在树中的位置
  • 能访问哪些祖先组件提供的数据
  • 如何与其他组件通信
class ContextExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 使用Context获取主题信息
    final theme = Theme.of(context);
    
    // 使用Context获取设备信息  
    final media = MediaQuery.of(context);
    
    // 使用Context进行导航
    void navigateToDetail() {
      Navigator.of(context).push(MaterialPageRoute(
        builder: (context) => DetailPage(),
      ));
    }
    
    return Container(
      color: theme.primaryColor,
      width: media.size.width * 0.8,
      child: ElevatedButton(
        onPressed: navigateToDetail,
        child: Text('跳转到详情'),
      ),
    );
  }
}

3.2 Context的层次结构

想象一下组件树就像公司组织架构:

  • 每个组件都有自己的Context
  • Context知道自己的"上级"(父组件)
  • 可以通过Context找到"领导"(祖先组件)
// 查找特定类型的祖先组件
final scaffold = context.findAncestorWidgetOfExactType<Scaffold>();

// 获取渲染对象
final renderObject = context.findRenderObject();

// 遍历子组件
context.visitChildElements((element) {
  print('子组件: ${element.widget}');
});

3.3 常见问题解决方案

问题:Scaffold.of()找不到Scaffold

// ❌ 可能失败
Widget build(BuildContext context) {
  return ElevatedButton(
    onPressed: () {
      ScaffoldMessenger.of(context).showSnackBar(SnackBar(
        content: Text('Hello'),
      ));
    },
    child: Text('显示提示'),
  );
}

// ✅ 使用Builder确保正确的Context
Widget build(BuildContext context) {
  return Builder(
    builder: (context) {
      return ElevatedButton(
        onPressed: () {
          ScaffoldMessenger.of(context).showSnackBar(SnackBar(
            content: Text('Hello'),
          ));
        },
        child: Text('显示提示'),
      );
    },
  );
}

4. 组件树与渲染原理:Flutter的三大支柱

4.1 三棵树架构:设计图、施工队和建筑物

我用建筑行业来比喻Flutter的三棵树,这样特别容易理解:

Widget树 = 建筑设计图

  • 描述UI应该长什么样
  • 配置信息(颜色、尺寸、文字等)
  • 不可变的(immutable)

Element树 = 施工队

  • 负责按照图纸施工
  • 管理组件生命周期
  • 可复用的

RenderObject树 = 建筑物本身

  • 实际可见的UI
  • 负责布局和绘制
  • 性能关键

4.2 渲染流程详解

阶段1:构建(Build)

// Flutter执行build方法,创建Widget树
Widget build(BuildContext context) {
  return Container(
    color: Colors.blue,
    child: Row(
      children: [
        Text('Hello'),
        Icon(Icons.star),
      ],
    ),
  );
}

阶段2:布局(Layout)

  • 计算每个组件的大小和位置
  • 父组件向子组件传递约束条件
  • 子组件返回自己的尺寸

阶段3:绘制(Paint)

  • 将组件绘制到屏幕上
  • 只绘制需要更新的部分
  • 高效的重绘机制

4.3 setState的工作原理

很多人对setState有误解,以为它直接更新UI。其实过程是这样的:

  1. 标记脏状态:setState标记当前Element为"脏"
  2. 重新构建Widget:调用build方法生成新的Widget
  3. 对比更新:比较新旧Widget的差异
  4. 更新RenderObject:只更新发生变化的部分
  5. 重绘:在屏幕上显示更新
void _updateCounter() {
  setState(() {
    // 1. 这里的代码同步执行
    _counter++;
  });
  // 2. setState完成后,Flutter会安排一帧来更新UI
  // 3. 不是立即更新,而是在下一帧时更新
}

5. 性能优化实战技巧

5.1 减少不必要的重建

// ❌ 不好的做法:在build中创建新对象
Widget build(BuildContext context) {
  return ListView(
    children: [
      ItemWidget(), // 每次build都创建新实例
      ItemWidget(),
    ],
  );
}

// ✅ 好的做法:使用const或成员变量
class MyWidget extends StatelessWidget {
  // 这些Widget只创建一次
  static const _itemWidgets = [
    ItemWidget(),
    ItemWidget(),
  ];
  
  @override
  Widget build(BuildContext context) {
    return ListView(children: _itemWidgets);
  }
}

5.2 合理使用const

// ✅ 尽可能使用const
const Text('Hello World');
const SizedBox(height: 16);
const Icon(Icons.star);

// 对于自定义Widget,也可以使用const构造函数
class MyWidget extends StatelessWidget {
  const MyWidget({required this.title});
  final String title;
  
  @override
  Widget build(BuildContext context) {
    return Text(title);
  }
}

5.3 使用Key优化列表

ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) {
    return ListItem(
      key: ValueKey(items[index].id), // 帮助Flutter识别项的身份
      item: items[index],
    );
  },
)

5.4 避免在build中执行耗时操作

// ❌ 不要在build中做这些
Widget build(BuildContext context) {
  // 网络请求
  // 复杂计算
  // 文件读写
  
  return Container();
}

// ✅ 在initState或专门的方法中执行
@override
void initState() {
  super.initState();
  _loadData();
}

Future<void> _loadData() async {
  final data = await api.fetchData();
  if (mounted) {
    setState(() {
      _data = data;
    });
  }
}

6. 实战案例:构建高性能列表

让我分享一个实际项目中的优化案例:

class ProductList extends StatefulWidget {
  @override
  _ProductListState createState() => _ProductListState();
}

class _ProductListState extends State<ProductList> {
  final List<Product> _products = [];
  bool _isLoading = false;
  
  @override
  void initState() {
    super.initState();
    _loadProducts();
  }
  
  Future<void> _loadProducts() async {
    if (_isLoading) return;
    
    setState(() => _isLoading = true);
    try {
      final products = await ProductApi.getProducts();
      setState(() => _products.addAll(products));
    } finally {
      if (mounted) {
        setState(() => _isLoading = false);
      }
    }
  }
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('商品列表')),
      body: _buildContent(),
    );
  }
  
  Widget _buildContent() {
    if (_products.isEmpty && _isLoading) {
      return Center(child: CircularProgressIndicator());
    }
    
    return ListView.builder(
      itemCount: _products.length + (_isLoading ? 1 : 0),
      itemBuilder: (context, index) {
        if (index == _products.length) {
          return _buildLoadingIndicator();
        }
        
        final product = _products[index];
        return ProductItem(
          key: ValueKey(product.id), // 重要:使用Key
          product: product,
          onTap: () => _showProductDetail(product),
        );
      },
    );
  }
  
  Widget _buildLoadingIndicator() {
    return Padding(
      padding: EdgeInsets.all(16),
      child: Center(child: CircularProgressIndicator()),
    );
  }
  
  void _showProductDetail(Product product) {
    Navigator.of(context).push(MaterialPageRoute(
      builder: (context) => ProductDetailPage(product: product),
    ));
  }
  
  @override
  void dispose() {
    // 清理工作
    super.dispose();
  }
}

7. 调试技巧:快速定位问题

7.1 使用Flutter Inspector

  • 在Android Studio或VS Code中打开Flutter Inspector
  • 查看Widget树结构
  • 检查渲染性能
  • 调试布局问题

7.2 打印生命周期日志

@override
void initState() {
  super.initState();
  debugPrint('$runtimeType initState');
}

@override
void dispose() {
  debugPrint('$runtimeType dispose');  
  super.dispose();
}

7.3 性能分析工具

  • 使用Flutter Performance面板
  • 检查帧率(目标是60fps)
  • 识别渲染瓶颈
  • 分析内存使用情况

最后的话

学习Flutter Widget就像学骑自行车,开始可能会摔倒几次,但一旦掌握了平衡,就能自由驰骋。记住几个关键点:

  1. 多动手实践 - 光看理论是不够的
  2. 理解原理 - 知道为什么比知道怎么做更重要
  3. 循序渐进 - 不要想一口吃成胖子
  4. 善用工具 - Flutter提供了很好的调试工具

我在学习过程中最大的体会是:每个Flutter高手都是从不断的踩坑和总结中成长起来的。希望我的经验能帮你少走一些弯路。


🎯 写这篇文章花了我很多时间,如果对你有帮助,动动发财的小手来个一键三连!

你的支持真的对我很重要!有什么问题欢迎在评论区留言,我会尽力解答。 我们下篇文章见! 🚀

❌
❌