日拱一卒,守正出奇

Hybrid 路由架构演进之路

    架构分析     Hybrid·webpack·react-router

  1. Hy 1.0 架构
  2. Hy 2.0 架构
    1. 异步加载
    2. 按需加载
  3. 问题
    1. 拆分粒度问题
    2. 离线包打包问题

经过近两年的发展,我们去哪儿内部的 hybrid 方案 hy,已经从 15 年年中的 hy 1.0 进一步完善和成熟,已于去年底推出了基于 react 的 hy2.0 解决方案。在框架升级的大背景下,他的路由架构体系也发生了不小的变化,希望能通过这篇文章,让大家了解到我们路由方案的选择变迁,与页面加载性能优化的手段。

Hy 1.0 架构

我们来看一下这样一个页面,它是我们 YMFE Conf 的邀请函:

邀请函

这个页面效果非常的炫,但是有一个显著的问题,『慢』。看到这个图片,我的脑海中就浮现出三个大字:SPA。相比大家对此都深有感触,一方面,在当时 SPA 代表着 h5 应用的尖端水准,它利用 h5 的 history-api,让前端能自由的控制浏览器历史,从而脱离后端,自主掌控路由;另一方面,由于一个页面要加载全部的静态资源,页面渲染完成之前漫长的 loading 动画成为了用户对 SPA 应用最深刻的印象。

而我们的 Hy 1.0 架构,就是基于 SPA 实现的路由系统,我们所有的页面都是单页应用,在一进入首页时加载所有资源。

为什么我们要采用这一套系统呢?这个跟当时的环境与我们的解决方案有很大的关系。在两年前,许多安卓手机的浏览器性能还比较差,在页面回退的时候没有页面缓存,需要重新刷新,在这种场景下,显然使用 SPA 来进行开发,用户的体验会更接近原生应用;我们在客户端上采用离线包机制,用户在打开 app 时会自动更新离线包,这样很大程度上也可以避免首页加载时间过长的问题。

Hy 1.0 架构体系如下:

Hy 1.0

然而,随着安卓手机性能的提升,现在很多浏览器都会进行页面缓存,实现页面切换的动画,所以单页和多页给用户的体验差不多;在 iOS 上,SPA 的动画与原生的手势回退动画会产生冲突;更重要的是,在 hybrid 环境下,我们采用的是多 webview 的解决方案,这一方案本身就是多页的 —— 这是在 hy 1.0 里始终无法逾越的一个问题:每次打开一个新 webview,实际我们只显示众多页面中的一个页面,但实际展示却要加载全部的资源,这实际是很不合理的。

针对这一问题,我们进行过很多讨论,既然现在 touch 的多页跟单页在手机上的体验差距也没有那么大,而且每页加载的资源体积更少,那是不是直接回归到之前的多页开发就行呢?

这种方式也有一定的优势:至少我们前端的开发量变少了 —— 不用控制路由逻辑,甚至依赖的代码体积还能更少一点。但这样带来的弊端也十分明显:灵活性变差了,往简单来说,如果一个 app 在某种场景下必须是 spa,比如整个应用可能会被离线保存为 file 协议,那这种依赖纯后端路由的手段就难以实现;往大了说,前端失去了对路由的控制权,以后增减页面可能都需要后端配合,或者修改后端工程。这种不便给我们带来的问题,显然是多于给我们节省的那点开发量的。

那么到底有没有一种方案,可以使我们的路由能够在单页/多页之间自由转换,而且还能让单页和多页应用都能实现资源的按需加载呢?

Hy 2.0 架构

我们 Hy2.0 架构选择的是使用 webpack 代码分割实现资源异步加载,使用 react-router 实现资源按需加载,我们通过这样的技术选型,打到了上面既要有性能,又要有自由的目标。

异步加载

我们先来看一下,js 资源异步加载的原理,将代码分割为首屏渲染需要的逻辑 A,与其他页面所需的静态资源逻辑 B,在入口页面引入 A,然后在 A 内向页面插入带有 async 属性的 script 标签,引入静态资源 B。

异步引入资源

但是我们的 js 文件通常不是单独工作的,每个文件之间可能复杂的互相依赖的模块关系,因此在将文件拆分之后,还需要使用一种类似 jsonp 的方式,让被拆分的文件调用一个挂载在 window 上的函数,获取入口文件的模块依赖,并将自己的模块注入到全局模块中。

通过这样的手段,我们就可以实现代码的异步加载,并能很好的处理模块间的依赖关系,那么还有一个问题没有解决,那就是怎么控制代码的分割点呢?

require('A.js');

require.ensure([], function() {
require('B.js');
});

webpack 给我们提供了一个非常简易的方式,使用 require.ensure 关键字,就可以区分文件是否被分片打包。如果正常书写 require('A.js'),这个 js 文件就会和入口文件一起打到一个文件中;但是如果使用 require.ensure 关键字,在回调中调用 require('B.js') 那么这个文件就在打包的时候打成一个独立的分块。webpack 会在 require.ensure 执行的时候,在页面异步加载这个独立的分块。

按需加载

我们想一想,按需加载,那应该需是什么呢?我们想 spa,又想能顺利过渡多页,其实是一个单页应用,每次打开新页面重新加载,且仅加载本页面所需的资源。这样才能做到所以与后端控制路由的多页没有本质的区别。因此最大的需求,就是按页面加载,而路由正是按页面加载最好的分割点。

react-router 本身提供了强大的异步支持,它的核心方法 match 方法本身就是通过回调层层传递的,因此通过 react-router 来支持异步按需加载也十分的容易。

我们来看一下 react-router 同步配置的方式:

<Route path="/">
<IndexRoute component={HomePage}/>
<Route path="list" component={List} />
<Route path="detail" component={Detail}/>
</Route>

那么将它修改为异步的方式,只需要将 Route 的配置组件的属性 component 修改为 getComponent 即可,如下所示:

<Route path="list" getComponent={(_, cb) => {
require.ensure([], () => {
cb(null, require('./List'))
})
}}/>

但是由于 react-router 本身有一套复杂的异步配置流程,加上 require.ensure 语法本身也是比较繁琐,因此整个配置流程对用户来说很不友好,我们在自己的 Hy 框架中,使用 babel 插件的方式对代码进行解析,提供了便捷的语法糖 requre.async,让异步引用变得更加简洁:

const List = require.async('./list');

<Route path="list" getComponent={List} />

问题

那么采用这套新架构体系,有没有遇到什么问题呢?确实也遇到一些问题,其中最主要的就是拆分粒度和离线包打包的问题。

拆分粒度问题

既然我们选择了资源异步加载的方案,那么是不是必须严格按照页面来进行资源拆分呢?答案是不确定的,因为毕竟每次新开请求也有额外的资源消耗,我们给定一个公式,认为 体积 > 网速 * TTFB(首字节响应时间) 的资源无需进行拆分。比如 4G 网 TTFB 0.02s,网速 800k/s,那么体积小于 800k/s * 0.02s = 16k 的资源就不应该进行拆分,因为新开请求的消耗大于加载资源的消耗。

离线包打包问题

还有一个比较棘手的问题是离线包的打包的问题,因为我们之前的离线包打包方案是采用入口文件进行打包的,使用 webpack 生成的 chunk 是非入口文件,按照原有的逻辑就不会被打到离线包里,因此我们修改了离线包打包工具的逻辑,并在发布的时候支持打出一个入口 json,让离线包读取这个 json 来进行非入口文件的打包,这样才解决的了这个问题。

新架构体系如下:

Hy 2.0

通过这套新架构,我们 app 的首屏渲染性能得到了显著的提高,最主要的是,我们编写的 app 能够自由的在单页、多页和 hybrid 场景下进行切换,这样就涵盖了业务在不同场景下、适应不同情况的需求。

页阅读量:  ・  站访问量:  ・  站访客数: