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