移动 web 最佳实践(干货长文,建议收藏),逐步讲解

Vue 社区的路由解决方案:vue-stack-router[43]

请求数据缓存


mem[44]

在我们的应用中,会存在一些很少改动的数据,而这些数据有需要从后端获取,比如公司人员、公司职位分类等,此类数据在很长一段时间时不会改变的,而每次打开页面或切换页面时,就重新向后端请求。为了能够减少不必要请求,加快页面渲染速度,可以引用 mem 缓存库。

mem 基本原理是通过以接收的函数为 key 创建一个 WeakMap,然后再以函数参数为 key 创建一个 Map,value 就是函数的执行结果,同时将这个 Map 作为刚刚的 WeakMap 的 value 形成嵌套关系,从而实现对同一个函数不同参数进行缓存。而且支持传入 maxAge,即数据的有效期,当某个数据到达有效期后,会自动销毁,避免内存泄漏。

选择 WeakMap 是因为其相对 Map 保持对键名所引用的对象是弱引用,即垃圾回收机制不将该引用考虑在内。只要所引用的对象的其他引用都被清除,垃圾回收机制就会释放该对象所占用的内存。也就是说,一旦不再需要,WeakMap 里面的键名对象和所对应的键值对会自动消失,不用手动删除引用。

mem 作为高阶函数,可以直接接受封装好的接口请求。但是为了更加直观简便,我们可以按照类的形式集成我们的接口函数,然后就可以用装饰器的方式使用 mem 了(装饰器只能修饰类和类的类的方法,因为普通函数会存在变量提升)。下面是相关代码:

import http from ‘…/http’;

import mem from ‘mem’;

/**

  • @param {MemOption} - mem 配置项

  • @return {Function} - 装饰器

*/

export default function m(options: AnyObject) {

return (target: AnyObject, name: string, descriptor: PropertyDescriptor) => {

const oldValue = descriptor.value;

descriptor.value = mem(oldValue, options);

return descriptor;

};

}

class Home {

@m({ maxAge: 60 * 1000 })

public async getUnderlingDailyList(

query: ListQuery

): Promise<{ total: number; list: DailyItem[] }> {

const {

data: { total, list }

} = await http({

method: ‘post’,

url: ‘/daily/getList’,

data: query

});

return { total, list };

}

}

export default new Home();

构建时预渲染


针对目前单页面首屏渲染时间长(需要下载解析 js 文件然后渲染元素并挂载到 id 为 app 的 div 上),SEO 不友好(index.html 的 body 上实际元素只有 id 为 app 的 div 元素,真正的页面元素都是动态挂载的,搜索引擎的爬虫无法捕捉到),目前主流解决方案就是服务端渲染(SSR),即从服务端生成组装好的完整静态 html 发送到浏览器进行展示,但配置较为复杂,一般都会借助框架,比如 vue 的 nuxt.js[45],react 的 next[46]。

其实有一种更简便的方式–构建时预渲染。顾名思义,就是项目打包构建完成后,启动一个 Web Server 来运行整个网站,再开启多个无头浏览器(例如 Puppeteer[47]、Phantomjs[48] 等无头浏览器技术)去请求项目中所有的路由,当请求的网页渲染到第一个需要预渲染的页面时(需提前配置需要预渲染页面的路由),会主动抛出一个事件,该事件由无头浏览器截获,然后将此时的页面内容生成一个 HTML(包含了 JS 生成的 DOM 结构和 CSS 样式),保存到打包文件夹中。

根据上面的描述,我们可以其实它本质上就只是快照页面,不适合过度依赖后端接口的动态页面,比较适合变化不频繁的静态页面。

实际项目相关工具方面比较推荐 prerender-spa-plugin[49] 这个 webpack 插件,下面是这个插件的原理图。不过有两点需要注意:

一个是这个插件需要依赖 Puppeteer,而因为国内网络原因以及本身体积较大,经常下载失败,不过可以通过 .npmrc 文件指定 Puppeteer 的下载路径为国内镜像;

另一个是需要设置路由模式为 history 模式(即基于 html5 提供的 history api 实现的,react 叫 BrowserRouter,vue 叫 history),因为 hash 路由无法对应到实际的物理路由。(即线上渲染时 history 下,如果 form 路由被设置成预渲染,那么访问 /form/ 路由时,会直接从服务端返回 form 文件夹下的 index.html,之前打包时就已经预先生成了完整的 HTML 文件 )

本项目已经集成了 prerender-spa-plugin,但由于和 vue-stack-page/vue-navigation 这类路由堆栈管理器一起使用有问题(原因还在查找,如果知道的朋友也可以告知下),所以 prerender 功能是关闭的。

同时推荐几篇相关文章:

vue 预渲染之 prerender-spa-plugin 解析(一)[50]

使用预渲提升 SPA 应用体验[51]

Webpack 策略


基础库抽离

对于一些基础库,例如 vue、moment 等,属于不经常变化的静态依赖,一般需要抽离出来以提升每次构建的效率。目前主流方案有两种:

一种是使用 webpack-dll-plugin[52] 插件,在首次构建时就讲这些静态依赖单独打包,后续只需引入早已打包好的静态依赖包即可;

另一种就是外部扩展 Externals[53] 方式,即把不需要打包的静态资源从构建中剔除,使用 CDN 方式引入。下面是 webpack-dll-plugin 相对 Externals 的缺点:

  1. 需要配置在每次构建时都不参与编译的静态依赖,并在首次构建时为它们预编译出一份 JS 文件(后文将称其为 lib 文件),每次更新依赖需要手动进行维护,一旦增删依赖或者变更资源版本忘记更新,就会出现 Error 或者版本错误。

  2. 无法接入浏览器的新特性 script type=“module”,对于某些依赖库提供的原生 ES Modules 的引入方式(比如 vue 的新版引入方式)无法得到支持,没法更好地适配高版本浏览器提供的优良特性以实现更好地性能优化。

  3. 将所有资源预编译成一份文件,并将这份文件显式注入项目构建的 HTML 模板中,这样的做法,在 HTTP1 时代是被推崇的,因为那样能减少资源的请求数量,但在 HTTP2 时代如果拆成多个 CDN Link,就能够更充分地利用 HTTP2 的多路复用特性。

不过选择 Externals 还是需要一个靠谱的 CDN 服务的。

本项目选择的是 Externals,各位可根据项目需求选择不同的方案。

更多内容请查看这篇文章(上面观点来自于这篇文章):

Webpack 优化——将你的构建效率提速翻倍[54]

手势库


hammer.js[55]

AlloyFinger[56]

在移动端开发中,一般都需要支持一些手势,例如拖动(Pan),缩放(Pinch),旋转(Rotate),滑动(swipe)等。目前已经有很成熟的方案了,例如 hammer.js 和腾讯前端团队开发的 AlloyFinger 都很不错。本项目选择基于 hammer.js 进行二次封装成 vue 指令集,各位可根据项目需求选择不同的方案。

下面是二次封装的关键代码,其中用到了 webpack 的 require.context 函数来获取特定模块的上下文,主要用来实现自动化导入模块,比较适用于像 vue 指令这种模块较多的场景:

// 用于导入模块的上下文

export const importAll = (

context: __WebpackModuleApi.RequireContext,

options: ImportAllOptions = {}

): AnyObject => {

const { useDefault = true, keyTransformFunc, filterFunc } = options;

let keys = context.keys();

if (isFunction(filterFunc)) {

keys = keys.filter(filterFunc);

}

return keys.reduce((acc: AnyObject, curr: string) => {

const key = isFunction(keyTransformFunc) ? keyTransformFunc(curr) : curr;

acc[key] = useDefault ? context(curr).default : context(curr);

return acc;

}, {});

};

// directives 文件夹下的 index.ts

const directvieContext = require.context(‘./’, false, /.ts$/);

const directives = importAll(directvieContext, {

filterFunc: (key: string) => key !== ‘./index.ts’,

keyTransformFunc: (key: string) =>

key.replace(/^.//, ‘’).replace(/.ts$/, ‘’)

});

export default {

install(vue: typeof Vue): void {

Object.keys(directives).forEach((key) =>

vue.directive(key, directives[key])

);

}

};

// touch.ts

export default {

bind(el: HTMLElement, binding: DirectiveBinding) {

const hammer: HammerManager = new Hammer(el);

const touch = binding.arg as Touch;

const listener = binding.value as HammerListener;

const modifiers = Object.keys(binding.modifiers);

switch (touch) {

case Touch.Pan:

const panEvent = detectPanEvent(modifiers);

hammer.on(pan${panEvent}, listener);

break;

}

}

};

另外推荐一篇关于 hammer.js 和一篇关于 require.context 的文章:

H5 案例分享:JS 手势框架 —— Hammer.js[57]

使用 require.context 实现前端工程自动化[58]

样式适配


postcss-px-to-viewport[59]

Viewport Units Buggyfill[60]

flexible[61]

postcss-pxtorem[62]

Autoprefixer[63]

browserslist[64]

在移动端网页开发时,样式适配始终是一个绕不开的问题。对此目前主流方案有 vw 和 rem(当然还有 vw + rem 结合方案,请见下方 rem-vw-layout 仓库),其实基本原理都是相通的,就是随着屏幕宽度或字体大小成正比变化。因为原理方面的详细资料网络上已经有很多了,就不在这里赘述了。下面主要提供一些这工程方面的工具。

关于 rem,阿里无线前端团队在 15 年的时候基于 rem 推出了 flexible 方案,以及 postcss 提供的自动转换 px 到 rem 的插件 postcss-pxtorem。

关于 vw,可以使用 postcss-px-to-viewport 进行自动转换 px 到 vw。postcss-px-to-viewport 相关配置如下:

“postcss-px-to-viewport”: {

viewportWidth: 375, // 视窗的宽度,对应的是我们设计稿的宽度,一般是375

viewportHeight: 667, // 视窗的高度,根据750设备的宽度来指定,一般指定1334,也可以不配置

unitPrecision: 3, // 指定px转换为视窗单位值的小数位数(很多时候无法整除)

viewportUnit: ‘vw’, // 指定需要转换成的视窗单位,建议使用vw

selectorBlackList: [‘.ignore’, ‘.hairlines’], // 指定不转换为视窗单位的类,可以自定义,可以无限添加,建议定义一至两个通用的类名

minPixelValue: 1, // 小于或等于1px不转换为视窗单位,你也可以设置为你想要的值

mediaQuery: false // 媒体查询里的单位是否需要转换单位

}

下面是 vw 和 rem 的优缺点对比图:

关于 vw 兼容性问题,目前在移动端 iOS 8 以上以及 Android 4.4 以上获得支持。如果有兼容更低版本需求的话,可以选择 viewport 的 pollify 方案,其中比较主流的是 Viewport Units Buggyfill[65]。

本方案因不准备兼容低版本,所以直接选择了 vw 方案,各位可根据项目需求选择不同的方案。

另外关于设置 css 兼容不同浏览器,想必大家都知道 Autoprefixer(vue-cli3 已经默认集成了),那么如何设置要兼容的范围呢?推荐使用 browserslist,可以在 .browserslistrc 或者 pacakage.json 中 browserslist 部分设置兼容浏览器范围。因为不止 Autoprefixer,还有 Babel,postcss-preset-env 等工具都会读取 browserslist 的兼容配置,这样比较容易使 js css 兼容浏览器的范围保持一致。下面是本项目的 .browserslistrc 配置:

iOS >= 10 // 即 iOS Safari

Android >= 6.0 // 即 Android WebView

last 2 versions // 每个浏览器最近的两个版本

最后推荐一些移动端样式适配的资料:

rem-vw-layout[66]

细说移动端 经典的 REM 布局 与 新秀 VW 布局[67]

如何在 Vue 项目中使用 vw 实现移动端适配[68]

表单校验


async-validator[69]

vee-validate[70]

由于大部分移动端组件库都不提供表单校验,因此需要自己封装。目前比较多的方式就是基于 async-validator 进行二次封装(elementUI 组件库提供的表单校验也是基于 async-validator ),或者使用 vee-validate(一种基于 vue 模板的轻量级校验框架)进行校验,各位可根据项目需求选择不同的方案。

本项目的表单校验方案是在 async-validator 基础上进行二次封装,代码如下,原理很简单,基本满足需求。如果还有更完善的方案,欢迎提出来。

其中 setRules 方法是将组件中设置的 rules(符合 async-validator 约定的校验规则)按照需要校验的数据的名字为 key 转化一个对象 validator,value 是 async-validator 生成的实例。validator 方法可以接收单个或多个需要校验的数据的 key,然后就会在 setRules 生成的对象 validator 中寻找 key 对应的 async-validator 实例,最后调用实例的校验方法。当然也可以不接受参数,那么就会校验所有传入的数据。

import schema from ‘async-validator’;

class ValidatorUtils {

private data: AnyObject;

private validators: AnyObject;

constructor({ rules = {}, data = {}, cover = true }) {

this.validators = {};

this.data = data;

this.setRules(rules, cover);

}

/**

  • 设置校验规则

  • @param rules async-validator 的校验规则

  • @param cover 是否替换旧规则

*/

public setRules(rules: ValidateRules, cover: boolean) {

if (cover) {

this.validators = {};

}

Object.keys(rules).forEach((key) => {

this.validators[key] = new schema({ [key]: rules[key] });

});

}

public validate(

dataKey?: string | string[]

): Promise<ValidateError[] | string | string[] | undefined> {

// 错误数组

const err: ValidateError[] = [];

Object.keys(this.validators)

.filter((key) => {

// 若不传 dataKey 则校验全部。否则校验 dataKey 对应的数据(dataKey 可以对应一个(字符串)或多个(数组))

return (

!dataKey ||

(dataKey &&

((_.isString(dataKey) && dataKey === key) ||

(_.isArray(dataKey) && dataKey.includes(key))))

);

})

.forEach((key) => {

this.validators[key].validate(

{ [key]: this.data[key] },

(error: ValidateError[]) => {

if (error) {

err.push(error[0]);

}

}

);

});

if (err.length > 0) {

return Promise.reject(err);

} else {

return Promise.resolve(dataKey);

}

}

}

阻止原生返回事件


开发中可能会遇到下面这个需求:当页面弹出一个 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 开发时,可能会遇到下面几种情况:

  1. 开发时都是在浏览器进行开发调试的,所以需要避免调用 native 的接口,因为这些接口在浏览器环境根本不存在;

  2. 有些情况需要区分所在环境是在 android webview 还是 ios webview,做一些针对特定平台的处理;

  3. 当 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 同步,导致页面逻辑出错。

两种解决办法:

各位可以选择适合自己项目的方式,有更好的处理方式欢迎留言。

  1. 客户端手动干预一下 cookie 的存储。将服务响应的 cookie,持久化到本地,在下次 webview 启动时,读取本地的 cookie 值,手动再去通过 native 往 webview 写入。但是偶尔还有 spa 的页面路由切换的时候丢失 cookie 的问题。

  2. 将 cookie 存储的 session 持久化到 localSorage,每次请求时都会取 localSorage 存储的 session,并在请求头部添加 cookieback 字段,服务端鉴权时,优先校验 cookieback 字段。这样即使 cookie 丢失或存储的上一次的 session,都不会有影响。不过这种方式相当于绕开了 cookie 传输机制,无法享受 这种机制带来的安全特性。

  3. iOS 登陆后立即进入网页,会出现 cookie 获取不到或获取的上一次登陆缓存的 cookie

  4. 重启 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]

抓包工具: #抓包工具

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数前端工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上前端开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:前端)

后记


总结一下这三次面试下来我的经验是:

  1. 一定不要死记硬背,要理解原理,否则面试官一深入就会露馅!

  2. 代码能力一定要注重,尤其是很多原理性的代码(之前两次让我写过Node中间件,Promise.all,双向绑定原理,被虐的怀疑人生)!

  3. 尽量从面试官的问题中表现自己知识的深度与广度,让面试官发现你的闪光点!

  4. 多刷面经!

我把所有遇到的面试题都做了一个整理,并且阅读了很多大牛的博客之后写了解析,免费分享给大家,算是一个感恩回馈吧,有需要的朋友【点击我】免费获取。祝大家早日拿到自己心怡的工作!

篇幅有限,仅展示部分内容



构建时预渲染: #构建时预渲染

[11]

Webpack 策略: #webpack-策略

[12]

基础库抽离: #基础库抽离

[13]

手势库: #手势库

[14]

样式适配: #样式适配

[15]

表单校验: #表单校验

[16]

阻止原生返回事件: #阻止原生返回事件

[17]

通过 UA 获取设备信息: #通过-ua-获取设备信息

[18]

mock 数据: #mock-数据

[19]

调试控制台: #调试控制台

[20]

抓包工具: #抓包工具

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数前端工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

[外链图片转存中…(img-UiDk8d8p-1712381233795)]

[外链图片转存中…(img-COiaP7XV-1712381233795)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上前端开发知识点,真正体系化!

[外链图片转存中…(img-I6dRqD0y-1712381233796)]

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:前端)

后记


总结一下这三次面试下来我的经验是:

  1. 一定不要死记硬背,要理解原理,否则面试官一深入就会露馅!

  2. 代码能力一定要注重,尤其是很多原理性的代码(之前两次让我写过Node中间件,Promise.all,双向绑定原理,被虐的怀疑人生)!

  3. 尽量从面试官的问题中表现自己知识的深度与广度,让面试官发现你的闪光点!

  4. 多刷面经!

我把所有遇到的面试题都做了一个整理,并且阅读了很多大牛的博客之后写了解析,免费分享给大家,算是一个感恩回馈吧,有需要的朋友【点击我】免费获取。祝大家早日拿到自己心怡的工作!

篇幅有限,仅展示部分内容



  • 19
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值