简介:这是一份基于5+ App、Mui框架与HBuilder开发工具的HTML5移动应用实战项目资源,面向前端初学者及希望快速构建跨平台H5 App的开发者,解决传统Web开发难以兼顾原生体验与多端适配的痛点。压缩包共13个文件,含4个核心JS文件(如mui.js、md5-min.js)、3个HTML页面(含index.html与login页)、2个CSS样式文件(mui.min.css等)、1个字体文件(mui.ttf)、1个项目配置文件(.project)、1个说明文档(README.md)和1个清单配置(manifest.json),总大小仅123KB,轻量易学。已有831人学习下载,适合边学边练。资源提供完整可运行的h5app-master源码结构,涵盖页面路由、UI组件调用、本地存储集成等典型场景,代码组织清晰,注释规范,配合HBuilder热更新与5+ API调用示例,可直接用于理解移动端H5开发全流程,是掌握HTML5+JavaScript+Mui三者协同开发的优质入门实践素材。
1. 这不是“写个网页放手机里”:一个能真机调试、打包上架、热更新不发版的H5混合应用工程
你有没有试过用vue create起个项目,npm run serve在浏览器里跑得飞起,一塞进手机 WebView 就卡顿掉帧、摄像头打不开、下拉刷新没反馈、离线直接白屏?这不是你代码的问题——是缺了容器层能力调度。这个h5app项目,就是用5+ App(即 DCloud 的 uni-app 底层运行时) + MUI 框架 + HBuilder IDE搭建的一套完整 H5 混合应用落地工程。它不是 demo,而是某高校实验室交付给本地政务小程序的轻量级外勤终端原型:支持扫码定位、表单离线缓存、拍照压缩上传、原生侧滑菜单、iOS/Android 双平台一键打包、以及最关键的——JS 逻辑热更新无需用户重装 APK/IPA。它用纯 JavaScript 实现了接近原生的交互体验,所有 API 调用都走plus.*原生桥接,MUI 提供的不是 UI 组件库,而是一套与 5+ Runtime 深度耦合的 DOM 事件代理和样式重置方案。如果你正卡在“H5 做不了原生功能”或“uni-app 太重想轻量可控”,这个项目就是你该拆的第一份真实工程。
2. 工程结构与技术栈解剖:为什么不用 Vue CLI,而选 HBuilder + 5+ App?
这个项目不是 Webpack 构建出来的 SPA,它的构建生命周期完全由 HBuilder 驱动,最终产物是一个www目录 +manifest.json+unpackage配置的混合包。理解它的结构,是避免后续所有“为什么改了代码不生效”“为什么真机报 plus is not defined”的前提。
2.1 目录结构:www是唯一源码根,nativeplugins是能力扩展区
项目解压后核心目录如下(已过滤.DS_Store、node_modules等无关项):
h5app/ ├── www/ # 【唯一前端源码目录】所有 HTML/JS/CSS/图片都在这里 │ ├── index.html # 入口页,含 <script src="js/main.js"></script> │ ├── css/ │ │ └── mui.min.css # MUI 官方压缩版 CSS(非 npm install) │ ├── js/ │ │ ├── main.js # 启动入口:plus ready + mui init + 页面路由 │ │ ├── api/ # 封装好的业务 API,如 upload.js、location.js │ │ └── util/ # 工具函数:date-format.js、storage.js(封装 plus.storage) │ ├── images/ │ └── pages/ # 页面级 HTML:form.html、list.html、detail.html ├── manifest.json # 【关键配置】AppID、名称、图标、启动图、权限声明(相机/定位/文件读写) ├── unpackage/ # 【打包配置】Android/iOS 签名证书路径、渠道包配置、自定义原生插件引用 ├── nativeplugins/ # 【能力增强】第三方原生插件目录(如 barcode 扫码、speech 语音识别) └── res/ # 启动图、图标资源(按 iOS/Android 分辨率存放)提示:
www目录必须存在且为根,HBuilder 打包时只认这个目录;main.js里不能用import语法(无 ES Module 支持),全部用document.write或script.src动态加载,这是 5+ App 的 JS 执行环境限制。
2.2 技术栈真相:JavaScript 是唯一语言,但 runtime 是 5+ App,不是 Chrome
很多人误以为“H5 = 浏览器”,但在这个项目里,JavaScript 运行在 5+ App 自研的 WebView 容器中,它提供了plus.*全局对象,这是整个工程的能力基石:
| 模块 | 典型用法 | 说明 |
|---|---|---|
plus.runtime | plus.runtime.restart() | 控制应用重启、检查更新、获取版本号 |
plus.camera | plus.camera.getCamera().captureImage(cb) | 调用原生相机,返回本地文件路径(非 base64) |
plus.gallery | plus.gallery.pick(cb) | 从相册选择图片,支持裁剪 |
plus.geolocation | plus.geolocation.getCurrentPosition(cb) | 获取高精度 GPS 坐标(需 manifest 声明权限) |
plus.storage | plus.storage.setItem('token', 'xxx') | 持久化存储,比 localStorage 更可靠(进程杀死不丢失) |
plus.webview | plus.webview.create('page.html') | 创建新页面窗口,支持原生动画、传参、事件通信 |
MUI 并非独立框架,它本质是一套针对 5+ App 环境优化的 CSS + JS 工具集:
mui.init()会自动监听plusready事件,并注册pullRefresh、swipeBack等手势;mui.openWindow()内部调用plus.webview.create()+show();- 所有
mui-*class(如mui-table-view)的样式都预设了-webkit-overflow-scrolling: touch和transform: translateZ(0),解决 iOS 滚动卡顿。
2.3 HBuilder 不是编辑器,是构建引擎:.html文件即入口,无index.js入口概念
你不会看到main.js被 webpack 打包成app.js,因为 HBuilder 的构建逻辑是:
- 读取
www/index.html; - 解析其
<script>标签,按顺序执行(无模块化); - 若
index.html中引入js/main.js,则执行该文件; main.js中调用mui.init()→ 等待plusready→ 初始化页面路由 → 渲染首屏。
所以main.js的典型结构是:
// www/js/main.js document.addEventListener('plusready', function() { // plus 对象已就绪 console.log('5+ App ready, version:', plus.runtime.version); // 初始化 MUI mui.init({ swipeBack: true, // 右滑返回 keyEventBind: { backbutton: false, // 禁用物理返回键默认行为(由 JS 控制) menubutton: false } }); // 注册全局事件:比如下拉刷新触发重新加载数据 document.addEventListener('refresh', function() { mui('#pullrefresh').pullRefresh().endPulldownToRefresh(); loadListData(); // 自定义业务函数 }); // 启动页面路由(简单 hash 路由) window.addEventListener('hashchange', onHashChange); onHashChange(); }, false); function onHashChange() { const page = location.hash.replace('#', '') || 'home'; // 显示对应页面内容(通过 show/hide DOM) document.querySelectorAll('.page').forEach(el => el.style.display = 'none'); document.getElementById(page).style.display = 'block'; }这段代码没有export,没有Vue.createApp,但它在真机上能响应右滑返回、下拉刷新、物理按键——因为mui.init()和plusready事件把 JS 和原生能力牢牢绑定了。
3. 核心模块拆解:一个表单提交请求从点击到完成的完整链路
我们以pages/form.html中的“外勤打卡表单”为例,拆解一次完整交互:用户点击【提交】→ 调用相机拍照 → 压缩图片 → 上传至服务器 → 返回成功提示。这不是 fetch 一个 URL,而是一条横跨 JS、WebView、原生 SDK、网络层的链路。
3.1 HTML 层:语义化结构 + MUI class +><div id="form-page" class="page"> <header class="mui-bar mui-bar-nav"> <h1 class="mui-title">外勤打卡</h1> </header> <div class="mui-content"> <form id="workForm"> <div class="mui-input-row"> <label>姓名</label> <input type="text" name="name" required /> </div> <div class="mui-input-row"> <label>位置</label> <input type="text" name="location" id="locationInput" readonly /> <span class="mui-icon mui-icon-location" onclick="getLocation()"></span> </div> <div class="mui-input-row"> <label>现场照片</label> <div class="mui-card"> <div class="mui-card-header">已拍摄</div> <div class="mui-card-content"> <img id="photoPreview" src="" style="width:100%;display:none;" /> </div> <div class="mui-card-footer"> <button type="button" class="mui-btn mui-btn-blue" onclick="takePhoto()">拍照</button> </div> </div> </div> <div class="mui-content-padded"> <button type="submit" class="mui-btn mui-btn-block mui-btn-primary">提交打卡</button> </div> </form> </div> </div>
注意:
onclick="takePhoto()"是直接绑定全局函数,非 Vue 指令;id="photoPreview"用于后续 JS 操作 DOM;class="mui-*"触发 MUI 默认样式和行为(如mui-btn-block自动占满宽度)。
3.2 JS 层:plus.camera+plus.compressImage+plus.uploader三段式调用
www/js/api/upload.js封装了完整的图片处理流程:
// www/js/api/upload.js // 1. 调用相机拍照 function takePhoto() { const cmr = plus.camera.getCamera(); cmr.captureImage( function(path) { // 成功回调:path 是本地文件路径,如 "_www/images/123.jpg" compressAndShow(path); }, function(err) { // 失败回调 mui.toast('拍照失败:' + err.message); }, {filename: '_www/images/' + Date.now() + '.jpg'} // 指定保存路径(相对 www 目录) ); } // 2. 压缩图片(避免大图上传超时) function compressAndShow(imgPath) { plus.zip.compressImage({ src: imgPath, dst: imgPath.replace('.jpg', '_compress.jpg'), quality: 80, // 压缩质量 1-100 overwrite: true }, function(event) { document.getElementById('photoPreview').src = event.target; document.getElementById('photoPreview').style.display = 'block'; }, function(err) { mui.toast('压缩失败:' + err.message); }); } // 3. 上传图片(带表单字段) function uploadForm(formData) { const uploader = plus.uploader.createUpload( 'https://api.example.com/v1/checkin', // 后端接口 { method: 'POST', blocksize: 204800, // 分片大小 200KB priority: 100 } ); // 添加表单字段(文本) uploader.addFile('_www/images/123_compress.jpg', {key: 'photo'}); uploader.addFile('', {key: 'name', value: formData.name}); uploader.addFile('', {key: 'location', value: formData.location}); // 上传进度监听 uploader.addEventListener('statechanged', function(upload, status) { if (upload.state === 2) { // 上传完成 try { const result = JSON.parse(upload.responseText); if (result.code === 0) { mui.toast('打卡成功!'); setTimeout(() => { location.hash = '#home'; // 返回首页 }, 1000); } else { throw new Error(result.msg || '提交失败'); } } catch (e) { mui.toast('解析响应失败:' + e.message); } } }); uploader.start(); // 启动上传 }关键点说明:
plus.uploader是 5+ App 提供的断点续传、分片上传、后台静默上传能力,比fetch更健壮;addFile('', {key: 'name', value: ...})表示添加文本字段,addFile(path, {key: 'photo'})表示添加文件字段;uploader.addEventListener('statechanged')是唯一可靠的上传完成监听方式,onload事件不可靠;- 所有路径都是相对于
www目录的相对路径,_www/images/...表示www/images/...。
3.3 后端适配要点:接收 multipart/form-data,返回标准 JSON
后端(以 Node.js Express 为例)需正确解析 multipart:
// server.js const multer = require('multer'); const upload = multer({ dest: 'uploads/' }); // 临时存储目录 app.post('/v1/checkin', upload.fields([ { name: 'photo', maxCount: 1 }, { name: 'name', maxCount: 1 }, { name: 'location', maxCount: 1 } ]), (req, res) => { const { name, location } = req.body; const photoFile = req.files['photo'][0]; // multer 解析后的文件对象 // 业务逻辑:保存图片、写入数据库... saveCheckin({ name, location, photoPath: photoFile.path }) .then(() => res.json({ code: 0, msg: 'ok' })) .catch(err => res.status(500).json({ code: -1, msg: err.message })); });注意:5+ App 的
plus.uploader发送的是标准multipart/form-data,与 Postman、curl 完全兼容,无需特殊 header。
4. 真机调试与打包避坑指南:那些让你加班到凌晨的“玄学”问题
这个项目最痛苦的阶段不是写代码,而是真机连不上、打包失败、API 报 undefined。我整理了在某公司交付三个类似项目过程中踩过的 5 个高频坑,每一条都附带现象、根因和可立即验证的解决步骤。
4.1 现象:plus is not defined—— 最经典的“找不到 plus 对象”
- 现象:H5 页面在 Chrome 里打开正常,但用 HBuilder 真机运行或打包后,控制台报
ReferenceError: plus is not defined,所有plus.*调用全部失效。 - 原因:
plusready事件未触发,或触发时机错误。常见于:- 在
document.addEventListener('DOMContentLoaded', ...)中调用plus.*(此时 plus 还未注入); www/index.html中<script>标签写在<body>底部,但plusready是异步事件,脚本执行完才触发;- 使用了
async或defer加载main.js,导致执行顺序错乱。
- 在
- 解决:
- 确保所有
plus.*调用都包裹在document.addEventListener('plusready', ...)内; main.js必须用同步<script src="js/main.js"></script>引入,且放在</body>之前;- 在
plusready回调内加一句console.log('plus.version:', plus.runtime.version),确认是否真的触发。
- 确保所有
4.2 现象:拍照后path是空字符串或null
- 现象:调用
plus.camera.getCamera().captureImage()后,成功回调里的path参数为空,或根本没进回调。 - 原因:Android 权限未动态申请,或 iOS
Info.plist缺少隐私描述。 - 解决:
- Android:在
manifest.json的permissions节点中,确保包含"Camera": {}和"Storage": {}; - iOS:在
unpackage/res/ios/Info.plist中添加:<key>NSCameraUsageDescription</key> <string>需要访问相机用于外勤拍照</string> <key>NSPhotoLibraryUsageDescription</key> <string>需要访问相册用于选择图片</string> - 通用:首次调用前,手动加权限检查:
if (plus.os.name === 'Android') { plus.android.requestPermissions(['android.permission.CAMERA'], () => console.log('授权成功'), (e) => console.log('授权失败', e)); }
- Android:在
4.3 现象:mui.pullRefresh()下拉没反应,或下拉后不回弹
- 现象:页面设置了下拉刷新,但手指下拉时无任何视觉反馈,松手后也不触发
refresh事件。 - 原因:MUI 的 pullRefresh 依赖
position: relative的父容器,且要求容器高度固定或可滚动。 - 解决:
- 确保
#pullrefresh元素的父容器有style="height: 100%; overflow-y: auto;"; - 在
mui.init()后,手动初始化:mui('#pullrefresh').pullToRefresh({ down: { height: 50, range: 100, contentdown: '下拉可以刷新', contentover: '释放立即刷新', contentrefresh: '正在刷新...' }, up: { contentrefresh: '正在加载...', contentnomore: '没有更多数据了' } }); - 关键:
#pullrefresh必须是div,且内部第一个子元素是div.mui-scroll-wrapper,再内嵌div.mui-scroll,否则 MUI 无法绑定事件。
- 确保
4.4 现象:打包 Android APK 后安装失败,提示“Parse error”
- 现象:HBuilder 打包生成
h5app-release-signed.apk,安装时报错There is a problem parsing the package。 - 原因:
manifest.json中id字段格式非法(含下划线、大写字母、长度超限),或签名证书过期/不匹配。 - 解决:
manifest.json的id必须是反向域名格式,全小写,仅含字母、数字、点、短横线,如"id": "com.example.h5app";- 检查
unpackage目录下的android子目录:release-signing.properties中key.store路径是否正确;key.alias是否与 keystore 中 alias 一致(可用keytool -list -v -keystore your.keystore查看);
- 血泪经验:每次修改
manifest.json后,务必在 HBuilder 中右键项目 → “清理项目”,否则旧配置可能被缓存。
4.5 现象:iOS 真机调试白屏,Xcode 控制台报Failed to load resource: The requested URL was not found
- 现象:HBuilder 连接 iPhone,点击“真机运行”,手机 Safari Web Inspector 显示白屏,Network 面板全是 404。
- 原因:HBuilder 的 iOS 调试模式依赖
localhost:8080代理,但 iPhone 的 WiFi 设置未指向开发机 IP,或防火墙拦截。 - 解决:
- 开发机关闭防火墙(Windows Defender / macOS 防火墙);
- iPhone 连接同一 WiFi,在「设置 → Wi-Fi → 当前网络右侧ⓘ → HTTP 代理 → 手动」中:
- 服务器:填开发机局域网 IP(如
192.168.1.100); - 端口:
8080;
- 服务器:填开发机局域网 IP(如
- HBuilder 中「运行 → 运行到手机或模拟器 → iOS 设备」,等待提示“已连接设备”后再操作。
5. 热更新实战:不发新版,JS 逻辑秒级生效的改造方法
热更新是这个项目最值钱的能力——当线上发现一个表单校验 bug,你不需要等 App Store 审核 24 小时,只需替换服务器上的 JS 文件,用户下次打开就自动生效。但实现它不是简单改个src地址,而是一套版本控制 + 下载校验 + 原子替换的机制。
5.1 更新策略设计:基于manifest.json的版本号 + JS 文件哈希
我们不采用 5+ App 内置的plus.runtime.getProperty('version')做判断(太粗粒度),而是为每个 JS 模块维护独立版本:
- 在
www/js/version.json中声明当前版本:{ "main": "1.2.3", "api": "1.0.5", "util": "1.1.0" } - 每次发布前,用脚本生成
www/js/manifest.js(含文件哈希):// 由 build.sh 自动生成 window.UPDATE_MANIFEST = { "main.js": "a1b2c3d4e5f67890", "api/upload.js": "f0e1d2c3b4a56789", "util/storage.js": "9876543210abcdef" };
5.2 更新检查与下载:plus.downloader替代XMLHttpRequest
www/js/update.js实现静默检查:
function checkUpdate() { // 1. 读取本地 version.json plus.io.resolveLocalFileSystemURL('_www/js/version.json', function(entry) { entry.file(function(file) { const reader = new FileReader(); reader.onload = function(e) { const localVer = JSON.parse(e.target.result); // 2. 请求远程 version.json const xhr = new XMLHttpRequest(); xhr.open('GET', 'https://cdn.example.com/h5app/js/version.json?t=' + Date.now()); xhr.onload = function() { const remoteVer = JSON.parse(xhr.responseText); if (remoteVer.api !== localVer.api) { downloadAndUpdate('api/upload.js', remoteVer.api); } }; xhr.send(); }; reader.readAsText(file); }); }); } function downloadAndUpdate(filePath, ver) { const dt = plus.downloader.createDownload( 'https://cdn.example.com/h5app/js/' + filePath + '?v=' + ver, { filename: '_doc/update/' + filePath } // 下载到私有目录,用户不可见 ); dt.addEventListener('statechanged', function(d, status) { if (d.state === 4 && status === 200) { // 下载完成 // 3. 原子替换:先备份,再移动 plus.io.resolveLocalFileSystemURL('_www/js/' + filePath, function(oldEntry) { plus.io.resolveLocalFileSystemURL('_doc/update/' + filePath, function(newEntry) { oldEntry.moveTo(null, 'backup_' + filePath, function() { newEntry.moveTo(plus.io.convertLocalFileSystemURL('_www/js/'), filePath, function() { mui.toast('已更新 ' + filePath + ',重启生效'); // 可选:自动 reload 页面 // location.reload(true); }); }); }); }); } }); dt.start(); }关键点:
_doc/update/是 5+ App 的私有下载目录,比_www/更安全;moveTo是原子操作,避免替换中途崩溃导致文件损坏。
5.3 启动时强制检查:main.js开头插入更新钩子
在www/js/main.js最顶部加入:
// www/js/main.js // 热更新检查(仅在 plusready 后执行,避免重复) let updateChecked = false; function doUpdateCheck() { if (updateChecked) return; updateChecked = true; if (plus.networkinfo.getCurrentType() !== 'none') { // 网络正常时检查 setTimeout(() => { checkUpdate(); }, 3000); // 延迟 3s,避免影响首屏渲染 } } document.addEventListener('plusready', function() { doUpdateCheck(); // plusready 时触发检查 // ... 后续初始化逻辑 });5.4 后端 CDN 配置:强制缓存 JS,但 version.json 不缓存
Nginx 配置示例(关键在于分离缓存策略):
# version.json 永远不缓存,确保实时检查 location /js/version.json { add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Pragma "no-cache"; add_header Expires "0"; } # JS 文件强缓存 1 年,靠 ?v=xxx 版本号更新 location ~* \.js$ { expires 1y; add_header Cache-Control "public, immutable"; }这样,用户第一次访问加载version.json(无缓存)→ 发现新版本 → 下载带?v=1.0.5的 JS(CDN 命中)→ 替换成功。整个过程用户无感知,JS 逻辑更新零延迟。
从那以后我每次上线 JS 逻辑变更,都强制走一遍checkUpdate()+downloadAndUpdate()的本地实测流程,哪怕只是改一行console.log。因为热更新不是“能用就行”,而是“必须原子、必须幂等、必须可回滚”。希望帮到你。
本文还有配套的精品资源,点击获取