已閱讀
基于Vue和TS的Web移動端發(fā)開項目實戰(zhàn)心得
因為公司在這方面沒有太多技術沉淀,所以在開發(fā)期間遇到了很多坑,經(jīng)過一年多的技術攻克積累,最終形成了這套比較完善的解決方案,總結出來希望能夠幫助到大家,尤其是對一些中小公司這方面經(jīng)驗不足的(PS: 大公司估計有他們自己的一套方案了)。
好了廢話不多說,先亮下這個庫的 GitHub 地址,后面還會不斷完善,歡迎 star:
mobile-web-best-practice[2]
移動端 web 最佳實踐,基于 vue-cli3[3] 搭建的 typescript[4] 項目,可以用于 hybrid 應用或者純 webapp 開發(fā)。以下大部分內(nèi)容同樣適用于 react[5] 等前端框架。
其中有三個點尚在完善中:領域驅(qū)動設計(DDD)應用、微前端、性能監(jiān)控,后續(xù)完成后會以單獨的文章發(fā)出來。其中性能監(jiān)控還沒有太好的選擇,類似錯誤監(jiān)控 sentry 那種開源免費而且功能強大的工具,如果有人知道的麻煩告知下。文中難免有些錯誤或者更好的方案,也歡迎不吝賜教。
目錄
-
組件庫[6]
-
JSBridge[7]
-
路由堆棧管理(模擬原生 APP 導航)[8]
-
請求數(shù)據(jù)緩存[9]
-
構建時預渲染[10]
-
Webpack 策略[11]
-
基礎庫抽離[12]
-
-
手勢庫[13]
-
樣式適配[14]
-
表單校驗[15]
-
阻止原生返回事件[16]
-
通過 UA 獲取設備信息[17]
-
mock 數(shù)據(jù)[18]
-
調(diào)試控制臺[19]
-
抓包工具[20]
-
異常監(jiān)控平臺[21]
-
常見問題[22]
組件庫
vant[23]
vux[24]
mint-ui[25]
cube-ui[26]
vue 移動端組件庫目前主要就是上面羅列的這幾個庫,本項目使用的是有贊前端團隊開源的 vant。
vant 官方目前已經(jīng)支持自定義樣式主題,基本原理就是在 less-loader[27] 編譯 less[28] 文件到 css 文件過程中,利用 less 提供的 modifyVars[29] 對 less 變量進行修改,本項目也采用了該方式,具體配置請查看相關文檔:
定制主題[30]
推薦一篇介紹各個組件庫特點的文章:
Vue 常用組件庫的比較分析(移動端)[31]
JSBridge
DSBridge-IOS[32]
DSBridge-Android[33]
WebViewJavascriptBridge[34]
混合應用中一般都是通過 webview 加載網(wǎng)頁,而當網(wǎng)頁要獲取設備能力(例如調(diào)用攝像頭、本地日歷等)或者 native 需要調(diào)用網(wǎng)頁里的方法,就需要通過 JSBridge 進行通信。
開源社區(qū)中有很多功能強大的 JSBridge,例如上面列舉的庫。本項目基于保持 iOS android 平臺接口統(tǒng)一原因,采用了 DSBridge,各位可以選擇適合自己項目的工具。
本項目以 h5 調(diào)用 native 提供的同步日歷接口為例,演示如何在 dsbridge 基礎上進行兩端通信的。下面是兩端的關鍵代碼摘要:
安卓端同步日歷核心代碼,具體代碼請查看與本項目配套的安卓項目 mobile-web-best-practice-container[35]:
public class JsApi {
/**
* 同步日歷接口
* msg 格式如下:
* ...
*/
@JavascriptInterface
public void syncCalendar(Object msg, CompletionHandler handler) {
try {
JSONObject obj = new JSONObject(msg.toString());
String id = obj.getString("id");
String title = obj.getString("title");
String location = obj.getString("location");
long startTime = obj.getLong("startTime");
long endTime = obj.getLong("endTime");
JSONArray earlyRemindTime = obj.getJSONArray("alarm");
String res = CalendarReminderUtils.addCalendarEvent(id, title, location, startTime, endTime, earlyRemindTime);
handler.complete(Integer.valueOf(res));
} catch (Exception e) {
e.printStackTrace();
handler.complete(6005);
}
}
}
h5 端同步日歷核心代碼(通過裝飾器來限制調(diào)用接口的平臺)
class NativeMethods {
// 同步到日歷
@p()
public syncCalendar(params: SyncCalendarParams) {
const cb = (errCode: number) => {
const msg = NATIVE_ERROR_CODE_MAP[errCode];
Vue.prototype.$toast(msg);
if (errCode !== 6000) {
this.errorReport(msg, 'syncCalendar', params);
}
};
dsbridge.call('syncCalendar', params, cb);
}
// 調(diào)用 native 接口出錯向 sentry 發(fā)送錯誤信息
private errorReport(errorMsg: string, methodName: string, params: any) {
if (window.$sentry) {
const errorInfo: NativeApiErrorInfo = {
error: new Error(errorMsg),
type: 'callNative',
methodName,
params: JSON.stringify(params)
};
window.$sentry.log(errorInfo);
}
}
}
/**
* @param {platforms} - 接口限制的平臺
* @return {Function} - 裝飾器
*/
function p(platforms = ['android', 'ios']) {
return (target: AnyObject, name: string, descriptor: PropertyDescriptor) => {
if (!platforms.includes(window.$platform)) {
descriptor.value = () => {
return Vue.prototype.$toast(
`當前處在 ${window.$platform} 環(huán)境,無法調(diào)用接口哦`
);
};
}
return descriptor;
};
}
另外推薦一個筆者之前寫的一個基于安卓平臺實現(xiàn)的教學版 JSBridge[36],里面詳細闡述了如何基于底層接口一步步封裝一個可用的 JSBridge:
JSBridge 實現(xiàn)原理[37]
路由堆棧管理(模擬原生 APP 導航)
vue-page-stack[38]
vue-navigation[39]
vue-stack-router[40]
在使用 h5 開發(fā) app,會經(jīng)常遇到下面的需求:從列表進入詳情頁,返回后能夠記住當前位置,或者從表單點擊某項進入到其他頁面選擇,然后回到表單頁,需要記住之前表單填寫的數(shù)據(jù)。可是目前 vue 或 react 框架的路由,均不支持同時存在兩個頁面實例,所以需要路由堆棧進行管理。
其中 vue-page-stack 和 vue-navigation 均受 vue 的 keepalive 啟發(fā),基于 vue-router[41],當進入某個頁面時,會查看當前頁面是否有緩存,有緩存的話就取出緩存,并且清除排在他后面的所有 vnode,沒有緩存就是新的頁面,需要存儲或者是 replace 當前頁面,向棧里面 push 對應的 vnode,從而實現(xiàn)記住頁面狀態(tài)的功能。
而邏輯思維前端團隊的 vue-stack-router 則另辟蹊徑,拋開了 vue-router,自己獨立實現(xiàn)了路由管理,相較于 vue-router,主要是支持同時可以存活 A 和 B 兩個頁面的實例,或者 A 頁面不同狀態(tài)的兩個實例,并支持原生左滑功能。但由于項目還在初期完善,功能還沒有 vue-router 強大,建議持續(xù)關注后續(xù)動態(tài)再做決定是否引入。
本項目使用的是 vue-page-stack,各位可以選擇適合自己項目的工具。同時推薦幾篇相關文章:
【vue-page-stack】Vue 單頁應用導航管理器 正式發(fā)布[42]
Vue 社區(qū)的路由解決方案:vue-stack-router[43]
請求數(shù)據(jù)緩存
mem[44]
在我們的應用中,會存在一些很少改動的數(shù)據(jù),而這些數(shù)據(jù)有需要從后端獲取,比如公司人員、公司職位分類等,此類數(shù)據(jù)在很長一段時間時不會改變的,而每次打開頁面或切換頁面時,就重新向后端請求。為了能夠減少不必要請求,加快頁面渲染速度,可以引用 mem 緩存庫。
mem 基本原理是通過以接收的函數(shù)為 key 創(chuàng)建一個 WeakMap,然后再以函數(shù)參數(shù)為 key 創(chuàng)建一個 Map,value 就是函數(shù)的執(zhí)行結果,同時將這個 Map 作為剛剛的 WeakMap 的 value 形成嵌套關系,從而實現(xiàn)對同一個函數(shù)不同參數(shù)進行緩存。而且支持傳入 maxAge,即數(shù)據(jù)的有效期,當某個數(shù)據(jù)到達有效期后,會自動銷毀,避免內(nèi)存泄漏。
選擇 WeakMap 是因為其相對 Map 保持對鍵名所引用的對象是弱引用,即垃圾回收機制不將該引用考慮在內(nèi)。只要所引用的對象的其他引用都被清除,垃圾回收機制就會釋放該對象所占用的內(nèi)存。也就是說,一旦不再需要,WeakMap 里面的鍵名對象和所對應的鍵值對會自動消失,不用手動刪除引用。
mem 作為高階函數(shù),可以直接接受封裝好的接口請求。但是為了更加直觀簡便,我們可以按照類的形式集成我們的接口函數(shù),然后就可以用裝飾器的方式使用 mem 了(裝飾器只能修飾類和類的類的方法,因為普通函數(shù)會存在變量提升)。下面是相關代碼:
import http from '../http';
import mem from 'mem';
/**
* @param {MemOption} - mem 配置項
* @return {Function} - 裝飾器
*/
export default function m(options: AnyObject) {
return (target: AnyObject, name: string, descriptor: PropertyDescriptor) => {
const oldValue = descriptor.value;
descriptor.value = mem(oldValue, options);
return descriptor;
};
}
class Home {
@m({ maxAge: 60 * 1000 })
public async getUnderlingDailyList(
query: ListQuery
): Promise<{ total: number; list: DailyItem[] }> {
const {
data: { total, list }
} = await http({
method: 'post',
url: '/daily/getList',
data: query
});
return { total, list };
}
}
export default new Home();
構建時預渲染
針對目前單頁面首屏渲染時間長(需要下載解析 js 文件然后渲染元素并掛載到 id 為 app 的 div 上),SEO 不友好(index.html 的 body 上實際元素只有 id 為 app 的 div 元素,真正的頁面元素都是動態(tài)掛載的,搜索引擎的爬蟲無法捕捉到),目前主流解決方案就是服務端渲染(SSR),即從服務端生成組裝好的完整靜態(tài) html 發(fā)送到瀏覽器進行展示,但配置較為復雜,一般都會借助框架,比如 vue 的 nuxt.js[45],react 的 next[46]。
其實有一種更簡便的方式--構建時預渲染。顧名思義,就是項目打包構建完成后,啟動一個 Web Server 來運行整個網(wǎng)站,再開啟多個無頭瀏覽器(例如 Puppeteer[47]、Phantomjs[48] 等無頭瀏覽器技術)去請求項目中所有的路由,當請求的網(wǎng)頁渲染到第一個需要預渲染的頁面時(需提前配置需要預渲染頁面的路由),會主動拋出一個事件,該事件由無頭瀏覽器截獲,然后將此時的頁面內(nèi)容生成一個 HTML(包含了 JS 生成的 DOM 結構和 CSS 樣式),保存到打包文件夾中。
根據(jù)上面的描述,我們可以其實它本質(zhì)上就只是快照頁面,不適合過度依賴后端接口的動態(tài)頁面,比較適合變化不頻繁的靜態(tài)頁面。
實際項目相關工具方面比較推薦 prerender-spa-plugin[49] 這個 webpack 插件,下面是這個插件的原理圖。不過有兩點需要注意:
一個是這個插件需要依賴 Puppeteer,而因為國內(nèi)網(wǎng)絡原因以及本身體積較大,經(jīng)常下載失敗,不過可以通過 .npmrc 文件指定 Puppeteer 的下載路徑為國內(nèi)鏡像;
另一個是需要設置路由模式為 history 模式(即基于 html5 提供的 history api 實現(xiàn)的,react 叫 BrowserRouter,vue 叫 history),因為 hash 路由無法對應到實際的物理路由。(即線上渲染時 history 下,如果 form 路由被設置成預渲染,那么訪問 /form/ 路由時,會直接從服務端返回 form 文件夾下的 index.html,之前打包時就已經(jīng)預先生成了完整的 HTML 文件 )
本項目已經(jīng)集成了 prerender-spa-plugin,但由于和 vue-stack-page/vue-navigation 這類路由堆棧管理器一起使用有問題(原因還在查找,如果知道的朋友也可以告知下),所以 prerender 功能是關閉的。
同時推薦幾篇相關文章:
vue 預渲染之 prerender-spa-plugin 解析(一)[50]
使用預渲提升 SPA 應用體驗[51]
Webpack 策略
基礎庫抽離
對于一些基礎庫,例如 vue、moment 等,屬于不經(jīng)常變化的靜態(tài)依賴,一般需要抽離出來以提升每次構建的效率。目前主流方案有兩種:
一種是使用 webpack-dll-plugin[52] 插件,在首次構建時就講這些靜態(tài)依賴單獨打包,后續(xù)只需引入早已打包好的靜態(tài)依賴包即可;
另一種就是外部擴展 Externals[53] 方式,即把不需要打包的靜態(tài)資源從構建中剔除,使用 CDN 方式引入。下面是 webpack-dll-plugin 相對 Externals 的缺點:
-
需要配置在每次構建時都不參與編譯的靜態(tài)依賴,并在首次構建時為它們預編譯出一份 JS 文件(后文將稱其為 lib 文件),每次更新依賴需要手動進行維護,一旦增刪依賴或者變更資源版本忘記更新,就會出現(xiàn) Error 或者版本錯誤。
-
無法接入瀏覽器的新特性 script type="module",對于某些依賴庫提供的原生 ES Modules 的引入方式(比如 vue 的新版引入方式)無法得到支持,沒法更好地適配高版本瀏覽器提供的優(yōu)良特性以實現(xiàn)更好地性能優(yōu)化。
-
將所有資源預編譯成一份文件,并將這份文件顯式注入項目構建的 HTML 模板中,這樣的做法,在 HTTP1 時代是被推崇的,因為那樣能減少資源的請求數(shù)量,但在 HTTP2 時代如果拆成多個 CDN Link,就能夠更充分地利用 HTTP2 的多路復用特性。
不過選擇 Externals 還是需要一個靠譜的 CDN 服務的。
本項目選擇的是 Externals,各位可根據(jù)項目需求選擇不同的方案。
更多內(nèi)容請查看這篇文章(上面觀點來自于這篇文章):
Webpack 優(yōu)化——將你的構建效率提速翻倍[54]
手勢庫
hammer.js[55]
AlloyFinger[56]
在移動端開發(fā)中,一般都需要支持一些手勢,例如拖動(Pan),縮放(Pinch),旋轉(zhuǎn)(Rotate),滑動(swipe)等。目前已經(jīng)有很成熟的方案了,例如 hammer.js 和騰訊前端團隊開發(fā)的 AlloyFinger 都很不錯。本項目選擇基于 hammer.js 進行二次封裝成 vue 指令集,各位可根據(jù)項目需求選擇不同的方案。
下面是二次封裝的關鍵代碼,其中用到了 webpack 的 require.context 函數(shù)來獲取特定模塊的上下文,主要用來實現(xiàn)自動化導入模塊,比較適用于像 vue 指令這種模塊較多的場景:
// 用于導入模塊的上下文
export const importAll = (
context: __WebpackModuleApi.RequireContext,
options: ImportAllOptions = {}
): AnyObject => {
const { useDefault = true, keyTransformFunc, filterFunc } = options;
let keys = context.keys();
if (isFunction(filterFunc)) {
keys = keys.filter(filterFunc);
}
return keys.reduce((acc: AnyObject, curr: string) => {
const key = isFunction(keyTransformFunc) ? keyTransformFunc(curr) : curr;
acc[key] = useDefault ? context(curr).default : context(curr);
return acc;
}, {});
};
// directives 文件夾下的 index.ts
const directvieContext = require.context('./', false, /.ts$/);
const directives = importAll(directvieContext, {
filterFunc: (key: string) => key !== './index.ts',
keyTransformFunc: (key: string) =>
key.replace(/^.//, '').replace(/.ts$/, '')
});
export default {
install(vue: typeof Vue): void {
Object.keys(directives).forEach((key) =>
vue.directive(key, directives[key])
);
}
};
// touch.ts
export default {
bind(el: HTMLElement, binding: DirectiveBinding) {
const hammer: HammerManager = new Hammer(el);
const touch = binding.arg as Touch;
const listener = binding.value as HammerListener;
const modifiers = Object.keys(binding.modifiers);
switch (touch) {
case Touch.Pan:
const panEvent = detectPanEvent(modifiers);
hammer.on(`pan${panEvent}`, listener);
break;
...
}
}
};
另外推薦一篇關于 hammer.js 和一篇關于 require.context 的文章:
H5 案例分享:JS 手勢框架 —— Hammer.js[57]
使用 require.context 實現(xiàn)前端工程自動化[58]
樣式適配
postcss-px-to-viewport[59]
Viewport Units Buggyfill[60]
flexible[61]
postcss-pxtorem[62]
Autoprefixer[63]
browserslist[64]
在移動端網(wǎng)頁開發(fā)時,樣式適配始終是一個繞不開的問題。對此目前主流方案有 vw 和 rem(當然還有 vw + rem 結合方案,請見下方 rem-vw-layout 倉庫),其實基本原理都是相通的,就是隨著屏幕寬度或字體大小成正比變化。因為原理方面的詳細資料網(wǎng)絡上已經(jīng)有很多了,就不在這里贅述了。下面主要提供一些這工程方面的工具。
關于 rem,阿里無線前端團隊在 15 年的時候基于 rem 推出了 flexible 方案,以及 postcss 提供的自動轉(zhuǎn)換 px 到 rem 的插件 postcss-pxtorem。
關于 vw,可以使用 postcss-px-to-viewport 進行自動轉(zhuǎn)換 px 到 vw。postcss-px-to-viewport 相關配置如下:
"postcss-px-to-viewport": {
viewportWidth: 375, // 視窗的寬度,對應的是我們設計稿的寬度,一般是375
viewportHeight: 667, // 視窗的高度,根據(jù)750設備的寬度來指定,一般指定1334,也可以不配置
unitPrecision: 3, // 指定`px`轉(zhuǎn)換為視窗單位值的小數(shù)位數(shù)(很多時候無法整除)
viewportUnit: 'vw', // 指定需要轉(zhuǎn)換成的視窗單位,建議使用vw
selectorBlackList: ['.ignore', '.hairlines'], // 指定不轉(zhuǎn)換為視窗單位的類,可以自定義,可以無限添加,建議定義一至兩個通用的類名
minPixelValue: 1, // 小于或等于`1px`不轉(zhuǎn)換為視窗單位,你也可以設置為你想要的值
mediaQuery: false // 媒體查詢里的單位是否需要轉(zhuǎn)換單位
}
下面是 vw 和 rem 的優(yōu)缺點對比圖:
關于 vw 兼容性問題,目前在移動端 iOS 8 以上以及 Android 4.4 以上獲得支持。如果有兼容更低版本需求的話,可以選擇 viewport 的 pollify 方案,其中比較主流的是 Viewport Units Buggyfill[65]。
本方案因不準備兼容低版本,所以直接選擇了 vw 方案,各位可根據(jù)項目需求選擇不同的方案。
另外關于設置 css 兼容不同瀏覽器,想必大家都知道 Autoprefixer(vue-cli3 已經(jīng)默認集成了),那么如何設置要兼容的范圍呢?推薦使用 browserslist,可以在 .browserslistrc 或者 pacakage.json 中 browserslist 部分設置兼容瀏覽器范圍。因為不止 Autoprefixer,還有 Babel,postcss-preset-env 等工具都會讀取 browserslist 的兼容配置,這樣比較容易使 js css 兼容瀏覽器的范圍保持一致。下面是本項目的 .browserslistrc 配置:
iOS >= 10 // 即 iOS Safari
Android >= 6.0 // 即 Android WebView
last 2 versions // 每個瀏覽器最近的兩個版本
最后推薦一些移動端樣式適配的資料:
rem-vw-layout[66]
細說移動端 經(jīng)典的 REM 布局 與 新秀 VW 布局[67]
如何在 Vue 項目中使用 vw 實現(xiàn)移動端適配[68]
表單校驗
async-validator[69]
vee-validate[70]
由于大部分移動端組件庫都不提供表單校驗,因此需要自己封裝。目前比較多的方式就是基于 async-validator 進行二次封裝(elementUI 組件庫提供的表單校驗也是基于 async-validator ),或者使用 vee-validate(一種基于 vue 模板的輕量級校驗框架)進行校驗,各位可根據(jù)項目需求選擇不同的方案。
本項目的表單校驗方案是在 async-validator 基礎上進行二次封裝,代碼如下,原理很簡單,基本滿足需求。如果還有更完善的方案,歡迎提出來。
其中 setRules 方法是將組件中設置的 rules(符合 async-validator 約定的校驗規(guī)則)按照需要校驗的數(shù)據(jù)的名字為 key 轉(zhuǎn)化一個對象 validator,value 是 async-validator 生成的實例。validator 方法可以接收單個或多個需要校驗的數(shù)據(jù)的 key,然后就會在 setRules 生成的對象 validator 中尋找 key 對應的 async-validator 實例,最后調(diào)用實例的校驗方法。當然也可以不接受參數(shù),那么就會校驗所有傳入的數(shù)據(jù)。
import schema from 'async-validator';
...
class ValidatorUtils {
private data: AnyObject;
private validators: AnyObject;
constructor({ rules = {}, data = {}, cover = true }) {
this.validators = {};
this.data = data;
this.setRules(rules, cover);
}
/**
* 設置校驗規(guī)則
* @param rules async-validator 的校驗規(guī)則
* @param cover 是否替換舊規(guī)則
*/
public setRules(rules: ValidateRules, cover: boolean) {
if (cover) {
this.validators = {};
}
Object.keys(rules).forEach((key) => {
this.validators[key] = new schema({ [key]: rules[key] });
});
}
public validate(
dataKey?: string | string[]
): Promisestring | string[] | undefined> {
// 錯誤數(shù)組
const err: ValidateError[] = [];
Object.keys(this.validators)
.filter((key) => {
// 若不傳 dataKey 則校驗全部。否則校驗 dataKey 對應的數(shù)據(jù)(dataKey 可以對應一個(字符串)或多個(數(shù)組))
return (
!dataKey ||
(dataKey &&
((_.isString(dataKey) && dataKey === key) ||
(_.isArray(dataKey) && dataKey.includes(key))))
);
})
.forEach((key) => {
this.validators[key].validate(
{ [key]: this.data[key] },
(error: ValidateError[]) => {
if (error) {
err.push(error[0]);
}
}
);
});
if (err.length > 0) {
return Promise.reject(err);
} else {
return Promise.resolve(dataKey);
}
}
}
阻止原生返回事件
開發(fā)中可能會遇到下面這個需求:當頁面彈出一個 popup 或 dialog 組件時,點擊返回鍵時是隱藏彈出的組件而不是返回到上一個頁面。
為了解決這個問題,我們可以從路由棧角度思考。一般彈出組件是不會在路由棧上添加任何記錄,因此我們在彈出組件時,可以在路由棧中 push 一個記錄,為了不讓頁面跳轉(zhuǎn),我們可以把跳轉(zhuǎn)的目標路由設置為當前頁面路由,并加上一個 query 來標記這個組件彈出的狀態(tài)。
然后監(jiān)聽 query 的變化,當點擊彈出組件時,query 中與該彈出組件有關的標記變?yōu)?true,則將彈出組件設為顯示;當用戶點擊 native 返回鍵時,路由返回上一個記錄,仍然是當前頁面路由,不過 query 中與該彈出組件有關的標記不再是 true 了,這樣我們就可以把彈出組件設置成隱藏,同時不會返回上一個頁面。