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

458 阅读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

9. app端使用html2canvassnapdom生成海报或分享卡片

主要依赖于uniapprenderjs功能,参考官方文档

两个库都使用过,这里推荐一下snapdom,对比html2canvas没有本地图片跨域的问题,生成图片速度也比它快,清晰度也高。

snapdom 1.9.11 有bug调用的时候会报错,我使用的1.9.9可以正常生成图片。

如果使用html2canvas本地图片需要转换为base64,不然可能会因为跨域造成生成图片失败

下面贴出示例代码:

<template>
<view @click="shareCard.create" style="display: flex; justify-content: center;position: relative;">
    <view class="testbg" style="position: relative; overflow: hidden;" id="poster">
        <image src="@/static/logo.png" mode="aspectFill" style="width: 100px;height:100px;"></image>
        <view style="text-align:center;position: absolute;left:0;right:0; bottom:15px;color:#fff;background-color: red;">分享</view>
    </view>
</view>
</template>

<script>
export default {
    setup() {
        const showLoding = ()=> uni.showLoading({title: '图片生成中'})
        const hideLoading = ()=> uni.hideLoading()

        // 保存图片
        function save(base64) {
            const bitmap = new plus.nativeObj.Bitmap("test")
            bitmap.loadBase64Data(base64, function() {
                // url为时间戳命名方式
                const url = "_doc/" + new Date().getTime() + ".png"; 
                bitmap.save(url, {
                    overwrite: true, // 是否覆盖
                    // quality: 'quality'  // 图片清晰度
                }, (i) => {
                        uni.saveImageToPhotosAlbum({
                            filePath: url,
                            success: function() {
                                uni.showToast({
                                    title: '图片保存成功',
                                    icon: 'none'
                                })
                                bitmap.clear()
                            }
                        });
                }, (e) => {
                        uni.showToast({
                            title: '图片保存失败',
                            icon: 'none'
                        })
                        bitmap.clear()
                });
            }, (e) => {
                uni.showToast({title: '图片保存失败',icon: 'none'})
                bitmap.clear()
            })
        }
        
        return {
            showLoding,
            hideLoading,
            save
        };
    },
};
</script>

<script module="shareCard" lang="renderjs">
import {snapdom} from '@zumer/snapdom';

export default {
    methods: {
        async create() {
            try {
                // this.$ownerInstance.callMethod用于两个script之间交流
                this.$ownerInstance.callMethod('showLoding', true)
                const dom = document.getElementById('poster')
                // 方式一: 如果后端支持 form data内上传blob file然后返回上传图片的url,那么使用这个方式是最快的。
                // const blob = await snapdom.toBlob(dom);
                // 方式二: 这里使用canvas转64,然后方便存储到手机内的相册内
                const canvas = await snapdom.toCanvas(dom);
                // 转换base64
                const base64 = await canvas.toDataURL('image/png');
                this.$ownerInstance.callMethod('save', base64)
            } catch (error) {
                console.error(error)
                this.$ownerInstance.callMethod('hideLoading', true)
            }
        }
    }
}
</script>

还可以同理实现大部分web上的功能,比如lottie动画threejs渲染的3d效果等,都可以放在renderjs内实现,然后渲染在uniapp的视图上。

10. App内回复键盘和emoji面板

比如实现:点击回复评论,弹起输入框和emoji选择面板功能,还有很多可以优化的地方。主要想说的是@touchend.stop.prevent阻止点击事件影响输入框的焦点,比如点击发送 如果内容为空的时候,如果不使用它,就会触发键盘的失焦

<template>
	<view class="content">
		<view class="coment-item">
			<view>模拟帖子内容</view>
			<view class="reply-btn" @click="showPanel=true">回复</view>
		</view>
		<!-- 输入框面板 -->
		<view class="panel" v-if="showPanel" :style="{ paddingBottom: paddingHeight + 'px' }">
			<view class="panel-input-wrapper" @touchend.stop.prevent="inputFocus=true">
				<textarea class="panel-input" v-model="input" :focus="inputFocus" :adjust-position="false" auto-height
					maxlength="250" @keyboardheightchange="handleKeyboardChange" @focus="onInputFocus"></textarea>
				<view class="panel-emoji" @touchend.stop.prevent="onEmojiPanel">😁</view>
				<view class="panel-send" @touchend.stop.prevent="onSend">发送</view>
			</view>
			<!-- emoji panel -->
			<view v-show="showEmojiPanel" class="emoji-panel" :style="{ height: PANEL_HEIGHT+'px' }">
				<view style="display: flex;padding: 10px 20px;">
					<view v-for="emoji in emojis" style="width: 30px; height: 30px;"
						@touchend.stop.prevent="onSelectEmoji(emoji)">
						{{ emoji }}
					</view>
				</view>
			</view>
		</view>
		<view class="overlay" v-if="showPanel" @click="showPanel=false"></view>
	</view>
</template>

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

	const PANEL_HEIGHT = 300

	const input = ref('')
	const showPanel = ref(false)
	const paddingHeight = ref(0)
	const inputFocus = ref(false)
	const showEmojiPanel = ref(false)

	const emojis = ref([
		'😀',
		'😃',
		'😄',
		'😁',
		'😅',
		'🤣',
		'🙂',
		'😉'
	])

	watchEffect(() => {
		if (showPanel.value) {
			inputFocus.value = true
		}
	})

	function handleKeyboardChange(event) {
		const { height, duration } = event.detail;
		paddingHeight.value = height

		// 如果隐藏键盘 且 没有显示emoji面板,则关闭
		if (height === 0 && !showEmojiPanel.value) {
			showPanel.value = false
		}
	}

	function onSend() {
		if (input.value === '') {
			uni.showToast({
				title: "请输入内容",
				icon: "none"
			})
			return;
		}
		console.warn('输入框文字', input.value)
	}

	function onEmojiPanel() {
		paddingHeight.value = PANEL_HEIGHT
		showEmojiPanel.value = !showEmojiPanel.value
		inputFocus.value = !showEmojiPanel.value
	}

	function onInputFocus() {
		showEmojiPanel.value = false
	}

	function onSelectEmoji(item) {
		input.value = input.value + item
	}
</script>

<style>
	.content {
		display: flex;
		flex-direction: column;
	}

	.overlay {
		position: fixed;
		left: 0;
		right: 0;
		top: 0;
		bottom: 0;
		background: rgba(0, 0, 0, .5);
		z-index: 9;
	}

	.panel {
		position: absolute;
		bottom: 0;
		left: 0;
		right: 0;
		z-index: 10;
		transition: bottom 0.2s ease 0s;
	}

	.panel-input-wrapper {
		display: flex;
		padding: 5px 10px;
		background-color: antiquewhite;
	}

	.panel-input {
		background-color: aqua;
		flex: 1;
		margin-right: 10px;
		padding: 5px 10px;
		border-radius: 3px;
	}

	.panel-emoji {
		margin-right: 5px;
	}

	.panel-send {
		background-color: orange;
		color: #fff;
		font-size: 13px;
		width: 40px;
		height: 20px;
		border-radius: 3px;
		display: flex;
		align-items: center;
		justify-content: center;
	}

	.coment-item {
		display: flex;
		flex-direction: column;
		padding-left: 20px;
	}

	.reply-btn {
		margin-top: 5px;
		background-color: gray;
		padding: 3px 6px;
		width: 40px;
		display: flex;
		align-items: center;
		justify-content: center;
		color: #fff;
	}

	.emoji-panel {
		background-color: #fff;
	}
</style>