返回笔记首页

Uni-app 实战完整指南

主题配置

一、多端条件编译

1.1 技术实现方案

多端条件编译是 Uni-app 的核心特性,通过编译时的条件判断实现一套代码多端运行。

核心原理

javascript
// 条件编译语法
// #ifdef H5
console.log('仅在 H5 端运行')
// #endif

// #ifdef MP-WEIXIN
console.log('仅在微信小程序运行')
// #endif

// #ifndef H5
console.log('除了 H5 外都运行')
// #endif

1.2 可运行代码示例

示例1: 多端 API 适配

vue
<!-- 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: 多端样式适配

vue
<!-- 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 调用原生功能,分为本地插件和云端插件。

核心架构

plain
Uni-app (JS)
    ↓
原生插件接口 (uni.requireNativePlugin)
    ↓
Android (Java/Kotlin) / iOS (Objective-C/Swift)
    ↓
原生功能实现

2.2 可运行代码示例

示例1: 本地原生插件封装

vue
<!-- 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: 原生模块封装工具类

javascript
// 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 长列表优化

vue
<!-- 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 动画优化

vue
<!-- 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: 跨端按钮组件

vue
<!-- 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: 跨端弹窗组件

vue
<!-- 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。

打包流程

plain
代码准备 → manifest.json 配置 → 选择证书 → 提交打包 → 下载安装包

5.2 实战配置示例

manifest.json 完整配置

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 框架,实现了一套代码多端运行,大幅降低了开发和维护成本。

核心职责:

  1. 主导跨端架构设计,通过条件编译实现不同平台的API适配,代码复用率达到 85%
  2. 开发高性能长列表组件,使用 nvue 原生渲染技术,列表滚动帧率稳定在 60fps
  3. 封装跨端组件库,包含 20+ 个业务组件,支持多平台统一调用
  4. 开发原生插件模块,集成设备硬件功能(相机、定位、扫码),提升用户体验
  5. 负责 APP 云打包流程,配置多渠道打包方案,单次打包时间从 30 分钟缩短到 8 分钟

技术亮点:

  1. 多端条件编译优化: 通过精细化的条件编译策略,针对不同平台实现差异化功能。H5 端使用浏览器 API,小程序端调用平台 SDK,APP 端使用原生能力,保证各平台体验最优
  2. nvue 性能优化: 核心页面使用 nvue 原生渲染,长列表采用 list + cell 组件,实现虚拟列表和懒加载,内存占用降低 40%,滑动卡顿率从 15% 降至 3%
  3. 原生插件架构: 设计统一的原生插件接口,封装 Native Module 工具类,支持 Promise 调用方式,提供完善的错误处理机制
  4. 组件库工程化: 建立组件开发规范和文档体系,实现按需加载和样式隔离,组件打包体积减少 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 实战部分的完整文档,包含了详细的技术实现、可运行代码示例、简历描述和面试话术。每个技术点都配有真实可用的代码,可以直接运行测试。