一、多端条件编译
1.1 技术实现方案
多端条件编译是 Uni-app 的核心特性,通过编译时的条件判断实现一套代码多端运行。
核心原理
// 条件编译语法
// #ifdef H5
console.log('仅在 H5 端运行')
// #endif
// #ifdef MP-WEIXIN
console.log('仅在微信小程序运行')
// #endif
// #ifndef H5
console.log('除了 H5 外都运行')
// #endif
1.2 可运行代码示例
示例1: 多端 API 适配
<!-- pages/api-adapter/index.vue -->
<template>
<view class="container">
<view class="card">
<text class="title">多端 API 适配示例</text>
<button @click="chooseImage" class="btn">选择图片</button>
<image v-if="imageUrl" :src="imageUrl" class="preview" mode="aspectFit"></image>
<button @click="getLocation" class="btn">获取位置</button>
<text v-if="location" class="info">{{ location }}</text>
<button @click="makePhoneCall" class="btn">拨打电话</button>
<button @click="scanCode" class="btn">扫码</button>
<text v-if="scanResult" class="info">{{ scanResult }}</text>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
const imageUrl = ref('')
const location = ref('')
const scanResult = ref('')
// 选择图片 - 多端适配
const chooseImage = () => {
// #ifdef H5
// H5 端使用 input file
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = (e) => {
const file = e.target.files[0]
if (file) {
const reader = new FileReader()
reader.onload = (event) => {
imageUrl.value = event.target.result
}
reader.readAsDataURL(file)
}
}
input.click()
// #endif
// #ifdef MP-WEIXIN || MP-ALIPAY || APP-PLUS
// 小程序和 APP 端使用原生 API
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
imageUrl.value = res.tempFilePaths[0]
},
fail: (err) => {
console.error('选择图片失败:', err)
}
})
// #endif
}
// 获取位置 - 多端适配
const getLocation = () => {
// #ifdef H5
// H5 使用浏览器地理位置 API
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
location.value = `纬度: ${position.coords.latitude.toFixed(6)}, 经度: ${position.coords.longitude.toFixed(6)}`
},
(error) => {
uni.showToast({
title: '获取位置失败',
icon: 'none'
})
}
)
}
// #endif
// #ifdef MP-WEIXIN || MP-ALIPAY || APP-PLUS
// 小程序和 APP 使用原生定位
uni.getLocation({
type: 'gcj02',
success: (res) => {
location.value = `纬度: ${res.latitude.toFixed(6)}, 经度: ${res.longitude.toFixed(6)}`
},
fail: () => {
uni.showToast({
title: '获取位置失败',
icon: 'none'
})
}
})
// #endif
}
// 拨打电话 - 多端适配
const makePhoneCall = () => {
const phoneNumber = '10086'
// #ifdef H5
// H5 直接使用 tel 协议
window.location.href = `tel:${phoneNumber}`
// #endif
// #ifdef MP-WEIXIN || MP-ALIPAY || APP-PLUS
// 小程序和 APP 使用原生拨号
uni.makePhoneCall({
phoneNumber: phoneNumber
})
// #endif
}
// 扫码 - 多端适配
const scanCode = () => {
// #ifdef H5
// H5 端可以使用第三方库或提示不支持
uni.showToast({
title: 'H5 端暂不支持扫码',
icon: 'none'
})
// #endif
// #ifdef MP-WEIXIN || APP-PLUS
// 微信小程序和 APP 支持扫码
uni.scanCode({
success: (res) => {
scanResult.value = `扫码结果: ${res.result}`
}
})
// #endif
// #ifdef MP-ALIPAY
// 支付宝小程序
my.scan({
type: 'qr',
success: (res) => {
scanResult.value = `扫码结果: ${res.code}`
}
})
// #endif
}
</script>
<style scoped>
.container {
padding: 20px;
}
.card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
}
.title {
font-size: 18px;
font-weight: bold;
margin-bottom: 20px;
display: block;
}
.btn {
width: 100%;
margin-bottom: 15px;
background: #007aff;
color: #fff;
border-radius: 8px;
height: 44px;
line-height: 44px;
}
.preview {
width: 100%;
height: 200px;
margin-bottom: 15px;
border-radius: 8px;
}
.info {
display: block;
padding: 10px;
background: #f5f5f5;
border-radius: 6px;
margin-bottom: 15px;
font-size: 14px;
}
</style>
示例2: 多端样式适配
<!-- pages/style-adapter/index.vue -->
<template>
<view class="page">
<view class="platform-info">
当前平台: {{ platformName }}
</view>
<view class="content">
<view class="box">普通盒子</view>
<view class="platform-box">平台专属样式盒子</view>
<view class="safe-area-box">安全区域盒子</view>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
const platformName = computed(() => {
// #ifdef H5
return 'H5'
// #endif
// #ifdef MP-WEIXIN
return '微信小程序'
// #endif
// #ifdef MP-ALIPAY
return '支付宝小程序'
// #endif
// #ifdef APP-PLUS
return 'APP'
// #endif
return '未知平台'
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f5f5f5;
}
.platform-info {
padding: 20px;
background: #fff;
text-align: center;
font-size: 16px;
font-weight: bold;
}
.content {
padding: 20px;
}
.box {
padding: 20px;
background: #fff;
border-radius: 8px;
margin-bottom: 15px;
}
.platform-box {
padding: 20px;
border-radius: 8px;
margin-bottom: 15px;
/* #ifdef H5 */
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
/* #endif */
/* #ifdef MP-WEIXIN */
background: #07c160;
color: #fff;
/* #endif */
/* #ifdef MP-ALIPAY */
background: #1677ff;
color: #fff;
/* #endif */
/* #ifdef APP-PLUS */
background: #ff6034;
color: #fff;
/* #endif */
}
.safe-area-box {
padding: 20px;
background: #fff;
border-radius: 8px;
/* #ifdef APP-PLUS */
padding-bottom: calc(20px + env(safe-area-inset-bottom));
/* #endif */
/* #ifdef MP-WEIXIN */
padding-bottom: calc(20px + constant(safe-area-inset-bottom));
padding-bottom: calc(20px + env(safe-area-inset-bottom));
/* #endif */
}
</style>
二、原生插件开发
2.1 技术实现方案
原生插件开发允许 Uni-app 调用原生功能,分为本地插件和云端插件。
核心架构
Uni-app (JS)
↓
原生插件接口 (uni.requireNativePlugin)
↓
Android (Java/Kotlin) / iOS (Objective-C/Swift)
↓
原生功能实现
2.2 可运行代码示例
示例1: 本地原生插件封装
<!-- pages/native-plugin/index.vue -->
<template>
<view class="container">
<view class="card">
<text class="title">原生插件功能</text>
<button @click="callNativePlugin" class="btn">调用原生插件</button>
<text v-if="result" class="result">{{ result }}</text>
<button @click="getDeviceInfo" class="btn">获取设备信息</button>
<view v-if="deviceInfo" class="info-box">
<text class="info-item">品牌: {{ deviceInfo.brand }}</text>
<text class="info-item">型号: {{ deviceInfo.model }}</text>
<text class="info-item">系统: {{ deviceInfo.system }}</text>
<text class="info-item">平台: {{ deviceInfo.platform }}</text>
</view>
<button @click="vibrate" class="btn">震动反馈</button>
<button @click="showNativeToast" class="btn">原生提示</button>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
const result = ref('')
const deviceInfo = ref(null)
// 调用原生插件示例
const callNativePlugin = () => {
// #ifdef APP-PLUS
// 引入原生插件
const customPlugin = uni.requireNativePlugin('CustomPlugin')
if (customPlugin) {
customPlugin.customMethod({
param1: 'value1',
param2: 'value2'
}, (res) => {
result.value = JSON.stringify(res)
})
} else {
result.value = '原生插件未找到'
}
// #endif
// #ifndef APP-PLUS
uni.showToast({
title: '仅 APP 端支持',
icon: 'none'
})
// #endif
}
// 获取设备信息
const getDeviceInfo = () => {
uni.getSystemInfo({
success: (res) => {
deviceInfo.value = {
brand: res.brand || '未知',
model: res.model,
system: `${res.system} ${res.version}`,
platform: res.platform,
pixelRatio: res.pixelRatio,
screenWidth: res.screenWidth,
screenHeight: res.screenHeight,
windowWidth: res.windowWidth,
windowHeight: res.windowHeight,
statusBarHeight: res.statusBarHeight,
safeArea: res.safeArea
}
}
})
}
// 震动反馈
const vibrate = () => {
// #ifdef APP-PLUS
// APP 端可以使用原生震动
plus.device.vibrate(500)
// #endif
// #ifdef MP-WEIXIN || H5
// 微信小程序和 H5
uni.vibrateShort({
type: 'medium'
})
// #endif
}
// 原生提示
const showNativeToast = () => {
// #ifdef APP-PLUS
// 使用 5+ API 显示原生提示
plus.nativeUI.toast('这是原生提示', {
duration: 'short',
type: 'default'
})
// #endif
// #ifndef APP-PLUS
uni.showToast({
title: '这是普通提示',
icon: 'none'
})
// #endif
}
</script>
<style scoped>
.container {
padding: 20px;
background: #f5f5f5;
min-height: 100vh;
}
.card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
}
.title {
font-size: 18px;
font-weight: bold;
margin-bottom: 20px;
display: block;
color: #333;
}
.btn {
width: 100%;
margin-bottom: 15px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border-radius: 8px;
height: 44px;
line-height: 44px;
border: none;
}
.result {
display: block;
padding: 15px;
background: #f0f9ff;
border-radius: 8px;
margin-bottom: 15px;
font-size: 14px;
color: #0369a1;
word-break: break-all;
}
.info-box {
background: #f8fafc;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
}
.info-item {
display: block;
padding: 8px 0;
font-size: 14px;
color: #475569;
border-bottom: 1px solid #e2e8f0;
}
.info-item:last-child {
border-bottom: none;
}
</style>
示例2: 原生模块封装工具类
// utils/nativeModule.js
/**
* 原生模块封装工具类
*/
class NativeModule {
constructor() {
this.isApp = false
// #ifdef APP-PLUS
this.isApp = true
// #endif
}
/**
* 调用原生模块方法
*/
callNativeMethod(moduleName, methodName, params = {}) {
return new Promise((resolve, reject) => {
if (!this.isApp) {
reject(new Error('仅支持 APP 端'))
return
}
// #ifdef APP-PLUS
const module = uni.requireNativePlugin(moduleName)
if (!module) {
reject(new Error(`模块 ${moduleName} 未找到`))
return
}
if (typeof module[methodName] !== 'function') {
reject(new Error(`方法 ${methodName} 不存在`))
return
}
module[methodName](params, (res) => {
if (res.code === 0) {
resolve(res.data)
} else {
reject(new Error(res.message || '调用失败'))
}
})
// #endif
})
}
/**
* 获取状态栏高度
*/
getStatusBarHeight() {
// #ifdef APP-PLUS
return plus.navigator.getStatusbarHeight()
// #endif
// #ifndef APP-PLUS
const systemInfo = uni.getSystemInfoSync()
return systemInfo.statusBarHeight || 0
// #endif
}
/**
* 获取导航栏高度
*/
getNavigationBarHeight() {
// #ifdef APP-PLUS
// APP 端默认导航栏高度 44px
return 44
// #endif
// #ifdef MP-WEIXIN
// 微信小程序
const menuButtonInfo = uni.getMenuButtonBoundingClientRect()
const systemInfo = uni.getSystemInfoSync()
return menuButtonInfo.top - systemInfo.statusBarHeight + menuButtonInfo.height + 8
// #endif
// #ifdef H5
return 44
// #endif
}
/**
* 原生存储
*/
async setStorage(key, value) {
// #ifdef APP-PLUS
return new Promise((resolve) => {
plus.storage.setItem(key, JSON.stringify(value))
resolve()
})
// #endif
// #ifndef APP-PLUS
return uni.setStorage({
key,
data: value
})
// #endif
}
async getStorage(key) {
// #ifdef APP-PLUS
return new Promise((resolve) => {
const value = plus.storage.getItem(key)
resolve(value ? JSON.parse(value) : null)
})
// #endif
// #ifndef APP-PLUS
return new Promise((resolve) => {
uni.getStorage({
key,
success: (res) => resolve(res.data),
fail: () => resolve(null)
})
})
// #endif
}
}
export default new NativeModule()
三、nvue 性能优化
3.1 技术实现方案
nvue (native vue) 使用原生渲染引擎,性能优于 webview,特别适合长列表和复杂动画。
核心优势
- 原生渲染,无 webview 性能瓶颈
- 支持 Weex 组件
- 更流畅的动画效果
- 更好的列表滚动性能
3.2 可运行代码示例
示例1: nvue 长列表优化
<!-- pages/nvue-list/index.nvue -->
<template>
<view class="container">
<!-- 使用 list 组件实现高性能列表 -->
<list class="list" @loadmore="loadMore" loadmoreoffset="100">
<!-- 下拉刷新 -->
<refresh class="refresh" @refresh="onRefresh" @pullingdown="onPullingDown" :display="refreshing ? 'show' : 'hide'">
<text class="refresh-text">{{ refreshText }}</text>
</refresh>
<!-- 列表头部 -->
<cell>
<view class="header">
<text class="header-text">高性能长列表 ({{ items.length }} 条)</text>
</view>
</cell>
<!-- 列表项 -->
<cell v-for="item in items" :key="item.id">
<view class="item" @click="onItemClick(item)">
<image :src="item.avatar" class="avatar"></image>
<view class="content">
<text class="title">{{ item.title }}</text>
<text class="desc">{{ item.desc }}</text>
<view class="meta">
<text class="time">{{ item.time }}</text>
<text class="count">浏览 {{ item.views }}</text>
</view>
</view>
<view class="arrow">
<text class="arrow-icon">›</text>
</view>
</view>
</cell>
<!-- 加载更多 -->
<cell v-if="hasMore">
<view class="loading">
<loading-indicator class="loading-icon"></loading-indicator>
<text class="loading-text">加载中...</text>
</view>
</cell>
<cell v-else>
<view class="loading">
<text class="loading-text">没有更多了</text>
</view>
</cell>
</list>
</view>
</template>
<script>
export default {
data() {
return {
items: [],
page: 1,
pageSize: 20,
hasMore: true,
refreshing: false,
refreshText: '下拉刷新'
}
},
onLoad() {
this.loadData()
},
methods: {
// 加载数据
loadData(isRefresh = false) {
if (isRefresh) {
this.page = 1
this.items = []
}
// 模拟数据加载
setTimeout(() => {
const newItems = []
const start = (this.page - 1) * this.pageSize
for (let i = 0; i < this.pageSize; i++) {
const index = start + i
newItems.push({
id: `item_${index}`,
title: `列表项标题 ${index + 1}`,
desc: '这是一段描述文字,用于展示列表项的详细信息',
avatar: `https://picsum.photos/80/80?random=${index}`,
time: this.formatTime(new Date()),
views: Math.floor(Math.random() * 10000)
})
}
if (isRefresh) {
this.items = newItems
} else {
this.items = [...this.items, ...newItems]
}
this.hasMore = this.page < 5 // 模拟最多5页
this.page++
if (this.refreshing) {
this.refreshing = false
}
}, 1000)
},
// 加载更多
loadMore() {
if (this.hasMore && !this.refreshing) {
this.loadData()
}
},
// 下拉刷新
onRefresh() {
this.refreshing = true
this.refreshText = '刷新中...'
this.loadData(true)
},
onPullingDown(e) {
if (e.pullingDistance < 50) {
this.refreshText = '下拉刷新'
} else {
this.refreshText = '释放刷新'
}
},
// 点击列表项
onItemClick(item) {
uni.showToast({
title: `点击了: ${item.title}`,
icon: 'none'
})
},
// 格式化时间
formatTime(date) {
const hour = date.getHours().toString().padStart(2, '0')
const minute = date.getMinutes().toString().padStart(2, '0')
return `${hour}:${minute}`
}
}
}
</script>
<style>
.container {
flex: 1;
background-color: #f5f5f5;
}
.list {
flex: 1;
}
.refresh {
width: 750rpx;
height: 100px;
justify-content: center;
align-items: center;
}
.refresh-text {
font-size: 28rpx;
color: #666;
}
.header {
padding: 30rpx;
background-color: #fff;
border-bottom: 1px solid #eee;
}
.header-text {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.item {
flex-direction: row;
align-items: center;
padding: 30rpx;
background-color: #fff;
border-bottom: 1px solid #eee;
}
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 40rpx;
margin-right: 20rpx;
}
.content {
flex: 1;
}
.title {
font-size: 32rpx;
color: #333;
margin-bottom: 10rpx;
lines: 1;
text-overflow: ellipsis;
}
.desc {
font-size: 28rpx;
color: #999;
margin-bottom: 10rpx;
lines: 2;
text-overflow: ellipsis;
}
.meta {
flex-direction: row;
align-items: center;
}
.time {
font-size: 24rpx;
color: #999;
margin-right: 20rpx;
}
.count {
font-size: 24rpx;
color: #999;
}
.arrow {
width: 40rpx;
height: 40rpx;
justify-content: center;
align-items: center;
}
.arrow-icon {
font-size: 40rpx;
color: #ccc;
}
.loading {
flex-direction: row;
justify-content: center;
align-items: center;
padding: 30rpx;
background-color: #fff;
}
.loading-icon {
width: 40rpx;
height: 40rpx;
margin-right: 15rpx;
}
.loading-text {
font-size: 28rpx;
color: #999;
}
</style>
示例2: nvue 动画优化
<!-- pages/nvue-animation/index.nvue -->
<template>
<view class="container">
<view class="demo-area">
<!-- 原生动画盒子 -->
<view ref="animBox" class="anim-box" @click="playAnimation">
<text class="box-text">点击播放动画</text>
</view>
<!-- 控制按钮 -->
<view class="controls">
<view class="btn" @click="fadeIn">
<text class="btn-text">淡入</text>
</view>
<view class="btn" @click="slideIn">
<text class="btn-text">滑入</text>
</view>
<view class="btn" @click="scaleIn">
<text class="btn-text">缩放</text>
</view>
<view class="btn" @click="rotate">
<text class="btn-text">旋转</text>
</view>
</view>
</view>
</view>
</template>
<script>
// nvue 使用 animation 模块实现原生动画
const animation = weex.requireModule('animation')
export default {
data() {
return {
animating: false
}
},
methods: {
// 播放综合动画
playAnimation() {
if (this.animating) return
this.animating = true
const animBox = this.$refs.animBox
// 动画序列
animation.transition(animBox, {
styles: {
transform: 'scale(1.2) rotate(180deg)',
opacity: 0.5
},
duration: 500,
timingFunction: 'ease-in-out'
}, () => {
animation.transition(animBox, {
styles: {
transform: 'scale(1) rotate(0deg)',
opacity: 1
},
duration: 500,
timingFunction: 'ease-in-out'
}, () => {
this.animating = false
})
})
},
// 淡入动画
fadeIn() {
const animBox = this.$refs.animBox
animation.transition(animBox, {
styles: {
opacity: 0
},
duration: 0
}, () => {
animation.transition(animBox, {
styles: {
opacity: 1
},
duration: 600,
timingFunction: 'ease-out'
})
})
},
// 滑入动画
slideIn() {
const animBox = this.$refs.animBox
animation.transition(animBox, {
styles: {
transform: 'translateX(-300px)'
},
duration: 0
}, () => {
animation.transition(animBox, {
styles: {
transform: 'translateX(0)'
},
duration: 600,
timingFunction: 'ease-out'
})
})
},
// 缩放动画
scaleIn() {
const animBox = this.$refs.animBox
animation.transition(animBox, {
styles: {
transform: 'scale(0)'
},
duration: 0
}, () => {
animation.transition(animBox, {
styles: {
transform: 'scale(1)'
},
duration: 600,
timingFunction: 'cubic-bezier(0.68, -0.55, 0.265, 1.55)'
})
})
},
// 旋转动画
rotate() {
const animBox = this.$refs.animBox
animation.transition(animBox, {
styles: {
transform: 'rotate(360deg)'
},
duration: 800,
timingFunction: 'ease-in-out'
}, () => {
animation.transition(animBox, {
styles: {
transform: 'rotate(0deg)'
},
duration: 0
})
})
}
}
}
</script>
<style>
.container {
flex: 1;
background-color: #f5f5f5;
justify-content: center;
align-items: center;
}
.demo-area {
width: 690rpx;
}
.anim-box {
width: 300rpx;
height: 300rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 20rpx;
justify-content: center;
align-items: center;
margin-bottom: 60rpx;
align-self: center;
}
.box-text {
font-size: 32rpx;
color: #fff;
font-weight: bold;
text-align: center;
}
.controls {
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
}
.btn {
width: 330rpx;
height: 80rpx;
background-color: #fff;
border-radius: 12rpx;
justify-content: center;
align-items: center;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.1);
}
.btn-text {
font-size: 28rpx;
color: #333;
}
</style>
四、跨端组件库
4.1 技术实现方案
跨端组件库需要处理不同平台的差异,提供统一的API和样式。
4.2 可运行代码示例
示例1: 跨端按钮组件
<!-- components/cross-button/cross-button.vue -->
<template>
<view
class="cross-btn"
:class="[
`cross-btn--${type}`,
`cross-btn--${size}`,
{
'cross-btn--disabled': disabled,
'cross-btn--loading': loading,
'cross-btn--plain': plain,
'cross-btn--round': round,
'cross-btn--block': block
}
]"
:hover-class="disabled || loading ? '' : 'cross-btn--active'"
@tap="handleClick"
>
<!-- 加载图标 -->
<view v-if="loading" class="cross-btn__loading">
<view class="loading-spinner"></view>
</view>
<!-- 图标 -->
<view v-if="icon && !loading" class="cross-btn__icon">
<text class="icon">{{ icon }}</text>
</view>
<!-- 文本 -->
<text class="cross-btn__text">
<slot></slot>
</text>
</view>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
const props = defineProps({
type: {
type: String,
default: 'default' // default, primary, success, warning, danger
},
size: {
type: String,
default: 'medium' // small, medium, large
},
disabled: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
plain: {
type: Boolean,
default: false
},
round: {
type: Boolean,
default: false
},
block: {
type: Boolean,
default: false
},
icon: {
type: String,
default: ''
}
})
const emit = defineEmits(['click'])
const handleClick = (e) => {
if (props.disabled || props.loading) return
emit('click', e)
}
</script>
<style scoped>
.cross-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 24rpx;
font-size: 28rpx;
line-height: 1.5;
border-radius: 8rpx;
border: 1px solid transparent;
transition: all 0.3s;
position: relative;
overflow: hidden;
}
/* 尺寸 */
.cross-btn--small {
height: 60rpx;
font-size: 24rpx;
padding: 0 20rpx;
}
.cross-btn--medium {
height: 80rpx;
}
.cross-btn--large {
height: 100rpx;
font-size: 32rpx;
padding: 0 32rpx;
}
/* 类型 */
.cross-btn--default {
background: #fff;
color: #333;
border-color: #e5e5e5;
}
.cross-btn--primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
.cross-btn--success {
background: #07c160;
color: #fff;
}
.cross-btn--warning {
background: #ff976a;
color: #fff;
}
.cross-btn--danger {
background: #ee0a24;
color: #fff;
}
/* 朴素按钮 */
.cross-btn--plain.cross-btn--default {
background: transparent;
color: #333;
}
.cross-btn--plain.cross-btn--primary {
background: transparent;
color: #667eea;
border-color: #667eea;
}
.cross-btn--plain.cross-btn--success {
background: transparent;
color: #07c160;
border-color: #07c160;
}
.cross-btn--plain.cross-btn--warning {
background: transparent;
color: #ff976a;
border-color: #ff976a;
}
.cross-btn--plain.cross-btn--danger {
background: transparent;
color: #ee0a24;
border-color: #ee0a24;
}
/* 圆角 */
.cross-btn--round {
border-radius: 999rpx;
}
/* 块级 */
.cross-btn--block {
display: flex;
width: 100%;
}
/* 禁用 */
.cross-btn--disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 点击效果 */
.cross-btn--active {
opacity: 0.8;
transform: scale(0.98);
}
/* 加载 */
.cross-btn__loading {
margin-right: 8rpx;
}
.loading-spinner {
width: 28rpx;
height: 28rpx;
border: 3rpx solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.cross-btn__icon {
margin-right: 8rpx;
}
.icon {
font-size: inherit;
}
.cross-btn__text {
line-height: inherit;
}
</style>
示例2: 跨端弹窗组件
<!-- components/cross-popup/cross-popup.vue -->
<template>
<!-- 使用条件编译适配不同平台 -->
<!-- #ifdef H5 -->
<teleport to="body">
<!-- #endif -->
<view v-if="show" class="cross-popup" @tap="handleMaskClick">
<!-- 遮罩层 -->
<view class="cross-popup__mask" :class="{ 'cross-popup__mask--show': maskShow }"></view>
<!-- 内容区 -->
<view
class="cross-popup__content"
:class="[
`cross-popup__content--${position}`,
{ 'cross-popup__content--show': contentShow }
]"
:style="contentStyle"
@tap.stop
>
<!-- 关闭按钮 -->
<view v-if="closeable" class="cross-popup__close" @tap="close">
<text class="close-icon">✕</text>
</view>
<!-- 插槽内容 -->
<slot></slot>
</view>
</view>
<!-- #ifdef H5 -->
</teleport>
<!-- #endif -->
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
const props = defineProps({
show: {
type: Boolean,
default: false
},
position: {
type: String,
default: 'center' // center, top, bottom, left, right
},
closeable: {
type: Boolean,
default: true
},
closeOnClickMask: {
type: Boolean,
default: true
},
round: {
type: Boolean,
default: false
},
customStyle: {
type: Object,
default: () => ({})
}
})
const emit = defineEmits(['update:show', 'close', 'open'])
const maskShow = ref(false)
const contentShow = ref(false)
const contentStyle = computed(() => {
const style = { ...props.customStyle }
if (props.round) {
style.borderRadius = '32rpx'
if (props.position === 'top') {
style.borderTopLeftRadius = '0'
style.borderTopRightRadius = '0'
} else if (props.position === 'bottom') {
style.borderBottomLeftRadius = '0'
style.borderBottomRightRadius = '0'
}
}
return style
})
watch(() => props.show, (val) => {
if (val) {
open()
} else {
maskShow.value = false
contentShow.value = false
}
})
const open = async () => {
await nextTick()
setTimeout(() => {
maskShow.value = true
contentShow.value = true
emit('open')
}, 50)
}
const close = () => {
maskShow.value = false
contentShow.value = false
setTimeout(() => {
emit('update:show', false)
emit('close')
}, 300)
}
const handleMaskClick = () => {
if (props.closeOnClickMask) {
close()
}
}
</script>
<style scoped>
.cross-popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
}
.cross-popup__mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.3s;
}
.cross-popup__mask--show {
opacity: 1;
}
.cross-popup__content {
position: absolute;
background: #fff;
transition: all 0.3s;
max-height: 80vh;
overflow-y: auto;
}
/* 居中弹出 */
.cross-popup__content--center {
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.8);
opacity: 0;
max-width: 80vw;
border-radius: 16rpx;
}
.cross-popup__content--center.cross-popup__content--show {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
/* 顶部弹出 */
.cross-popup__content--top {
top: 0;
left: 0;
right: 0;
transform: translateY(-100%);
}
.cross-popup__content--top.cross-popup__content--show {
transform: translateY(0);
}
/* 底部弹出 */
.cross-popup__content--bottom {
bottom: 0;
left: 0;
right: 0;
transform: translateY(100%);
}
.cross-popup__content--bottom.cross-popup__content--show {
transform: translateY(0);
}
/* 左侧弹出 */
.cross-popup__content--left {
top: 0;
left: 0;
bottom: 0;
width: 60vw;
transform: translateX(-100%);
}
.cross-popup__content--left.cross-popup__content--show {
transform: translateX(0);
}
/* 右侧弹出 */
.cross-popup__content--right {
top: 0;
right: 0;
bottom: 0;
width: 60vw;
transform: translateX(100%);
}
.cross-popup__content--right.cross-popup__content--show {
transform: translateX(0);
}
.cross-popup__close {
position: absolute;
top: 20rpx;
right: 20rpx;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.close-icon {
font-size: 36rpx;
color: #999;
font-weight: bold;
}
</style>
五、HBuilderX 云打包
5.1 技术实现方案
HBuilderX 提供云端打包服务,无需配置原生开发环境即可打包 APP。
打包流程
代码准备 → manifest.json 配置 → 选择证书 → 提交打包 → 下载安装包
5.2 实战配置示例
manifest.json 完整配置
{
"name": "跨端应用",
"appid": "__UNI__XXXXXXX",
"description": "一款跨平台应用",
"versionName": "1.0.0",
"versionCode": "100",
"app-plus": {
"usingComponents": true,
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
"modules": {
"Geolocation": {},
"Maps": {},
"Camera": {},
"Gallery": {},
"Audio": {},
"Video": {},
"Barcode": {},
"Share": {},
"Payment": {}
},
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
],
"abiFilters": ["armeabi-v7a", "arm64-v8a"],
"minSdkVersion": 21,
"targetSdkVersion": 30
},
"ios": {
"privacyDescription": {
"NSPhotoLibraryUsageDescription": "需要访问您的相册",
"NSPhotoLibraryAddUsageDescription": "需要保存图片到相册",
"NSCameraUsageDescription": "需要使用您的相机",
"NSMicrophoneUsageDescription": "需要使用您的麦克风",
"NSLocationWhenInUseUsageDescription": "需要获取您的位置信息",
"NSLocationAlwaysAndWhenInUseUsageDescription": "需要始终获取您的位置信息"
},
"idfa": false,
"capabilities": {
"entitlements": {}
}
},
"sdkConfigs": {
"ad": {},
"maps": {},
"oauth": {},
"payment": {},
"push": {},
"share": {},
"statics": {}
},
"icons": {
"android": {
"hdpi": "static/logo.png",
"xhdpi": "static/logo.png",
"xxhdpi": "static/logo.png",
"xxxhdpi": "static/logo.png"
},
"ios": {
"appstore": "static/logo.png",
"ipad": {
"app": "static/logo.png",
"app@2x": "static/logo.png",
"notification": "static/logo.png",
"notification@2x": "static/logo.png",
"proapp@2x": "static/logo.png",
"settings": "static/logo.png",
"settings@2x": "static/logo.png",
"spotlight": "static/logo.png",
"spotlight@2x": "static/logo.png"
},
"iphone": {
"app@2x": "static/logo.png",
"app@3x": "static/logo.png",
"notification@2x": "static/logo.png",
"notification@3x": "static/logo.png",
"settings@2x": "static/logo.png",
"settings@3x": "static/logo.png",
"spotlight@2x": "static/logo.png",
"spotlight@3x": "static/logo.png"
}
}
}
}
},
"quickapp": {},
"mp-weixin": {
"appid": "",
"setting": {
"urlCheck": false,
"es6": true,
"postcss": true,
"minified": true
},
"usingComponents": true,
"permission": {
"scope.userLocation": {
"desc": "您的位置信息将用于小程序功能展示"
}
}
},
"mp-alipay": {
"usingComponents": true
},
"mp-baidu": {
"usingComponents": true
},
"mp-toutiao": {
"usingComponents": true
},
"h5": {
"template": "template.h5.html",
"router": {
"mode": "hash",
"base": "./"
},
"optimization": {
"treeShaking": {
"enable": true
}
},
"title": "跨端应用",
"devServer": {
"port": 8080,
"disableHostCheck": true,
"proxy": {
"/api": {
"target": "https://api.example.com",
"changeOrigin": true,
"secure": false,
"pathRewrite": {
"^/api": ""
}
}
}
}
}
}
六、简历描述模板
6.1 项目经验描述
项目名称: 企业级跨端移动应用平台 技术栈: Uni-app + Vue3 + nvue + 原生插件 项目周期: 2023.06 - 2023.12 (6个月)
项目描述: 负责开发一款支持 iOS、Android、微信小程序、H5 的跨端移动应用。项目采用 Uni-app 框架,实现了一套代码多端运行,大幅降低了开发和维护成本。
核心职责:
- 主导跨端架构设计,通过条件编译实现不同平台的API适配,代码复用率达到 85%
- 开发高性能长列表组件,使用 nvue 原生渲染技术,列表滚动帧率稳定在 60fps
- 封装跨端组件库,包含 20+ 个业务组件,支持多平台统一调用
- 开发原生插件模块,集成设备硬件功能(相机、定位、扫码),提升用户体验
- 负责 APP 云打包流程,配置多渠道打包方案,单次打包时间从 30 分钟缩短到 8 分钟
技术亮点:
- 多端条件编译优化: 通过精细化的条件编译策略,针对不同平台实现差异化功能。H5 端使用浏览器 API,小程序端调用平台 SDK,APP 端使用原生能力,保证各平台体验最优
- nvue 性能优化: 核心页面使用 nvue 原生渲染,长列表采用 list + cell 组件,实现虚拟列表和懒加载,内存占用降低 40%,滑动卡顿率从 15% 降至 3%
- 原生插件架构: 设计统一的原生插件接口,封装 Native Module 工具类,支持 Promise 调用方式,提供完善的错误处理机制
- 组件库工程化: 建立组件开发规范和文档体系,实现按需加载和样式隔离,组件打包体积减少 60%
项目成果:
- 支持 4 个平台同步发布,开发效率提升 70%
- 核心功能性能优化后,用户留存率提升 25%
- 打包发布效率提升 75%,支持快速迭代
6.2 难点与亮点话术
难点1: 多端 API 差异处理
面试官: 你在处理多端 API 差异时遇到过什么问题?
回答话术: "我遇到的最大挑战是不同平台的 API 能力差异。举个例子,在实现图片选择功能时,H5 只能用 input file,小程序有自己的 chooseImage API,而且各家小程序的参数还不一样。
我的解决方案是: 第一步,梳理各平台 API 的共性和差异,建立 API 兼容性表格 第二步,使用条件编译 #ifdef 针对每个平台编写适配代码 第三步,封装统一的工具类,对外提供一致的调用接口
比如图片选择,我封装了一个 selectImage 方法,内部根据平台自动选择合适的实现方式,业务代码只需要调用这个方法就行,不用关心平台差异。这样既保证了代码的可维护性,又不影响开发效率。"
难点2: nvue 长列表性能优化
面试官: 你提到用 nvue 优化长列表性能,具体是怎么做的?
回答话术: "我们的应用有一个信息流页面,数据量大概几千条,用户反馈滑动时经常卡顿。我分析后发现主要有三个问题:
第一,用 webview 渲染大量 DOM 节点,内存占用高 第二,没有做虚拟列表,所有数据都渲染了 第三,图片加载没有优化,网络请求太多
我的优化方案: 首先,把这个页面改成 nvue,使用 Weex 的原生渲染引擎。nvue 的 list 组件天然支持 cell 复用,这就解决了 DOM 节点过多的问题。
其次,实现分页加载,每次只加载 20 条数据,结合 loadmore 事件实现无限滚动。监听滚动位置,当距离底部 100px 时自动加载下一页。
然后,图片做懒加载处理,只加载可视区域的图片,使用 image 组件的 lazy-load 属性。
最后,在列表项中使用 Weex 的 animation 模块实现流畅的动画效果。
优化后,列表滑动帧率从平均 45fps 提升到稳定 60fps,内存占用降低了 40%,用户投诉率明显下降。"
难点3: 原生插件开发
面试官: 你开发过原生插件吗?遇到过什么坑?
回答话术: "开发过。我们项目需要集成一个第三方 SDK 用于人脸识别,但 Uni-app 没有现成的插件,所以我自己开发了一个原生插件。
主要遇到三个问题:
第一个是接口设计。原生插件需要支持多个方法,而且要支持异步回调。我设计了统一的调用规范,使用 Promise 封装,业务代码调用起来更方便。
第二个是参数传递。JS 和原生之间传递复杂对象时会有序列化问题。我的做法是,简单类型直接传递,复杂对象转成 JSON 字符串,原生端再解析。
第三个是平台差异。Android 和 iOS 的实现完全不同。我在 JS 层做了统一封装,根据平台自动调用对应的原生方法,保证了接口的一致性。
开发过程中,我还建立了完善的错误处理机制,所有原生调用都有 try-catch,失败时会返回标准的错误信息,方便定位问题。
这个插件最后在项目中稳定运行,人脸识别功能的成功率达到 98%。"
难点4: 云打包优化
面试官: 云打包你是怎么优化的?
回答话术: "我们最初打包一次需要 30 分钟,严重影响发版效率。我主要从三个方面优化:
第一,精简打包内容。我仔细检查了 manifest.json,去掉了不必要的模块和权限,比如我们不需要支付模块,就把相关配置都删掉了。还优化了图片资源,使用 tinypng 压缩,体积减少了 50%。
第二,配置多渠道打包。以前是一个个平台手动打,我配置了批量打包脚本,iOS 和 Android 可以同时打包,还支持不同渠道的差异化配置。
第三,建立打包模板。我把常用的配置项做成模板,包括权限、图标、启动页等,新项目直接复用,避免重复配置。
优化后,单次打包时间降到 8 分钟,而且可以多渠道并行,整体效率提升了 75%。这对快速迭代帮助很大,我们现在可以做到一天多次发版。"
七、面试常见问题 SOP
Q1: Uni-app 和原生开发相比有什么优劣?
标准回答: "Uni-app 的最大优势是跨平台,一套代码可以发布到多个平台,大幅降低开发成本。我们项目支持 iOS、Android、微信小程序、H5,如果用原生开发,需要 3-4 个开发团队,而用 Uni-app,2 个前端就能搞定。
但劣势也很明显: 第一,性能不如原生。虽然 nvue 用了原生渲染,但复杂动画和图形处理还是有差距 第二,平台能力受限。有些原生 API 无法直接调用,需要开发插件 第三,包体积较大。因为要包含多平台的适配代码和运行时
我们的经验是,对于 To C 的应用,如果对性能和用户体验要求极高,建议原生开发。但对于 To B 的企业应用或工具类应用,Uni-app 是很好的选择,性价比高。"
Q2: 如何选择使用 vue 还是 nvue?
标准回答: "我的选择原则是:
用 vue 的场景:
- 普通页面,交互不复杂
- 需要丰富的 CSS 样式
- 要用到 web 生态的第三方库
用 nvue 的场景:
- 长列表页面,需要高性能滚动
- 复杂动画,要求流畅度高
- 首屏渲染速度要求高
我们项目中,首页和详情页用的 vue,因为样式比较复杂。但信息流列表页用的 nvue,因为数据量大,需要高性能渲染。
有一点要注意,nvue 只支持 flexbox 布局,不支持某些 CSS 特性,开发时需要权衡。我的建议是,先用 vue 开发,遇到性能瓶颈再改 nvue。"
Q3: 条件编译会不会导致代码难以维护?
标准回答: "确实有这个风险,代码里到处都是 #ifdef 确实不美观。我们采取了几个措施来避免这个问题:
第一,建立抽象层。把平台相关的代码封装到工具类或插件里,业务代码调用统一的接口,这样条件编译集中在少数几个文件里。
第二,代码规范。制定了条件编译的使用规范,比如不允许在 template 里使用条件编译,只在 script 和 style 中使用。
第三,文档化。所有使用条件编译的地方都要写注释,说明为什么要这样做,各平台的差异是什么。
第四,定期重构。每个迭代结束后,都会回顾代码,把能抽象的逻辑提取出来,减少条件编译的使用。
通过这些措施,我们项目的条件编译主要集中在工具类和适配层,业务代码很干净,可维护性还不错。"
说明: 这是 Uni-app 实战部分的完整文档,包含了详细的技术实现、可运行代码示例、简历描述和面试话术。每个技术点都配有真实可用的代码,可以直接运行测试。