vue3+ts+uniapp+unibest 微信小程序(第三篇)—— 实战解析动态导航栏适配、多主题切换与性能优化技巧
1. 动态导航栏适配:告别“刘海”与“胶囊”的烦恼
做微信小程序开发,最头疼的莫过于顶部导航栏的适配。不同型号的手机,状态栏高度、胶囊按钮位置、刘海屏、挖孔屏……这些差异让“写死”的样式寸步难行。在上一篇文章里,我们用了 wd-navbar 组件来封装自定义导航栏,解决了基础问题。但实战中你会发现,这仅仅是开始。今天,我就结合 uniapp + unibest 这套技术栈,跟你聊聊怎么把动态导航栏适配做得既优雅又稳固。
首先,你得明白小程序导航栏的“三座大山”:状态栏高度(StatusBarHeight)、胶囊按钮信息(MenuButtonInfo) 和 导航栏高度(NavigationBarHeight)。uniapp 提供了 uni.getSystemInfoSync() 和 uni.getMenuButtonBoundingClientRect() 这两个核心 API 来获取这些数据。但直接在每个页面里调用,代码会非常冗余。我的做法是,在应用启动时,就把这些关键尺寸计算好,存到全局状态里,比如 pinia 或者 unibest 内置的 useApp 里。
这里有个我踩过的坑:uni.getMenuButtonBoundingClientRect() 在微信开发者工具和真机上的表现有时会有细微差异,尤其是在一些安卓机型上。所以,绝对不能在组件挂载的 onLoad 或 onShow 生命周期里才去获取,否则可能导致页面渲染完成后布局突然跳动一下。最佳实践是在 App.vue 的 onLaunch 里就完成计算和存储。
下面是我在项目中封装的一个工具函数,放在 utils/system.ts 里:
// utils/system.ts
import { getCurrentInstance } from 'vue'
/**
* 获取微信小程序导航栏相关尺寸信息
*/
export function getNavBarInfo() {
// 获取系统信息
const systemInfo = uni.getSystemInfoSync()
// 获取菜单按钮(胶囊)的布局位置信息
const menuButtonInfo = uni.getMenuButtonBoundingClientRect()
// 计算导航栏高度
// 状态栏到胶囊按钮底部的距离 + 胶囊按钮高度 + 状态栏到胶囊按钮顶部的距离(上下间隙)
// 通常公式为:menuButtonInfo.top + menuButtonInfo.height + (menuButtonInfo.top - systemInfo.statusBarHeight)
const navBarHeight = (menuButtonInfo.top - systemInfo.statusBarHeight) * 2 + menuButtonInfo.height + systemInfo.statusBarHeight
return {
// 状态栏高度
statusBarHeight: systemInfo.statusBarHeight,
// 胶囊按钮信息
menuButtonInfo,
// 导航栏总高度(状态栏+标题栏区域)
navBarHeight,
// 导航栏内容区域高度(不包含状态栏)
navBarContentHeight: navBarHeight - systemInfo.statusBarHeight,
// 屏幕宽度
windowWidth: systemInfo.windowWidth,
// 屏幕可用高度(窗口高度)
windowHeight: systemInfo.windowHeight,
// 屏幕安全区域
safeArea: systemInfo.safeArea
}
}
// 在全局状态管理中使用(例如 pinia)
import { defineStore } from 'pinia'
export const useSystemStore = defineStore('system', {
state: () => ({
navBarInfo: {} as ReturnType<typeof getNavBarInfo>
}),
actions: {
async initSystemInfo() {
this.navBarInfo = getNavBarInfo()
// 可以在这里额外处理一些异步逻辑
}
}
})
然后在 App.vue 里初始化:
<script setup lang="ts">
import { onLaunch } from '@dcloudio/uni-app'
import { useSystemStore } from '@/stores/system'
onLaunch(() => {
console.log('App Launch')
const systemStore = useSystemStore()
systemStore.initSystemInfo()
// 将信息挂载到全局,方便非组合式API页面使用(可选)
uni.$system = systemStore.navBarInfo
})
</script>
有了全局的尺寸信息,我们的自定义导航栏组件就能游刃有余了。我们可以改造之前的 custom-page 组件,让它根据这些动态数据来设置样式,而不是写死的 rpx 或 px。关键点在于,导航栏的定位和占位元素的高度必须精确匹配系统返回的值,这样才能保证页面内容从导航栏下方开始,并且滚动时不会被遮挡。
2. 多主题切换:从“日间模式”到“暗黑模式”的平滑过渡
现在用户对应用的主题个性化要求越来越高,尤其是暗黑模式,几乎成了标配。在 vue3 + ts + uniapp 的环境下实现多主题切换,核心思路是 CSS变量(CSS Custom Properties) 配合状态管理。unibest 框架本身对状态管理有很好的集成,我们可以利用 pinia 来管理当前主题状态。
首先,我们需要定义主题。我习惯在 src/styles/ 目录下创建主题相关的文件:
src/styles/
├── theme/
│ ├── index.scss # 主题入口文件
│ ├── variables.scss # 定义所有CSS变量
│ ├── light.scss # 浅色主题变量值
│ └── dark.scss # 深色主题变量值
└── main.scss # 全局样式入口
在 variables.scss 中,我们定义一套与设计系统对应的 CSS 变量名,不赋予具体值,只做声明。
// styles/theme/variables.scss
:root {
// 主色调
--color-primary: ;
--color-primary-light: ;
--color-primary-dark: ;
// 背景色
--color-bg-page: ;
--color-bg-container: ;
--color-bg-secondary: ;
// 文字色
--color-text-primary: ;
--color-text-regular: ;
--color-text-secondary: ;
--color-text-placeholder: ;
// 边框色
--color-border: ;
--color-border-light: ;
// 其他功能色(成功、警告、错误等)
--color-success: ;
--color-warning: ;
--color-error: ;
}
然后,在 light.scss 和 dark.scss 中,分别为这些变量填充具体的颜色值。
// styles/theme/light.scss
@import './variables.scss';
:root {
--color-primary: #07c160;
--color-primary-light: #95e7b9;
--color-primary-dark: #069c4e;
--color-bg-page: #f5f5f5;
--color-bg-container: #ffffff;
--color-bg-secondary: #fafafa;
--color-text-primary: #303133;
--color-text-regular: #606266;
--color-text-secondary: #909399;
--color-text-placeholder: #c0c4cc;
--color-border: #dcdfe6;
--color-border-light: #e4e7ed;
--color-success: #67c23a;
--color-warning: #e6a23c;
--color-error: #f56c6c;
}
// styles/theme/dark.scss
@import './variables.scss';
:root {
--color-primary: #07c160; // 品牌色可以保持不变
--color-primary-light: #2a2a2a;
--color-primary-dark: #95e7b9;
--color-bg-page: #121212;
--color-bg-container: #1e1e1e;
--color-bg-secondary: #252525;
--color-text-primary: #e0e0e0;
--color-text-regular: #b0b0b0;
--color-text-secondary: #888888;
--color-text-placeholder: #5a5a5a;
--color-border: #444444;
--color-border-light: #333333;
--color-success: #85d44a;
--color-warning: #f0c242;
--color-error: #f78989;
}
接下来,在 theme/index.scss 中,我们根据当前主题动态导入对应的样式文件。但这里有个关键:我们如何动态切换?我们不能直接动态导入 SCSS 文件。更通用的做法是,在 App.vue 或根组件上,通过一个类名(如 .theme-dark)来控制。然后,在 variables.scss 中,我们利用 CSS 的级联特性来覆盖变量。
// styles/theme/index.scss
// 导入变量定义
@import './variables.scss';
// 默认导入浅色主题
@import './light.scss';
// 当根元素有 .theme-dark 类时,应用深色主题变量
:root.theme-dark {
@import './dark.scss';
}
现在,我们需要一个全局状态来管理主题,并提供一个切换主题的方法。
// stores/theme.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
type ThemeMode = 'light' | 'dark' | 'auto'
export const useThemeStore = defineStore('theme', () => {
// 当前主题模式
const themeMode = ref<ThemeMode>('light')
// 计算当前实际应用的主题(如果模式是auto,需要根据系统偏好判断)
const currentTheme = computed(() => {
if (themeMode.value === 'auto') {
// 检测系统是否开启暗色模式
const systemPrefersDark = uni.getSystemInfoSync().theme === 'dark'
return systemPrefersDark ? 'dark' : 'light'
}
return themeMode.value
})
// 切换主题
function toggleTheme(mode?: ThemeMode) {
if (mode) {
themeMode.value = mode
} else {
// 如果没有指定模式,则在 light 和 dark 之间切换
themeMode.value = currentTheme.value === 'light' ? 'dark' : 'light'
}
applyThemeToDom()
}
// 将主题类名应用到DOM根元素
function applyThemeToDom() {
const page = uni.getCurrentPages()[0]
const vuePage = page?.$vm
if (vuePage?.$el) {
const rootEl = vuePage.$el
if (currentTheme.value === 'dark') {
rootEl.classList.add('theme-dark')
} else {
rootEl.classList.remove('theme-dark')
}
} else {
// 对于非Vue组件或App.vue,直接操作document(注意小程序环境限制)
// 在H5端可以这样操作,小程序端需要另寻他法
// 更通用的做法是:在App.vue的根view上绑定一个类名,通过全局状态驱动
console.warn('无法直接操作DOM根元素,请在App.vue的根元素上绑定类名')
}
}
// 初始化主题,例如读取本地缓存
function initTheme() {
const savedTheme = uni.getStorageSync('app_theme') as ThemeMode
if (savedTheme) {
themeMode.value = savedTheme
} else {
// 默认跟随系统
themeMode.value = 'auto'
}
applyThemeToDom()
// 监听系统主题变化(如果支持)
if (uni.onThemeChange) {
uni.onThemeChange((res) => {
if (themeMode.value === 'auto') {
applyThemeToDom()
}
})
}
}
// 监听主题变化,持久化到本地
watch(themeMode, (newVal) => {
uni.setStorageSync('app_theme', newVal)
})
return {
themeMode,
currentTheme,
toggleTheme,
initTheme
}
})
最后,在 App.vue 的根元素上,我们需要根据 currentTheme 动态绑定类名。由于小程序环境的限制,直接操作 document.documentElement 可能不行。一个可靠的方法是在 App.vue 的模板中使用一个动态类名,并通过 watch currentTheme 来更新它。
<!-- App.vue -->
<template>
<view :class="['app-container', `theme-${currentTheme}`]">
<!-- 页面内容,例如page.json中配置的第一个页面 -->
</view>
</template>
<script setup lang="ts">
import { useThemeStore } from '@/stores/theme'
import { storeToRefs } from 'pinia'
const themeStore = useThemeStore()
const { currentTheme } = storeToRefs(themeStore)
onLaunch(() => {
themeStore.initTheme()
})
</script>
这样,当我们调用 themeStore.toggleTheme('dark') 时,根元素会增加 theme-dark 类,所有使用 CSS 变量定义样式的元素都会自动切换为深色主题的值,实现了无缝的主题切换。在组件中使用时,只需要用 var(--color-primary) 这样的方式引用颜色即可。
3. 性能优化技巧:让你的小程序如丝般顺滑
小程序性能优化是个大课题,涉及到启动速度、渲染效率、包体积等多个方面。结合 vue3 和 uniapp,我分享几个实战中特别有效的技巧。
3.1 善用 vite 构建与分包策略
unibest 默认集成了 vite,这本身就是一个巨大的性能优势。但默认配置可能还不够。我们可以调整 vite.config.ts 中的一些配置来进一步优化。
首先,启用 build.minify 和 build.terserOptions 来压缩代码。对于小程序,我们可以更激进地移除一些开发环境代码和注释。
// vite.config.ts
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
export default defineConfig({
plugins: [uni()],
build: {
minify: 'terser',
terserOptions: {
compress: {
drop_console: true, // 生产环境移除console
drop_debugger: true,
},
},
// 启用分包
rollupOptions: {
output: {
manualChunks(id) {
// 将 node_modules 中的大依赖包单独分包
if (id.includes('node_modules')) {
if (id.includes('lodash')) {
return 'vendor-lodash'
}
if (id.includes('dayjs')) {
return 'vendor-dayjs'
}
// 其他大型库...
return 'vendor' // 剩余的node_modules打包到一起
}
}
}
}
}
})
其次,分包加载 是小程序性能优化的重中之重。主包大小限制很严格(目前是2M),必须把非首页必需的页面和组件放到分包里。在 pages.json 中配置分包:
{
"pages": [
{
"path": "pages/index/index",
"style": { "navigationBarTitleText": "首页" }
}
],
"subPackages": [
{
"root": "pagesA",
"pages": [
{
"path": "detail/detail",
"style": { "navigationBarTitleText": "详情页" }
}
]
},
{
"root": "pagesB",
"pages": [
{
"path": "user/user",
"style": { "navigationBarTitleText": "个人中心" }
}
]
}
]
}
3.2 列表渲染与图片懒加载优化
长列表是性能杀手。uniapp 的 scroll-view 和 vue 的 v-for 在渲染大量数据时很容易卡顿。我的经验是:
- 使用
uniapp的<list>或<recycle-list>组件(如果平台支持),它们有更好的回收机制。但兼容性需要注意。 - 自己实现虚拟列表。对于超长列表,只渲染可视区域及附近的部分元素。虽然
uniapp官方没有直接提供虚拟列表组件,但我们可以基于scroll-view和动态计算来实现,或者使用社区成熟的组件,比如基于z-paging的虚拟列表模式(我们在上一篇文章用过z-paging)。 - 图片懒加载。
uniapp的image组件自带lazy-load属性,一定要用上。对于列表中的图片,可以结合IntersectionObserverAPI(小程序也支持)来实现更精确的懒加载控制,即图片进入可视区域再加载。
<template>
<scroll-view scroll-y @scrolltolower="loadMore">
<view v-for="item in visibleList" :key="item.id">
<!-- 使用 lazy-load,并设置一个占位图或低质量预览图 -->
<image
:src="item.thumbnail || '/static/placeholder.png'"
:lazy-load="true"
mode="aspectFill"
@load="onImageLoad(item)"
/>
<!-- 其他内容 -->
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
const allList = ref([]) // 所有数据
const scrollTop = ref(0)
const viewportHeight = ref(0)
// 计算当前可视区域应该渲染的数据
const visibleList = computed(() => {
const itemHeight = 100 // 假设每个列表项高度固定为100rpx
const startIndex = Math.max(0, Math.floor(scrollTop.value / itemHeight) - 5) // 多渲染几项做缓冲
const endIndex = Math.min(allList.value.length, startIndex + Math.ceil(viewportHeight.value / itemHeight) + 10)
return allList.value.slice(startIndex, endIndex)
})
function onScroll(e) {
scrollTop.value = e.detail.scrollTop
}
onMounted(() => {
// 获取滚动视图高度
const query = uni.createSelectorQuery().in(this)
query.select('.scroll-view').boundingClientRect(data => {
viewportHeight.value = data.height
}).exec()
})
</script>
3.3 数据缓存与请求优化
频繁的网络请求是耗电和卡顿的元凶。对于不常变的数据,一定要用缓存。
- 接口数据缓存:使用
uni.setStorageSync和uni.getStorageSync配合接口的timestamp或版本号。可以封装一个带缓存功能的request函数。 - 图片缓存:小程序本身有图片缓存机制,但我们可以做得更好。对于用户头像、商品封面等图片,可以在首次下载后存储到本地文件系统(使用
uni.downloadFile和uni.saveFile),下次直接使用本地路径。不过要注意小程序的本地存储空间限制和清理策略。 - 请求防抖与节流:像搜索框这类场景,必须用防抖。上一篇文章的示例里已经用到了
lodash-es的debounce。对于上拉加载更多,可以用节流(throttle)避免过快触发。
// utils/request.ts
import { debounce } from 'lodash-es'
// 带缓存的请求函数示例
export async function cachedRequest<T>(options: {
url: string
params?: any
cacheKey: string
cacheTime?: number // 缓存时间,毫秒
}): Promise<T> {
const { url, params, cacheKey, cacheTime = 5 * 60 * 1000 } = options
const now = Date.now()
// 尝试从缓存读取
const cacheStr = uni.getStorageSync(cacheKey)
if (cacheStr) {
const cacheObj = JSON.parse(cacheStr)
if (now - cacheObj.timestamp < cacheTime) {
console.log(`[Cached] ${url}`)
return Promise.resolve(cacheObj.data)
}
}
// 无缓存或已过期,发起请求
return new Promise((resolve, reject) => {
uni.request({
url,
data: params,
success: (res) => {
const data = res.data as T
// 存储到缓存
uni.setStorageSync(cacheKey, JSON.stringify({
data,
timestamp: now
}))
resolve(data)
},
fail: reject
})
})
}
// 防抖搜索函数
export const debouncedSearch = debounce((keyword: string, callback: (result: any[]) => void) => {
// 执行搜索请求...
console.log('搜索关键词:', keyword)
// 模拟请求
setTimeout(() => {
callback([/* 搜索结果 */])
}, 300)
}, 500)
3.4 减少不必要的响应式数据与计算属性
vue3 的响应式系统非常高效,但滥用也会成为负担。对于纯粹展示、不会改变的数据,或者只在初始化时计算一次的数据,使用 ref 或 reactive 是浪费。可以使用 shallowRef、shallowReactive 或者直接使用普通变量。
import { ref, shallowRef, computed, onMounted } from 'vue'
// 不好的做法:一个巨大的、内部结构复杂的对象,用reactive包裹,任何嵌套属性的修改都会触发响应。
const heavyObject = reactive({
/* 非常深、非常大的对象 */
})
// 好一点的做法:如果只有顶层属性需要响应式,用shallowReactive
const heavyObject = shallowReactive({
/* 大对象 */
})
// 对于完全静态的配置数据,直接用常量即可
const STATIC_CONFIG = {
appName: '我的小程序',
version: '1.0.0'
}
// 计算属性也要谨慎,避免在模板中频繁触发复杂计算。
// 如果计算依赖的数据不变,考虑用 memoize 函数缓存结果,或者用 onMounted 计算一次后存起来。
const expensiveValue = ref('')
onMounted(() => {
expensiveValue.value = calculateSomethingHeavy() // 只计算一次
})
4. 实战案例:集成动态导航栏与主题切换的完整页面
最后,我们把前面讲的知识点串起来,打造一个“高配版”的页面布局组件。这个组件将具备:
- 自适应各种屏幕的动态导航栏。
- 支持跟随系统或手动切换的主题。
- 良好的性能基础(如图片懒加载、请求防抖)。
我们基于第一篇的 custom-page 组件进行升级。
<!-- components/smart-page/smart-page.vue -->
<template>
<view :class="['smart-page-container', `theme-${themeStore.currentTheme}`]" :style="containerStyle">
<!-- 可自定义的背景图 -->
<image
v-if="backgroundImage"
:src="backgroundImage"
class="smart-page-bg"
mode="widthFix"
:lazy-load="true"
/>
<view class="smart-page-main" :style="mainStyle">
<!-- 动态导航栏 -->
<wd-navbar
:title="title"
safeAreaInsetTop
:placeholder="true"
:left-arrow="showBack"
@click-left="handleClickLeft"
:custom-class="['wd-navbar-custom', `theme-${themeStore.currentTheme}`]"
:style="navbarStyle"
>
<!-- 可选:在导航栏右侧添加主题切换按钮 -->
<template #right v-if="showThemeToggle">
<view class="theme-toggle-btn" @tap="toggleTheme">
<text class="icon">{{ themeStore.currentTheme === 'dark' ? '🌙' : '☀️' }}</text>
</view>
</template>
</wd-navbar>
<!-- 页面主体内容 -->
<view class="smart-page-content" :style="contentStyle">
<view :class="['page-content-inner', { 'has-padding': showPagePadding }]">
<slot name="content"></slot>
</view>
</view>
<!-- 页面底部固定区域 -->
<view v-if="$slots.footer" class="smart-page-footer">
<slot name="footer"></slot>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, CSSProperties } from 'vue'
import { useSystemStore } from '@/stores/system'
import { useThemeStore } from '@/stores/theme'
interface Props {
title: string
showBack?: boolean
showPagePadding?: boolean
backgroundImage?: string
showThemeToggle?: boolean
// 允许覆盖默认的导航栏样式
navbarBgColor?: string
navbarTextColor?: string
}
const props = withDefaults(defineProps<Props>(), {
showBack: true,
showPagePadding: true,
showThemeToggle: false,
})
const systemStore = useSystemStore()
const themeStore = useThemeStore()
// 容器样式:根据主题和是否有背景图设置
const containerStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {
height: '100vh',
width: '100%',
}
// 如果提供了背景图,容器需要相对定位
if (props.backgroundImage) {
style.position = 'relative'
}
return style
})
// 主区域样式:动态计算顶部偏移,确保内容在导航栏下方
const mainStyle = computed<CSSProperties>(() => {
return {
paddingTop: `${systemStore.navBarInfo.navBarHeight}px`,
height: '100%',
display: 'flex',
flexDirection: 'column',
}
})
// 导航栏自定义样式
const navbarStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {}
if (props.navbarBgColor) {
style.backgroundColor = props.navbarBgColor
}
if (props.navbarTextColor) {
style.color = props.navbarTextColor
}
return style
})
// 内容区域样式
const contentStyle = computed(() => ({
flex: 1,
overflow: 'hidden',
}))
function handleClickLeft() {
uni.navigateBack()
}
function toggleTheme() {
themeStore.toggleTheme()
}
</script>
<style lang="scss" scoped>
.smart-page-container {
// 使用CSS变量定义颜色,主题切换时会自动变化
background-color: var(--color-bg-page);
color: var(--color-text-primary);
.smart-page-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: auto; // 使用 mode="widthFix" 时,高度自适应
z-index: 1;
}
.smart-page-main {
position: relative;
z-index: 2;
box-sizing: border-box;
.smart-page-content {
.page-content-inner {
height: 100%;
overflow-y: auto;
-webkit-overflow-scrolling: touch; // 在iOS上实现弹性滚动
&.has-padding {
padding: 28rpx;
}
}
}
.smart-page-footer {
background-color: var(--color-bg-container);
border-top: 1rpx solid var(--color-border-light);
padding: 30rpx 28rpx;
flex-shrink: 0;
}
}
}
// 主题切换按钮样式
.theme-toggle-btn {
padding: 10rpx;
.icon {
font-size: 40rpx;
}
}
// 深色主题下的一些微调
:root.theme-dark {
.smart-page-container {
.smart-page-footer {
border-top-color: var(--color-border);
}
}
}
</style>
这个 smart-page 组件就是一个功能齐全的页面容器。在使用时,你只需要关注业务内容本身:
<template>
<smart-page title="个人中心" :show-theme-toggle="true">
<template #content>
<view class="user-info">
<!-- 你的业务内容 -->
</view>
</template>
<template #footer>
<wd-button type="primary" block @click="logout">退出登录</wd-button>
</template>
</smart-page>
</template>
<script setup lang="ts">
import SmartPage from '@/components/smart-page/smart-page.vue'
function logout() {
// 退出登录逻辑
}
</script>
通过这样的封装,我们不仅统一了页面风格,还将动态适配、主题切换、性能考量等复杂逻辑内聚在组件内部,极大提升了开发效率和应用的健壮性。在实际项目中,你可能还需要根据具体UI设计稿调整样式细节,但这个核心架构已经能帮你解决大部分常见问题。记住,好的架构和封装不是为了炫技,而是为了在业务需求快速变化时,你还能从容不迫,笑对需求。
更多推荐

所有评论(0)