记一次移动端H5开发

3,038 阅读9分钟

前言

从3月到4月之间,一个月基本无休,做完了一个倒排的 H5 项目,需求大致是一个有多端入口的H5页面,分别有两个入口为公司的APP,一个入口为用户移动端的浏览器,同时这个 H5 里面会接入第三方的页面,即是一个依赖方多的项目。

做这个项目期间遇到了很多技术难点,写此文记录下。如果大家遇到相似问题,或许能有所帮助。

登录

背景

简单介绍一下登录的逻辑,服务端有登录校验的中间件,加在了各必要请求路由上,前端在Fetch接口层,统一做好处理,待接收到与后端约定好的状态码后,触发登录方法,该方法会根据当前端的情况,走不同的登录方式(产品需求)。此处问题主要出现在移动端上,表现为:用户在首页通过返回按钮,返回到来源的列表页(不是我方业务的页面)后,选择不同的内容项重新进入我方业务页面后,服务端返回的依旧是上次内容。文字描述可能不好理解,画了下图:

即第一次选 A 进入,通过返回按钮后,使用B再次进入,在我方页面得到的数据仍旧是A的数据。

初版 Cookie

经过抓包分析(此处使用的是whistle),第二次进入时,发现是后端登录中间件通过,后面的逻辑路由就认为登录信息成功,返回了正常的数据。这是因为携带上的Cookie,实际上是A的Cookie,服务端无法区分来源是否发生过变更,即认为B用了A的信息登录,为合法登录。

既然知道了原因,那么怎么解决呢?大家都会想清Cookie对吧,那么在什么时机清呢? 关闭页面?还是一进入我们的业务页面?

最终决定了在进入业务时清,因为你永远不知道用户是怎么退出的,可能是杀死进程强制退出,可能是返回,可能是正常关闭等,不可控因素远大于进入页面时清除。正当决定好方案后,使用JS删除的时候傻眼了,服务端按安全规范Cookie加上了http only,那么我们是无法通过JS去删除 Cookie 了。因此走到了下一步方案。

改版 Session

既然前端没法做,那么换服务端同学上吧,服务端按常规操作,Session存Redis,触发登录,待登录成功后,返回 Session 的id 种在 Cookie 里,待关闭页面时自动清除。理论上看起来还行,但现实狠狠的给了一巴掌。

虽然来源页、我方业务页面是分开独立的页面,但是从表现上他们仍然在同一个浏览器下的不同页面。从我方业务返回到来源页面时,相当于同一浏览器重新开了下新的Tab页,session还是保留了上次的信息。

终版 Token

既然服务端同学也失败了,那么前端、服务端一起上吧。经过协商,采用Token解决。结合上述经验:

  1. 登录成功后,返回登录token,前端存储到localstorage中,后续的每次请求,从localstorage读取。
  2. 在进入到我方页面时,记录一个白名单列表,标识哪些页面是从外部跳转到我方页面,需要清除localstorage。需要白名单的原因是,产品逻辑要求部分页面有刷新页面操作,那么这种情况下,这时候的登录Token是不需要清除的。

用 Token 还有一个好处是,微信小程序是不支持 Cookie (之前做小程序时是不支持的,现在不知道了),使用Token的话,到时接入小程序,改动量可以小一点。

上传

背景

上面也提到了,此处入口较多,因此上传不能使用APP端的上传,要使用原生的 H5 上传,本次使用的是Antd-mobile的imagePicker加以封装处理。遇到的问题主要是三个:

  1. 上传虽然写死的是单选,但是 APP 做了劫持,其中有一个APP不管选没选单选,强制变成了多选。
  2. 上传图片时很慢,因为现在大家手机像素越来越好,拍照拍出来的相片,一般都有个3-5M
  3. 上传的时候图片会旋转

第一个问题不用说,我们是没法子去推APP端人改动的,只能我们自己改了,而且从业务场景上,的确是多选更方便一些。

上传慢

针对上传慢的问题,百度、Google其实提供了很多例子了。那么不用说,抄呗。

原理很简单,调用Canvas 重新绘制一下图片,且根据图片的大小(一般手机拍出来的这种大图像素都是3000、4000),按比例调整下图片的像素。代码如下

getFileInfo = (file) => (
        new Promise(res => {
                const reader = new FileReader();
                reader.readAsDataURL(file);
                reader.onload = (e) => {
                    const image = new Image();
                    image.src = e.target.result;
                    // eslint-disable-next-line func-names
                    image.onload = function () {
                        let imgWidth = this.width;
                        let imgHeight = this.height;
                        // 控制上传图片的宽高 用于图片压缩
                        if (imgWidth > imgHeight && imgWidth > 750) {
                            imgWidth = 750;
                            imgHeight = Math.ceil(750 * this.height / this.width);
                        } else if (imgWidth < imgHeight && imgHeight > 1080) {
                            imgWidth = Math.ceil(1080 * this.width / this.height);
                            imgHeight = 1080;
                        }
                        // 创建 Canvas 绘图
                        const canvas = document.createElement("canvas");
                        const ctx = canvas.getContext('2d');
                        canvas.width = imgWidth;
                        canvas.height = imgHeight;
                        
                        // 根据后端接口需要 传递 base64 或者 文件二进制
                        // base64 流
                        res(canvas.toDataURL(file.type));
                        // 文件二进制流形式
                        // canvas.toBlob((blobObj) => {
                        //     res(blobObj);
                        // });
                    };
                };
        })
    )

上传图片旋转

有时候上传的图片莫名其妙的被旋转,其实这是图片本身就是旋转的,与你的手机拍摄角度有关,最常见的场景为竖着拍,然后图片会逆时针旋转了90°。但是你用浏览器直接打开图片,却又正常。实际上这是浏览器能识别这些图片的 EXIF信息,然后给你展示的时候自动摆正了图片的角度。

知道了原因,那么我们上库解决就好:github.com/exif-js/exi…

安装就不提了,我们看看结合上述代码压缩,合并下代码:

getFileInfo = (file) => (
        new Promise(res => {
            const reader = new FileReader();
            // eslint-disable-next-line func-names
            EXIF.getData(file, function () {
                // 此处读取的照片的额外信息
                const Orientation = EXIF.getTag(this, 'Orientation');
                reader.readAsDataURL(file);
                reader.onload = (e) => {
                    const image = new Image();
                    image.src = e.target.result;
                    // eslint-disable-next-line func-names
                    image.onload = function () {
                        let imgWidth = this.width;
                        let imgHeight = this.height;
                        // 控制上传图片的宽高 用于图片压缩
                        if (imgWidth > imgHeight && imgWidth > 750) {
                            imgWidth = 750;
                            imgHeight = Math.ceil(750 * this.height / this.width);
                        } else if (imgWidth < imgHeight && imgHeight > 1080) {
                            imgWidth = Math.ceil(1080 * this.width / this.height);
                            imgHeight = 1080;
                        }
                        // 创建 Canvas 绘图
                        const canvas = document.createElement("canvas");
                        const ctx = canvas.getContext('2d');
                        canvas.width = imgWidth;
                        canvas.height = imgHeight;
                        // 利用EXIF 判断图片之前的旋转角度
                        if (Orientation) {
                            switch (Orientation) {
                                case 6: // 旋转90度
                                    canvas.width = imgHeight;
                                    canvas.height = imgWidth;
                                    ctx.rotate(Math.PI / 2);
                                    // (0,-imgHeight) 从旋转原理图那里获得的起始点
                                    ctx.drawImage(this, 0, -imgHeight, imgWidth, imgHeight);
                                    break;
                                case 3: // 旋转180度
                                    ctx.rotate(Math.PI);
                                    ctx.drawImage(this, -imgWidth, -imgHeight, imgWidth, imgHeight);
                                    break;
                                case 8: // 旋转270度,即-90度
                                    canvas.width = imgHeight;
                                    canvas.height = imgWidth;
                                    ctx.rotate(3 * Math.PI / 2);
                                    ctx.drawImage(this, -imgWidth, 0, imgWidth, imgHeight);
                                    break;
                                default:
                                    // 其余的不用做旋转,正常绘图即可
                                    ctx.drawImage(this, 0, 0, imgWidth, imgHeight);
                            }
                        }
                        // base64 流
                        res(canvas.toDataURL(file.type));
                        // 文件二进制流形式
                        // canvas.toBlob((blobObj) => {
                        //     res(blobObj);
                        // });
                    };
                };
            });
        })
    )

至此,我们就能在上传图片时,提高了上传的速度,也能保证图片回显时角度正常。可能有人想放在展示图片时去摆正,这种方案在本次需求中不太合适,因为如果图片一多,这个渲染效率、以及性能的问题都无法评估。

使用的话,在antd-mobile的 onchange里,调用就好了。

    composeFile = async (fileList) => {
        const newFiles = await Promise.all(
            fileList.map((fileItem) => this.getFileInfo(fileItem.file))
        );
        // 此处的newFiles 就是压缩、摆正好的图片数组了,根据自行业务调用即可
    }
    
    onChange = (files,type) => {
        // files 是全量的文件列表(包括新增的、与以前已经上传好的)
        if (type === 'add') {
            // 传入的是 将要上传的文件列表
            this.composeFile(files.slice(this.state.files.length));
        }
    }

浏览器后退

背景

这个问题也是本次开发中遇到的最恶心的问题,为了解决这个问题,折腾到了凌晨4点。这个问题如下:从 我方 A 页面跳转到 B页面(外部),外部页面逻辑做完后,会重定向到我方的 C 页面,在C页面点返回,会返回到A页面,A页面接口偶现不刷新。即如图所示:

据外部开发老哥说,从B到C是调用location.href打开的,理论上按路由栈逻辑,调用go(-1),应该是到回他们的B页面,在PC端,点返回的确是会到了B页面,但是在 APP 上表现就到了 A 页面。

先不管路由跳转的表现,虽然逻辑上说不通,但是现在正好是满足我的业务需要。 我们来看如何解决这个回退,偶现不刷新接口问题。

pageshow / visibilitychange

百度、Google得来最常见的方法:为监听 pageshow、visibilitychange两个事件。 本次使用的是 pageshow ,因为 visibilitychange 在不刷新接口的时候(即本问题场景下),是根本不会触发事件。

window.addEventListener('pageshow', function (event) {
        if (event.persisted || window.performance && 
            window.performance.navigation.type == 2) 
        {
            location.reload();
        }
    },false
);

网上可能最常看见的代码就是这个了,然后这个代码,在我这根本不管用,虽然函数触发了,但是 event.persisted 与 window.performance.navigation.type 永远都是上一次的值,值根本没有变。

分析下这种表现,其实很像是 APP 新开了webview,而点返回的时候实际上是关闭了webview。用图示意下:

这种情况下其实对于原来的A页面,是任何操作都没有发生的,所以值没有发生变化。从理论上来说此刻用的 visibilitychange 应该就能解决问题了,但是很迷的是,在不刷新接口的时候,是根本不会触发事件。加上这个现象是偶现的,所以这个问题是真的脑壳疼。

无奈之举

最后的不得以下,所有回退到这个页面的时候,都重新刷新页面,解决方案如下:

这样虽然从B页面回退到A页面时,也会刷新页面,但实在是没有好的方案能解决这个问题,如果有方案,大家可以分享一下。

最后

本篇文章代码都是从业务中摘出,删除了业务逻辑,难免会有代码错误,直接Copy 的话请注意改动~