}
阻止原生返回事件
开发中可能会遇到下面这个需求:当页面弹出一个 popup 或 dialog 组件时,点击返回键时是隐藏弹出的组件而不是返回到上一个页面。
为了解决这个问题,我们可以从路由栈角度思考。一般弹出组件是不会在路由栈上添加任何记录,因此我们在弹出组件时,可以在路由栈中 push 一个记录,为了不让页面跳转,我们可以把跳转的目标路由设置为当前页面路由,并加上一个 query 来标记这个组件弹出的状态。
然后监听 query 的变化,当点击弹出组件时,query 中与该弹出组件有关的标记变为 true,则将弹出组件设为显示;当用户点击 native 返回键时,路由返回上一个记录,仍然是当前页面路由,不过 query 中与该弹出组件有关的标记不再是 true 了,这样我们就可以把弹出组件设置成隐藏,同时不会返回上一个页面。相关代码如下:
<van-cell title=“几时入坑”
is-link
:value=“textData.pitDateStr”
@click=“goToSelect(‘calendar’)” />
<van-popup v-model=“showCalendar”
position=“right”
:style=“{ height: ‘100%’, width: ‘100%’ }”>
<Calendar title=“选择入坑时间”
@select=“onSelectPitDate” />
通过 UA 获取设备信息
在开发 h5 开发时,可能会遇到下面几种情况:
-
开发时都是在浏览器进行开发调试的,所以需要避免调用 native 的接口,因为这些接口在浏览器环境根本不存在;
-
有些情况需要区分所在环境是在 android webview 还是 ios webview,做一些针对特定平台的处理;
-
当 h5 版本已经更新,但是客户端版本并没有同步更新,那么如果之间的接口调用发生了改变,就会出现调用出错。
所以需要一种方式来检测页面当前所处设备的平台类型、app 版本、系统版本等,目前比较靠谱的方式是通过 android / ios webview 修改 UserAgent,在原有的基础上加上特定后缀,然后在网页就可以通过 UA 获取设备相关信息了。当然这种方式的前提是 native 代码是可以为此做出改动的。以安卓为例关键代码如下:
安卓关键代码:
// Activity -> onCreate
…
// 获取 app 版本
PackageManager packageManager = getPackageManager();
PackageInfo packInfo = null;
try {
// getPackageName()是你当前类的包名,0代表是获取版本信息
packInfo = packageManager.getPackageInfo(getPackageName(),0);
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
String appVersion = packInfo.versionName;
// 获取系统版本
String systemVersion = android.os.Build.VERSION.RELEASE;
mWebSettings.setUserAgentString(
mWebSettings.getUserAgentString() + " DSBRIDGE_" + appVersion + “_” + systemVersion + “_android”
);
h5 关键代码:
const initDeviceInfo = () => {
const UA = navigator.userAgent;
const info = UA.match(/s{1}DSBRIDGE[w.]+$/g);
if (info && info.length > 0) {
const infoArray = info[0].split(‘_’);
window.$appVersion = infoArray[1];
window.$systemVersion = infoArray[2];
window.$platform = infoArray[3] as Platform;
} else {
window.$appVersion = undefined;
window.$systemVersion = undefined;
window.$platform = ‘browser’;
}
};
mock 数据
Mock[71]
当前后端进度不一致,接口还尚未实现时,为了不影响彼此的进度,此时前后端约定好接口数据格式后,前端就可以使用 mock 数据进行独立开发了。本项目使用了 Mock 实现前端所需的接口。
调试控制台
eruda[72]
vconsole[73]
在调试方面,本项目使用 eruda 作为手机端调试面板,功能相当于打开 PC 控制台,可以很方便地查看 console, network, cookie, localStorage 等关键调试信息。与之类似地工具还有微信的前端研发团队开发的 vconsole,各位可以选择适合自己项目的工具。
关于 eruda 使用,推荐使用 cdn 方式加载,至于什么时候加载 eruda,可以根据不同项目制定不同策略。示例代码如下:
抓包工具
charles[74]
fiddler[75]
虽然有了 eruda 调试工具,但某些情况下仍不能满足需求,比如现网完全关闭 eruda 等情况。
此时就需要抓包工具,相关工具主要就是上面罗列的这两个,各位可以选择适合自己项目的工具。
通过 charles 可以清晰的查看所有请求的信息(注:https 下抓包需要在手机上配置相关证书)。当然 charles 还有更多强大功能,比例模拟弱网情况,资源映射等。
推荐一篇不错的 charles 使用教程:
解锁 Charles 的姿势[76]
异常监控平台
sentry[77]
移动端网页相对 PC 端,主要有设备众多,网络条件各异,调试困难等特点。导致如下问题:
-
设备兼容或网络异常导致只有部分情况下才出现的 bug,测试无法全面覆盖
-
无法获取出现 bug 的用户的设备,又不能复现反馈的 bug
-
部分 bug 只出现几次,后面无法复现,不能还原事故现场
这时就非常需要一个异常监控平台,将异常实时上传到平台,并及时通知相关人员。
相关工具有 sentry,fundebug 等,其中 sentry 因为功能强大,支持多平台监控(不仅可以监控前端项目),完全开源,可以私有化部署等特点,而被广泛采纳。
下面是 sentry 在本项目应用时使用的相关配套工具。
sentry 针对 javascript 的 sdk
sentry-javascript[78]
自动上传 sourcemap 的 webpack 插件
sentry-webpack-plugin[79]
编译时自动在 try catch 中添加错误上报函数的 babel 插件
babel-plugin-try-catch-error-report[80]
补充:
前端的异常主要有以下几个部分:
-
静态资源加载异常
-
接口异常(包括与后端和 native 的接口)
-
js 报错
-
网页崩溃
其中静态资源加载失败,可以通过 window.addEventListener(‘error’, …, true) 在事件捕获阶段获取,然后筛选出资源加载失败的错误并手动上报错误。核心代码如下:
// 全局监控资源加载错误
window.addEventListener(
‘error’,
(event) => {
// 过滤 js error
const target = event.target || event.srcElement;
const isElementTarget =
target instanceof HTMLScriptElement ||
target instanceof HTMLLinkElement ||
target instanceof HTMLImageElement;
if (!isElementTarget) {
return false;
}
// 上报资源地址
const url =
(target as HTMLScriptElement | HTMLImageElement).src ||
(target as HTMLLinkElement).href;
this.log({
error: new Error(ResourceLoadError: ${url}
),
type: ‘resource load’
});
},
true
);
关于服务端接口异常,可以通过在封装的 http 模块中,全局集成上报错误函数(native 接口的错误上报类似,可在项目中查看)。核心代码如下:
function errorReport(
url: string,
error: string | Error,
requestOptions: AxiosRequestConfig,
response?: AnyObject
) {
if (window.$sentry) {
const errorInfo: RequestErrorInfo = {
error: typeof error === ‘string’ ? new Error(error) : error,
type: ‘request’,
requestUrl: url,
requestOptions: JSON.stringify(requestOptions)
};
if (response) {
errorInfo.response = JSON.stringify(response);
}
window.$sentry.log(errorInfo);
}
}
关于全局 js 报错,sentry 针对的前端的 sdk 已经通过 window.onerror 和 window.addEventListener(‘unhandledrejection’, …, false) 进行全局监听并上报。
需要注意的是其中 window.onerror = (message, source, lineno, colno, error) =>{} 不同于 window.addEventListener(‘error’, …),window.onerror 捕获的信息更丰富,包括了错误字符串信息、发生错误的 js 文件,错误所在的行数、列数、和 Error 对象(其中还会有调用堆栈信息等)。所以 sentry 会选择 window.onerror 进行 js 全局监控。
但有一种错误是 window.onerror 监听不到的,那就是 unhandledrejection 错误,这个错误是当 promise reject 后没有 catch 住所引起的。当然 sentry 的 sdk 也已经做了监听。
针对 vue 项目,也可对 errorHandler 钩子进行全局监听,react 的话可以通过 componentDidCatch 钩子,vue 相关代码如下:
// 全局监控 Vue errorHandler
Vue.config.errorHandler = (error, vm, info) => {
window.$sentry.log({
error,
type: ‘vue errorHandler’,
vm,
info
});
};
但是对于我们业务中,经常会对一些以报错代码使用 try catch,这些错误如果没有在 catch 中向上抛出,是无法通过 window.onerror 捕获的,针对这种情况,笔者开发了一个 babel 插件 babel-plugin-try-catch-error-report[81],该插件可以在 babel[82] 编译 js 的过程中,通过在 ast 中查找 catch 节点,然后再 catch 代码块中自动插入错误上报函数,可以自定义函数名,和上报的内容(源码所在文件,行数,列数,调用栈,以及当前 window 属性,比如当前路由信息 window.location.href)。相关配置代码如下:
if (!IS_DEV) {
plugins.push([
‘try-catch-error-report’,
{
expression: ‘window.$sentry.log’,
needFilename: true,
needLineNo: true,
needColumnNo: false,
needContext: true,
exclude: [‘node_modules’]
}
]);
}
针对跨域 js 问题,当加载的不同域的 js 文件时,例如通过 cdn 加载打包后的 js。如果 js 报错,window.onerror 只能捕获到 script error,没有任何有效信息能帮助我们定位问题。此时就需要我们做一些事情:第一步、服务端需要在返回 js 的返回头设置 Access-Control-Allow-Origin: *第二部、设置 script 标签属性 crossorigin,代码如下:
如果是动态添加的,也可动态设置:
const script = document.createElement(‘script’);
script.crossOrigin = ‘anonymous’;
script.src = url;
document.body.appendChild(script);
针对网页崩溃问题,推荐一个基于 service work 的监控方案,相关文章已列在下面的。如果是 webview 加载网页,也可以通过 webview 加载失败的钩子监控网页崩溃等。
如何监控网页崩溃?[83]
最后,因为部署到线上的代码一般都是经过压缩混淆的,如果没有上传 sourcemap 的话,是无法定位到具体源码的,可以现在 项目中添加 .sentryclirc 文件,其中内容可参考本项目的 .sentryclirc,然后通过 sentry-cli (需要全局全装 sentry-cli 即npm install sentry-cli
)命令行工具进行上传,命令如下:
sentry-cli releases -o 机构名 -p 项目名 files 版本 upload-sourcemaps sourcemap 文件相对位置 --url-prefix js 在线上相对根目录的位置 --rewrite
// 示例
sentry-cli releases -o mcukingdom -p hello-world files 0.2.1 upload-sourcemaps dist/js --url-prefix ‘~/js/’ --rewrite
当然官方也提供了 webpack 插件 sentry-webpack-plugin[84],当打包时触发 webpack 的 after-emit 事件钩子(即生成资源到 output 目录之后),插件会自动上传打包目录中的 sourcemap 和关联的 js,相关配置可参考本项目的 vue.config.js 文件。
通常为了安全,是不允许在线上部署 sourcemap 文件的,所以上传 sourcemap 到 sentry 后,可手动删除线上 sourcemap 文件。
常见问题
- iOS WKWebView cookie 写入慢以及易丢失
现象:
**原因:**WKWebView 对 NSHTTPCookieStorage 写入 cookie,不是实时存储的。从实际的测试中发现,不同的 IOS 版本,延迟的时间还不一样。同样,发起请求时,也不是实时读取,无法做到和 native 同步,导致页面逻辑出错。
两种解决办法:
各位可以选择适合自己项目的方式,有更好的处理方式欢迎留言。
-
客户端手动干预一下 cookie 的存储。将服务响应的 cookie,持久化到本地,在下次 webview 启动时,读取本地的 cookie 值,手动再去通过 native 往 webview 写入。但是偶尔还有 spa 的页面路由切换的时候丢失 cookie 的问题。
-
将 cookie 存储的 session 持久化到 localSorage,每次请求时都会取 localSorage 存储的 session,并在请求头部添加 cookieback 字段,服务端鉴权时,优先校验 cookieback 字段。这样即使 cookie 丢失或存储的上一次的 session,都不会有影响。不过这种方式相当于绕开了 cookie 传输机制,无法享受 这种机制带来的安全特性。
-
iOS 登陆后立即进入网页,会出现 cookie 获取不到或获取的上一次登陆缓存的 cookie
-
重启 App 后,cookie 会丢失
- input 标签在部分安卓 webview 上无法实现上传图片功能
因为 Android 的版本碎片问题,很多版本的 WebView 都对唤起函数有不同的支持。我们需要重写 WebChromeClient 下的 openFileChooser()(5.0 及以上系统回调 onShowFileChooser())。我们通过 Intent 在 openFileChooser()中唤起系统相机和支持 Intent 的相关 app。
相关文章:【Android】WebView 的 input 上传照片的兼容问题[85]
- input 标签在 iOS 上唤起软键盘,键盘收回后页面不回落(部分情况页面看上去已经回落,实际结构并未回落)
input 焦点失焦后,ios 软键盘收起,但没有触发 window resize,导致实际页面 dom 仍然被键盘顶上去–错位。解决办法:全局监听 input 失焦事件,当触发事件后,将 body 的 scrollTop 设置为 0。
document.addEventListener(‘focusout’, () => {
document.body.scrollTop = 0;
});
- 唤起软键盘后会遮挡输入框
当 input 或 textarea 获取焦点后,软键盘会遮挡输入框。解决办法:全局监听 window 的 resize 事件,当触发事件后,获取当前 active 的元素并检验是否为 input 或 textarea 元素,如果是则调用元素的 scrollIntoViewIfNeeded 即可。
window.addEventListener(‘resize’, () => {
// 判断当前 active 的元素是否为 input 或 textarea
if (
document.activeElement!.tagName === ‘INPUT’ ||
document.activeElement!.tagName === ‘TEXTAREA’
) {
setTimeout(() => {
// 原生方法,滚动至需要显示的位置
document.activeElement!.scrollIntoView();
}, 0);
}
});
- 唤起键盘后
position: fixed;bottom: 0px;
元素被键盘顶起
解决办法:全局监听 window 的 resize 事件,当触发事件后,获取 id 名为 fixed-bottom 的元素(可提前约定好如何区分定位在窗口底部的元素),将其设置成 display: none
。键盘收回时,则设置成 display: block;
。
const clientHeight = document.documentElement.clientHeight;
window.addEventListener(‘resize’, () => {
const bodyHeight = document.documentElement.clientHeight;
const ele = document.getElementById(‘fixed-bottom’);
if (!ele) return;
if (clientHeight > bodyHeight) {
(ele as HTMLElement).style.display = ‘none’;
} else {
(ele as HTMLElement).style.display = ‘block’;
}
});
- 点击网页输入框会导致网页放大通过 viewport 设置 user-scalable=no 即可,(注意:当 user-scalable=no 时,无需设置 minimum-scale=1, maximum-scale=1,因为已经禁止了用户缩放页面了,允许的缩放范围也就不存在了)。代码如下:
<meta
name=“viewport”
content=“width=device-width,initial-scale=1.0,user-scalable=0,viewport-fit=cover”
/>
- webview 通过 loadUrl 加载的页面运行时却通过第三方浏览器打开,代码如下
// 创建一个 Webview
Webview webview = (Webview) findViewById(R.id.webView);
// 调用 Webview loadUrl
webview.loadUrl(“http://www.baidu.com/”);
解决办法:在调用 loadUrl 之前,设置下 WebviewClient 类,当然如果需要也可自己实现 WebviewClient(例如通过拦截 prompt 实现 js 与 native 的通信)
webview.setWebViewClient(new WebViewClient());
[1]
mattermost-mobile: https://github.com/mattermost/mattermost-mobile
[2]
mobile-web-best-practice: https://github.com/mcuking/mobile-web-best-practice
[3]
vue-cli3: https://cli.vuejs.org/
[4]
typescript: http://www.typescriptlang.org/
[5]
react: https://reactjs.org/
[6]
组件库: #组件库
[7]
JSBridge: #jsbridge
[8]
路由堆栈管理(模拟原生 APP 导航): #路由堆栈管理模拟原生-app-导航
[9]
请求数据缓存: #请求数据缓存
[10]
构建时预渲染: #构建时预渲染
[11]
Webpack 策略: #webpack-策略
[12]
基础库抽离: #基础库抽离
[13]
手势库: #手势库
[14]
样式适配: #样式适配
[15]
表单校验: #表单校验
[16]
阻止原生返回事件: #阻止原生返回事件
[17]
通过 UA 获取设备信息: #通过-ua-获取设备信息
[18]
mock 数据: #mock-数据
[19]
调试控制台: #调试控制台
[20]
抓包工具: #抓包工具
[21]
异常监控平台: #异常监控平台
[22]
常见问题: #常见问题
[23]
vant: https://youzan.github.io/vant/#/zh-CN/intro
[24]
vux: https://github.com/airyland/vux
[25]
mint-ui: https://github.com/ElemeFE/mint-ui
[26]
cube-ui: https://github.com/didi/cube-ui
[27]
less-loader: https://github.com/webpack-contrib/less-loader
[28]
less: http://lesscss.org/
[29]
modifyVars: http://lesscss.org/usage/#using-less-in-the-browser-modify-variables
[30]
定制主题: https://youzan.github.io/vant/#/zh-CN/theme
[31]
Vue 常用组件库的比较分析(移动端): https://blog.csdn.net/weixin_38633659/article/details/89736656
[32]
DSBridge-IOS: https://github.com/wendux/DSBridge-IOS
[33]
DSBridge-Android: https://github.com/wendux/DSBridge-Android
[34]
WebViewJavascriptBridge: https://github.com/marcuswestin/WebViewJavascriptBridge
[35]
mobile-web-best-practice-container: https://github.com/mcuking/mobile-web-best-practice-container
[36]
JSBridge: https://github.com/mcuking/JSBridge
[37]
JSBridge 实现原理: https://github.com/mcuking/JSBridge
[38]
vue-page-stack: https://github.com/hezhongfeng/vue-page-stack
[39]
vue-navigation: https://github.com/zack24q/vue-navigation
[40]
vue-stack-router: https://github.com/luojilab/vue-stack-router
[41]
vue-router: https://router.vuejs.org/
[42]
【vue-page-stack】Vue 单页应用导航管理器 正式发布: https://juejin.im/post/5d2ef417f265da1b971aa94f
[43]
Vue 社区的路由解决方案:vue-stack-router: https://juejin.im/post/5d4ce4fd6fb9a06acd450e8c
[44]
mem: https://github.com/sindresorhus/mem
[45]
nuxt.js: https://github.com/nuxt/nuxt.js
[46]
next: https://github.com/zeit/next.js
[47]
Puppeteer: https://github.com/GoogleChrome/puppeteer
[48]
Phantomjs: https://github.com/ariya/phantomjs
[49]
prerender-spa-plugin: https://github.com/chrisvfritz/prerender-spa-plugin
[50]
vue 预渲染之 prerender-spa-plugin 解析(一): https://blog.csdn.net/vv_bug/article/details/84593052
[51]
使用预渲提升 SPA 应用体验: https://juejin.im/post/5d5fa22ee51d4561de20b5f5
[52]
webpack-dll-plugin: https://webpack.docschina.org/plugins/dll-plugin/
[53]
Externals: https://webpack.docschina.org/configuration/externals/
[54]
Webpack 优化——将你的构建效率提速翻倍: https://juejin.im/post/5d614dc96fb9a06ae3726b3e
[55]
hammer.js: https://github.com/hammerjs/hammer.js
[56]
AlloyFinger: https://github.com/AlloyTeam/AlloyFinger
[57]
H5 案例分享:JS 手势框架 —— Hammer.js: https://www.h5anli.com/articles/201609/hammerjs.html
[58]
使用 require.context 实现前端工程自动化: https://www.jianshu.com/p/c894ea00dfec
[59]
postcss-px-to-viewport: https://github.com/evrone/postcss-px-to-viewport
[60]
Viewport Units Buggyfill: https://github.com/rodneyrehm/viewport-units-buggyfill
[61]
flexible: https://github.com/amfe/lib-flexible
[62]
postcss-pxtorem: https://github.com/cuth/postcss-pxtorem
[63]
Autoprefixer: https://github.com/postcss/autoprefixer
[64]
browserslist: https://github.com/browserslist/browserslist
[65]
Viewport Units Buggyfill: https://github.com/rodneyrehm/viewport-units-buggyfill
[66]
rem-vw-layout: https://github.com/imwtr/rem-vw-layout
[67]
细说移动端 经典的 REM 布局 与 新秀 VW 布局: https://www.cnblogs.com/imwtr/p/9648233.html
[68]
如何在 Vue 项目中使用 vw 实现移动端适配: https://www.jianshu.com/p/1f1b23f8348f
[69]
async-validator: https://github.com/yiminghe/async-validator
[70]
vee-validate: https://github.com/baianat/vee-validate
[71]
Mock: https://github.com/nuysoft/Mock
[72]
eruda: https://github.com/liriliri/eruda
[73]
vconsole: https://github.com/Tencent/vConsole
[74]
charles: https://www.charlesproxy.com/
[75]
fiddler: https://www.telerik.com/fiddler
[76]
解锁 Charles 的姿势: https://juejin.im/post/5a1033d2f265da431f4aa81f
[77]
sentry: https://github.com/getsentry/sentry
[78]
sentry-javascript: https://github.com/getsentry/sentry-javascript
[79]
sentry-webpack-plugin: https://github.com/getsentry/sentry-webpack-plugin
[80]
babel-plugin-try-catch-error-report: https://github.com/mcuking/babel-plugin-try-catch-error-report
[81]
babel-plugin-try-catch-error-report: https://github.com/mcuking/babel-plugin-try-catch-error-report
[82]
babel: https://babeljs.io/
总结
我在成长过程中也是一路摸爬滚打,没有任何人的指点,所以走的很艰难。例如在大三的时候,如果有个学长可以阶段性的指点一二,如果有已经工作的师兄可以告诉我工作上需要什么,我应该前面的三年可以缩短一半;后来去面试bat,失败了有5、6次,每次也不知道具体是什么原因,都是靠面试回忆去猜测可能是哪方面的问题,回来学习和完善,当你真正去招人的时候,你就会知道面试记录是多么重要,面试官可以从面试记录里看到你的成长,总是去面试,总是没有成长,就会被定义为缺乏潜力。
开源分享:【大厂前端面试题解析+核心总结学习笔记+真实项目实战+最新讲解视频】
如何在 Vue 项目中使用 vw 实现移动端适配: https://www.jianshu.com/p/1f1b23f8348f
[69]
async-validator: https://github.com/yiminghe/async-validator
[70]
vee-validate: https://github.com/baianat/vee-validate
[71]
Mock: https://github.com/nuysoft/Mock
[72]
eruda: https://github.com/liriliri/eruda
[73]
vconsole: https://github.com/Tencent/vConsole
[74]
charles: https://www.charlesproxy.com/
[75]
fiddler: https://www.telerik.com/fiddler
[76]
解锁 Charles 的姿势: https://juejin.im/post/5a1033d2f265da431f4aa81f
[77]
sentry: https://github.com/getsentry/sentry
[78]
sentry-javascript: https://github.com/getsentry/sentry-javascript
[79]
sentry-webpack-plugin: https://github.com/getsentry/sentry-webpack-plugin
[80]
babel-plugin-try-catch-error-report: https://github.com/mcuking/babel-plugin-try-catch-error-report
[81]
babel-plugin-try-catch-error-report: https://github.com/mcuking/babel-plugin-try-catch-error-report
[82]
babel: https://babeljs.io/
总结
我在成长过程中也是一路摸爬滚打,没有任何人的指点,所以走的很艰难。例如在大三的时候,如果有个学长可以阶段性的指点一二,如果有已经工作的师兄可以告诉我工作上需要什么,我应该前面的三年可以缩短一半;后来去面试bat,失败了有5、6次,每次也不知道具体是什么原因,都是靠面试回忆去猜测可能是哪方面的问题,回来学习和完善,当你真正去招人的时候,你就会知道面试记录是多么重要,面试官可以从面试记录里看到你的成长,总是去面试,总是没有成长,就会被定义为缺乏潜力。
开源分享:【大厂前端面试题解析+核心总结学习笔记+真实项目实战+最新讲解视频】
[外链图片转存中…(img-LJ9S43pC-1714735110894)]