本文介紹一款基於 Vue 的使 App 支持離線緩存 Web 資源的混合開發框架。本人小白一枚,請將它視做一份個人學習總結,歡迎大神們賜教。本文多闡述思路,實現細節請閱讀源碼。css
相比於從遠程服務器請求加載 Web 資源,App 優先加載本地預置資源,可提高頁面響應速度,節省用戶流量。html
問題來了...本地預置的 Web 資源也隨 App 安裝包一塊兒成爲潑出去的水,修復 H5 線上 Bug 也需發版了?丟西瓜撿芝麻的事定不可作!請注意「優先加載本地預置資源」,但檢測到更新時加載遠程最新資源,如何檢測更新我稍後闡明。前端
進入正題~vue
將 Web 資源文件打包至 dist/(含 routes.json 及 N 多 .html)並壓縮爲 dist.zip,圖片資源單獨打包至 assets/,一同上傳至 CDN。node
App 內預置 dist/ 下所有資源(發版時僅下載 dist.zip,安裝 App 時解壓),在攔截並解析 URL 後,經過 routes.json 查找並加載本地 .html 頁面。webpack
routes.json 以下:git
{ "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)" }
欠你一個回答~github
請注意「優先加載本地預置資源」,但檢測到更新時加載遠程最新資源,如何檢測更新我稍後闡明。
檢測 .html 文件更新的橋樑即是 routes.json。每啓動 App 從 CDN 靜默更新 routes.json 一次(CDN 緩存會致使 routes.json 沒法及時更新,下載路由表請添加時間戳參數強制更新),任一資源更新均同步至 routes.json 並上傳 CDN。web
標記更新的方式則是爲 .html 打 Hash(MD5)戳,於 App 而言不一樣 Hash 後綴的 .html 爲不一樣文件。App 根據路由表 remote_file 查尋本地 .html,若該 .html 不存在則直接加載遠程資源同時靜默下載更新。vue-router
注:因爲 js、css 腳本均被內聯至對應 .html,App 僅需監聽 .html 文件的變化。其實咱們能夠提取公用腳本併爲之打 Hash 戳,將該資源的變化記錄至一張表供 App 監聽。常年不更新的公用腳本,緩存在 App 內不隨 .html 一同加載也可提高頁面響應速度。
綜上,Web 資源雖被預置於 App,但其 Fixbug 級別的更新沒必要走發版這條路。
爲什麼圖片資源單獨打包至 assets/,先欠着~
Web 框架設計圍繞:
機智的你發現使用 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,如何實現?
引入 Sass 也可必定程度的去除無用代碼:
使用 @mixin、% 定義的通用樣式未被繼承不會被解析產生相應的 css。
想了解更多的同窗請研讀 Sass: Syntactically Awesome Style Sheets。
因爲 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 多頁面拆分如何作。相關文章不少在此推薦一篇,點我~
核心思想:
假定含 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 }
因爲 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 參數。
App 加載本地預置資源在 file:/// 域,沒法直接將 Cookie 載入 Webview,對 file:/// 開放 Cookie 將致使安全問題。幾種解決思路:
一般在頁面 render 時服務器會將 CSRFToken 寫入 Cookie,Request 時再將 CSRFToken 傳回服務器防止跨域攻擊。但加載本地 HTML 缺乏上述步驟,需額外注意 CSRFToken 的獲取問題。
未完待續~
做者:呆戀小喵
個人後花園:https://sunmengyuan.github.io...
個人 github:https://github.com/sunmengyuan