本文介紹一款基于 Vue 的使 App 支持離線緩存 Web 資源的混合開發(fā)框架。本人小白一枚,請將它視作一份我的學(xué)習(xí)總結(jié),歡迎大神們賜教。本文多闡述思路,實現(xiàn)細(xì)節(jié)請閱讀源碼。
為何選擇混合開發(fā)?
高效率界面開發(fā):HTML + CSS + JavaScript 被證實具備極高的界面開發(fā)效率。
跨平臺:較統(tǒng)一的瀏覽器內(nèi)核標(biāo)準(zhǔn),使 H5 頁面在 IOS、Android 共享同套代碼。使用 Native 開發(fā)一功能需 IOS、Android 研發(fā)各一枚,而使用 H5 一枚前端工程師足矣。但混合 App 并非 Native 越少越佳,性能要求較高的仍需勞 Native 大駕...分工需明確,不可厚此薄彼。
熱更新:不依賴于發(fā)布渠道自主更新應(yīng)用。Native 修復(fù)線上 Bug 需發(fā)布新版本,用戶未升級 App 該 Bug 將一直呈現(xiàn)。而修復(fù) H5 只需將 Fixbug 的代碼推至服務(wù)器,任一版本 App 便可同步更新對應(yīng)功能無需升級。
為何離線緩存 Web 資源?
相比于從遠(yuǎn)程服務(wù)器請求加載 Web 資源,App 優(yōu)先加載本地預(yù)置資源,可提升頁面響應(yīng)速度,節(jié)省用戶流量。
問題來了...本地預(yù)置的 Web 資源也隨 App 安裝包一起成為潑出去的水,修復(fù) H5 線上 Bug 也需發(fā)版了?丟西瓜撿芝麻的事定不可做!請注意“優(yōu)先加載本地預(yù)置資源”,但檢測到更新時加載遠(yuǎn)程最新資源,如何檢測更新我稍后闡明。
對我司前端團(tuán)隊的意義
- 技術(shù)棧由 Jinja + jQuery + Require + Gulp 遷移至 Vue + Webpack + Gulp + Sass,擁抱 Vue!

實現(xiàn)前后端分離:原 Jinja 為 Python 模板引擎,前端代碼的運作依賴于服務(wù)端,服務(wù)端異常等待環(huán)境維修嚴(yán)重影響前端工作進(jìn)度。分離后,服務(wù)器掛了我們愉快的開啟 Mock Server 繼續(xù)搬磚便是。
App 優(yōu)先加載本地預(yù)置 Web 資源,可提升 H5 頁面加載速度。
弊端
技術(shù)重構(gòu)本身具備風(fēng)險性。
增加團(tuán)隊學(xué)習(xí)成本。
前端框架通過 JS 渲染 HTML 對 SEO 不友好。但你可選擇使用 Vue 2.2 的服務(wù)端渲染(SSR)。增添 Node 層除實現(xiàn) SSR,能做的事還很多...
進(jìn)入正題~
混合開發(fā)框架運作機制

將 Web 資源文件打包至 dist/(含 routes.json 及 N 多 .html)并壓縮為 dist.zip,圖片資源單獨打包至 assets/,一同上傳至 CDN。

App 內(nèi)預(yù)置 dist/ 下全部資源(發(fā)版時僅下載 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)"
}
欠你一個回答~
請注意“優(yōu)先加載本地預(yù)置資源”,但檢測到更新時加載遠(yuǎn)程最新資源,如何檢測更新我稍后闡明。
檢測 .html 文件更新的橋梁便是 routes.json。每啟動 App 從 CDN 靜默更新 routes.json 一次(CDN 緩存會導(dǎo)致 routes.json 無法及時更新,下載路由表請?zhí)砑訒r間戳參數(shù)強制更新),任一資源更新均同步至 routes.json 并上傳 CDN。
標(biāo)記更新的方式則是為 .html 打 Hash(MD5)戳,于 App 而言不同 Hash 后綴的 .html 為不同文件。App 根據(jù)路由表 remote_file 查尋本地 .html,若該 .html 不存在則直接加載遠(yuǎn)程資源同時靜默下載更新。
注:由于 js、css 腳本均被內(nèi)聯(lián)至對應(yīng) .html,App 僅需監(jiān)聽 .html 文件的變化。其實我們可以提取公用腳本并為之打 Hash 戳,將該資源的變化記錄至一張表供 App 監(jiān)聽。常年不更新的公用腳本,緩存在 App 內(nèi)不隨 .html 一同加載也可提升頁面響應(yīng)速度。
綜上,Web 資源雖被預(yù)置于 App,但其 Fixbug 級別的更新不必走發(fā)版這條路。
為何圖片資源單獨打包至 assets/,先欠著~
Web 框架設(shè)計
Web 框架設(shè)計圍繞:
減少無用資源及冗余資源
減小依賴模塊對 Hash 的影響
開發(fā)環(huán)境模式盡量簡易
減少無用資源及冗余資源
機智的你發(fā)現(xiàn)使用 Vue 腳手架 build 后產(chǎn)生單 .html、單 .js、單 .css(所有頁面資源打包在一坨啦),而我所舉例的卻是多 .html。如何實現(xiàn) Vue 多頁面拆分我會細(xì)講,先討論拆分多頁面的意義吧:“快” + “節(jié)約”!
假定我站含頁面 A、B、C,用戶僅訪問 A 但單頁應(yīng)用卻將 A、B、C 所依賴的全部資源加載。B、C 于用戶而言是無用的,我們偷偷吃用戶流量下載無用資源很不厚道。
拆分資源可減小 .html 體積自然提升頁面加載速度,且 App 優(yōu)先訪問本地 .html 免去遠(yuǎn)程請求更是快上加快。
無用資源需丟棄,公共資源也需提取。假定頁面 A、B 均引用資源 C,資源 C 便可單獨提取。可使用 CommonsChunkPlugin 達(dá)成對第三方庫,公用組件的抽離。一提取項目所應(yīng)用 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
)
}
})
項目中所應(yīng)用到的 node_module 將統(tǒng)一打包至 vendor.js。公用腳本也需預(yù)置,也需檢測更新,若認(rèn)為監(jiān)聽眾多資源較麻煩將腳本內(nèi)聯(lián)至 .html 也可,但我不提倡這樣做(失去了去冗余的意義)。預(yù)置的公用腳本拷貝到哪里?拷貝至手機內(nèi)存空間不夠怎么破,拷貝至存儲卡被用戶誤刪怎么破,客戶端同學(xué)為此很糾結(jié)...emmm
vendor.js 含所有頁面依賴到的 node_module。假定頁面 A 使用了 Swiper 而其它頁面未引用它,vendor.js 中的 Swiper 相關(guān)代碼便應(yīng)僅打包至頁面 A,如何實現(xiàn)?
生成 vendor.js 時過濾 Swiper 并將其單獨打包,node_modules 仍含 Swiper。
將 Swiper 從 node_modules 移動至其它路徑,引用時使用遷移后的路徑。
引入 Sass 也可一定程度的去除無用代碼:
使用 @mixin、% 定義的通用樣式未被繼承不會被解析產(chǎn)生相應(yīng)的 css。
想了解更多的同學(xué)請研讀 Sass: Syntactically Awesome Style Sheets。
減小依賴模塊對 Hash 的影響
由于 App 需監(jiān)聽眾 .html 變化并實時更新資源,應(yīng)格外注意 Hash 值的穩(wěn)定性,為此應(yīng)堅守代碼模塊化原則。假定全局引入 app.js、app.css,則不允許添加非全局性質(zhì)的代碼至上述兩個文件。
假如模塊 A 被注入 app.js,它的修改將影響所有 .html 的 Hash 值,未調(diào)用模塊 A 的頁面實際上未做修改卻被動更新 Hash。App 根據(jù) Hash 的變化判斷資源更新則認(rèn)為所有 .html 更新了,進(jìn)而重新下載所有 Web 資源。
總之 A 未調(diào)用 B,B 的修改不要影響 A 的 Hash,模塊如何拆分請自行依照此原則把握。
接下來討論 manifest 的注入時機。manifest 包含模塊處理邏輯,在 Webpack 編譯及映射應(yīng)用代碼時,模塊信息被記錄至 manifest,runtime 則根據(jù) manifest 加載模塊。
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest',
minChunks: Infinity
})
任一模塊更新均會引發(fā)它的細(xì)微變化(但可通過 minChunks 控制 manifest 影響范圍),且所有頁面加載依賴 manifest。可怕的現(xiàn)象發(fā)生了:manifest 更新所有 .html 的 Hash 更新 -> 所有 .html 被重新下載。我們可先為 .html 打 Hash 再將 manifest 內(nèi)聯(lián),因為未更新模塊調(diào)用舊 manifest 不會受影響。
開發(fā)環(huán)境模式盡量簡易
一個項目參與者眾多,開發(fā)環(huán)境模式復(fù)雜將提高學(xué)習(xí)成本與風(fēng)險。在簡化開發(fā)模式上我做了哪些:
開發(fā)環(huán)境單入口、生產(chǎn)環(huán)境多入口
先講下 Vue 多頁面拆分如何做。相關(guān)文章很多在此推薦一篇,點我~
核心思想:

單頁:多 View 對應(yīng) 單 index.html + 單 entry.js。
多頁:多 View 對應(yīng) 多 index.html + 多 entry.js。
假定含 100 個 View 則需對應(yīng)創(chuàng)建 100 個 index.html、100 個 entry.js!但它們幾乎一模一樣,重復(fù)創(chuàng)建十分浪費,開發(fā)成本也被增加。
index.html 可被多個 View 復(fù)用,entry.js 不可。共享 entry 需在其中 import 全部 View,則 build 生成的每一頁面含每一 View 的全部資源,即 100 個內(nèi)容一模一樣的 .html。
我們可形式上單入口,實際上多入口,如何做?定義一含占位符的 entry 模板,build 時將占位符替換為對應(yīng) 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 不重復(fù)構(gòu)造
flag = false
})
.on('end', () => {
if (flag) {
console.log('new entry: ', `/entries/${routes[key].view}.js`)
// 構(gòu)造新 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
})
}
})
僅生產(chǎn)環(huán)境執(zhí)行 gulp entries 構(gòu)造多入口,開發(fā)環(huán)境單入口即可,免去研發(fā)同學(xué)構(gòu)造 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
}
開發(fā)環(huán)境引用本地圖片、生產(chǎn)環(huán)境引用 CDN 圖片
由于 App 僅監(jiān)聽 .html 變化,圖片資源需從遠(yuǎn)程引用。研發(fā)自行上傳圖片至 CDN 似乎并不復(fù)雜,但我司 CDN 上傳權(quán)限泛濫是不被允許的。
圖片上傳交專人負(fù)責(zé),方法原始溝通成本高,等待他人上傳也影響自身開發(fā)效率。
開發(fā)階段將圖片上傳測試 CDN,生產(chǎn)階段再統(tǒng)一拷貝至線上環(huán)境?轉(zhuǎn)化成本不小,遺漏上傳還會引發(fā)線上事故。
開發(fā)階段書寫相對路徑引用本地資源,免去研發(fā)自行上傳圖片的煩惱且模式與傳統(tǒng) Web 開發(fā)保持一致。生產(chǎn)環(huán)境直接轉(zhuǎn)化圖片鏈接為 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 緩存導(dǎo)致圖片無法及時更新,build 后圖片名稱添加 Hash 后綴。在此我設(shè)置 Base64 轉(zhuǎn)化 limit 為 1,防止 HTML 穿插過多 Base64 格式圖片阻塞加載。
生產(chǎn)環(huán)境圖片鏈接轉(zhuǎn)化 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 資源內(nèi)聯(lián)工具。
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)"
}
開發(fā)環(huán)境通過 localhost:8080/demo?topic_id=&service_id= 訪問 Demo 頁面,形如 vue-router 為我們構(gòu)建的路由。而生產(chǎn)環(huán)境訪問路徑為 file:////dist/demo/Demo-2392a800be.html?uri=https%3A%2F%2Fbackend.igengmei.com%2Fdemo%3Ftopic_id%3D%26service_id%3D,獲取參數(shù)需解析 uri。
因兩大環(huán)境參數(shù)解析方式不同,需自行封裝 $router,例如 this.$router.query 的定義:
const App = {
$router: {
query: (key) => {
var search = window.location.search
var value = ''
var tmp = []
if (search) {
// 生產(chǎn)環(huán)境解析 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 執(zhí)行:
Vue.use(App)
此時任一 .vue 可直接調(diào)用 this.$router,無需 import。調(diào)用頻率較高的 method 均可 bind 至 Vue.prototype,例如對請求的封裝 this.$request。
缺陷:自制 router 僅支持 query 參數(shù)不支持 param 參數(shù)。
Cookie 同步問題
App 加載本地預(yù)置資源在 file:/// 域,無法直接將 Cookie 載入 Webview,對 file:/// 開放 Cookie 將導(dǎo)致安全問題。幾種解決思路:
區(qū)分 file:/// 來源,判定來源安全則載入 Cookie,但 H5 依然無法將 Cookie 帶到請求中。
偽造類似 http 請求形成假域。
Native 維護(hù) Cookie 并提供獲取接口,H5 拼接 Cookie 自行寫入 Request Header。
Native 代發(fā)請求回傳返回值,但無法實現(xiàn)大數(shù)據(jù)量 POST 請求(例 POST File)。
通常在頁面 render 時服務(wù)器會將 CSRFToken 寫入 Cookie,Request 時再將 CSRFToken 傳回服務(wù)器防止跨域攻擊。但加載本地 HTML 缺少上述步驟,需額外注意 CSRFToken 的獲取問題。
未完待續(xù)~
作者:呆戀小喵
我的后花園:https://sunmengyuan.github.io/garden/
我的 github:https://github.com/sunmengyuan
原文鏈接:https://sunmengyuan.github.io/garden/2018/03/05/ballade.html