本文介紹一款基於 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 的獲取問題。
未完待續~
作者:呆戀小喵
我的後花園:sunmengyuan.github.io/garden/
我的 github:github.com/sunmengyuan