本文介绍一款基于 Vue 的使 App 支持离线缓存 Web 资源的混合开发框架。本人小白一枚,请将它视作一份我的学习总结,欢迎大神们赐教。本文多阐述思路,实现细节请阅读源码。
为何选择混合开发?
- 高效率界面开发:HTML + CSS + JavaScript 被证实具备极高的界面开发效率。
- 跨平台:较统一的浏览器内核标准,使 H5 页面在 IOS、Android 共享同套代码。使用 Native 开发一功能需 IOS、Android 研发各一枚,而使用 H5 一枚前端工程师足矣。但混合 App 并非 Native 越少越佳,性能要求较高的仍需劳 Native 大驾...分工需明确,不可厚此薄彼。
- 热更新:不依赖于发布渠道自主更新应用。Native 修复线上 Bug 需发布新版本,用户未升级 App 该 Bug 将一直呈现。而修复 H5 只需将 Fixbug 的代码推至服务器,任一版本 App 便可同步更新对应功能无需升级。
为何离线缓存 Web 资源?
相比于从远程服务器请求加载 Web 资源,App 优先加载本地预置资源,可提升页面响应速度,节省用户流量。
问题来了...本地预置的 Web 资源也随 App 安装包一起成为泼出去的水,修复 H5 线上 Bug 也需发版了?丢西瓜捡芝麻的事定不可做!请注意“优先加载本地预置资源”,但检测到更新时加载远程最新资源,如何检测更新我稍后阐明。
对我司前端团队的意义
- 技术栈由 Jinja + jQuery + Require + Gulp 迁移至 Vue + Webpack + Gulp + Sass,拥抱 Vue!
- 实现前后端分离:原 Jinja 为 Python 模板引擎,前端代码的运作依赖于服务端,服务端异常等待环境维修严重影响前端工作进度。分离后,服务器挂了我们愉快的开启 Mock Server 继续搬砖便是。
- App 优先加载本地预置 Web 资源,可提升 H5 页面加载速度。
弊端
- 技术重构本身具备风险性。
- 增加团队学习成本。
- 前端框架通过 JS 渲染 HTML 对 SEO 不友好。但你可选择使用 Vue 2.2 的服务端渲染(SSR)。增添 Node 层除实现 SSR,能做的事还很多...
进入正题~
混合开发框架运作机制
将 Web 资源文件打包至 dist/(含 routes.json 及 N 多 .html)并压缩为 dist.zip,图片资源单独打包至 assets/,一同上传至 CDN。
App 内预置 dist/ 下全部资源(发版时仅下载 dist.zip,安装 App 时解压),在拦截并解析 URL 后,通过 routes.json 查找并加载本地 .html 页面。
routes.json 如下:
{ "items": [ { "remote_file": "http://p2znmi5xx.bkt.clouddn.com/dist/demo/Demo-13700fc663.html", "uri": "https://backend.igengmei.com/demo[/]?.*" }, { "remote_file": "http://p2znmi5xx.bkt.clouddn.com/dist/demo/Album-a757d93443.html", "uri": "https://backend.igengmei.com/album[/]?.*" }, { "remote_file": "http://p2znmi5xx.bkt.clouddn.com/dist/post/ArticleDetail-d5c43ffc46.html", "uri": "https://backend.igengmei.com/article/detail[/]?.*" } ], "deploy_time": "Fri Mar 16 2018 15:27:57 GMT+0800 (CST)" }
欠你一个回答~
请注意“优先加载本地预置资源”,但检测到更新时加载远程最新资源,如何检测更新我稍后阐明。
检测 .html 文件更新的桥梁便是 routes.json。每启动 App 从 CDN 静默更新 routes.json 一次(CDN 缓存会导致 routes.json 无法及时更新,下载路由表请添加时间戳参数强制更新),任一资源更新均同步至 routes.json 并上传 CDN。
标记更新的方式则是为 .html 打 Hash(MD5)戳,于 App 而言不同 Hash 后缀的 .html 为不同文件。App 根据路由表 remote_file 查寻本地 .html,若该 .html 不存在则直接加载远程资源同时静默下载更新。
注:由于 js、css 脚本均被内联至对应 .html,App 仅需监听 .html 文件的变化。其实我们可以提取公用脚本并为之打 Hash 戳,将该资源的变化记录至一张表供 App 监听。常年不更新的公用脚本,缓存在 App 内不随 .html 一同加载也可提升页面响应速度。
综上,Web 资源虽被预置于 App,但其 Fixbug 级别的更新不必走发版这条路。
为何图片资源单独打包至 assets/,先欠着~
Web 框架设计
Web 框架设计围绕:
- 减少无用资源及冗余资源
- 减小依赖模块对 Hash 的影响
- 开发环境模式尽量简易
减少无用资源及冗余资源
机智的你发现使用 Vue 脚手架 build 后产生单 .html、单 .js、单 .css(所有页面资源打包在一坨啦),而我所举例的却是多 .html。如何实现 Vue 多页面拆分我会细讲,先讨论拆分多页面的意义吧:“快” + “节约”!
假定我站含页面 A、B、C,用户仅访问 A 但单页应用却将 A、B、C 所依赖的全部资源加载。B、C 于用户而言是无用的,我们偷偷吃用户流量下载无用资源很不厚道。
拆分资源可减小 .html 体积自然提升页面加载速度,且 App 优先访问本地 .html 免去远程请求更是快上加快。
无用资源需丢弃,公共资源也需提取。假定页面 A、B 均引用资源 C,资源 C 便可单独提取。可使用 CommonsChunkPlugin 达成对第三方库,公用组件的抽离。一提取项目所应用 node_module 脚本示例:
new webpack.optimize.CommonsChunkPlugin({ name: 'vendor', minChunks: function (module) { return ( module.resource && /\.js$/.test(module.resource) && module.resource.indexOf( path.join(__dirname, '../node_modules') ) === 0 ) } })
项目中所应用到的 node_module 将统一打包至 vendor.js。公用脚本也需预置,也需检测更新,若认为监听众多资源较麻烦将脚本内联至 .html 也可,但我不提倡这样做(失去了去冗余的意义)。预置的公用脚本拷贝到哪里?拷贝至手机内存空间不够怎么破,拷贝至存储卡被用户误删怎么破,客户端同学为此很纠结...emmm
vendor.js 含所有页面依赖到的 node_module。假定页面 A 使用了 Swiper 而其它页面未引用它,vendor.js 中的 Swiper 相关代码便应仅打包至页面 A,如何实现?
- 生成 vendor.js 时过滤 Swiper 并将其单独打包,node_modules 仍含 Swiper。
- 将 Swiper 从 node_modules 移动至其它路径,引用时使用迁移后的路径。
引入 Sass 也可一定程度的去除无用代码:
使用 @mixin、% 定义的通用样式未被继承不会被解析产生相应的 css。
想了解更多的同学请研读 Sass: Syntactically Awesome Style Sheets。
减小依赖模块对 Hash 的影响
由于 App 需监听众 .html 变化并实时更新资源,应格外注意 Hash 值的稳定性,为此应坚守代码模块化原则。假定全局引入 app.js、app.css,则不允许添加非全局性质的代码至上述两个文件。
假如模块 A 被注入 app.js,它的修改将影响所有 .html 的 Hash 值,未调用模块 A 的页面实际上未做修改却被动更新 Hash。App 根据 Hash 的变化判断资源更新则认为所有 .html 更新了,进而重新下载所有 Web 资源。
总之 A 未调用 B,B 的修改不要影响 A 的 Hash,模块如何拆分请自行依照此原则把握。
接下来讨论 manifest 的注入时机。manifest 包含模块处理逻辑,在 Webpack 编译及映射应用代码时,模块信息被记录至 manifest,runtime 则根据 manifest 加载模块。
new webpack.optimize.CommonsChunkPlugin({ name: 'manifest', minChunks: Infinity })
任一模块更新均会引发它的细微变化(但可通过 minChunks 控制 manifest 影响范围),且所有页面加载依赖 manifest。可怕的现象发生了:manifest 更新所有 .html 的 Hash 更新 -> 所有 .html 被重新下载。我们可先为 .html 打 Hash 再将 manifest 内联,因为未更新模块调用旧 manifest 不会受影响。
开发环境模式尽量简易
一个项目参与者众多,开发环境模式复杂将提高学习成本与风险。在简化开发模式上我做了哪些:
开发环境单入口、生产环境多入口
先讲下 Vue 多页面拆分如何做。相关文章很多在此推荐一篇,点我~
核心思想:
- 单页:多 View 对应 单 index.html + 单 entry.js
- 多页:多 View 对应 多 index.html + 多 entry.js
假定含 100 个 View 则需对应创建 100 个 index.html、100 个 entry.js!但它们几乎一模一样,重复创建十分浪费,开发成本也被增加。
index.html 可被多个 View 复用,entry.js 不可。共享 entry 需在其中 import 全部 View,则 build 生成的每一页面含每一 View 的全部资源,即 100 个内容一模一样的 .html。
我们可形式上单入口,实际上多入口,如何做?定义一含占位符的 entry 模板,build 时将占位符替换为对应 View 的引入,如此 import 资源将按需拆分。
含 <%=Page%> 占位符的 entry.js:
import Vue from 'vue' import Page from '<%=Page%>' /* eslint-disable no-new */ new Vue({ el: '#app', template: '<Page />', components: { Page } })
生成多 entry 的 gulp task:
gulp.task('entries', () => { var flag = true for (let key in routes) { // 检查 entry 是否已存在 gulp.src(`./entry/entries/${routes[key].view}.js`) .on('data', () => { // 已存在 entry 不重复构造 flag = false }) .on('end', () => { if (flag) { console.log('new entry: ', `/entries/${routes[key].view}.js`) // 构造新 entry gulp.src('./entry/entry.js') .pipe(replace({ patterns: [ { match: /<%=Page%>/g, replacement: `../../src/views/${routes[key].path}${routes[key].view}` } ] })) .pipe(rename(`entries/${routes[key].view}.js`)) .pipe(gulp.dest('./entry/')) } flag = true }) } })
仅生产环境执行 gulp entries 构造多入口,开发环境单入口即可,免去研发同学构造 entry 的成本。
function entries () { var entries = {} for (let key in routes) { entries[routes[key].view] = process.env.NODE_ENV === 'production' ? `./entry/entries/${routes[key].view}.js` : './entry/dev.js' } return entries }
开发环境引用本地图片、生产环境引用 CDN 图片
由于 App 仅监听 .html 变化,图片资源需从远程引用。研发自行上传图片至 CDN 似乎并不复杂,但我司 CDN 上传权限泛滥是不被允许的。
图片上传交专人负责,方法原始沟通成本高,等待他人上传也影响自身开发效率。
开发阶段将图片上传测试 CDN,生产阶段再统一拷贝至线上环境?转化成本不小,遗漏上传还会引发线上事故。
开发阶段书写相对路径引用本地资源,免去研发自行上传图片的烦恼且模式与传统 Web 开发保持一致。生产环境直接转化图片链接为 CDN 路径。并将所有 image 单独打包至 assets/ 一同上传 CDN,此时 .html 对 CDN 图片的引用生效了。
{ test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, loader: 'url-loader', options: { limit: 1, name: 'assets/imgs/[name]-[hash:10].[ext]' } }
为防止 CDN 缓存导致图片无法及时更新,build 后图片名称添加 Hash 后缀。在此我设置 Base64 转化 limit 为 1,防止 HTML 穿插过多 Base64 格式图片阻塞加载。
生产环境图片链接转化 CDN 路径代码如下:
const settings = require('../settings') module.exports = { dev: { // code... }, build: { assetsRoot: path.resolve(__dirname, '../../dist'), assetsSubDirectory: 'static', assetsPublicPath: `${settings.cdn}/`, // code... } }
工具一览
html-webpack-inline-source-plugin、gulp-inline-source:JS、CSS 资源内联工具。
commons-chunk-plugin:公共模块拆分工具。
gulp-rev、hashed-module-ids-plugin:MD5 签名生成工具。
gulp-zip:压缩工具。
其它常用 Gulp 工具:gulp-rename、gulp-replace-task、del
踩坑札记
路由解析问题
假定路由配置为:
{ "/demo": { "view": "Demo", "path": "demo/", "query": [ "topic_id", "service_id" ] }, "/album": { "view": "Album", "path": "demo/" } }
生成 routes.json 为:
{ "items": [ { "remote_file": "http://p2znmi5xx.bkt.clouddn.com/dist/demo/Demo-2392a800be.html", "uri": "https://backend.igengmei.com/demo[/]?.*" }, { "remote_file": "http://p2znmi5xx.bkt.clouddn.com/dist/demo/Album-1564b12a1c.html", "uri": "https://backend.igengmei.com/album[/]?.*" } ], "deploy_time": "Mon Mar 19 2018 19:41:22 GMT+0800 (CST)" }
开发环境通过 localhost:8080/demo?topic_id=&service_id= 访问 Demo 页面,形如 vue-router 为我们构建的路由。而生产环境访问路径为 file:////dist/demo/Demo-2392a800be.html?uri=https%3A%2F%2Fbackend.igengmei.com%2Fdemo%3Ftopic_id%3D%26service_id%3D,获取参数需解析 uri。
因两大环境参数解析方式不同,需自行封装 $router,例如 this.$router.query 的定义:
const App = { $router: { query: (key) => { var search = window.location.search var value = '' var tmp = [] if (search) { // 生产环境解析 uri tmp = (process.env.NODE_ENV === 'production') ? decodeURIComponent(search.split('uri=')[1]).split('?')[1].split('&') : search.slice(1).split('&') } for (let i in tmp) { if (key === tmp[i].split('=')[0]) { value = tmp[i].split('=')[1] break } } return value } } }
可将 $router 绑定至 Vue.prototype:
App.install = (Vue, options) => { Vue.prototype.$router = App.$router } export default App
在 entry.js 执行:
Vue.use(App)
此时任一 .vue 可直接调用 this.$router,无需 import。调用频率较高的 method 均可 bind 至 Vue.prototype,例如对请求的封装 this.$request。
缺陷:自制 router 仅支持 query 参数不支持 param 参数。
Cookie 同步问题
App 加载本地预置资源在 file:/// 域,无法直接将 Cookie 载入 Webview,对 file:/// 开放 Cookie 将导致安全问题。几种解决思路:
- 区分 file:/// 来源,判定来源安全则载入 Cookie,但 H5 依然无法将 Cookie 带到请求中。
- 伪造类似 http 请求形成假域。
- Native 维护 Cookie 并提供获取接口,H5 拼接 Cookie 自行写入 Request Header。
- Native 代发请求回传返回值,但无法实现大数据量 POST 请求(例 POST File)。
通常在页面 render 时服务器会将 CSRFToken 写入 Cookie,Request 时再将 CSRFToken 传回服务器防止跨域攻击。但加载本地 HTML 缺少上述步骤,需额外注意 CSRFToken 的获取问题。
未完待续~
作者:呆恋小喵
我的后花园:https://sunmengyuan.github.io...
我的 github:https://github.com/sunmengyuan
原文链接:https://sunmengyuan.github.io...