听说你小程序玩的挺溜?

5,177 阅读6分钟

前言

最近,因为业务需要,要新启一个小程序项目,于是乎便有了这篇选型的文章,本篇将简单讲述基础框架建设及部分兼容问题和注意事项,欢迎阅完后指点。

选型

虽说是小程序项目,但是考虑到后续可能有额外拓展(如H5等其实可能性还是蛮小的,但确实得考虑到),页面标签采用原生 html 写法,即传统 div span 等标签。

Taro 和 Uniapp 我选择了 Uniapp,首先两者的生态圈,我是感觉 Uniapp 的生态圈会更丰富一些,解决方案也相对多些,Uniapp 毕竟是基于 Vue,国内的 Vue 也是追随者比较多。

Taro 由于时间关系了解并不多,虽然说支持 Vue 但是大多数方案都是基于 React 来的,而团队中熟悉 Vue 居多,考虑技术栈不一致的问题,所以最终还是选择了 Uniapp。

Uniapp

在确实好主框架后,我采用 Vue-cli 中自带的 Uniapp模板(Vue3.0 + TS)作为此次技术栈。

vue create -p dcloudio/uni-preset-vue#vue3 <project Name>

建立完成后,结构如下

image.png

简单调整下目录

image.png

  • api 具体的请求都放在这里,这里面还可以根据业务模块来细分,不必多说。
  • assets 静态资源,存放图片。
  • components 公共组件。
  • constant 常量,主要用于储存一些使用居多的常量比如商品类型(1:实物,2:虚拟物品)等。
  • layout 全局页面容器,在开发页面时最外层包裹一层 layout,主要是为了适配各个机型(如IphoneX的刘海屏),可以在这里安全区域的适配处理,这个后面说。
  • lib 这里我上面没有标(可选),这里用来存放一些 Ts 定义好的结构。
  • Packages 小程序分包,用来做分包测试用的 Demo。
  • pages 业务页面。
  • router 路由。
  • serve 公共请求方法,请求拦截器等都在这里实现。
  • static 静态资源,忘记删了,跟上面重复了看个人命名喜好。
  • store 全局状态管理模块,Vuex。
  • utils 工具函数。

简单讲解一些做移动端会遇到的问题,诸如公共组件、请求等大同小异,不再赘叙。

layout

首先说一下,layout 存在的意义,他就是为了给全局适配机型而存在,裹上他只需要关注业务层面即可,无需在做适配。

Uniapp 非常的友好为我们提供了 getSystemInfoSync 这个方法用于获取系统信息,该方法会返回一个 safeArea,在竖屏正方向下的安全区域,我们可以基于此来做文章。

之后我们通过 getMenuButtonBoundingClientRect 来获取微信小程序右上角的胶囊位置信息,来简单适配一下安全区域。

/**
 * @feat < 获取 操作栏在视图上的top值 >
 * @param {number}  height 与小程序菜单按钮对齐的操作条高度
 */
export function getBarClientTop(height: number): number {
  let top: number;
  const { safeArea } = uni.getSystemInfoSync();
  const safeAreaInsets = safeArea ? safeArea.top : 0;

  // #ifdef  MP
  if (height > 0) {
    const menuBtnRect =
      uni.getMenuButtonBoundingClientRect &&
      uni.getMenuButtonBoundingClientRect();

    top = menuBtnRect.height / 2 + menuBtnRect.top - height / 2;
  } else {
    top = safeAreaInsets;
  }
  // #endif

  // #ifdef  H5 || APP-PLUS
  top = safeAreaInsets;
  // #endif

  return top;
}

这里 height 默认是可以传 44,具体可以根据实际结果来。我们通过这些api获取到高度信息用于我们元素的间距。

.eslintrc.js

由于项目是基于 Node 环境的 ts 来开发,需要添加一下 uni 及 wx 全局规则以便 ts 能够正常运行。

module.exports = {
  globals: {
    uni: true,
    wx: true,
  }
}

postcss

由于设计稿是2倍出的图(设计稿宽度为750px,实际为375px),并且需要用 rpx 来适配小程序。

const path = require("path");
module.exports = {
  parser: require("postcss-comment"),
  plugins: [
    require("postcss-pxtorpx-pro")({
      // 转化的单位
      unit: "rpx",
      // 单位精度
      unitPrecision: 2,
      // 需要转化的最小的pixel值,低于该值的px单位不做转化
      minPixelValue: 1,
      // 不处理的文件
      exclude: /node_modules|components/gi,
      // 默认设计稿按照750宽,2倍图的出
      // 640 0.85
      transform: (x) => x * 2,
    }),
    require("postcss-import")({
      resolve(id) {
        if (id.startsWith("~@/")) {
          return path.resolve(process.env.UNI_INPUT_DIR, id.substr(3));
        } else if (id.startsWith("@/")) {
          return path.resolve(process.env.UNI_INPUT_DIR, id.substr(2));
        } else if (id.startsWith("/") && !id.startsWith("//")) {
          return path.resolve(process.env.UNI_INPUT_DIR, id.substr(1));
        }
        return id;
      },
    }),
    require("autoprefixer")({
      remove: process.env.UNI_PLATFORM !== "h5",
    }),
    require("@dcloudio/vue-cli-plugin-uni/packages/postcss"),
  ],
};

assets/css/constant.scss

在这里,定义一些常用的样式变量,如果后期有换肤功能即可很方便的切换,具体规则可以由设计给出。

// 主题色
$main-color: #EE5A61; // 品牌色
$sub-color: #FFA264; // 辅助色

// 背景色
$page-background-color: #F2F4F5; // 页面背景色
// More ...

相机组件 && 音频组件

相机组件

先说相机组件,因为涉及到录视频等操作。而 Uniapp 也提供了 createCameraContext,以便我们获取 Camera 的上下文。但是该 api 不能兼容 H5 及 App。如果要做多端涉及到 H5 这就很麻烦,可能需要调原生的摄像头(不赘叙)。

该组件也并非全屏摄像头,可以自己用样式控制。于是,给出一个简单 Demo。

<template>
  <LayoutMain>
    <template v-slot:mains>
      <div class="carmare-wrapper">
        <camera
          :device-position="cameraConfig.devicePosition"
          :flash="cameraConfig.flash"
          binderror="error"
          @error="handleOnError"
          style="width: 100%; height: 300px"
        ></camera>

        <button @click="handleTakePhoto">拍照</button>
        <button @click="handleStartReord">开始录像</button>
        <button @click="handleStopRecord">停止录像</button>
        <button @click="handleSwitchDevicePosition">
          切换摄像头朝向{{ cameraConfig.devicePosition }}
        </button>
        <button @click="handleSwitchFlashLight">
          {{ cameraConfig.flash }}闪光灯
        </button>

        <div v-if="photoList.length > 0">
          已拍出的照片
          <div v-for="(item, index) in photoList" :key="index">
            <img :src="item" alt="" />
          </div>
        </div>

        <div v-if="videoSrc">
          已录制的视频
          <video :src="videoSrc" style="width: 100px; height: 100px"></video>
        </div>
      </div>
    </template>
  </LayoutMain>
</template>

<script lang="ts">
import { onReady } from "@dcloudio/uni-app";
import { defineComponent, reactive, ref, Ref } from "vue";
import LayoutMain from "@/layout/layoutMain.vue";

export default defineComponent({
  setup() {
    let carmareContext: any;
    let videoSrc: Ref<string> = ref("");
    let currentFlashLightStatus = 0;
    const statusList = ["off", "on", "auto"];
    const cameraConfig: any = reactive({
      devicePosition: "back",
      flash: statusList[currentFlashLightStatus],
    });
    onReady(() => {
      carmareContext = uni.createCameraContext();
    });
    const photoList: Ref<string[]> = ref([]);
    return {
      cameraConfig,
      photoList,
      videoSrc,
      handleOnError: (error: any) => {
        console.error("handleOnError-eerror", error);
      },
      handleTakePhoto: () => {
        carmareContext.takePhoto({
          quality: "high",
          success: (res: any) => {
            console.info("res", res);
            photoList.value.push(res.tempImagePath);
          },
        });
      },
      handleSwitchDevicePosition: () => {
        console.info("cameraConfig.devicePosition");
        cameraConfig.devicePosition =
          cameraConfig.devicePosition === "back" ? "front" : "back";
      },
      handleSwitchFlashLight: () => {
        const lastStatus = statusList.length - 1;
        console.info("333handleSwitchFlashLight");
        if (currentFlashLightStatus < lastStatus) {
          cameraConfig.flash = statusList[(currentFlashLightStatus += 1)];
        } else {
          currentFlashLightStatus = 0;
          cameraConfig.flash = statusList[currentFlashLightStatus];
        }
      },
      // 开始录像
      handleStartReord: () => {
        carmareContext.startRecord({
          success: (res: any) => {
            console.log("handleStartReord-success", res);
            uni.showToast({
              title: "开始录像",
            });
          },
          fail: (error: any) => {
            console.error("handleStartReord-error", error);
          },
        });
      },
      // 停止录像
      handleStopRecord: () => {
        carmareContext.stopRecord({
          success: (res: any) => {
            console.log("handleStopRecord-success", res);
            uni.showToast({
              title: "停止录像",
            });
            videoSrc.value = res.tempVideoPath;
          },
          fail: (error: any) => {
            console.error("handleStopRecord-error", error);
          },
        });
      },
    };
  },
  components: { LayoutMain },
});
</script>

在微信开发者工具上点击真机调试,效果如图:

image.png

音频组件

同样,Uniapp也提供了 createInnerAudioContext(),创建并返回内部 audio 上下文 innerAudioContext 对象。

该 api 可兼容 H5 及 App,在 IOS 端该组件支持的格式会比较少些,仅支持m4a、wav、mp3、aac、aiff、caf格式。但在安卓端会相对多些。同样也是给出简答 Demo 用于调试。

<template>
  <LayoutMain>
    <template v-slot:container>
      <button @click="handleStartRecord">开始录音</button>
      <button @click="handleEndRecord">结束录音</button>
      <button @click="handlePlay">播放录音</button>
      <button @click="handlePausePlay">暂停播放录音</button>
      <button @click="handlePausePlay">暂停播放录音</button>
      <button @click="handleEndPlay">结束播放录音</button>

      <div>
        操作记录

        <div v-for="(item, index) in operateRecordList" :key="index">
          {{ item }}
        </div>
      </div>
    </template>
  </LayoutMain>
</template>

<script lang="ts">
import { ref, onMounted, Ref } from "vue";

export default {
  data() {
    return {};
  },
  setup() {
    const operateRecordList: Ref<string[]> = ref([]);

    // let getRecorderManager;
    let recorderManager: any;
    let innerAudioContext: any;
    let voicePath: string;

    onMounted(() => {
      const current = (recorderManager = uni.getRecorderManager());

      operateRecordList.value.push("prending");

      current.onError(function (e: unknown) {
        uni.showToast({
          title: "getRecorderManager.onError",
        });

        console.error("getRecorderManager.onError", e);
      });

      current.onStart(function () {
        operateRecordList.value.push("开始录音");
      });
      current.onStop(function (res: any) {
        operateRecordList.value.push("结束录音");

        console.log("recorder stop" + JSON.stringify(res));
        voicePath = res.tempFilePath;
      });
      current.onPause(function () {
        operateRecordList.value.push("暂停录音");
      });
    });

    onMounted(() => {
      const current = (innerAudioContext = uni.createInnerAudioContext());

      current.obeyMuteSwitch = false;

      uni.setInnerAudioOption({
        obeyMuteSwitch: false,
      });

      current.onError((res) => {
        console.error("innerAudioContext-onError", res);
      });

      current.onPlay(() => {
        operateRecordList.value.push("开始播放");
      });
      current.onPause(() => {
        operateRecordList.value.push("暂停播放");
      });
      current.onStop(() => {
        operateRecordList.value.push("结束播放");
      });
    });

    return {
      operateRecordList,
      handleStartRecord: () => {
        recorderManager.start({
          duration: 60000, //录音的时长,单位 ms,最大值 600000(10 分钟)
          format: "mp3",
        });
      },
      handleEndRecord: () => {
        recorderManager.stop();
      },
      handlePlay: () => {
        innerAudioContext.src = voicePath;
        innerAudioContext.play();
      },
      handleEndPlay: () => {
        innerAudioContext.stop();
      },
      handlePausePlay: () => {
        innerAudioContext.pause();
      },
    };
  },
};
</script>


录音及播放等不好展示,故没有截图,感兴趣的朋友可以自行拷贝拿去用。

遇到的坑

layout

前面提到的 layout 组件在不同同事的电脑运行时发现,少数个别的会存在 layout 没有生效即页面没有包裹 layout层。

在main.ts中全局注册:

import { createApp } from "vue";
import Layout from "@/layout/layoutMain.vue";
import store from "@/store/index";
import App from "./App.vue";
const app = createApp(App);
app.use(store);
// 全局注册组件
app.component("Layout", Layout);
app.mount("#app");

在页面中使用(此处layout没生效,然而我的电脑生效):

<template>
  <Layout>
    <template v-slot:mains>
      <div>分类页</div>
    </template>
  </Layout>
</template>

在确保环境相同、插件及各方面都没有影响的情况下也还是这样,这点现在还不清楚咋回事,希望有大佬可以指出,现在的方案是将 Layout 改名为 LayoutMain 即生效。(黑人问号?)

image

Uniapp 自带的 Image 组件的懒加载是不生效的,这点针对测试过,怀疑 lazy-load 这个属性就是个摆设 Q A Q。

目测得自己手动实现图片懒加载。

关于组件传值

假设有如下组件:

<template>
    <div>{{ hello }}</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { Props } from "./interface";

export default defineComponent({
    props: {
        hello: {
          type: String as PropType<Props["hello"]>,
          default: 'fff',
        },
    }
})
</script>

可以看到 hello 为 String 类型,默认值为 fff。

但当 hello = undefined 时,hello 会显示空字符串 ""。 如果 hello 不传就会 "fff"。

最后

都看到这里了不点赞留言再走吗?

关注公众号:饼干前端,拉近你我距离(^▽^)