☰
跨端开发实战:uniapp三端适配与避坑指南
2026/10/17 14:21:42 网站建设 项目流程

简介:《uniapp从入门到精通》是一份面向 uni-app 初学者的系统学习资料,适合希望掌握 Vue.js 跨平台开发、快速构建小程序与 App 的开发者。内容由环境搭建入手,逐步讲解 HBuilderX 与微信开发者工具的使用、项目目录结构、开发规范、全局配置文件 pages.json 中的 globalStyle、页面路由、tabBar 与条件编译,以及 text、icon 等常用组件;随后深入页面样式与布局,涵盖尺寸单位、样式导入、选择器、全局与局部样式、sass 用法,并系统复习 Vue 基本语法与生命周期。在此基础上,延伸至下拉刷新、上拉加载、网络请求及二次封装、数据缓存、图片上传和预览等 16 个主题,覆盖从基础到进阶的完整知识链路。资源为 1 个 docx 文档,压缩包约 5.98MB,文本形式便于按章节检索、复制代码示例或做学习笔记。已有 573 人浏览学习,内容中融入作者实际操作中的常见问题与排查思路(如微信开发者工具服务端口设置),目录结构清晰,适合边看边练,快速建立 uni-app 整体知识框架。

1. uniapp 的“一套代码”到底省在哪:先看清跨端开发的真实成本

如果你同时被小程序、H5、App 三端需求压着,大概率听过这句话:用 uniapp 写一套,三端都有。这句话对,但只对一半。真正跑过 uniapp 项目的人都知道,一套代码省的是业务层重复开发,不是把三端差异也抹平。导航栏、存储、支付、路由这些按平台分开处理的细节,才是决定项目能不能顺利上线的那另一半。下面按一个真实落地方案的顺序往下走:从脚手架怎么选、页面和状态怎么管,到条件编译怎么写才不乱,最后把我在真机和审核阶段反复踩过的坑一条条讲清楚。适合已经会 Vue 基础、正准备用 uniapp 接第一个跨端项目的开发者,也适合正在排查线上问题的半熟手。

2. 本地跑通 uniapp 最小项目:从 CLI 脚手架到三端预览

2.1 为什么我选 CLI 创建而不是图形界面工具

很多教程上来就是打开图形界面工具,点按钮新建项目。这条路对单人开发最省事,编译器内置、真机预览一键到位,但一旦项目进入多人协作,问题就来了:图形界面创建的项目本质上是编辑器替你管理编译环境,换个电脑、接进 CI、和现有 git flow 融合时,环境差异会成为第一个隐形炸弹。我一般建议用 CLI 方式创建项目,核心原因是它能得到一个干净的 Node 工程,依赖、脚本、构建产物全部落在 package.json 里,团队成员克隆下来 npm install 就能跑,和前端团队现有的代码规范、eslint、git 钩子可以无缝衔接。

选型时还要定 Vue 版本。uniapp 同时支持 Vue2 和 Vue3:Vue2 走 webpack 构建,生态成熟但维护成本逐年上升;Vue3 走 Vite 构建,启动快、组合式 API 写起来舒服,而且现在官方主推的也是这条线。新项目我无脑选 Vue3,除非团队里有人对 Vue2 特别熟且项目必须兼容一套老组件库。Vue3 + Vite 的代价是部分老插件需要换替代方案,但对比跨端开发本身的复杂度,这个代价值得付。

2.2 创建项目的最小命令与 src 目录结构拆解

用 CLI 拉模板不需要图形界面,核心就三条命令:

# 拉取官方 vue3 + vite 预设模板,my-uniapp-app 是项目名 npx degit dcloudio/uni-preset-vue#vite my-uniapp-app cd my-uniapp-app npm install

这里 degit 的作用是拉取模板但不携带 git 历史,比直接 git clone 更干净。项目名注意用小写字母和连字符,不要带中文和大写,npm 包名规范会限制这一点。npm install 如果卡在网络,可以临时切 registry,但根因通常是依赖数量多,耐心等完即可。

依赖装完后,核心代码都在 src 目录下,它和普通 Vue 项目最大的区别是多了两个文件:

src/ pages.json # 路由、窗口样式、tabBar 配置,三端统一读它 manifest.json # AppID、各平台权限、SDK 配置 App.vue # 应用入口组件,onLaunch 在这里 main.js # 入口,导出 createApp 工厂函数

pages.json 是 uniapp 项目的地基,后面会单独讲。manifest.json 里最常改的是 AppID、小程序 AppID 和各平台模块权限,这些配置忘掉的后果在第五章会看到。

2.3 跑进小程序模拟器和浏览器:两条必会命令

项目装完先别急着写代码,把最小链路跑通再说。两条命令是日常开发主力:

# 运行到微信小程序,产物输出到 dist/dev/mp-weixin npm run dev:mp-weixin # 运行到 H5,默认起本地服务,端口 5173 npm run dev:h5

跑小程序那步,产物会输出到 dist/dev/mp-weixin,你需要打开微信开发者工具,选择“导入项目”并指向这个目录。首次导入时,开发者工具会要求填 AppID,没有正式 AppID 可以选测试号,并不影响本地开发。注意微信开发者工具里要开启“服务端口”,否则 CLI 的构建结果不会自动被识别。

跑 H5 那条命令更直接,浏览器打开终端提示的地址就能看到。这里有一个容易误解的点:uniapp 的 H5 端并不是把小程序代码翻译成网页,而是用同一套 Vue 页面编译成标准 Web 应用,所以浏览器里能看到完整页面。两条命令都跑通之后,你手里就同时有了小程序和 H5 两套可调试产物,后面所有代码改动都会实时编译过去,这就是跨端开发第一个直观体感。

3. 页面、路由与状态管理:搭好单页骨架再去碰多端差异

3.1 pages.json 不只是路由表:窗口、tabBar 与首屏都在这

用 uniapp 的人千万不要去找 vue-router,它就是不用 vue-router。页面路由、窗口样式、导航栏、tabBar 全部集中在 pages.json 一个文件里。这个设计初看别扭,实际用起来反而省心:三端共用一个配置,不需要为小程序维护一套路由、H5 再维护一套路由。

{ "pages": [ { "path": "pages/index/index", "style": { "navigationBarTitleText": "首页", "navigationBarBackgroundColor": "#ffffff" } }, { "path": "pages/detail/detail", "style": { "navigationBarTitleText": "详情" } } ], "tabBar": { "list": [ { "pagePath": "pages/index/index", "text": "首页" }, { "pagePath": "pages/detail/detail", "text": "详情" } ] } }

pages 数组里的第一个页面就是启动页,这决定了 App 冷启动后看到的第一个界面,常见误用是页面顺序写得随意,导致打开 App 先进了一个不该进的内页。tabBar 的 list 每一项的 pagePath 必须在 pages 数组里存在,漏配会直接编译报错。这里还有一个隐藏规则:tabBar 页面之间跳转要用 uni.switchTab,不能用 uni.navigateTo,后者在微信小程序端会因为找不到页面而静默失败,很折磨人。

style 里 navigationBarTitleText 是导航栏标题,在 H5 端映射成浏览器标签页标题,在小程序端是原生导航栏文字,在 App 端则要看是否启用了自定义导航栏。如果你要做一个沉浸式头部,就全局关闭原生导航栏,这套配置三端生效,但坑在于不同端的关闭方式不完全一样,这个后面单独展开。

3.2 页面生命周期和组件生命周期的触发顺序

跨端项目最常见的运行问题,是把页面生命周期和小程序自定义组件的生命周期混为一谈。uniapp 的页面不是普通 Vue 组件,它额外提供了一套生命周期:onLoad、onShow、onReady、onHide、onUnload。这些钩子的触发顺序,决定了你该在哪一步初始化数据。

export default { onLoad(options) { // 页面创建时触发,只触发一次,options 携带上一个页面传来的参数 console.log('页面加载', options) }, onShow() { // 每次页面切换到前台都触发,包括从后台返回 console.log('页面显示') }, onReady() { // 页面首次渲染完成,适合操作 canvas 或获取 dom 信息 console.log('页面就绪') } }

onLoad 只会在页面实例创建时走一次,适合放初始化请求;onShow 每次进入前台都会走,适合做需要实时刷新的逻辑,比如从详情页返回列表页后刷新当前筛选条件。常见翻车是开发者把请求全放在 onLoad 里,结果从二级页面返回时列表数据不更新,或者把请求放在 onShow 里导致每次切换都重复拉取。这里没有银弹,正确做法是:首次渲染的数据放 onLoad,依赖页面焦点变化的数据放 onShow。

另一个容易踩的点是组件生命周期。页面里的子组件走的是标准 Vue 生命周期 created、mounted,它们和页面的 onLoad 是平行关系,不是嵌套关系。实测下来,页面 onLoad 触发时,子组件 created 可能还没执行完,所以在 onLoad 里拿 this.$refs 经常拿到 undefined。要操作子组件方法,放在 onReady 里更稳。

3.3 vue3 项目统一用 Pinia:main.js 的导出方式先改对

Vue3 项目状态管理直接用 Pinia,Vue2 才继续用 Vuex。Pinia 对 TypeScript 支持和组合式 API 的融合度更好,而且 uniapp 官方在 Vue3 这条线上已经适配了 Pinia,不必担心跨端兼容性问题。接入方式和普通 Vue 项目基本一致,唯一的区别是 main.js 的导出结构。

// src/main.js import { createSSRApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' export function createApp() { const app = createSSRApp(App) app.use(createPinia()) return { app } }

注意这里的 createApp 导出的是工厂函数,不是直接挂载,这是 uniapp 的 Vue3 模板特有问题:每个端在启动时都会调用一次 createApp 来生成实例。如果你按普通 Vue 项目习惯写成const app = createApp(App); app.use(createPinia()); app.mount('#app'),在小程序端会直接挂载失败或导致状态不共享。初次迁移的人在这个文件上卡住的不在少数,但照着上面这个结构抄就不会有事。

状态定义本身和 Web 端完全相同,页面里通过useCounterStore()取实例、调用 action 更新状态。我实际用下来的体感是:跨页面同步的数据,比如用户登录态、全局筛选条件、购物车,放 Pinia 没问题;一次性页面间跳转传参,别放 Pinia,直接用 onLoad 的参数通道更直观,否则会积累大量“只被一个页面用过但挂在全局”的无效状态,项目越到后期越难维护。

4. 多端差异处理:条件编译与平台 API 封装的正确姿势

4.1 条件编译是注释不是 if:平台分支代码的写法边界

uniapp 跨端最核心的机制是条件编译。它的写法看起来像注释,但编译阶段会被真实解析:符合平台条件的代码进入产物,不符合的直接丢弃。这个机制和运行时判断有本质区别,很多人初学时不理解,把条件编译写成普通 if 判断,导致所有平台的代码都被打进包里,体积变大不说,还会出现在 A 端引用 B 端专属 API 导致的运行时报错。

// #ifdef MP-WEIXIN // 只有微信小程序会编译进这行代码 uni.showShareMenu({ withShareTicket: true }) // #endif // #ifdef H5 // 只有 H5 端会编译进这行代码 console.log('当前是浏览器环境') // #endif // #ifndef MP-WEIXIN // 非微信小程序平台都会编译进这行代码 console.log('不是微信小程序') // #endif

平台标识里,MP-WEIXIN 是微信小程序,MP-ALIPAY 是支付宝小程序,H5 是浏览器端,APP-PLUS 是 App 端。实际开发中,分享、支付、登录这三个功能几乎每个平台都有差异,条件编译是唯一能让代码保持干净的写法。注意这个语法在 JS、CSS、template 模板里都能用,CSS 里用注释包裹:/* #ifdef H5 */ ... /* #endif */。

条件编译还有一个容易被忽略的用法:用#ifndef做反向编译,即“除了某个平台都执行”。这个在写统一降级逻辑时很好用,比如支付在 App 端走原生 SDK,其他端统一走 H5 跳转。写条件编译的核心纪律是:分支不要嵌套太深,能拆成独立函数就拆,不要在一个函数里铺满三四个平台分支,否则后期同事改起来根本不知道哪个分支是有效的。

4.2 跨端 API 差异清单与统一封装:从 storage 到支付登录

uniapp 提供了很多 uni.* 前缀的跨端 API,但这个“跨端”是相对的,API 名称统一不代表行为统一。以 storage 为例,uni.setStorageSync 在三端都有,但 App 端这个同步方法底层是异步实现,调用后立刻 getStorageSync 可能拿不到刚写的值,这个细节放到第五章展开。支付和登录则是另一个极端:API 长得完全不一样,不封装就会把业务代码搅成一锅粥。

我常用的方式是做一个轻量封装层,只在差异明显的地方包一层:

// src/utils/platform.js export function login() { // #ifdef MP-WEIXIN return new Promise((resolve, reject) => { uni.login({ provider: 'weixin', success: (res) => resolve(res.code), fail: reject }) }) // #endif // #ifdef APP-PLUS return new Promise((resolve, reject) => { uni.login({ provider: 'apple', success: (res) => resolve(res.code), fail: reject }) }) // #endif // #ifdef H5 return Promise.reject(new Error('H5 端请走扫码登录')) // #endif }

这个封装解决的痛点很具体:业务层永远只调 login(),不需要关心自己在哪个端。注意里面用到的是 Promise 包装,因为不同平台登录成功后的回调结构不统一,统一转成 Promise 后,调用方可以用同一套 async/await 处理。类似的还有支付,App 端的支付需要先向服务端下单拿订单编号,再调 uni.requestPayment;小程序端的流程类似但参数不同。把这些逻辑收进一个 createPayment(order) 函数里,切换平台时才不会改得满屏都是。

还有一个容易忽略的差异是 URL 跳转。H5 端可以用 window.location,小程序端没有这个概念,需要用 uni.navigateTo 或 web-view 组件。如果项目里有落地页跳转外链的需求,这一块必须要做平台分化,否则 H5 能跑的代码在小程序端压崩溃。

4.3 自定义组件样式隔离:H5 与小程序的两套逻辑

组件化在 uniapp 里逃不掉,但样式隔离机制在三端表现不一样,这是页面“本地正常、真机错乱”的高发区。H5 端天然有 CSS 作用域,Vue 的 scoped 属性就能把样式锁在组件内;小程序端则默认开启样式隔离,父组件的样式传不进子组件。

<template> <view class="card"> <view class="card__title">标题</view> </view> </template> <style scoped> .card { border-radius: 8px; } /* H5 端穿透子组件内部样式,小程序端写法略有差异 */ :deep(.card__title) { font-size: 28rpx; } </style>

这里的关键词是 :deep(),它能把 scoped 样式穿透到子组件内部。H5 端这个写法完全兼容,小程序端在较新版本的基础库上也支持,但如果你遇到穿透无效,就要在小程序端手动处理组件样式隔离:给子组件增加options: { styleIsolation: 'apply-shared' }。这是小程序原生 API 提供的样式共享配置,允许页面样式渗入组件内部。

实践里我一般遵循三条纪律:第一,样式尽量写在组件自己身上,不要依赖父组件穿透,这是最省心的;第二,全局样式(字体、主题色)放 App.vue 里的公共样式表,并显式确认各端都能读到;第三,当遇到某个组件在小程序端样式怎么都不对时,先检查是不是样式隔离机制在作怪,再去怀疑 css 写法,排查顺序反了会浪费大量时间。

5. uniapp 避坑实录:五个反复出现的翻车场景

跨端开发的特点就是:每个端单独跑都正常,一合起来就出问题。下面五条是项目里反复出现的典型翻车,每一条都按现象、原因、解决三步拆开,照做能省不少排查时间。

5.1 跳转参数变成 undefined 字符串:onLoad 解析要防一手

现象:列表页跳详情页,onLoad 里打印 options.id 是 undefined,但跳转代码里明明传了 id。原因:页面路径如果是pages/detail/detail?id=123&name=测试,在小程序端参数会原样传递,但如果某个参数值本身含中文或特殊字符,部分端不会自动解码,导致取值错位。解决:跳转前对参数做 encodeURIComponent,接收后用 decodeURIComponent 还原。

// 跳转前 uni.navigateTo({ url: `/pages/detail/detail?id=${encodeURIComponent(id)}` }) // 接收处 onLoad(options) { const id = decodeURIComponent(options.id || '') }

这个坑在 H5 端通常不明显,因为浏览器会自动处理编码,但小程序端不总这么智能。另一个隐藏点是:onLoad 的 options 里所有值都是字符串,即使你传的是数字 123,拿到手也是字符串 "123",直接拿去做布尔判断会踩到 “'0' 为真” 的经典坑。养成习惯:onLoad 收到的参数一律先转类型再使用。

5.2 弹层出现后页面跟着滚动:滚动穿透的三种解法

现象:页面弹出底部弹层或筛选面板,手指在弹层上滑动,底下的页面也跟着滚。原因:小程序端的 web-view 和页面滚动是独立体系,弹层没有阻止事件冒泡,滚动事件穿透到了 page 层。解决按平台分三套写法。

<!-- 弹层最外层,小程序和 H5 通用拦截 --> <view class="mask" @touchmove.stop.prevent > <!-- 弹层内容 --> </view>

第一,小程序端在弹层根节点加@touchmove.stop.prevent,多数场景能直接解决。第二,处理不了时在页面级引入page-meta组件,设置disable-scroll动态控制页面滚动。第三,H5 端可以直接给 body 加overflow: hidden类名,小程序端没有 document.body,所以这条路不通。我的建议是优先用 stop.prevent,少动页面级滚动开关,因为 disable-scroll 在部分安卓机上有概率导致弹层关闭后页面滚动位置丢失。

5.3 App 端 setStorageSync 后立刻读不到:同步 API 的异步实现

现象:App 端登录成功后 uni.setStorageSync 写入 token,紧接着 uni.getStorageSync 读取,返回 null,但过 100ms 再读又能拿到。原因:uni 官方文档里明确写过,App 端的同步存储 API 底层实现是异步的,setStorageSync 只是把数据交给系统,不保证写入落盘完成。解决:App 端敏感数据写入改用异步版 setStorage,在回调里再处理后续逻辑。

// App 端这个写法更稳妥 uni.setStorage({ key: 'token', data: token, success() { // 写入完成的回调里再跳转或读值 } })

这里最坑的是:同样的代码在微信小程序端和 H5 端完全正常,一跑到 App 真机就偶发复现,很容易怀疑是时序问题。正规做法是把存储封装成统一函数,在 App 端走异步、其他端走同步,业务层不感知差异。同时,登录态非要立刻同步校验的话,可以把 token 同时放在内存变量里,不依赖 storage 的即时一致性。

5.4 vue3 的 setup 里 onLoad 未定义:组合式 API 导入路径

现象:用 Vue3 组合式 API 写页面,import { onLoad } from 'vue',编译报错 onLoad is not defined。原因:uniapp 的页面生命周期不是 Vue 自带的,它由 @dcloudio/uni-app 提供,从 'vue' 里根本导不出来。解决:换导入来源。

import { onLoad, onShow } from '@dcloudio/uni-app' onLoad((options) => { // 这里能拿到跳转参数 })

这个 API 在 uniapp 的 Vue3 项目里是组合式 API 风格的生命周期钩子,用法和 Vue 的 onMounted 一致,传入回调即可。注意它的回调接收的参数和选项式写法一样,onLoad 接收 options、onShow 不接收。另外,别把它和 Vue 的 onMounted 混用,页面级别的数据初始化用 onLoad,组件内部用 onMounted,两者触发时机完全不同。

5.5 真机白屏但模拟器正常:manifest 权限与合法域名

现象:在开发者工具里预览一切正常,一上真机就白屏或所有请求失败。原因:真机环境比模拟器严格得多。微信小程序真机要求所有请求域名在公众平台配置合法域名,且必须是 HTTPS;App 端则要求 manifest.json 里勾选对应的模块权限,比如定位、支付,漏勾了模块 API 调用直接无反

应。解决:两个地方逐一核对。

第一,小程序后台的“开发管理-服务器域名”加好 request 合法域名,开发模式下可在开发者工具里临时关闭域名校验,但真机预览不受这个开关控制。第二,App 端打开 manifest.json,在 App 模块配置里勾选实际用到的原生插件,再重新打包基座,只改代码不重打基座,权限配置不会生效。第三,如果是 H5 端真机预览白屏,多半是局域网 ip 访问被浏览器拦了,或者是部署路径的 base 配置不对。白屏问题我排查时固定顺序是:先看控制台有没有请求失败,再看 manifest 权限,最后看是不是代码在某个端编译报错,这个顺序能过滤掉八成问题。

6. 发布前的最后一个习惯:三端核查清单跑一遍

项目走到发版这一步,功能代码通常已经不是主要风险,配置遗漏才是。我现在的习惯是每次发版前花十分钟过一遍下面这张清单,能拦住大部分发版后才发现的问题。

检查项位置常见错误
小程序 AppID 与 request 合法域名manifest.json / 平台后台只配了开发域名,线上请求全被拦截
H5 路由基路径 router.basemanifest.json -> h5部署到子目录没配 base,刷新后 404
App 端模块权限manifest.json -> App 模块配置漏勾定位/支付模块,真机调用无响应
隐私协议与用户隐私保护指引平台后台收集用户信息未声明,审核被打回
主包体积与分包策略微信开发者工具主包超 2MB 无法上传

这套清单的验证方法不复杂:H5 端发布到测试环境后,用浏览器无痕模式完整走一遍注册登录流程;小程序端用真机预览模式跑同样的流程,重点看请求是否带上了 token、支付回调是否正常;App 端打自定义基座,把每个涉及原生能力的页面点一遍。三端跑完同一套业务冒烟用例,比看多少文档都管用。冒烟用例不需要多,覆盖登录、首页加载、详情跳转、支付、个人中心这五条主链路就够了,每条链路在三个端各走一遍,总共十五次操作,基本能把 80% 的发版问题拦下来。

我自己的血泪教训是:有次急着发版,只测了小程序端,结果 H5 端因为 router.base 没配,线上所有页面刷新后直接 404,用户反馈才意识到。从那以后,发版前跑一遍三端清单就成了固定动作,虽然每次要多花十分钟,但比起线上事故后的紧急修复,这十分钟实在太便宜。希望这份笔记能帮你在 uniapp 这条路上少踩几个坑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询