记得是四年前用 Vue2 搭建了一个脚手架用来开发小程序,使用到现在还是很稳定。这个脚手架开发了不下 10 个项目。但是技术是进步的,Vue2 也不再更新,还是得跟上时代,所以 PC 端的项目都换了 Vue3。
公司现在有个需求要兼容 H5 和小程序,因为之前的脚手架 node 版本很低,很多库已经不好兼容了,开发一个项目经常要切换 node 的版本才能运行也行麻烦。才有了今天的搭建兼容 H5 和小程序的 vue3 版本的 uniapp
技术选型
我不喜欢HBuilderX的体验,使用 Uniapp 都是用 Cli 的方式搭建运行,用 Vscode 来开发,之前老项目使用的是 vue2 版+uview 来实现
vue3 版本想着使用 tdesign,但是 H5 和小程序是两个组件库,使用方式也不一样,网上找了办法说可以用小程序版来开发,地址https://northes.io/posts/uni-app/wx-components/,但是有的组件能用,一些有莫名的bug决定还是找一个ui组件就兼容小程序和H5的才好,找到uvui组件,算是uview的升级和延续
Uniapp 官方 cli
官方地址:https://uniapp.dcloud.net.cn/quickstart-cli.html
安装 uv-ui
npm i @climblee/uv-ui
-
在项目根目录 pages.json 中配置 easycom:
// pages.json { "easycom": { "autoscan": true, "custom": { "^uv-(.*)": "@climblee/uv-ui/components/uv-$1/uv-$1.vue" } }, // 其他内容 "pages": [ // ... ] }
安装 sass
npm i -D sass sass-loader
安装的时候我的 node 版本是 20.18.2,安装的 sass 版本太新报错,ui 组件的语法不支持了

# 指定版本
npm install sass@1.55.0 --save-dev
运行效果图
H5

小程序

web 集成流播放
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
</head>
<body>
<video id="video" style="width: 100%; height: 800px"></video>
<script>
if (Hls.isSupported()) {
var video = document.getElementById("video");
var hls = new Hls();
hls.on(Hls.Events.MEDIA_ATTACHED, function () {
console.log("video and hls.js are now bound together !");
});
hls.on(Hls.Events.MANIFEST_PARSED, function (event, data) {
console.log(
"manifest loaded, found " + data.levels.length + " quality level"
);
});
hls.loadSource(
"http://dbliveplay.xxx.cn/DaobaSportLiveAli/100_A0001_2025-03-28.m3u8?auth_key=xxx-100A0001-0-37032952ec51656ca09f5dc967a90fe3"
);
hls.attachMedia(video);
}
</script>
</body>
</html>
bug 出现
使用 ni.getImageInfo怎么都获取不到图片的地址,打包后 H5 也不能访问
const loadImage1 = async () => {
try {
uni.getImageInfo({
src: "/static/img/animator/1.png",
success: function (res) {
console.log("success", res);
},
fail: function (err) {
console.log("fail", err);
},
complete: function (err) {
console.log("complete", err);
},
});
} catch (error) {
console.log(error);
}
};
为了不在出现其它不可控制的 bug,决定用HBuilder X来搭建一个,后期如果app开发也可以直接使用
HBuilder X
下载最新的HBuilder X我的版本是 4.57

基于 uni-ui 生成一个项目

安装 uv-ui 组件库
官方地址:https://www.uvui.cn/components/install.html,使用插件市场的下载插件并导入`HBuilderX` 引入样式
/* APP.vue */
<style lang="scss">
@import '@/uni_modules/uv-ui-tools/index.scss';
</style>
配置环境变量
三个文件在根目录:.env.development,.env.production,.env
#env.development
VITE_BASE_URL="http://192.168.2.131:100"
#.env.production
VITE_BASE_URL="https://2025.xxx.com/api"
Pinia使用
项目结构
├── pages
├── static
└── stores
└── counter.js
├── App.vue
├── main.js
├── manifest.json
├── pages.json
└── uni.scss
在 main.js 中编写以下代码:
// #ifdef VUE3
import { createSSRApp } from "vue";
import * as Pinia from "pinia";
import App from "./App.vue";
export function createApp() {
const app = createSSRApp(App);
app.use(Pinia.createPinia());
return {
app,
Pinia,
};
}
// #endif
在项目根中运行命令
npm init -y
HbuilderX运行内置终端

pinia-plugin-persistedstate
pinia 持久化插件安装
npm i pinia-plugin-persistedstate
luch-request
luch-request 是一个基于 Promise 开发的 uni-app 跨平台、项目级别的请求库,它有更小的体积,易用的 api,方便简单的自定义能力
官方文档https://www.quanzhan.co/luch-request/
安装
npm i luch-request -S
请求封装
baseUrl.js
const getBaseUrl = () => {
let isDev = import.meta.env.MODE === "development";
let baseUrlObj = {
baseUrl: import.meta.env.VITE_BASE_URL,
};
if (isDev) {
// baseUrlObj.baseUrl = "/apilocal";
//本地切换线上
// baseUrlObj.baseUrl = "/apiline";
}
// console.log("baseUrl", baseUrlObj);
return baseUrlObj;
};
const baseUrl = getBaseUrl();
export default baseUrl;
config.js
import baseUrl from "./baseUrl";
import mutual from "@/utils/util/mutual";
let header = {
// Authorization: authorize,
"Content-Type": "application/json; charset=UTF-8",
// 'content-type': method === 'POST' ? 'application/json' : 'application/x-www-form-urlencoded', // 默认值,
};
export default {
baseURL: baseUrl.baseUrl,
header: header,
method: "GET",
dataType: "json",
// #ifndef MP-ALIPAY
responseType: "text",
// #endif
// 注:如果局部custom与全局custom有同名属性,则后面的属性会覆盖前面的属性,相当于Object.assign(全局,局部)
custom: { auth: false }, // 全局自定义参数默认值
// #ifdef H5 || APP-PLUS || MP-ALIPAY || MP-WEIXIN
timeout: 20000,
// #endif
// #ifdef APP-PLUS
sslVerify: true,
// #endif
// #ifdef H5
// 跨域请求时是否携带凭证(cookies)仅H5支持(HBuilderX 2.6.15+)
withCredentials: false,
// #endif
// #ifdef APP-PLUS
firstIpv4: false, // DNS解析时优先使用ipv4 仅 App-Android 支持 (HBuilderX 2.8.0+)
// #endif
// 全局自定义验证器。参数为statusCode 且必存在,不用判断空情况。
validateStatus: (statusCode) => {
if (statusCode == 400) mutual.showToast("参数错误");
if (statusCode == 404) mutual.showToast("资源不存在");
if (statusCode >= 500 && statusCode != 502) mutual.showToast("服务器错误");
// statusCode 必存在。此处示例为全局默认配置
return statusCode >= 200 && statusCode < 300;
},
};
requestjs
import Request from "luch-request";
import config from "./config";
import baseUrl from "./baseUrl";
import { useUserStore } from "@/stores/modules/user";
import { authLogin } from "@/http/modules/user.js";
const http = new Request(config);
let isRefreshing = false;
let refreshSubscribers = []; // 存储等待请求的回调
/**
* 当 refreshToken 刷新成功后,重新执行挂起的请求
*/
function onRefreshed(token) {
refreshSubscribers.forEach((callback) => callback(token));
refreshSubscribers = []; // 清空等待队列
}
/**
* 订阅 Token 刷新
*/
function subscribeTokenRefresh(callback) {
refreshSubscribers.push(callback);
}
/**
* 刷新 Token
*/
let refreshlogin = async function (refreshToken) {
isRefreshing = true;
const userStore = useUserStore();
let data = {
account: undefined,
pwd: null,
password: "",
captchaVerification: null,
clientId: "PcK8xDtgS11smvem",
refreshToken: userStore.refreshToken,
secret: "LjGaldZe66wTxIcz5d1iz4zV1TPVQnuJ",
tokenType: "refresh",
};
try {
let res = await authLogin(data);
if (res.status == 0) {
userStore.updateAccessToken(res.data);
onRefreshed(res.data.access_token);
isRefreshing = false;
return res.data.access_token;
} else {
throw new Error("登录状态过期");
}
} catch (error) {
console.log("刷新 token 失败", error);
isRefreshing = false;
refreshSubscribers = [];
uni.showToast({ title: "登录状态过期", icon: "none" });
setTimeout(() => {
if (process.env.UNI_PLATFORM === "h5") {
window.location.href = "/pages/user/login";
} else {
uni.navigateTo({ url: "/pages/user/login" });
}
}, 1000);
return false;
}
};
/**
* 在请求之前拦截
*/
http.interceptors.request.use(
async (config) => {
const {
custom: { auth },
} = config;
let userStore = useUserStore();
let accessToken = userStore.accessToken;
let refreshToken = userStore.refreshToken;
let expiresIn = userStore.expiresIn;
let timeStamp = Math.floor(new Date().getTime() / 1000);
//没有登录
if (auth && !accessToken) {
console.log("没有登录,去登录");
if (process.env.UNI_PLATFORM === "h5") {
window.location.href = "/pages/user/login";
} else {
uni.navigateTo({ url: "/pages/user/login" });
}
return Promise.reject("未登录");
}
// 如果 Token 过期
if (auth && accessToken && timeStamp > expiresIn - 30) {
if (!isRefreshing) {
console.log("登录过期重新获取", refreshToken);
await refreshlogin();
}
return new Promise((resolve) => {
subscribeTokenRefresh((newToken) => {
config.header = {
...config.header,
Authorization: newToken,
};
console.log("刷新成功,带token继续请求");
resolve(http.request(config));
});
});
} else {
config.header = {
...config.header,
Authorization: accessToken,
};
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
/**
* 在请求之后拦截
*/
http.interceptors.response.use(
(response) => {
if (response.statusCode == 200) {
return Promise.resolve(response.data);
} else {
return Promise.reject(response);
}
},
(error) => {
return Promise.reject(error);
}
);
export default http;
api.js
/**
* 接口统一集成模块
*/
const requireComponent = require.context("./modules", false, /\.js$/);
const modules = {};
requireComponent.keys().forEach((key) => {
const moduleName = key.replace(/(.*\/)*([^.]+).*/gi, "$2");
const value = requireComponent(key);
// 兼容ts
modules[moduleName] = value;
});
// 默认全部导出
export default modules;
modules/user.js
import http from "@/http/request";
/**
* 用户登录
* @returns {Promise | Promise<unknown>}
*/
export function authLogin(data = {}) {
return http.post("/auth/oauth/noAuth/token", data, {
custom: { auth: false },
});
}
/**
* 用户端直接上传到七牛,获取七牛的token
* @returns {Promise | Promise<unknown>}
*/
export function fileUpToken() {
return http.get("/match/file/getUpToken", {
custom: { auth: false },
});
}