Skip to content

社交应用实战案例

本文将介绍如何使用 uni-app 开发一个功能完善的社交应用,包括应用架构设计、核心功能实现和关键代码示例。

1. 应用概述

1.1 功能特点

社交应用是移动互联网时代最受欢迎的应用类型之一,主要功能包括:

  • 用户认证:注册、登录、第三方登录、找回密码
  • 个人资料:头像、昵称、个人简介、兴趣标签
  • 社交关系:好友添加、关注、粉丝管理
  • 内容分享:动态发布、图文内容、话题讨论
  • 互动功能:点赞、评论、转发、收藏
  • 即时通讯:私聊、群聊、语音消息、图片分享
  • 内容发现:个性化推荐、热门话题、附近的人

1.2 技术架构

前端技术栈

  • uni-app:跨平台开发框架,实现一次开发多端运行
  • Vue.js:响应式数据绑定,提供组件化开发模式
  • Vuex:状态管理,处理复杂组件间通信
  • Socket.io:实时通讯,支持即时消息收发

后端技术栈

  • Node.js/Express:服务端框架,处理API请求
  • MongoDB:数据存储,适合社交应用的非结构化数据
  • Redis:缓存和会话管理,提高访问速度
  • WebSocket:实时通讯支持,保持长连接

2. 项目结构

├── components            // 自定义组件
│   ├── chat-item         // 聊天列表项组件
│   ├── comment-item      // 评论项组件
│   ├── post-card         // 动态卡片组件
│   └── user-card         // 用户卡片组件
├── pages                 // 页面文件夹
│   ├── auth              // 认证相关页面
│   ├── chat              // 聊天相关页面
│   ├── discover          // 发现页面
│   ├── post              // 动态相关页面
│   └── user              // 用户相关页面
├── store                 // Vuex 状态管理
│   ├── index.js          // 组装模块并导出
│   ├── modules           // 状态模块
│   │   ├── user.js       // 用户状态
│   │   ├── post.js       // 动态状态
│   │   └── chat.js       // 聊天状态
├── utils                 // 工具函数
│   ├── request.js        // 请求封装
│   ├── socket.js         // WebSocket封装
│   └── validator.js      // 表单验证
├── static                // 静态资源
├── App.vue               // 应用入口
├── main.js               // 主入口
├── manifest.json         // 配置文件
└── pages.json            // 页面配置

3. 核心功能实现

3.1 用户认证系统

用户认证是社交应用的基础,包括注册、登录、第三方登录等功能。

状态管理设计

js
// store/modules/user.js
export default {
  namespaced: true,
  state: {
    token: uni.getStorageSync('token') || '',
    userInfo: uni.getStorageSync('userInfo') ? JSON.parse(uni.getStorageSync('userInfo')) : null,
    isLogin: Boolean(uni.getStorageSync('token'))
  },
  mutations: {
    SET_TOKEN(state, token) {
      state.token = token;
      state.isLogin = Boolean(token);
      uni.setStorageSync('token', token);
    },
    SET_USER_INFO(state, userInfo) {
      state.userInfo = userInfo;
      uni.setStorageSync('userInfo', JSON.stringify(userInfo));
    },
    LOGOUT(state) {
      state.token = '';
      state.userInfo = null;
      state.isLogin = false;
      uni.removeStorageSync('token');
      uni.removeStorageSync('userInfo');
    }
  },
  actions: {
    // 登录
    async login({ commit }, params) {
      try {
        const res = await this._vm.$api.user.login(params);
        commit('SET_TOKEN', res.data.token);
        commit('SET_USER_INFO', res.data.userInfo);
        return res.data;
      } catch (error) {
        throw error;
      }
    },
    
    // 注册
    async register({ commit }, params) {
      try {
        const res = await this._vm.$api.user.register(params);
        commit('SET_TOKEN', res.data.token);
        commit('SET_USER_INFO', res.data.userInfo);
        return res.data;
      } catch (error) {
        throw error;
      }
    },
    
    // 获取用户信息
    async getUserInfo({ commit }) {
      try {
        const res = await this._vm.$api.user.getUserInfo();
        commit('SET_USER_INFO', res.data);
        return res.data;
      } catch (error) {
        throw error;
      }
    },
    
    // 退出登录
    logout({ commit }) {
      commit('LOGOUT');
    }
  }
};

登录页面实现

vue
<!-- pages/auth/login/login.vue -->
<template>
  <view class="login-container">
    <view class="logo-box">
      <image src="/static/logo.png" class="logo"></image>
      <text class="app-name">社交圈</text>
    </view>
    
    <view class="form-box">
      <view class="input-group">
        <text class="iconfont icon-user"></text>
        <input 
          type="text" 
          v-model="form.username" 
          placeholder="用户名/手机号/邮箱" 
          class="input"
        />
      </view>
      
      <view class="input-group">
        <text class="iconfont icon-lock"></text>
        <input 
          :type="showPassword ? 'text' : 'password'" 
          v-model="form.password" 
          placeholder="请输入密码" 
          class="input"
        />
        <text 
          class="iconfont" 
          :class="showPassword ? 'icon-eye' : 'icon-eye-close'"
          @click="togglePasswordVisibility"
        ></text>
      </view>
      
      <button class="login-btn" @click="handleLogin" :loading="loading">登录</button>
      
      <view class="action-links">
        <navigator url="/pages/auth/register/register" class="link">注册账号</navigator>
        <navigator url="/pages/auth/forgot-password/forgot-password" class="link">忘记密码</navigator>
      </view>
    </view>
    
    <view class="third-party-login">
      <view class="divider">
        <text class="divider-text">其他登录方式</text>
      </view>
      
      <view class="third-party-icons">
        <view class="icon-item" @click="thirdPartyLogin('wechat')">
          <text class="iconfont icon-wechat"></text>
        </view>
        <view class="icon-item" @click="thirdPartyLogin('qq')">
          <text class="iconfont icon-qq"></text>
        </view>
        <view class="icon-item" @click="thirdPartyLogin('weibo')">
          <text class="iconfont icon-weibo"></text>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
import { mapActions } from 'vuex';

export default {
  data() {
    return {
      form: {
        username: '',
        password: ''
      },
      showPassword: false,
      loading: false
    }
  },
  methods: {
    ...mapActions('user', ['login']),
    
    // 切换密码可见性
    togglePasswordVisibility() {
      this.showPassword = !this.showPassword;
    },
    
    // 处理登录
    async handleLogin() {
      // 表单验证
      if (!this.form.username.trim()) {
        uni.showToast({ title: '请输入用户名', icon: 'none' });
        return;
      }
      
      if (!this.form.password) {
        uni.showToast({ title: '请输入密码', icon: 'none' });
        return;
      }
      
      this.loading = true;
      
      try {
        // 调用登录接口
        await this.login(this.form);
        
        // 登录成功,跳转到首页
        uni.switchTab({ url: '/pages/index/index' });
      } catch (error) {
        uni.showToast({
          title: error.message || '登录失败,请重试',
          icon: 'none'
        });
      } finally {
        this.loading = false;
      }
    },
    
    // 第三方登录
    thirdPartyLogin(type) {
      // 根据不同平台调用不同的登录API
      uni.login({
        provider: type === 'wechat' ? 'weixin' : (type === 'qq' ? 'qq' : 'sinaweibo'),
        success: (loginRes) => {
          this.handleThirdPartyLoginSuccess(type, loginRes);
        },
        fail: (err) => {
          uni.showToast({
            title: `${type}登录失败`,
            icon: 'none'
          });
        }
      });
    },
    
    // 处理第三方登录成功
    async handleThirdPartyLoginSuccess(type, loginRes) {
      try {
        // 调用后端接口,验证第三方登录凭证
        await this.login({
          type: type,
          code: loginRes.code
        });
        
        // 登录成功,跳转到首页
        uni.switchTab({ url: '/pages/index/index' });
      } catch (error) {
        uni.showToast({
          title: error.message || '登录失败,请重试',
          icon: 'none'
        });
      }
    }
  }
}
</script>

3.2 动态发布与互动

社交应用的核心功能是用户发布动态并进行互动,包括点赞、评论、转发等。

动态状态管理

js
// store/modules/post.js
export default {
  namespaced: true,
  state: {
    postList: [],
    currentPost: null,
    loading: false,
    refreshing: false,
    hasMore: true,
    page: 1,
    pageSize: 10
  },
  mutations: {
    SET_POST_LIST(state, list) {
      state.postList = list;
    },
    ADD_POST_LIST(state, list) {
      state.postList = [...state.postList, ...list];
    },
    SET_CURRENT_POST(state, post) {
      state.currentPost = post;
    },
    SET_LOADING(state, status) {
      state.loading = status;
    },
    SET_REFRESHING(state, status) {
      state.refreshing = status;
    },
    SET_HAS_MORE(state, status) {
      state.hasMore = status;
    },
    SET_PAGE(state, page) {
      state.page = page;
    },
    ADD_POST(state, post) {
      state.postList.unshift(post);
    },
    UPDATE_POST(state, { id, data }) {
      const index = state.postList.findIndex(item => item.id === id);
      if (index !== -1) {
        state.postList[index] = { ...state.postList[index], ...data };
      }
      
      if (state.currentPost && state.currentPost.id === id) {
        state.currentPost = { ...state.currentPost, ...data };
      }
    },
    DELETE_POST(state, id) {
      state.postList = state.postList.filter(item => item.id !== id);
      
      if (state.currentPost && state.currentPost.id === id) {
        state.currentPost = null;
      }
    }
  },
  actions: {
    // 获取动态列表
    async getPostList({ commit, state }, refresh = false) {
      if (refresh) {
        commit('SET_REFRESHING', true);
        commit('SET_PAGE', 1);
        commit('SET_HAS_MORE', true);
      } else {
        if (!state.hasMore || state.loading) return;
        commit('SET_LOADING', true);
      }
      
      try {
        const res = await this._vm.$api.post.getList({
          page: refresh ? 1 : state.page,
          pageSize: state.pageSize
        });
        
        const list = res.data.list || [];
        
        if (refresh) {
          commit('SET_POST_LIST', list);
        } else {
          commit('ADD_POST_LIST', list);
        }
        
        commit('SET_HAS_MORE', list.length === state.pageSize);
        commit('SET_PAGE', refresh ? 2 : state.page + 1);
      } catch (error) {
        console.error('获取动态列表失败', error);
      } finally {
        commit('SET_LOADING', false);
        commit('SET_REFRESHING', false);
      }
    },
    
    // 获取动态详情
    async getPostDetail({ commit }, id) {
      try {
        const res = await this._vm.$api.post.getDetail(id);
        commit('SET_CURRENT_POST', res.data);
        return res.data;
      } catch (error) {
        throw error;
      }
    },
    
    // 发布动态
    async createPost({ commit }, data) {
      try {
        const res = await this._vm.$api.post.create(data);
        commit('ADD_POST', res.data);
        return res.data;
      } catch (error) {
        throw error;
      }
    },
    
    // 点赞/取消点赞
    async toggleLike({ commit }, id) {
      try {
        const res = await this._vm.$api.post.toggleLike(id);
        commit('UPDATE_POST', { 
          id, 
          data: { 
            isLiked: res.data.isLiked,
            likeCount: res.data.likeCount
          } 
        });
        return res.data;
      } catch (error) {
        throw error;
      }
    },
    
    // 发表评论
    async addComment({ commit }, { postId, content }) {
      try {
        const res = await this._vm.$api.post.addComment(postId, { content });
        commit('UPDATE_POST', { 
          id: postId, 
          data: { 
            commentCount: res.data.commentCount,
            latestComments: res.data.latestComments
          } 
        });
        return res.data;
      } catch (error) {
        throw error;
      }
    }
  }
};

动态发布页面

vue
<!-- pages/post/create/create.vue -->
<template>
  <view class="post-container">
    <view class="textarea-box">
      <textarea 
        v-model="content" 
        placeholder="分享你的想法..." 
        maxlength="500"
        class="content-textarea"
      ></textarea>
      <text class="word-count">{{content.length}}/500</text>
    </view>
    
    <view class="image-list" v-if="images.length > 0">
      <view 
        class="image-item" 
        v-for="(item, index) in images" 
        :key="index"
      >
        <image :src="item" mode="aspectFill" class="preview-image"></image>
        <text class="delete-icon" @click="removeImage(index)">×</text>
      </view>
      
      <view class="add-image" v-if="images.length < 9" @click="chooseImage">
        <text class="iconfont icon-add"></text>
      </view>
    </view>
    <view class="add-image-btn" v-else @click="chooseImage">
      <text class="iconfont icon-image"></text>
      <text class="btn-text">添加图片</text>
    </view>
    
    <view class="location-box" @click="chooseLocation">
      <text class="iconfont icon-location"></text>
      <text class="location-text">{{location ? location : '所在位置'}}</text>
      <text class="iconfont icon-right" v-if="!location"></text>
      <text class="clear-icon" v-else @click.stop="clearLocation">×</text>
    </view>
    
    <view class="topic-box" @click="chooseTopic">
      <text class="iconfont icon-topic"></text>
      <text class="topic-text">{{topic ? `#${topic}#` : '添加话题'}}</text>
      <text class="iconfont icon-right" v-if="!topic"></text>
      <text class="clear-icon" v-else @click.stop="clearTopic">×</text>
    </view>
    
    <view class="privacy-box">
      <text class="privacy-label">谁可以看</text>
      <picker 
        @change="onPrivacyChange" 
        :value="privacyIndex" 
        :range="privacyOptions"
      >
        <view class="picker-value">
          <text>{{privacyOptions[privacyIndex]}}</text>
          <text class="iconfont icon-down"></text>
        </view>
      </picker>
    </view>
    
    <button 
      class="publish-btn" 
      :disabled="!canPublish" 
      :loading="publishing"
      @click="publishPost"
    >发布</button>
  </view>
</template>

<script>
import { mapActions } from 'vuex';

export default {
  data() {
    return {
      content: '',
      images: [],
      location: '',
      topic: '',
      privacyOptions: ['公开', '仅好友可见', '仅自己可见'],
      privacyIndex: 0,
      publishing: false
    }
  },
  computed: {
    canPublish() {
      return this.content.trim() || this.images.length > 0;
    }
  },
  methods: {
    ...mapActions('post', ['createPost']),
    
    // 选择图片
    chooseImage() {
      uni.chooseImage({
        count: 9 - this.images.length,
        sizeType: ['compressed'],
        sourceType: ['album', 'camera'],
        success: (res) => {
          this.images = [...this.images, ...res.tempFilePaths];
        }
      });
    },
    
    // 移除图片
    removeImage(index) {
      this.images.splice(index, 1);
    },
    
    // 选择位置
    chooseLocation() {
      uni.chooseLocation({
        success: (res) => {
          this.location = res.name;
        }
      });
    },
    
    // 清除位置
    clearLocation() {
      this.location = '';
    },
    
    // 选择话题
    chooseTopic() {
      // 这里可以跳转到话题选择页面
      uni.navigateTo({
        url: '/pages/topic/select/select',
        events: {
          // 监听选择话题页面返回的数据
          selectTopic: (data) => {
            this.topic = data.name;
          }
        }
      });
    },
    
    // 清除话题
    clearTopic() {
      this.topic = '';
    },
    
    // 隐私设置变更
    onPrivacyChange(e) {
      this.privacyIndex = e.detail.value;
    },
    
    // 发布动态
    async publishPost() {
      if (!this.canPublish) {
        return;
      }
      
      this.publishing = true;
      
      try {
        // 上传图片
        let imageUrls = [];
        if (this.images.length > 0) {
          imageUrls = await this.uploadImages();
        }
        
        // 构建动态数据
        const postData = {
          content: this.content,
          images: imageUrls,
          location: this.location,
          topic: this.topic,
          privacy: this.privacyIndex
        };
        
        // 调用发布接口
        await this.createPost(postData);
        
        uni.showToast({
          title: '发布成功',
          icon: 'success'
        });
        
        // 返回上一页
        setTimeout(() => {
          uni.navigateBack();
        }, 1500);
      } catch (error) {
        uni.showToast({
          title: error.message || '发布失败,请重试',
          icon: 'none'
        });
      } finally {
        this.publishing = false;
      }
    },
    
    // 上传图片
    uploadImages() {
      return new Promise((resolve, reject) => {
        const uploadTasks = this.images.map(image => {
          return new Promise((resolveUpload, rejectUpload) => {
            uni.uploadFile({
              url: this.$api.baseUrl + '/api/upload',
              filePath: image,
              name: 'file',
              header: {
                Authorization: 'Bearer ' + uni.getStorageSync('token')
              },
              success: (uploadRes) => {
                const data = JSON.parse(uploadRes.data);
                if (data.code === 0) {
                  resolveUpload(data.data.url);
                } else {
                  rejectUpload(new Error(data.message || '上传失败'));
                }
              },
              fail: (err) => {
                rejectUpload(err);
              }
            });
          });
        });
        
        Promise.all(uploadTasks)
          .then(urls => {
            resolve(urls);
          })
          .catch(err => {
            reject(err);
          });
      });
    }
  }
}
</script>

3.3 即时通讯功能

社交应用的即时通讯功能是用户交流的重要渠道,需要实现消息的发送、接收和存储。

WebSocket封装

js
// utils/socket.js
import io from 'socket.io-client';
import store from '@/store';

class SocketIO {
  constructor() {
    this.socket = null;
    this.url = process.env.NODE_ENV === 'development' 
      ? 'http://localhost:3000' 
      : 'https://api.example.com';
    this.options = {
      transports: ['websocket'],
      autoConnect: false,
      reconnection: true,
      reconnectionAttempts: 5,
      reconnectionDelay: 1000
    };
    this.events = {};
  }
  
  // 初始化连接
  init() {
    if (this.socket) {
      this.socket.disconnect();
    }
    
    const token = uni.getStorageSync('token');
    if (!token) return;
    
    this.options.query = { token };
    this.socket = io(this.url, this.options);
    
    // 注册内置事件
    this.socket.on('connect', () => {
      console.log('WebSocket连接成功');
      this.onConnect();
    });
    
    this.socket.on('disconnect', (reason) => {
      console.log('WebSocket断开连接', reason);
      this.onDisconnect(reason);
    });
    
    this.socket.on('error', (error) => {
      console.error('WebSocket错误', error);
    });
    
    this.socket.on('reconnect', (attemptNumber) => {
      console.log('WebSocket重连成功', attemptNumber);
    });
    
    this.socket.on('reconnect_error', (error) => {
      console.error('WebSocket重连失败', error);
    });
    
    this.socket.on('reconnect_failed', () => {
      console.error('WebSocket重连失败次数超限');
      uni.showToast({
        title: '网络连接异常,请检查网络设置',
        icon: 'none'
      });
    });
    
    // 注册消息事件
    this.socket.on('message', (data) => {
      this.onMessage(data);
    });
    
    // 连接
    this.socket.connect();
  }
  
  // 连接成功回调
  onConnect() {
    // 重新注册之前的事件
    Object.keys(this.events).forEach(event => {
      const callbacks = this.events[event];
      callbacks.forEach(callback => {
        this.socket.on(event, callback);
      });
    });
  }
  
  // 断开连接回调
  onDisconnect(reason) {
    if (reason === 'io server disconnect') {
      // 服务器主动断开连接,可能是token过期
      store.dispatch('user/logout');
      uni.showToast({
        title: '登录已过期,请重新登录',
        icon: 'none'
      });
      
      setTimeout(() => {
        uni.reLaunch({
          url: '/pages/auth/login/login'
        });
      }, 1500);
    }
  }
  
  // 接收消息回调
  onMessage(data) {
    // 处理接收到的消息
    store.dispatch('chat/receiveMessage', data);
    
    // 如果应用在后台,发送通知
    if (uni.getSystemInfoSync().platform === 'android' || uni.getSystemInfoSync().platform === 'ios') {
      uni.getBackgroundAudioManager().title = '新消息提醒';
      uni.getBackgroundAudioManager().src = '/static/audio/message.mp3';
    }
  }
  
  // 注册事件
  on(event, callback) {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    
    this.events[event].push(callback);
    
    if (this.socket) {
      this.socket.on(event, callback);
    }
  }
  
  // 发送消息
  emit(event, data, callback) {
    if (!this.socket || !this.socket.connected) {
      uni.showToast({
        title: '网络连接异常,请检查网络设置',
        icon: 'none'
      });
      return;
    }
    
    this.socket.emit(event, data, callback);
  }
  
  // 断开连接
  disconnect() {
    if (this.socket) {
      this.socket.disconnect();
      this.socket = null;
    }
  }
}

export default new SocketIO();

聊天页面实现

vue
<!-- pages/chat/chat.vue -->
<template>
  <view class="chat-container">
    <!-- 消息列表 -->
    <scroll-view 
      scroll-y 
      class="message-list" 
      :scroll-top="scrollTop"
      :scroll-into-view="scrollIntoView"
      @scrolltoupper="loadMoreMessages"
      upper-threshold="50"
    >
      <view class="loading" v-if="loading">
        <uni-load-more status="loading" :contentText="loadingText"></uni-load-more>
      </view>
      
      <view 
        class="message-item" 
        :class="{ 'message-self': item.senderId === userId }"
        v-for="item in messages" 
        :key="item.id"
        :id="'msg-' + item.id"
      >
        <image 
          :src="item.senderId === userId ? userAvatar : targetAvatar" 
          class="avatar"
        ></image>
        
        <view class="message-content">
          <view class="message-bubble" :class="{ 'self-bubble': item.senderId === userId }">
            <!-- 文本消息 -->
            <text v-if="item.type === 'text'">{{item.content}}</text>
            
            <!-- 图片消息 -->
            <image 
              v-else-if="item.type === 'image'" 
              :src="item.content" 
              mode="widthFix" 
              class="message-image"
              @tap="previewImage(item.content)"
            ></image>
            
            <!-- 语音消息 -->
            <view 
              v-else-if="item.type === 'voice'" 
              class="voice-message"
              :class="{ 'playing': playingVoiceId === item.id }"
              @tap="playVoice(item)"
            >
              <text class="voice-duration">{{item.duration}}''</text>
              <view class="voice-icon">
                <text class="iconfont icon-voice"></text>
              </view>
            </view>
          </view>
          
          <text class="message-time">{{formatTime(item.createdAt)}}</text>
        </view>
      </view>
    </scroll-view>
    
    <!-- 输入区域 -->
    <view class="input-area">
      <view class="input-box">
        <view class="input-tools">
          <text 
            class="iconfont" 
            :class="isVoiceMode ? 'icon-keyboard' : 'icon-voice'"
            @tap="toggleInputMode"
          ></text>
        </view>
        
        <input 
          v-if="!isVoiceMode"
          type="text" 
          v-model="inputContent" 
          class="text-input"
          placeholder="请输入消息..."
          confirm-type="send"
          @confirm="sendTextMessage"
        />
        
        <view 
          v-else
          class="voice-input"
          @touchstart="startRecordVoice"
          @touchend="stopRecordVoice"
          @touchcancel="cancelRecordVoice"
        >
          {{recording ? '松开发送' : '按住说话'}}
        </view>
      </view>
      
      <!-- 发送按钮 -->
      <view 
        class="send-btn" 
        :class="{ 'active': inputContent.trim() }"
        @tap="sendTextMessage"
      >
        <text>发送</text>
      </view>
    </view>
  </view>
</template>

<script>
import { mapState } from 'vuex';
import socket from '@/utils/socket.js';
import { formatTime } from '@/utils/time.js';

export default {
  data() {
    return {
      targetId: '', // 聊天对象ID
      targetName: '', // 聊天对象名称
      targetAvatar: '', // 聊天对象头像
      messages: [], // 消息列表
      inputContent: '', // 输入内容
      isVoiceMode: false, // 是否为语音输入模式
      recording: false, // 是否正在录音
      recorderManager: null, // 录音管理器
      innerAudioContext: null, // 音频播放器
      playingVoiceId: '', // 正在播放的语音消息ID
      scrollTop: 0, // 滚动位置
      scrollIntoView: '', // 滚动到指定消息
      loading: false, // 是否正在加载更多消息
      loadingText: {
        contentdown: '上拉加载更多',
        contentrefresh: '正在加载...',
        contentnomore: '没有更多了'
      },
      page: 1, // 当前页码
      hasMore: true // 是否有更多消息
    }
  },
  computed: {
    ...mapState('user', {
      userId: state => state.userInfo ? state.userInfo.id : '',
      userAvatar: state => state.userInfo ? state.userInfo.avatar : ''
    })
  },
  onLoad(options) {
    this.targetId = options.id;
    this.targetName = options.name || '聊天';
    this.targetAvatar = options.avatar || '/static/images/default-avatar.png';
    
    // 设置导航栏标题
    uni.setNavigationBarTitle({
      title: this.targetName
    });
    
    // 初始化录音管理器
    this.recorderManager = uni.getRecorderManager();
    this.recorderManager.onStop(this.onRecordStop);
    
    // 初始化音频播放器
    this.innerAudioContext = uni.createInnerAudioContext();
    this.innerAudioContext.onEnded(this.onVoicePlayEnd);
    this.innerAudioContext.onError(this.onVoicePlayError);
    
    // 加载消息列表
    this.loadMessages();
    
    // 监听新消息
    socket.on('receiveMessage', this.onReceiveMessage);
  },
  onUnload() {
    // 移除消息监听
    socket.off('receiveMessage', this.onReceiveMessage);
    
    // 释放资源
    if (this.innerAudioContext) {
      this.innerAudioContext.destroy();
    }
  },
  methods: {
    // 加载消息列表
    async loadMessages() {
      try {
        const res = await this.$api.chat.getMessages({
          targetId: this.targetId,
          page: 1,
          pageSize: 20
        });
        
        this.messages = res.data.list || [];
        this.hasMore = res.data.hasMore;
        this.page = 2;
        
        // 滚动到最新消息
        this.$nextTick(() => {
          if (this.messages.length > 0) {
            this.scrollIntoView = 'msg-' + this.messages[this.messages.length - 1].id;
          }
        });
      } catch (error) {
        uni.showToast({
          title: '加载消息失败',
          icon: 'none'
        });
      }
    },
    
    // 加载更多消息
    async loadMoreMessages() {
      if (!this.hasMore || this.loading) return;
      
      this.loading = true;
      
      try {
        const res = await this.$api.chat.getMessages({
          targetId: this.targetId,
          page: this.page,
          pageSize: 20
        });
        
        const list = res.data.list || [];
        this.messages = [...list, ...this.messages];
        this.hasMore = res.data.hasMore;
        this.page++;
      } catch (error) {
        uni.showToast({
          title: '加载更多消息失败',
          icon: 'none'
        });
      } finally {
        this.loading = false;
      }
    },
    
    // 发送文本消息
    async sendTextMessage() {
      if (!this.inputContent.trim()) return;
      
      const content = this.inputContent;
      this.inputContent = '';
      
      // 构建消息对象
      const message = {
        id: 'temp-' + Date.now(),
        senderId: this.userId,
        receiverId: this.targetId,
        type: 'text',
        content: content,
        status: 'sending',
        createdAt: new Date().toISOString()
      };
      
      // 添加到消息列表
      this.messages.push(message);
      
      // 滚动到最新消息
      this.$nextTick(() => {
        this.scrollIntoView = 'msg-' + message.id;
      });
      
      try {
        // 发送消息
        const res = await this.$api.chat.sendMessage({
          receiverId: this.targetId,
          type: 'text',
          content: content
        });
        
        // 更新消息状态
        const index = this.messages.findIndex(item => item.id === message.id);
        if (index !== -1) {
          this.messages[index] = {
            ...this.messages[index],
            id: res.data.id,
            status: 'sent'
          };
        }
      } catch (error) {
        // 更新消息状态为发送失败
        const index = this.messages.findIndex(item => item.id === message.id);
        if (index !== -1) {
          this.messages[index] = {
            ...this.messages[index],
            status: 'failed'
          };
        }
        
        uni.showToast({
          title: '发送失败,请重试',
          icon: 'none'
        });
      }
    },
    
    // 切换输入模式
    toggleInputMode() {
      this.isVoiceMode = !this.isVoiceMode;
    },
    
    // 开始录音
    startRecordVoice(e) {
      this.recording = true;
      this.recorderManager.start({
        duration: 60000, // 最长录音时间,单位ms
        sampleRate: 16000, // 采样率
        numberOfChannels: 1, // 录音通道数
        encodeBitRate: 64000, // 编码码率
        format: 'mp3' // 音频格式
      });
    },
    
    // 停止录音
    stopRecordVoice(e) {
      if (!this.recording) return;
      
      this.recorderManager.stop();
      this.recording = false;
    },
    
    // 取消录音
    cancelRecordVoice(e) {
      if (!this.recording) return;
      
      this.recorderManager.stop();
      this.recording = false;
      
      uni.showToast({
        title: '已取消',
        icon: 'none'
      });
    },
    
    // 录音结束回调
    async onRecordStop(res) {
      if (res.duration < 1000) {
        uni.showToast({
          title: '说话时间太短',
          icon: 'none'
        });
        return;
      }
      
      try {
        // 上传语音文件
        const uploadRes = await new Promise((resolve, reject) => {
          uni.uploadFile({
            url: this.$api.baseUrl + '/api/upload',
            filePath: res.tempFilePath,
            name: 'file',
            header: {
              Authorization: 'Bearer ' + uni.getStorageSync('token')
            },
            success: (uploadRes) => {
              const data = JSON.parse(uploadRes.data);
              if (data.code === 0) {
                resolve(data.data);
              } else {
                reject(new Error(data.message || '上传失败'));
              }
            },
            fail: (err) => {
              reject(err);
            }
          });
        });
        
        // 发送语音消息
        await this.$api.chat.sendMessage({
          receiverId: this.targetId,
          type: 'voice',
          content: uploadRes.url,
          duration: Math.floor(res.duration / 1000)
        });
      } catch (error) {
        uni.showToast({
          title: '发送失败,请重试',
          icon: 'none'
        });
      }
    },
    
    // 播放语音消息
    playVoice(message) {
      if (this.playingVoiceId === message.id) {
        // 停止播放
        this.innerAudioContext.stop();
        this.playingVoiceId = '';
      } else {
        // 停止当前播放
        if (this.playingVoiceId) {
          this.innerAudioContext.stop();
        }
        
        // 播放新语音
        this.playingVoiceId = message.id;
        this.innerAudioContext.src = message.content;
        this.innerAudioContext.play();
      }
    },
    
    // 语音播放结束
    onVoicePlayEnd() {
      this.playingVoiceId = '';
    },
    
    // 语音播放错误
    onVoicePlayError(err) {
      console.error('语音播放错误', err);
      this.playingVoiceId = '';
      
      uni.showToast({
        title: '语音播放失败',
        icon: 'none'
      });
    },
    
    // 预览图片
    previewImage(url) {
      // 获取所有图片消息的URL
      const urls = this.messages
        .filter(item => item.type === 'image')
        .map(item => item.content);
      
      uni.previewImage({
        current: url,
        urls: urls
      });
    },
    
    // 接收新消息
    onReceiveMessage(message) {
      if (message.senderId === this.targetId || message.senderId === this.userId) {
        this.messages.push(message);
        
        // 滚动到最新消息
        this.$nextTick(() => {
          this.scrollIntoView = 'msg-' + message.id;
        });
      }
    },
    
    // 格式化时间
    formatTime(timestamp) {
      return formatTime(timestamp);
    }
  }
}
</script>

4. 应用优化与最佳实践

4.1 性能优化

社交应用通常需要处理大量数据和频繁的网络请求,以下是一些性能优化措施:

  1. 列表虚拟化:使用虚拟列表技术处理长列表,只渲染可视区域内的元素
  2. 图片懒加载:只加载可视区域内的图片,减少初始加载时间
  3. 数据缓存:缓存已加载的数据,避免重复请求
  4. 请求合并:合并多个小请求为一个批量请求,减少网络开销
  5. 增量更新:只更新变化的部分,而不是整个列表
js
// 图片懒加载示例
export default {
  data() {
    return {
      images: [],
      observer: null
    }
  },
  mounted() {
    // 创建交叉观察器
    this.observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          // 元素进入可视区域,加载图片
          const img = entry.target;
          const realSrc = img.dataset.src;
          if (realSrc) {
            img.src = realSrc;
            img.removeAttribute('data-src');
            // 停止观察该元素
            this.observer.unobserve(img);
          }
        }
      });
    });
    
    // 开始观察所有图片元素
    this.$nextTick(() => {
      const imgs = document.querySelectorAll('img[data-src]');
      imgs.forEach(img => {
        this.observer.observe(img);
      });
    });
  },
  beforeDestroy() {
    // 销毁观察器
    if (this.observer) {
      this.observer.disconnect();
      this.observer = null;
    }
  }
}

4.2 安全措施

社交应用需要特别注重用户数据安全和隐私保护:

  1. 数据加密:敏感数据传输和存储时进行加密
  2. 输入验证:前后端都进行严格的输入验证,防止注入攻击
  3. 权限控制:严格控制用户权限,确保用户只能访问自己有权限的数据
  4. 防刷机制:限制API调用频率,防止恶意请求
  5. 敏感内容过滤:过滤违规内容,保护用户体验
js
// 输入验证示例
export const validator = {
  // 验证用户名
  username(value) {
    if (!value) return '用户名不能为空';
    if (value.length < 3) return '用户名不能少于3个字符';
    if (value.length > 20) return '用户名不能超过20个字符';
    if (!/^[a-zA-Z0-9_]+$/.test(value)) return '用户名只能包含字母、数字和下划线';
    return '';
  },
  
  // 验证密码
  password(value) {
    if (!value) return '密码不能为空';
    if (value.length < 6) return '密码不能少于6个字符';
    if (value.length > 20) return '密码不能超过20个字符';
    if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value)) {
      return '密码必须包含大小写字母和数字';
    }
    return '';
  },
  
  // 验证手机号
  phone(value) {
    if (!value) return '手机号不能为空';
    if (!/^1[3-9]\d{9}$/.test(value)) return '手机号格式不正确';
    return '';
  },
  
  // 验证邮箱
  email(value) {
    if (!value) return '邮箱不能为空';
    if (!/^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/.test(value)) return '邮箱格式不正确';
    return '';
  }
};

4.3 用户体验优化

良好的用户体验是社交应用成功的关键:

  1. 响应式设计:适配不同屏幕尺寸和设备
  2. 骨架屏:在内容加载过程中显示骨架屏,减少用户等待感
  3. 下拉刷新和上拉加载:提供流畅的列表交互体验
  4. 状态反馈:操作后给予及时的状态反馈
  5. 离线支持:支持基本的离线浏览功能
vue
<!-- 骨架屏示例 -->
<template>
  <view class="skeleton-container" v-if="loading">
    <view class="skeleton-item" v-for="i in 5" :key="i">
      <view class="skeleton-avatar"></view>
      <view class="skeleton-content">
        <view class="skeleton-title"></view>
        <view class="skeleton-text"></view>
        <view class="skeleton-text short"></view>
      </view>
    </view>
  </view>
  <view v-else>
    <!-- 实际内容 -->
  </view>
</template>

<style lang="scss">
.skeleton-container {
  padding: 20rpx;
  
  .skeleton-item {
    display: flex;
    padding: 20rpx 0;
    border-bottom: 1rpx solid #f5f5f5;
    
    .skeleton-avatar {
      width: 80rpx;
      height: 80rpx;
      border-radius: 50%;
      background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 37%, #f2f2f2 63%);
      background-size: 400% 100%;
      animation: skeleton-loading 1.4s ease infinite;
    }
    
    .skeleton-content {
      flex: 1;
      margin-left: 20rpx;
      
      .skeleton-title {
        height: 32rpx;
        width: 40%;
        background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 37%, #f2f2f2 63%);
        background-size: 400% 100%;
        animation: skeleton-loading 1.4s ease infinite;
        margin-bottom: 16rpx;
      }
      
      .skeleton-text {
        height: 24rpx;
        width: 100%;
        background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 37%, #f2f2f2 63%);
        background-size: 400% 100%;
        animation: skeleton-loading 1.4s ease infinite;
        margin-bottom: 16rpx;
        
        &.short {
          width: 60%;
        }
      }
    }
  }
}

@keyframes skeleton-loading {
  0% {
    background-position: 100% 50%;
  }
  100% {
    background-position: 0 50%;
  }
}
</style>

5. 总结与拓展

5.1 开发要点总结

  1. 模块化设计:将应用拆分为多个功能模块,提高代码可维护性
  2. 状态管理:使用Vuex集中管理应用状态,处理复杂的数据流
  3. 实时通讯:基于WebSocket实现即时消息功能,提供良好的聊天体验
  4. 性能优化:针对大量数据和频繁网络请求进行优化,提高应用响应速度
  5. 安全措施:重视用户数据安全和隐私保护,实施必要的安全措施

5.2 功能拓展方向

基于社交应用的基础功能,可以考虑以下拓展方向:

  1. 社区功能:添加兴趣小组、话题讨论等社区功能
  2. 内容推荐:基于用户兴趣和行为的个性化内容推荐
  3. 直播功能:支持用户发起和观看直播
  4. 电商功能:集成电商功能,支持商品展示和交易
  5. AR互动:添加AR滤镜、特效等互动功能

5.3 商业化思路

社交应用的商业化路径通常包括:

  1. 广告变现:基于用户画像的精准广告投放
  2. 会员订阅:提供高级功能和特权的会员服务
  3. 虚拟物品:销售虚拟礼物、表情包等数字内容
  4. 电商佣金:通过社交电商获取交易佣金
  5. 数据服务:在合规前提下,提供匿名化的数据分析服务

6. 参考资源

一次开发,多端部署 - 让跨平台开发更简单