普通视图

发现新文章,点击刷新页面。
昨天以前首页

孩子的名字有救了

作者 fthux
2025年11月9日 21:57

开发经历

在孩子出生前很早起名字就排上了日程,当时起名字用到了各种AI,各种修改提示词,每天一有时间就点开AI,让AI吐N个名字出来,这种情况陆陆续续持续了好几个月,但始终还是没有满意的名字。后来还是回到了随机生成这条看天意的路上。看到 gushi_namer 项目后,为了便于查看,我就做了一个丑陋不堪的 mvp 微信小程序(名语屋的原型),基于这个原型,我最终选中了6个名字(3男3女),然后和家里的领导请示,经领导批示,最终敲定了1男1女两个名字。而其中的一个名字,正是我孩子的正式名字。

从我确认了孩子的正式名字后,就想把这个原型整成正式的小程序(也许也有想要靠随机来起名字的同学),但是因为各种事情的耽搁,几个月后小程序才正式上线——名语屋。作为一个没有审美的搬砖工人,UI 页面基本上全部由 Gemini 生成。具体的链接可查看UI生成部分

最后,希望每个兄弟姐妹叔叔阿姨都能给自己的孩子取到心怡的名字。

名语屋

起名小程序

从诗经、楚辞、唐诗宋词、乐府诗集、古诗三百首、著名楚辞等古典文籍中起名字

小程序码

特别感谢

数据源来自 gushi_namer

仓库地址

UI生成

UI 页面基本上全部由 Gemini 生成,小程序版本做了一些小的UI调整和适配,完整的 Gemini APP 实现可访问 Google AI Studio。贴几张 Gemini 生成的 UI 和小程序实现的 UI

Gemini 实现

小程序实现

名字的意义

注:该部分文案由AI生成

  • 一名之立,旬月踟蹰”。名字,是父母赠予孩子的第一份厚礼。我们深知其重,故潜心于诗、骚、词、赋的浩瀚文海,为您精心撷取文采斐然、寓意深远的灵感,愿每个名字都承载着经典与祝福。
  • 为避常用名之普遍,并追求音律与字形之美,我们专选双字为名。与您的姓氏结合,构成和谐的三字姓名,既独特不易重复,又蕴含悠长的文化意境。

Q&A

注:该部分文案由AI生成

  • Question 为什么不直接用 AI 生成名字,而是让我自己挑选?

  • Answer 我们认为,AI 最强大的地方在于“博学”,而非“创造”。借助AI的能力,我们可以从浩如烟海的古典文籍中精准地检索和匹配相关的诗句,这大大提升了效率。然而,名字是情感与期望的载体,最终的选择应源自人心的触动。我们为您呈现这些蕴含美意的“璞玉”,由您亲自雕琢、赋予其独特的生命力。这既保证了名字的文化底蕴,又保留了起名过程中最宝贵的人文温度。

  • Question 为什么建议使用“姓+双字名”的组合?

  • Answer 在当代,单字名(如“李昂”)虽然简洁,但重名率相对较高。双字名(如“李思源”)不仅能有效降低重名的可能性,也为名字的音律和谐与寓意深度提供了更广阔的空间。三个字的组合在汉语发音中往往更具节奏感,读来朗朗上口,也更能承载父母对孩子的美好祝愿。

  • Question 随机“换一批”的方式会不会很麻烦?

  • Answer 我们理解您可能觉得这需要花费更多时间,但这正是我们设计的初衷。起名是一次充满喜悦的探索之旅,而非一项追求效率的任务。“换一批”的功能就像在书海中随手翻阅,每一次点击都可能带来一次与美好名字的“不期而遇”。我们相信,这个为孩子寻觅专属印记的过程本身就充满意义,而花费的时间,终将化为名字里沉甸甸的爱与祝福。

  • Question 未来会加入更多的古典文籍吗?

  • Answer 当然会!我们正持续不断地扩充我们的灵感文库,计划未来会囊括更多朝代、更多体裁的经典之作。我们的目标是打造一个最全面、最富诗意的古典文学起名宝库,陪伴每一个家庭完成这项神圣而美好的使命。敬请期待!

  • Question 可以指定生成男孩或女孩的名字吗?

  • Answer 我们特意未加入严格的性别筛选功能,原因在于中国古典文学中的许多优美词汇本身是中性的,其意境的“刚”与“柔”往往取决于搭配和个人的解读。例如,“清川”既可形容男子的俊朗,亦可描绘女子的澄澈。我们鼓励您超越固有的性别标签,用心感受每个名字背后的音韵与意象,为您未来的“他”或“她”选择一个最能触动您心弦的佳名。真正的专属,往往诞生于这份不设限的探索之中。

UniApp + Vue3 开发微信小程序数字人:TTS PCM 音频流与 SVGA 动画同步实战

2025年11月7日 21:00

一、项目背景与目标

在当前的智能客服和 AI 交互场景中, “数字人” 已成为提升用户体验的重要手段。我们团队开发了一款基于 UniApp + Vue3 的微信小程序,核心功能是通过语音或文字与 AI 进行健康咨询。

为了增强交互感与亲和力,我们引入了 SVGA 格式的 3D 数字人动画,并实现了:

  • ✅ AI 回复通过 TTS 生成 PCM 音频流
  • ✅ 音频边接收边播放(流式处理)
  • ✅ 数字人“说话”动画与语音同步
  • ✅ 语音结束自动切回待机动画
  • ✅ 全程丝滑过渡,无动画突变或打断

在这里大家肯定会问:做数字人为什么不用three.js呢?在项目初期,我也尝试在微信小程序使用 three-platformize.js 通过加载GLB 3D模型的方式去实现,但是在实际场景中发现了以下几个问题:

  • GLB 模型体积较大,在微信小程序中加载渲染会比较慢
  • 在对模型进行灯光处理时和web端差别较大,难以达到和web端一致的效果
  • 微信小程序渲染对模型要求较高

二、整体架构设计

image.png

三、关键技术实现

1. SVGA 数字人动画控制:SVGAController.js

我基于svgaplayer-weapp.js封装了一个 SVGAController 类,用于管理动画的播放、暂停、队列执行与循环。其中动画执行使用startAnimationWithRange函数来执行指定范围的动画。

核心功能:

  • 初始化加载 SVGA 文件
  • 支持分段动画播放(如 0-100帧为待机,229-428帧为说话)
  • 动画队列管理(先进先出)
  • 默认动画循环(所有动画播完后自动循环默认动画)
// src/utils/SVGAController.js
class SVGAController {
  constructor(canvasSelector) {
    this.canvasSelector = canvasSelector;
    this.player = null;
    this.parser = null;
    this.animationsQueue = [];
    this.isPlaying = false;
    this.defaultAnimation = null;
  }

  async init(svgaUrl) {
    this.parser = new Parser();
    this.player = new Player();
    await this.player.setCanvas(this.canvasSelector);
    const videoItem = await this.parser.load(svgaUrl);
    await this.player.setVideoItem(videoItem);
  }

  addAnimation(startFrame, endFrame, durationSeconds, singleDuration) {
    const repeatCount = Math.round(durationSeconds / singleDuration);
    this.animationsQueue.push({ startFrame, endFrame, repeatCount });
    if (!this.isPlaying) this._playNextAnimation();
  }

  async _playNextAnimation() {
    if (this.animationsQueue.length === 0 && this.defaultAnimation) {
      this.animationsQueue.push({...this.defaultAnimation});
    }
    const task = this.animationsQueue.shift();
    // ... 逐帧播放 logic
  }
}

优势:解耦动画逻辑,支持动态添加,避免硬编码。

避坑:pauseAnimation() 导致 startAnimationWithRange 报错

在执行动画暂停后、执行动画停止后、当前有动画在执行,这三种情况下如果需要再次执行任何动画会报错,如下面这段代码:

this.player.pauseAnimation();
this.player.startAnimationWithRange(range, true);
// 报错信息为
TypeError: Cannot read property 'width' of undefined

报错原因是pauseAnimation() 内部可能释放了 context,导致后续操作失败。因此我无法做到非常及时的切换模型动画,只能使每个动画段尽量的短,从而保证每个动画执行及时准确,当然这种过渡也是有好处的,我们的动画衔接会非常自然,不会突然变换。

2. PCM 音频流播放:PCMPlayer.js

AI 返回的 TTS 语音是 PCM 格式 的二进制流,我们需要边接收边播放。

核心挑战:

  • PCM 数据是分段接收的
  • 无法预知总时长
  • 需要与 SVGA 动画同步

解决方案:

我们封装了 PCMPlayer 类,基于 wx.createWebAudioContext() 实现流式播放。

// src/utils/PCMPlayer.js
export class PCMPlayer {
  constructor(config) {
    this.context = null;
    this.currentSource = null;
    this.playQueue = [];
    this.currentGroup = [];
    this.isDestroyed = false;
    this.groupSize = 22; // 接收22段音频流再合并播放
  }

  feed(data) {
    if (data === 'stop') {
      // 流结束
    } else if (data === 'end') {
      // 播放结束清理
    } else if (data instanceof ArrayBuffer) {
      this.currentGroup.push(data);
      if (this.currentGroup.length >= this.groupSize) {
        this._processCurrentGroup();
        this.currentGroup = [];
      }
      if (!this.isPlaying) this._playNext();
    }
  }

  _processCurrentGroup() {
    const merged = this._concatPCM(this.currentGroup);
    const audioBuffer = this._pcmToAudioBuffer(merged);
    this.playQueue.push(audioBuffer);
  }

  _playNext() {
    const buffer = this.playQueue.shift();
    // 创建 sourceNode 并播放
  }
}

关键点

  • 使用 groupSize 合并多段 PCM,减少 AudioBuffer 创建频率
  • 根据每组的音频时间和动画时间按比例添加动画
  • 最后一段动画播放完毕之后循环播放静默动画

3. 音画同步:语音播放时启动“说话”动画

这是最核心的交互体验。

实现逻辑:

阶段 行为
第一段 PCM 到达 启动 SVGA 说话动画
按组接收PCM音频流 接收groupSize段音频流之后合并播放
按比例添加动画 根据每组音频播放时间和模型说话动画时间按比例添加动画
当前动画循环播完 自动切回默认动画

避坑:每段 AudioBuffer 之间播放不流畅

在创建的AudioBuffer过多并且音频段过短的情况下会出现每段之间播放不流畅甚至出现爆音的情况,这也是为什么要合并多端PCM的原因了,合并之后播放的效果就好很多了。当然如果有小伙伴还有更好的播放机制可以一起交流讨论一下。

我们项目最开始的音频格式是OPUS音频流,但是微信小程序不支持对OPUS音频流的播放和解析所以要求后端同学把格式转换成了PCM,在音频格式这一块需要注意,不然就像我一样为了转换格式花了很多时间,最后还是没能实现。

四、经验总结与建议

问题 经验总结
SVGA 动画卡顿 避免频繁强行切换,可合按比例添加动画
音频播放 使用 WebAudioContext 而非 audio 标签
内存泄漏 destroy() 时清理 setIntervalWebSocketWebAudioContext
兼容性 svgaplayer-weapp 在不同小程序平台表现不同,建议测试
合理的处理音频流 根据实际情况去调整自己的播放机制,避免出现卡顿和爆音

五、未来优化方向

  1. 支持多语言 TTS:根据用户语言切换语音与动画
  2. 优化语言播放机制:优化语言播放机制,能够及时流畅的播放音频流
  3. 表情同步:不同情绪对应不同 SVGA 动画
  4. 唇形同步 :更精细的口型匹配
  5. 离线包:缓存 SVGA 文件,提升加载速度

六、结语

通过本次开发,我们成功实现了 “AI 语音 + 数字人动画” 的深度融合,显著提升了小程序的交互体验。关键在于:

  • 解耦设计:将音频、动画、通信逻辑分离
  • 流式处理:PCM 边接收边播放
  • 状态同步:通过标志位实现音画协同
  • 避坑实践:不用 pauseAnimation,改用 stepToFrame

上述代码只是提供了思路,并不是完整的代码,如果有需要也可以联系我共同探讨。

希望本文能为正在开发数字人、语音交互类应用的开发者提供有价值的参考。

《uni-app跨平台开发完全指南》- 05 - 基础组件使用

2025年11月8日 10:55

基础组件

欢迎回到《uni-app跨平台开发完全指南》系列!在之前的文章中,我们搭好了开发环境,了解了项目目录结构、Vue基础以及基本的样式,这一章节带大家了解基础组件如何使用。掌握了基础组件的使用技巧,就能独立拼装出应用的各个页面了!

一、 初识uni-app组件

在开始之前,先自问下什么是组件?

你可以把它理解为一个封装了结构(WXML)、样式(WXSS)和行为(JS)的、可复用的自定义标签。比如一个按钮、一个导航栏、一个商品卡片,都可以是组件。

uni-app的组件分为两类:

  1. 基础组件:框架内置的,如<view>, <text>, <image>等。这些是官方为我们准备好的标准组件。
  2. 自定义组件:开发者自己封装的,用于实现特定功能或UI的组件,可反复使用。

就是这些基础组件,它们遵循小程序规范,同时被映射到各端,是实现“一套代码,多端运行”的基础。

为了让大家对基础组件有个全面的认识,参考下面的知识脉络图:

graph TD
    A[uni-app 基础组件] --> B[视图容器类];
    A --> C[基础内容类];
    A --> D[表单类];
    A --> E[导航类];
    A --> F[自定义组件];

    B --> B1[View];
    B --> B2[Scroll-View];

    C --> C1[Text];
    C --> C2[Image];

    D --> D1[Button];
    D --> D2[Input];
    D --> D3[Checkbox/Radio];

    E --> E1[Navigator];

    F --> F1[创建];
    F --> F2[通信];
    F --> F3[生命周期];

接下来,我们详细介绍下这些内容。


二、 视图与内容:View、Text、Image

这三个组件是构建页面最基础、最核心的部分,几乎无处不在。

2.1 一切的容器:View

<view> 组件是一个视图容器。它相当于传统HTML中的 <div> 标签,是一个块级元素,主要用于布局和包裹其他内容。

核心特性:

  • 块级显示:默认独占一行。
  • 样式容器:通过为其添加classstyle,可以轻松实现Flex布局、Grid布局等。
  • 事件容器:可以绑定各种触摸事件,如@tap(点击)、@touchstart(触摸开始)等。

以一个简单的Flex布局为例:

<!-- 模板部分 -->
<template>
  <view class="container">
    <view class="header">我是头部</view>
    <view class="content">
      <view class="left-sidebar">左边栏</view>
      <view class="main-content">主内容区</view>
    </view>
    <view class="footer">我是底部</view>
  </view>
</template>

<style scoped>
/* 样式部分 */
.container {
  display: flex;
  flex-direction: column; /* 垂直排列 */
  height: 100vh; /* 满屏高度 */
}
.header, .footer {
  height: 50px;
  background-color: #007AFF;
  color: white;
  text-align: center;
  line-height: 50px; /* 垂直居中 */
}
.content {
  flex: 1; /* 占据剩余所有空间 */
  display: flex; /* 内部再启用Flex布局 */
}
.left-sidebar {
  width: 100px;
  background-color: #f0f0f0;
}
.main-content {
  flex: 1; /* 占据content区域的剩余空间 */
  background-color: #ffffff;
}
</style>

以上代码:

  • 我们通过多个<view>的嵌套,构建了一个经典的“上-中-下”布局。
  • 外层的.container使用flex-direction: column实现垂直排列。
  • 中间的.content自己也是一个Flex容器,实现了内部的水平排列。
  • flex: 1 是Flex布局的关键,表示弹性扩展,填满剩余空间。

小结一下View:

  • 它是布局的骨架,万物皆可<view>
  • 熟练掌握Flex布局,再复杂的UI也能用<view>拼出来。

2.2 Text

<text> 组件是一个文本容器。它相当于HTML中的 <span> 标签,是行内元素。最重要的特点是:只有 <text> 组件内部的文字才是可选中的、长按可以复制!

核心特性:

  • 行内显示:默认不会换行。
  • 文本专属:用于包裹文本,并对文本设置样式和事件。
  • 选择与复制:支持user-select属性控制文本是否可选。
  • 嵌套与富文本:内部可以嵌套,自身也支持部分HTML实体和富文本。

以一个文本样式与事件为例:

<template>
  <view>
    <!-- 普通的view里的文字无法长按复制 -->
    <view>这段文字在view里,无法长按复制。</view>
    
    <!-- text里的文字可以 -->
    <text user-select @tap="handleTextTap" class="my-text">
      这段文字在text里,可以长按复制!点击我也有反应。
      <text style="color: red; font-weight: bold;">我是嵌套的红色粗体文字</text>
    </text>
  </view>
</template>

<script>
export default {
  methods: {
    handleTextTap() {
      uni.showToast({
        title: '你点击了文字!',
        icon: 'none'
      });
    }
  }
}
</script>

<style>
.my-text {
  color: #333;
  font-size: 16px;
  /* 注意:text组件不支持设置宽高和margin-top/bottom,因为是行内元素 */
  /* 如果需要,可以设置 display: block 或 inline-block */
}
</style>

以上代码含义:

  • user-select属性开启了文本的可选状态。
  • <text>组件可以绑定@tap事件,而<view>里的纯文字不能。
  • 内部的<text>嵌套展示了如何对部分文字进行特殊样式处理。

Text使用小技巧:

  1. 何时用? 只要是涉及交互(点击、长按)或需要复制功能的文字,必须用<text>包裹。
  2. 样式注意:它是行内元素,设置宽高和垂直方向的margin/padding可能不生效,可通过display: block改变。
  3. 性能:避免深度嵌套,尤其是与富文本一起使用时。

2.3 Image

<image> 组件用于展示图片。它相当于一个增强版的HTML <img>标签,提供了更丰富的功能和更好的性能优化。

核心特性与原理:

  • 多种模式:通过mode属性控制图片的裁剪、缩放模式,这是它的灵魂所在
  • 懒加载lazy-load属性可以在页面滚动时延迟加载图片,提升性能。
  • 缓存与 headers:支持配置网络图片的缓存策略和请求头。

mode属性详解(非常重要!) mode属性决定了图片如何适应容器的宽高。我们来画个图理解一下:

stateDiagram-v2
    [*] --> ImageMode选择
    
    state ImageMode选择 {
        [*] --> 首要目标判断
        
        首要目标判断 --> 保持完整不裁剪: 选择
        首要目标判断 --> 保持比例不变形: 选择  
        首要目标判断 --> 固定尺寸裁剪: 选择
        
        保持完整不裁剪 --> scaleToFill: 直接进入
        scaleToFill : scaleToFill\n拉伸至填满,可能变形
        
        保持比例不变形 --> 适应方式判断
        适应方式判断 --> aspectFit: 完全显示
        适应方式判断 --> aspectFill: 填满容器
        
        aspectFit : aspectFit\n适应模式\n容器可能留空
        aspectFill : aspectFill\n填充模式\n图片可能被裁剪
        
        固定尺寸裁剪 --> 多种裁剪模式
        多种裁剪模式 : widthFix / top / bottom\n等裁剪模式
    }
    
    scaleToFill --> [*]
    aspectFit --> [*]
    aspectFill --> [*]
    多种裁剪模式 --> [*]

下面用一段代码来展示不同Mode的效果

<template>
  <view>
    <view class="image-demo">
      <text>scaleToFill (默认,拉伸):</text>
      <!-- 容器 200x100,图片会被拉伸 -->
      <image src="/static/logo.png" mode="scaleToFill" class="img-container"></image>
    </view>

    <view class="image-demo">
      <text>aspectFit (适应):</text>
      <!-- 图片完整显示,上下或左右留白 -->
      <image src="/static/logo.png" mode="aspectFit" class="img-container"></image>
    </view>

    <view class="image-demo">
      <text>aspectFill (填充):</text>
      <!-- 图片填满容器,但可能被裁剪 -->
      <image src="/static/logo.png" mode="aspectFill" class="img-container"></image>
    </view>

    <view class="image-demo">
      <text>widthFix (宽度固定,高度自适应):</text>
      <!-- 非常常用!高度会按比例自动计算 -->
      <image src="/static/logo.png" mode="widthFix" class="img-auto-height"></image>
    </view>
  </view>
</template>

<style>
.img-container {
  width: 200px;
  height: 100px; /* 固定高度的容器 */
  background-color: #eee; /* 用背景色看出aspectFit的留白 */
  border: 1px solid #ccc;
}
.img-auto-height {
  width: 200px;
  /* 不设置height,由图片根据widthFix模式自动计算 */
}
.image-demo {
  margin-bottom: 20rpx;
}
</style>

Image使用注意:

  1. 首选 widthFix:在需要图片自适应宽度(如商品详情图、文章配图)时,mode="widthFix" 是神器,无需计算高度。
  2. ** 必设宽高**:无论是直接设置还是通过父容器继承,必须让<image>有确定的宽高,否则可能显示异常。
  3. 加载失败处理:使用@error事件监听加载失败,并设置默认图。
    <image :src="avatarUrl" @error="onImageError" class="avatar"></image>
    
    onImageError() {
      this.avatarUrl = '/static/default-avatar.png'; // 替换为默认头像
    }
    
  4. 性能优化:对于列表图片,务必加上 lazy-load

三、 按钮与表单组件

应用不能只是展示,更需要与用户交互。

3.1 Button

<button> 组件用于捕获用户的点击操作。它功能强大,样式多样,甚至能直接调起系统的某些功能。

核心特性

  • 多种类型:通过type属性控制基础样式,如default(默认)、primary(主要)、warn(警告)。
  • 开放能力:通过open-type属性可以直接调起微信的获取用户信息、分享、客服等功能。
  • 样式自定义:虽然提供了默认样式,但可以通过hover-class等属性实现点击反馈,也可以通过CSS完全自定义。

用一段代码来展示各种按钮:

<template>
  <view class="button-group">
    <!-- 基础样式按钮 -->
    <button type="default">默认按钮</button>
    <button type="primary">主要按钮</button>
    <button type="warn">警告按钮</button>

    <!-- 禁用状态 -->
    <button :disabled="true" type="primary">被禁用的按钮</button>

    <!-- 加载状态 -->
    <button loading type="primary">加载中...</button>

    <!-- 获取用户信息 -->
    <button open-type="getUserInfo" @getuserinfo="onGetUserInfo">获取用户信息</button>

    <!-- 分享 -->
    <button open-type="share">分享</button>

    <!-- 自定义样式 - 使用 hover-class -->
    <button class="custom-btn" hover-class="custom-btn-hover">自定义按钮</button>
  </view>
</template>

<script>
export default {
  methods: {
    onGetUserInfo(e) {
      console.log('用户信息:', e.detail);
      // 在这里处理获取到的用户信息
    }
  }
}
</script>

<style>
.button-group button {
  margin-bottom: 10px; /* 给按钮之间加点间距 */
}
.custom-btn {
  background-color: #4CD964; /* 绿色背景 */
  color: white;
  border: none; /* 去除默认边框 */
  border-radius: 10px; /* 圆角 */
}
.custom-btn-hover {
  background-color: #2AC845; /*  hover时更深的绿色 */
}
</style>

Button要点:

  • open-type:这是uni-app和小程序生态打通的关键,让你能用一行代码实现复杂的原生功能。
  • 自定义样式:默认按钮样式可能不符合设计,记住一个原则:先重置,再定义。使用border: none; background: your-color;来覆盖默认样式。
  • 表单提交:在<form>标签内,<button>form-type属性可以指定为submitreset

3.2 表单组件 - Input, Checkbox, Radio, Picker...

表单用于收集用户输入。uni-app提供了一系列丰富的表单组件。

Input - 文本输入框

核心属性:

  • v-model:双向绑定输入值,最常用
  • type:输入框类型,如text, number, idcard, password等。
  • placeholder:占位符。
  • focus:自动获取焦点。
  • @confirm:点击完成按钮时触发。

下面写一个登录输入框:

<template>
  <view class="login-form">
    <input v-model="username" type="text" placeholder="请输入用户名" class="input-field" />
    <input v-model="password" type="password" placeholder="请输入密码" class="input-field" @confirm="onLogin" />
    <button type="primary" @tap="onLogin">登录</button>
  </view>
</template>

<script>
export default {
  data() {
    return {
      username: '',
      password: ''
    };
  },
  methods: {
    onLogin() {
      // 验证用户名和密码
      if (!this.username || !this.password) {
        uni.showToast({ title: '请填写完整', icon: 'none' });
        return;
      }
      console.log('登录信息:', this.username, this.password);
      // 发起登录请求...
    }
  }
}
</script>

<style>
.input-field {
  border: 1px solid #ddd;
  border-radius: 4px;
  padding: 10px;
  margin-bottom: 15px;
  height: 40px;
}
</style>

Checkbox 与 Radio - 选择与单选

这两个组件需要和<checkbox-group>, <radio-group>一起使用,来管理一组选项。

代码实战:选择兴趣爱好

<template>
  <view>
    <text>请选择你的兴趣爱好:</text>
    <checkbox-group @change="onHobbyChange">
      <label class="checkbox-label">
        <checkbox value="reading" :checked="true" /> 阅读
      </label>
      <label class="checkbox-label">
        <checkbox value="music" /> 音乐
      </label>
      <label class="checkbox-label">
        <checkbox value="sports" /> 运动
      </label>
    </checkbox-group>
    <view>已选:{{ selectedHobbies.join(', ') }}</view>

    <text>请选择性别:</text>
    <radio-group @change="onGenderChange">
      <label class="radio-label">
        <radio value="male" /></label>
      <label class="radio-label">
        <radio value="female" /></label>
    </radio-group>
    <view>已选:{{ selectedGender }}</view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      selectedHobbies: ['reading'], // 默认选中阅读
      selectedGender: ''
    };
  },
  methods: {
    onHobbyChange(e) {
      // e.detail.value 是一个数组,包含所有被选中的checkbox的value
      this.selectedHobbies = e.detail.value;
      console.log('兴趣爱好变化:', e.detail.value);
    },
    onGenderChange(e) {
      // e.detail.value 是单个被选中的radio的value
      this.selectedGender = e.detail.value;
      console.log('性别变化:', e.detail.value);
    }
  }
}
</script>

<style>
.checkbox-label, .radio-label {
  display: block;
  margin: 5px 0;
}
</style>

表单组件使用技巧:

  1. 善用v-model:能够极大简化双向数据绑定的代码。
  2. 理解事件checkboxradio的change事件发生在组(group) 上,通过e.detail.value获取所有值。
  3. UI统一:原生组件样式在各端可能略有差异,对于要求高的场景,可以考虑使用UI库(如uView)的自定义表单组件。

四、 导航与容器组件

当应用内容变多,我们需要更好的方式来组织页面结构和实现页面跳转。

4.1 Navigator

<navigator> 组件是一个页面链接,用于在应用内跳转到指定页面。它相当于HTML中的 <a> 标签,但功能更丰富。

核心属性与跳转模式:

  • url必填,指定要跳转的页面路径。
  • open-type跳转类型,决定了跳转行为。
    • navigate:默认值,保留当前页面,跳转到新页面(可返回)。
    • redirect:关闭当前页面,跳转到新页面(不可返回)。
    • switchTab:跳转到tabBar页面,并关闭所有非tabBar页面。
    • reLaunch:关闭所有页面,打开到应用内的某个页面。
    • navigateBack:关闭当前页面,返回上一页面或多级页面。
  • delta:当open-typenavigateBack时有效,表示返回的层数。

为了更清晰地理解这几种跳转模式对页面栈的影响,我画了下面这张图:

mermaid-diagram-2025-11-08-094700.png

下面用代码实现一个简单的导航

<template>
  <view class="nav-demo">
    <!-- 普通跳转,可以返回 -->
    <navigator url="/pages/about/about" hover-class="navigator-hover">
      <button>关于我们(普通跳转)</button>
    </navigator>

    <!-- 重定向,无法返回 -->
    <navigator url="/pages/index/index" open-type="redirect">
      <button type="warn">回首页(重定向)</button>
    </navigator>

    <!-- 跳转到TabBar页面 -->
    <navigator url="/pages/tabbar/my/my" open-type="switchTab">
      <button type="primary">个人中心(Tab跳转)</button>
    </navigator>

    <!-- 返回上一页 -->
    <navigator open-type="navigateBack">
      <button>返回上一页</button>
    </navigator>
    <!-- 返回上两页 -->
    <navigator open-type="navigateBack" :delta="2">
      <button>返回上两页</button>
    </navigator>
  </view>
</template>

<style>
.nav-demo button {
  margin: 10rpx;
}
.navigator-hover {
  background-color: #f0f0f0; /* 点击时的反馈色 */
}
</style>

Navigator避坑:

  1. url路径:必须以/开头,在pages.json中定义。
  2. 跳转TabBar:必须使用open-type="switchTab",否则无效。
  3. 传参:可以在url后面拼接参数,如/pages/detail/detail?id=1&name=test,在目标页面的onLoad生命周期中通过options参数获取。
  4. 跳转限制:小程序中页面栈最多十层,注意使用redirect避免层级过深。

4.2 Scroll-View

<scroll-view> 是一个可滚动的视图容器。当内容超过容器高度(或宽度)时,提供滚动查看的能力。

核心特性:

  • 滚动方向:通过scroll-x(横向)和scroll-y(纵向)控制。
  • 滚动事件:可以监听@scroll事件,获取滚动位置。
  • 上拉加载/下拉刷新:通过@scrolltolower@scrolltoupper等事件模拟,但更推荐使用页面的onReachBottomonPullDownRefresh

代码实现一个横向滚动导航和纵向商品列表

<template>
  <view>
    <!-- 横向滚动导航 -->
    <scroll-view scroll-x class="horizontal-scroll">
      <view v-for="(item, index) in navList" :key="index" class="nav-item">
        {{ item.name }}
      </view>
    </scroll-view>

    <!-- 纵向滚动商品列表 -->
    <scroll-view scroll-y :style="{ height: scrollHeight + 'px' }" @scrolltolower="onLoadMore">
      <view v-for="(product, idx) in productList" :key="idx" class="product-item">
        <image :src="product.image" mode="aspectFill" class="product-img"></image>
        <text class="product-name">{{ product.name }}</text>
      </view>
      <view v-if="loading" class="loading-text">加载中...</view>
    </scroll-view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      navList: [ /* ... 导航数据 ... */ ],
      productList: [ /* ... 商品数据 ... */ ],
      scrollHeight: 0,
      loading: false
    };
  },
  onLoad() {
    // 动态计算scroll-view的高度,使其充满屏幕剩余部分
    const sysInfo = uni.getSystemInfoSync();
    // 假设横向导航高度为50px,需要根据实际情况计算
    this.scrollHeight = sysInfo.windowHeight - 50;
  },
  methods: {
    onLoadMore() {
      // 加载更多
      if (this.loading) return;
      this.loading = true;
      console.log('开始加载更多数据...');
      // 请求数据
      setTimeout(() => {
        // ... 获取新数据并拼接到productList ...
        this.loading = false;
      }, 1000);
    }
  }
}
</script>

<style>
.horizontal-scroll {
  white-space: nowrap; /* 让子元素不换行 */
  width: 100%;
  background-color: #f7f7f7;
}
.nav-item {
  display: inline-block; /* 让子元素行内排列 */
  padding: 10px 20px;
  margin: 5px;
  background-color: #fff;
  border-radius: 15px;
}
.product-item {
  display: flex;
  padding: 10px;
  border-bottom: 1px solid #eee;
}
.product-img {
  width: 80px;
  height: 80px;
  border-radius: 5px;
}
.product-name {
  margin-left: 10px;
  align-self: center;
}
.loading-text {
  text-align: center;
  padding: 10px;
  color: #999;
}
</style>

Scroll-View使用心得:

  • 横向滚动:牢记两个CSS:容器white-space: nowrap;,子项display: inline-block;
  • 性能<scroll-view>内不适合放过多或过于复杂的子节点,尤其是图片,可能导致滚动卡顿。对于长列表,应使用官方的<list>组件或社区的长列表组件。
  • 高度问题:纵向滚动的<scroll-view>必须有一个固定的高度,否则会无法滚动。通常通过JS动态计算。

五、 自定义组件基础

当项目变得复杂,我们会发现很多UI模块或功能块在重复编写。这时,就该自定义组件了!它能将UI和功能封装起来,实现复用和解耦。

5.1 为什么要用自定义组件?

  1. 复用性:一次封装,到处使用。
  2. 可维护性:功能集中在一处,修改方便。
  3. 清晰性:将复杂页面拆分成多个组件,结构清晰,便于协作。

5.2 创建与使用一个自定义组件

让我们来封装一个简单的UserCard组件。

第一步:创建组件文件 在项目根目录创建components文件夹,然后在里面创建user-card/user-card.vue文件。uni-app会自动识别components目录下的组件。

第二步:编写组件模板、逻辑与样式

<!-- components/user-card/user-card.vue -->
<template>
  <view class="user-card" @tap="onCardClick">
    <image :src="avatarUrl" class="avatar" mode="aspectFill"></image>
    <view class="info">
      <text class="name">{{ name }}</text>
      <text class="bio">{{ bio }}</text>
    </view>
    <view class="badge" v-if="isVip">VIP</view>
  </view>
</template>

<script>
export default {
  // 声明组件的属性,外部传入的数据
  props: {
    avatarUrl: {
      type: String,
      default: '/static/default-avatar.png' 
    },
    name: {
      type: String,
      required: true 
    },
    bio: String, // 简写方式,只定义类型
    isVip: Boolean
  },
  // 组件内部数据
  data() {
    return {
      // 这里放组件自己的状态
    };
  },
  methods: {
    onCardClick() {
      // 触发一个自定义事件,通知父组件
      this.$emit('cardClick', { name: this.name });
      // 也可以在这里处理组件内部的逻辑
      uni.showToast({
        title: `点击了${this.name}的名片`,
        icon: 'none'
      });
    }
  }
}
</script>

<style scoped>
.user-card {
  display: flex;
  padding: 15px;
  background-color: #fff;
  border-radius: 8px;
  margin: 10px;
  position: relative;
  box-shadow: 0 2px 6px rgba(0,0,0,0.1);
}
.avatar {
  width: 50px;
  height: 50px;
  border-radius: 25px;
}
.info {
  display: flex;
  flex-direction: column;
  margin-left: 12px;
  justify-content: space-around;
}
.name {
  font-size: 16px;
  font-weight: bold;
}
.bio {
  font-size: 12px;
  color: #999;
}
.badge {
  position: absolute;
  top: 10px;
  right: 10px;
  background-color: #ffd700;
  color: #333;
  font-size: 10px;
  padding: 2px 6px;
  border-radius: 4px;
}
</style>

第三步:在页面中使用组件

<!-- pages/index/index.vue -->
<template>
  <view>
    <text>用户列表</text>
    <!-- 使用自定义组件 -->
    <!-- 1. 通过属性传递数据 -->
    <user-card 
      name="码小明" 
      bio="热爱编程" 
      :is-vip="true"
      avatar-url="/static/avatar1.jpg"
      @cardClick="onUserCardClick" <!-- 2. 监听子组件发出的自定义事件 -->
    />
    <user-card 
      name="产品经理小鱼儿" 
      bio="让世界更美好" 
      :is-vip="false"
      @cardClick="onUserCardClick"
    />
  </view>
</template>

<script>
// 2. 导入组件
// import UserCard from '@/components/user-card/user-card.vue';
export default {
  // 3. 注册组件
  // components: { UserCard },
  methods: {
    onUserCardClick(detail) {
      console.log('父组件收到了卡片的点击事件:', detail);
      // 这里可以处理跳转逻辑
      // uni.navigateTo({ url: '/pages/user/detail?name=' + detail.name });
    }
  }
}
</script>

5.3 核心概念:Props, Events, Slots

一个完整的自定义组件通信机制,主要围绕这三者展开。它们的关系可以用下图清晰地表示:

mermaid-diagram-2025-11-08-095516.png

  1. Props(属性)由外到内的数据流。父组件通过属性的方式将数据传递给子组件。子组件用props选项声明接收。
  2. Events(事件)由内到外的通信。子组件通过this.$emit('事件名', 数据)触发一个自定义事件,父组件通过v-on@来监听这个事件。
  3. Slots(插槽)内容分发。父组件可以将一段模板内容“插入”到子组件指定的位置。这极大地增强了组件的灵活性。

插槽(Slot)简单示例: 假设我们的UserCard组件,想在bio下面留一个区域给父组件自定义内容。

在子组件中:

<!-- user-card.vue -->
<view class="info">
  <text class="name">{{ name }}</text>
  <text class="bio">{{ bio }}</text>
  <!-- 默认插槽,父组件传入的内容会渲染在这里 -->
  <slot></slot>
  <!-- 具名插槽 -->
  <!-- <slot name="footer"></slot> -->
</view>

在父组件中:

<user-card name="小明" bio="...">
  <!-- 传入到默认插槽的内容 -->
  <view style="margin-top: 5px;">
    <button size="mini">关注</button>
  </view>
  <!-- 传入到具名插槽footer的内容 -->
  <!-- <template v-slot:footer> ... </template> -->
</user-card>

5.4 EasyCom

你可能会注意到,在上面的页面中,我们并没有importcomponents注册,但组件却正常使用了。这是因为uni-app的 easycom 规则。

规则:只要组件安装在项目的components目录下,并符合components/组件名称/组件名称.vue的目录结构,就可以不用手动引入和注册,直接在页面中使用。极大地提升了开发效率!


六、 内容总结

至此基本组件内容就介绍完了,又到了总结的时候了,本节主要内容:

  1. View、Text、Image:构建页面的三大核心组件。注意图片Image的mode属性。
  2. Button与表单组件:与用户交互的核心。Button的open-type能调起强大原生功能。表单组件用v-model实现数据双向绑定。
  3. Navigator与Scroll-View:组织页面和内容。Navigator负责路由跳转,要理解五种open-type的区别。Scroll-View提供滚动区域,要注意它的高度和性能问题。
  4. 自定义组件:必会内容。理解了Props下行、Events上行、Slots分发的数据流,你就掌握了组件通信的精髓。easycom规则让组件使用更便捷。

如果你觉得这篇文章对你有所帮助,能够对uni-app的基础组件有更清晰的认识,不要吝啬你的“一键三连”(点赞、关注、收藏)哦(手动狗头)!你的支持是我持续创作的最大动力。 在学习过程中遇到任何问题,或者有哪里没看明白,都欢迎在评论区留言,我会尽力解答。


版权声明:本文为【《uni-app跨平台开发完全指南》】系列第五篇,原创文章,转载请注明出处。

《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高级特性,欢迎在评论区留言,我会及时解答。


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

❌
❌