本文介绍一款基于 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: ' ',
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/garden/
我的 github:https://github.com/sunmengyuan
原文链接:https://sunmengyuan.github.io/garden/2018/03/05/ballade.html