出于简单原则,使用的是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
})
9. app端使用html2canvas或snapdom生成海报或分享卡片
主要依赖于uniapp的renderjs功能,参考官方文档。
两个库都使用过,这里推荐一下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>