移动 Web 最佳实践


本文为作者mcuking投稿,原文链接:https://juejin.im/post/5d759f706fb9a06afa32adec

笔者在公司用 web 技术开发移动端应用已经有一年多的时间了,开始主要以 vue 技术栈配合 native 为主,目前演进成 vue + react native 技术架构,vue 主要负责开发 OA 业务,react native 主要负责即时通信部分,是在 mattermost-mobile 的基础上修改的(mattermost 是一个开源的即时通讯方案)。

因为公司在这方面没有太多技术沉淀,所以在开发期间遇到了很多坑,经过一年多的技术攻克积累,最终形成了这套比较完善的解决方案,总结出来希望能够帮助到大家,尤其是对一些中小公司这方面经验不足的(PS: 大公司估计有他们自己的一套方案了)。

好了废话不多说,先亮下这个库的 GitHub 地址,后面还会不断完善,欢迎 star:

mobile-web-best-practice

移动端 web 最佳实践,基于 vue-cli3 搭建的 typescript 项目,可以用于 hybrid 应用或者纯 webapp 开发。以下大部分内容同样适用于 react 等前端框架。

其中有三个点尚在完善中:领域驱动设计(DDD)应用、微前端、性能监控,后续完成后会以单独的文章发出来。其中性能监控还没有太好的选择,类似错误监控 sentry 那种开源免费而且功能强大的工具,如果有人知道的麻烦告知下。文中难免有些错误或者更好的方案,也欢迎不吝赐教。

目录

  • 组件库

  • JSBridge

  • 路由堆栈管理(模拟原生 APP 导航)

  • 请求数据缓存

  • 构建时预渲染

  • Webpack 策略

    • 基础库抽离

  • 手势库

  • 样式适配

  • 表单校验

  • 阻止原生返回事件

  • 通过 UA 获取设备信息

  • mock 数据

  • 调试控制台

  • 抓包工具

  • 异常监控平台

  • 常见问题

组件库

vue 移动端组件库目前主要就是上面罗列的这几个库,本项目使用的是有赞前端团队开源的 vant。

vant 官方目前已经支持自定义样式主题,基本原理就是在 less-loader 编译 less 文件到 css 文件过程中,利用 less 提供的 modifyVars 对 less 变量进行修改,本项目也采用了该方式,具体配置请查看相关文档:

定制主题

推荐一篇介绍各个组件库特点的文章:

Vue 常用组件库的比较分析(移动端)

JSBridge

DSBridge-IOS

DSBridge-Android

WebViewJavascriptBridge

混合应用中一般都是通过 webview 加载网页,而当网页要获取设备能力(例如调用摄像头、本地日历等)或者 native 需要调用网页里的方法,就需要通过 JSBridge 进行通信。

开源社区中有很多功能强大的 JSBridge,例如上面列举的库。本项目基于保持 iOS android 平台接口统一原因,采用了 DSBridge,各位可以选择适合自己项目的工具。

本项目以 h5 调用 native 提供的同步日历接口为例,演示如何在 dsbridge 基础上进行两端通信的。下面是两端的关键代码摘要:

安卓端同步日历核心代码,具体代码请查看与本项目配套的安卓项目 mobile-web-best-practice-container:

public class JsApi {
    /**
     * 同步日历接口
     * msg 格式如下:
     * ...
     */
    @JavascriptInterface
    public void syncCalendar(Object msg, CompletionHandler handler) {
        try {
            JSONObject obj = new JSONObject(msg.toString());
            String id = obj.getString("id");
            String title = obj.getString("title");
            String location = obj.getString("location");
            long startTime = obj.getLong("startTime");
            long endTime = obj.getLong("endTime");
            JSONArray earlyRemindTime = obj.getJSONArray("alarm");
            String res = CalendarReminderUtils.addCalendarEvent(id, title, location, startTime, endTime, earlyRemindTime);
            handler.complete(Integer.valueOf(res));
        } catch (Exception e) {
            e.printStackTrace();
            handler.complete(6005);
        }
    }
}

h5 端同步日历核心代码(通过装饰器来限制调用接口的平台)

class NativeMethods {
  // 同步到日历
  @p()
  public syncCalendar(params: SyncCalendarParams) {
    const cb = (errCode: number) => {
      const msg = NATIVE_ERROR_CODE_MAP[errCode];

      Vue.prototype.$toast(msg);

      if (errCode !== 6000) {
        this.errorReport(msg, 'syncCalendar', params);
      }
    };
    dsbridge.call('syncCalendar', params, cb);
  }

  // 调用 native 接口出错向 sentry 发送错误信息
  private errorReport(errorMsg: string, methodName: string, params: any) {
    if (window.$sentry) {
      const errorInfo: NativeApiErrorInfo = {
        error: new Error(errorMsg),
        type: 'callNative',
        methodName,
        params: JSON.stringify(params)
      };
      window.$sentry.log(errorInfo);
    }
  }
}

/**
 * @param {platforms} - 接口限制的平台
 * @return {Function} - 装饰器
 */
function p(platforms = ['android', 'ios']) {
  return (target: AnyObject, name: string, descriptor: PropertyDescriptor) => {
    if (!platforms.includes(window.$platform)) {
      descriptor.value = () => {
        return Vue.prototype.$toast(
          `当前处在 ${window.$platform} 环境,无法调用接口哦`
        );
      };
    }

    return descriptor;
  };
}

另外推荐一个笔者之前写的一个基于安卓平台实现的教学版 JSBridge,里面详细阐述了如何基于底层接口一步步封装一个可用的 JSBridge:

JSBridge 实现原理

路由堆栈管理(模拟原生 APP 导航)

vue-page-stack

vue-navigation

vue-stack-router

在使用 h5 开发 app,会经常遇到下面的需求:从列表进入详情页,返回后能够记住当前位置,或者从表单点击某项进入到其他页面选择,然后回到表单页,需要记住之前表单填写的数据。可是目前 vue 或 react 框架的路由,均不支持同时存在两个页面实例,所以需要路由堆栈进行管理。

其中 vue-page-stack 和 vue-navigation 均受 vue 的 keepalive 启发,基于 vue-router,当进入某个页面时,会查看当前页面是否有缓存,有缓存的话就取出缓存,并且清除排在他后面的所有 vnode,没有缓存就是新的页面,需要存储或者是 replace 当前页面,向栈里面 push 对应的 vnode,从而实现记住页面状态的功能。

而逻辑思维前端团队的 vue-stack-router 则另辟蹊径,抛开了 vue-router,自己独立实现了路由管理,相较于 vue-router,主要是支持同时可以存活 A 和 B 两个页面的实例,或者 A 页面不同状态的两个实例,并支持原生左滑功能。但由于项目还在初期完善,功能还没有 vue-router 强大,建议持续关注后续动态再做决定是否引入。

本项目使用的是 vue-page-stack,各位可以选择适合自己项目的工具。同时推荐几篇相关文章:

【vue-page-stack】Vue 单页应用导航管理器 正式发布

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

请求数据缓存

mem

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

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

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

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

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

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

移动 Web 最佳实践_第1张图片

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

同时推荐几篇相关文章:

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

使用预渲提升 SPA 应用体验

Webpack 策略

基础库抽离

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

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

另一种就是外部扩展 Externals 方式,即把不需要打包的静态资源从构建中剔除,使用 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 优化——将你的构建效率提速翻倍

手势库

hammer.js

AlloyFinger

在移动端开发中,一般都需要支持一些手势,例如拖动(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

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

样式适配

postcss-px-to-viewport

Viewport Units Buggyfill

flexible

postcss-pxtorem

Autoprefixer

browserslist

在移动端网页开发时,样式适配始终是一个绕不开的问题。对此目前主流方案有 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 的优缺点对比图:

移动 Web 最佳实践_第2张图片

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

本方案因不准备兼容低版本,所以直接选择了 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

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

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

表单校验

async-validator

vee-validate

由于大部分移动端组件库都不提供表单校验,因此需要自己封装。目前比较多的方式就是基于 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 {
    // 错误数组
    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 了,这样我们就可以把弹出组件设置成隐藏,同时不会返回上一个页面。相关代码如下: