uniapp + vue3 + ts开发的一些记录

161 阅读5分钟

出于简单原则,使用的是hbuilder创建的vue3项目,默认模版,vue版本选择了3,自带支持了组合式声明、ts、scss这些(scss会提示你安装下载hbuilder插件),相对来说还是比较方便的。

下面介绍一些vue3版本的区别:

1. 小程序生命周期,组合式声明

<script lang="ts" setup>
import { onLoad, onShow } from "@dcloudio/uni-app";

// 获取路由参数
onLoad((option)=>{
    const { 参数可以在这里解构出来 } = option
})
</script>

如果是想多平台兼容,可能需要额外注意 uniapp的生命周期,之前用taroV2开发多平台的时候,踩了很多百度小程序的坑。

2. Hbuilder的项目生产环境下移除console

项目根目录创建vite.config.js文件,然后用Hbuilder发行小程序的时候就会自动移除console了,配置如下:

import {defineConfig} from "vite";
import uni from "@dcloudio/vite-plugin-uni";

export default defineConfig({
  plugins: [
    uni(),
  ],
  esbuild: {
    pure: ['console.log'], // 删除 console.log
    drop: ['debugger'], // 删除 debugger
  }
});

3. pinia持久化状态

安装pinia-plugin-persistedstate,然后配置,代码如下:

// 自定义storage
export const customStorage = {
  getItem: (key : string) => uni.getStorageSync(key),
  setItem: (key : string, value : string) => uni.setStorageSync(key, value),
  removeItem: (key : string) => uni.removeStorageSync(key)
}

export const useAppStore = defineStore('app-store', {
    state: () => ({
        count: 0
    }),
    getters: {},
    actions: {
        addCount(){
            this.count++
        }
    },
    // 重要的是这里
    persist: { storage: customStorage }
}

4. 利用provide给页面和组件传递属性或事件

创建一个provide.ts,方便父子组件的引入

// provide.ts
import { InjectionKey } from 'vue'

export type HomeProvide = {
  count : number
}

export const homeProvideKey:InjectionKey<HomeProvide> = Symbol('HomeProvide')

比如引入Home页面,其他Home页面内的子组件,可以直接获取或修改provide内的属性

// home.vue
<view class="page">
    {{ homeProvide.count }} 
</view>

<script lang="ts" setup>
import { provide, reactive } from 'vue';
import { homeProvideKey } from './provide';

const homeProvide = reactive({
    count: 0
})

provide(homeProvideKey, homeProvide)
</script>

5. 防止误操作快速点击,又不想麻烦增加loading,可以使用节流函数包裹

<script lang="ts" setup>
import { throttle } from 'radash'

const onThrottleSubmit = throttle({ interval: 300 }, onSubmit)

function onSubmit(){}
</script>

6. 对uniapp popup的简单组件封装

<template>
  <uni-popup ref="popupRef" :mask-click="false">
    <view class="popup-main">
      <view class="close-wrapper" @click="onClose">
        <view class="icon-close"></view>
      </view>
      <view class="title">{{data.title}}</view>
      <view class="content">{{data.desc}}</view>
      <view class="btn-group">
        <view class="btn-cancel" @click="()=>emit('onCancel')">
          {{data.cancelButtonText}}
        </view>
        <view class="btn-submit" @click="()=>emit('onSubmit')">
          {{data.confirmButtonText}}
        </view>
      </view>
    </view>
  </uni-popup>
</template>

<script lang="ts" setup>
  import { ref, Ref, watch, watchEffect } from 'vue'

  interface Props {
    modelValue : Ref<boolean>
  }

  const props = withDefaults(defineProps<Props>(), {
    modelValue: undefined,
  })

  const emit = defineEmits(["update:modelValue", "onCancel", "onSubmit"]);

  const popupRef = ref<any>(undefined)
  // defineExpose({ ref: popupRef })

  const status = {
    1: {
      title: '标题!',
      desc: "描述",
      cancelButtonText: "取消按钮",
      confirmButtonText: "提交按钮",
    }
  }

  watchEffect(() => {
    if (!popupRef.value) return
    if (props.modelValue) {
      popupRef.value.open('center')
    } else {
      popupRef.value.close()
    }
  })

  function onClose() {
    popupRef.value.close()
    emit("update:modelValue", false)
  }
</script>

<style lang="scss" scoped>
  .popup-main {
    background-size: contain;
    background-color: #ffffff;
    min-height: 325rpx;
    border-radius: 16rpx;
    position: relative;
    width: 590rpx;
    display: flex;
    flex-direction: column;
  }

  .title {
    margin-top: 52rpx;
    text-align: center;
    font-weight: bold;
    font-size: 34rpx;
    color: #191919;
  }

  .content {
    font-weight: 400;
    font-size: 26rpx;
    color: #666666;
    margin-top: 16rpx;
    text-align: center;
  }

  .btn-group {
    display: flex;
    justify-content: space-between;
    margin: 0 48rpx;
    position: absolute;
    bottom: 36rpx;
    left: 0;
    right: 0;
  }

  .btn-cancel {
    width: 235rpx;
    height: 88rpx;
    background: #FFFFFF;
    border-radius: 16rpx;
    border: 1rpx solid #FF4750;

    font-weight: bold;
    font-size: 32rpx;
    color: #FF4750;
    display: flex;
    align-items: center;
    justify-content: center;
  }

  .btn-submit {
    width: 235rpx;
    height: 88rpx;
    background: #FF4750;
    border-radius: 16rpx;
    border: 1rpx solid #FF4750;

    font-weight: bold;
    font-size: 32rpx;
    color: #ffffff;
    display: flex;
    align-items: center;
    justify-content: center;
  }

  .icon-close {
    background: url('icon.png') no-repeat center top;
    background-size: contain;
    width: 48rpx;
    height: 48rpx;
    box-sizing: border-box;
  }

  .close-wrapper {
    position: absolute;
    right: 0;
    top: 0;
    padding: 20rpx;
    margin: -20rpx;
  }
</style>

7. canvas画布,微信小程序支持canvas2d,百度小程序不支持

直接使用echarts会报错,因为小程序canvas的上下文和web不一样,但是也有方法集成,但是考虑到echarts实在太大了,不太推荐小程序使用它。

如果想要省心一点、兼容全平台,可以使用ucharts,相比echarts体积会稍微小一些,但不如echarts好用,而且看具体的配置需要收费。

其实最推荐的是自己手写,但是需要额外花时间去了解canvas

工作中用到了雷达图,边学、边练习手写了一部分,目前还比较粗糙,待优化:

// radar.vue
<template>
  <view class="radar-chart">
    <canvas class="chart" type="2d" canvas-id="chartCanvas" id="chartCanvas"</canvas>
  </view>
</template>

<script setup lang="ts">
  import { onMounted } from 'vue';

  const numberOfSides = 5; // 五边形的边数
  const angle = (Math.PI * 2) / numberOfSides; // 五边形的内角

  const fontSize = 14;
  const padding = 18;
  const max = 100;

  const data = [
     {name: "分类一",value: 20},
     {name: "分类二",value: 90},
     {name: "分类三",value: 55},
     {name: "分类四",value: 32},
     {name: "分类五",value: 75},
  ];

  onMounted(() => {
    // 如果是组件内,则需要加this
    // const query = uni.createSelectorQuery().in(this)
    // const ctx = uni.createCanvasContext("chartCanvas", this);
    
    const ctx = uni.createCanvasContext("chartCanvas");

    const query = uni.createSelectorQuery()
    
    query.select('#chartCanvas')
      .fields({
        // 支持type 2d 才有node
        node: true,
        size: true
      }, (res : any) => { })
      .exec((res) => {
        const { width, height } = res[0]
        const canvas = res[0].node
        if (!canvas) {
          throw new Error('未获取到canvas 2d节点')
        }

        const ctx = canvas.getContext('2d')

        ctx.font = `${fontSize}px sans-serif`;
                
        // 缩放canvas,实现高清图像
        const dpr = uni.getSystemInfoSync().pixelRatio
        canvas.width = res[0].width * dpr
        canvas.height = res[0].height * dpr
        // #ifndef H5
        ctx.scale(dpr, dpr)
        // #endif

        console.log('ctx---', ctx)

        const centerX = width / 2
        const centerY = height / 2

        for (let i = 1; i < data.length; i++) {
          // 正五边形外接圆半径
          const radius = Math.min(canvas.width, canvas.height) * (i / (11 * dpr)); 

          console.log('radius--', radius)
          drawPolygon(ctx, centerX, centerY, radius, i);

          if (i == data.length - 1) {
            drawLine(ctx, centerX, centerY, radius);
            drawRegion(ctx, centerX, centerY, radius);
          }
        }
      })
  })

  function drawPolygon(ctx, centerX, centerY, radius, idx) {
    // 开始绘制路径
    ctx.beginPath();
    // 依次连接每个顶点的坐标,绘制五边形的边
    let x, y;
    for (let i = 0; i < numberOfSides; i++) {
      const percent = ((max / (data.length - 1)) * idx).toString();
      // 将画笔移动到第一个顶点的坐标
      if (i === 0) {
        x = centerX + radius * Math.cos(-Math.PI / 2);
        y = centerY + radius * Math.sin(-Math.PI / 2);
        ctx.moveTo(x, y);

        // 显示百分比
        ctx.font = `${12}px sans-serif`
        ctx.fillStyle = "#BEBDC3";
        ctx.fillText(percent, x + 6, y + 10);
      } else {
        x = centerX + radius * Math.cos(angle * i - Math.PI / 2);
        y = centerY + radius * Math.sin(angle * i - Math.PI / 2);
        ctx.lineTo(x, y);
      }

      // 填充文字
      if (idx === data.length - 1) {
        ctx.font = `${fontSize}px sans-serif`;
        const textX =
          centerX + (radius + padding) * Math.cos(angle * i - Math.PI / 2);
        const textY =
          centerY + (radius + padding) * Math.sin(angle * i - Math.PI / 2);

        //   console.log("idx", idx, textX, textY);
        ctx.textAlign = "center";
        ctx.fillStyle = "#191919";
        // ctx.fillText(data[i].name, textX, textY + fontSize / 2);

        // ctx.measureText(data[i].name).width
        // 优化文字显示距离
        if (angle * i === 0) {
          ctx.fillText(data[i].name, textX, textY + padding / 2);
        } else if (angle * i > 0 && angle * i <= Math.PI / 2) {
          ctx.fillText(data[i].name, textX + padding, textY + padding / 2);
        } else if (angle * i > Math.PI / 2 && angle * i <= Math.PI) {
          ctx.fillText(data[i].name, textX, textY + padding / 2);
        } else if (angle * i > Math.PI && angle * i <= (Math.PI * 3) / 2) {
          ctx.fillText(data[i].name, textX, textY + padding / 2);
        } else {
          ctx.fillText(data[i].name, textX - padding, textY + padding / 2);
        }
      }
    }
    ctx.strokeStyle = "#BEBDC3";
    // 闭合路径,连接最后一个顶点和第一个顶点
    ctx.closePath();
    ctx.stroke();
    return ctx
  }

  function drawLine(ctx, centerX, centerY, radius) {
    ctx.beginPath();
    for (let i = 0; i < numberOfSides; i++) {
      // const x = centerX + radius * Math.cos(angle * i);
      // const y = centerY + radius * Math.sin(angle * i);
      const x = centerX + radius * Math.cos(angle * i - Math.PI / 2);
      const y = centerY + radius * Math.sin(angle * i - Math.PI / 2);
      ctx.moveTo(centerX, centerY);
      ctx.lineTo(x, y);
      // console.log(x, y);
    }
    ctx.closePath();
    ctx.stroke();
    return ctx
  }

  function drawRegion(ctx, centerX, centerY, radius) {
    const points = [];
    for (let i = 0; i < numberOfSides; i++) {
      ctx.beginPath();
      const ratio = data[i].value / max;

      const x = centerX + radius * ratio * Math.cos(angle * i - Math.PI / 2);
      const y = centerY + radius * ratio * Math.sin(angle * i - Math.PI / 2);
      ctx.moveTo(centerX, centerY);

      ctx.arc(x, y, 3, 0, 2 * Math.PI);
      ctx.fillStyle = "#FF4750";
      ctx.fill();
      ctx.closePath();

      points.push({ x, y });
    }

    //  生成的点连线
    ctx.beginPath();
    points.reduce((prev, cur, curIdx, arr) => {
      if (curIdx === 0) {
        ctx.moveTo(cur.x, cur.y);
      }
      const next = curIdx === arr.length - 1 ? arr[0] : arr[curIdx + 1];
      ctx.lineTo(next.x, next.y);
      // console.log(prev, cur, curIdx);
    }, undefined);
    ctx.closePath();
    ctx.fillStyle = "rgba(255,71,80,0.2)";
    ctx.fill();
    
    return ctx
  }
</script>

<style lang="scss">
  .radar-chart {
    // width: 600rpx;
    // height: 400rpx;
    width: 100vw;
    height: 400rpx;
  }

  .chart {
    width: 100%;
    height: 100%;
  }
</style>

8. 集成PageSpy进行调试

参考pagespy官网文档进行集成,这里选择的是node.js部署

npm全局安装pagespy,然后执行page-spy-api命令,会启动一个服务<host>:6752

// 安装1
npm install -g @huolala-tech/page-spy-api@latest

// 启动
page-spy-api

输入本地调试ip地址以后就可以看到下图的界面,选择接入SDK,跟着步骤来就好了,默认是https,需要手动配置关闭一下!,接入完成以后就可以在房间列表看到接入的小程序了。

// main.js

new PageSpy({
  api: '192.168.0.191:6752',
  // 这里配置关闭HTTPS
  enableSSL: false
})

image.png