該內(nèi)容由銀科控股融匯研發(fā)部曹俊及其團(tuán)隊(duì)授權(quán)提供。該團(tuán)隊(duì)擁有 10 多名小程序開(kāi)發(fā),深耕小程序領(lǐng)域,總結(jié)出了本篇優(yōu)質(zhì)長(zhǎng)文。同時(shí)本篇內(nèi)容也已經(jīng)合并入我的 開(kāi)源項(xiàng)目 中,目前項(xiàng)目?jī)?nèi)容包含了 JS、網(wǎng)絡(luò)、瀏覽器相關(guān)、性能優(yōu)化、安全、框架、Git、數(shù)據(jù)結(jié)構(gòu)、算法等內(nèi)容,無(wú)論是基礎(chǔ)還是進(jìn)階,亦或是源碼解讀,你都能在本圖譜中得到滿(mǎn)意的答案,希望這個(gè)面試圖譜能夠幫助到大家更好的準(zhǔn)備面試。
了解小程序登陸之前,我們寫(xiě)了解下小程序/公眾號(hào)登錄涉及到兩個(gè)最關(guān)鍵的用戶(hù)標(biāo)識(shí):
OpenId UnionId
wx.login 官方提供的登錄能力
wx.checkSession 校驗(yàn)用戶(hù)當(dāng)前的session_key是否有效
wx.authorize 提前向用戶(hù)發(fā)起授權(quán)請(qǐng)求
wx.getUserInfo 獲取用戶(hù)基本信息
以下從筆者接觸過(guò)的幾種登錄流程來(lái)做闡述:
直接復(fù)用現(xiàn)有系統(tǒng)的登錄體系,只需要在小程序端設(shè)計(jì)用戶(hù)名,密碼/驗(yàn)證碼輸入頁(yè)面,便可以簡(jiǎn)便的實(shí)現(xiàn)登錄,只需要保持良好的用戶(hù)體驗(yàn)即可。
:point_up_2:提過(guò), OpenId 是一個(gè)小程序?qū)τ谝粋€(gè)用戶(hù)的標(biāo)識(shí),利用這一點(diǎn)我們可以輕松的實(shí)現(xiàn)一套基于小程序的用戶(hù)體系,值得一提的是這種用戶(hù)體系對(duì)用戶(hù)的打擾最低,可以實(shí)現(xiàn)靜默登錄。具體步驟如下:
小程序客戶(hù)端通過(guò) wx.login 獲取 code
傳遞 code 向服務(wù)端,服務(wù)端拿到 code 調(diào)用微信登錄憑證校驗(yàn)接口,微信服務(wù)器返回 openid和會(huì)話密鑰 session_key ,此時(shí)開(kāi)發(fā)者服務(wù)端便可以利用 openid 生成用戶(hù)入庫(kù),再向小程序客戶(hù)端返回自定義登錄態(tài)
小程序客戶(hù)端緩存 (通過(guò) storage )自定義登錄態(tài)(token),后續(xù)調(diào)用接口時(shí)攜帶該登錄態(tài)作為用戶(hù)身份標(biāo)識(shí)即可
如果想實(shí)現(xiàn)多個(gè)小程序,公眾號(hào),已有登錄系統(tǒng)的數(shù)據(jù)互通,可以通過(guò)獲取到用戶(hù) unionid 的方式建立用戶(hù)體系。因?yàn)?unionid 在同一開(kāi)放平臺(tái)下的所所有應(yīng)用都是相同的,通過(guò) unionid 建立的用戶(hù)體系即可實(shí)現(xiàn)全平臺(tái)數(shù)據(jù)的互通,更方便的接入原有的功能,那如何獲取 unionid 呢,有以下兩種方式:
如果戶(hù)關(guān)注了某個(gè)相同主體公眾號(hào),或曾經(jīng)在某個(gè)相同主體App、公眾號(hào)上進(jìn)行過(guò)微信登錄授權(quán),通過(guò) wx.login 可以直接獲取 到 unionid
結(jié)合 wx.getUserInfo 和 <button open-type="getUserInfo"><button/> 這兩種方式引導(dǎo)用戶(hù)主動(dòng)授權(quán),主動(dòng)授權(quán)后通過(guò)返回的信息和服務(wù)端交互 (這里有一步需要服務(wù)端解密數(shù)據(jù)的過(guò)程,很簡(jiǎn)單,微信提供了示例代碼) 即可拿到 unionid 建立用戶(hù)體系, 然后由服務(wù)端返回登錄態(tài),本地記錄即可實(shí)現(xiàn)登錄,附上微信提供的最佳實(shí)踐:
調(diào)用 wx.login 獲取 code,然后從微信后端換取到 session_key,用于解密 getUserInfo返回的敏感數(shù)據(jù)。
使用 wx.getSetting 獲取用戶(hù)的授權(quán)情況
獲取到用戶(hù)數(shù)據(jù)后可以進(jìn)行展示或者發(fā)送給自己的后端。
wx.login(獲取code) ===> wx.getUserInfo(用戶(hù)授權(quán)) ===> 獲取 unionid 復(fù)制代碼
因?yàn)樾〕绦虿淮嬖?nbsp;cookie 的概念, 登錄態(tài)必須緩存在本地,因此強(qiáng)烈建議為登錄態(tài)設(shè)置過(guò)期時(shí)間
值得一提的是如果需要支持風(fēng)控安全校驗(yàn),多平臺(tái)登錄等功能,可能需要加入一些公共參數(shù),例如platform,channel,deviceParam等參數(shù)。在和服務(wù)端確定方案時(shí),作為前端同學(xué)應(yīng)該及時(shí)提出這些合理的建議,設(shè)計(jì)合理的系統(tǒng)。
openid , unionid 不要在接口中明文傳輸,這是一種危險(xiǎn)的行為,同時(shí)也很不專(zhuān)業(yè)。
經(jīng)常開(kāi)發(fā)和使用小程序的同學(xué)對(duì)這個(gè)功能一定不陌生,這是一種常見(jiàn)的引流方式,一般同時(shí)會(huì)在圖片中附加一個(gè)小程序二維碼。
借助 canvas 元素,將需要導(dǎo)出的樣式首先在 canvas 畫(huà)布上繪制出來(lái) (api基本和h5保持一致,但有輕微差異,使用時(shí)注意即可)
借助微信提供的 canvasToTempFilePath 導(dǎo)出圖片,最后再使用 saveImageToPhotosAlbum(需要授權(quán))保存圖片到本地
根據(jù)上述的原理來(lái)看,實(shí)現(xiàn)是很簡(jiǎn)單的,只不過(guò)就是設(shè)計(jì)稿的提取,繪制即可,但是作為一個(gè)常用功能,每次都這樣寫(xiě)一坨代碼豈不是非常的難受。那小程序如何設(shè)計(jì)一個(gè)通用的方法來(lái)幫助我們導(dǎo)出圖片呢?思路如下:
繪制出需要的樣式這一步是省略不掉的。但是我們可以封裝一個(gè)繪制庫(kù),包含常見(jiàn)圖形的繪制,例如矩形,圓角矩形,圓, 扇形, 三角形, 文字,圖片減少繪制代碼,只需要提煉出樣式信息,便可以輕松的繪制,最后導(dǎo)出圖片存入相冊(cè)。筆者覺(jué)得以下這種方式繪制更為優(yōu)雅清晰一些,其實(shí)也可以使用加入一個(gè)type參數(shù)來(lái)指定繪制類(lèi)型,傳入的一個(gè)是樣式數(shù)組,實(shí)現(xiàn)繪制。
結(jié)合上一步的實(shí)現(xiàn),如果對(duì)于同一類(lèi)型的卡片有多次導(dǎo)出需求的場(chǎng)景,也可以使用自定義組件的方式,封裝同一類(lèi)型的卡片為一個(gè)通用組件,在需要導(dǎo)出圖片功能的地方,引入該組件即可。
class CanvasKit { constructor() { } drawImg(option = {}) { ... return this } drawRect(option = {}) { return this } drawText(option = {}) { ... return this } static exportImg(option = {}) { ... } } let drawer = new CanvasKit('canvasId').drawImg(styleObj1).drawText(styleObj2) drawer.exportImg() |
數(shù)據(jù)統(tǒng)計(jì)作為目前一種常用的分析用戶(hù)行為的方式,小程序端也是必不可少的。小程序采取的曝光,點(diǎn)擊數(shù)據(jù)埋點(diǎn)其實(shí)和h5原理是一樣的。但是埋點(diǎn)作為一個(gè)和業(yè)務(wù)邏輯不相關(guān)的需求,我們?nèi)绻诿恳粋€(gè)點(diǎn)擊事件,每一個(gè)生命周期加入各種埋點(diǎn)代碼,則會(huì)干擾正常的業(yè)務(wù)邏輯,和使代碼變的臃腫,筆者提供以下幾種思路來(lái)解決數(shù)據(jù)埋點(diǎn):
小程序的代碼結(jié)構(gòu)是,每一個(gè) Page 中都有一個(gè) Page 方法,接受一個(gè)包含生命周期函數(shù),數(shù)據(jù)的 業(yè)務(wù)邏輯對(duì)象 包裝這層數(shù)據(jù),借助小程序的底層邏輯實(shí)現(xiàn)頁(yè)面的業(yè)務(wù)邏輯。通過(guò)這個(gè)我們可以想到思路,對(duì)Page進(jìn)行一次包裝,篡改它的生命周期和點(diǎn)擊事件,混入埋點(diǎn)代碼,不干擾業(yè)務(wù)邏輯,只要做一些簡(jiǎn)單的配置即可埋點(diǎn),簡(jiǎn)單的代碼實(shí)現(xiàn)如下:
page = function(params) { let keys = params.keys() keys.forEach(v => { if (v === 'onLoad') { params[v] = function(options) { stat() //曝光埋點(diǎn)代碼 params[v].call(this, options) } } else if (v.includes('click')) { params[v] = funciton(event) { let data = event.dataset.config stat(data) // 點(diǎn)擊埋點(diǎn) param[v].call(this) } } }) } |
這種思路不光適用于埋點(diǎn),也可以用來(lái)作全局異常處理,請(qǐng)求的統(tǒng)一處理等場(chǎng)景。
對(duì)于特殊的一些業(yè)務(wù),我們可以采取 接口埋點(diǎn) ,什么叫接口埋點(diǎn)呢?很多情況下,我們有的api并不是多處調(diào)用的,只會(huì)在某一個(gè)特定的頁(yè)面調(diào)用,通過(guò)這個(gè)思路我們可以分析出,該接口被請(qǐng)求,則這個(gè)行為被觸發(fā)了,則完全可以通過(guò)服務(wù)端日志得出埋點(diǎn)數(shù)據(jù),但是這種方式局限性較大,而且屬于分析結(jié)果得出過(guò)程,可能存在誤差,但可以作為一種思路了解一下。
微信本身提供的數(shù)據(jù)分析能力,微信本身提供了常規(guī)分析和自定義分析兩種數(shù)據(jù)分析方式,在小程序后臺(tái)配置即可。借助 小程序數(shù)據(jù)助手 這款小程序可以很方便的查看。
目前的前端開(kāi)發(fā)過(guò)程,工程化是必不可少的一環(huán),那小程序工程化都需要做些什么呢,先看下目前小程序開(kāi)發(fā)當(dāng)中存在哪些問(wèn)題需要解決:
對(duì)于目前常用的工程化方案,webpack,rollup,parcel等來(lái)看,都常用與單頁(yè)應(yīng)用的打包和處理,而小程序天生是 “多頁(yè)應(yīng)用” 并且存在一些特定的配置。根據(jù)要解決的問(wèn)題來(lái)看,無(wú)非是文件的編譯,修改,拷貝這些處理,對(duì)于這些需求,我們想到基于流的 gulp 非常的適合處理,并且相對(duì)于webpack配置多頁(yè)應(yīng)用更加簡(jiǎn)單。所以小程序工程化方案推薦使用 gulp
通過(guò) gulp 的 task 實(shí)現(xiàn):
上述實(shí)現(xiàn)起來(lái)其實(shí)并不是很難,但是這樣的話就是一份純粹的 gulp 構(gòu)建腳本和 約定好的目錄而已,每次都有一個(gè)新的小程序都來(lái)拷貝這份腳本來(lái)處理嗎?顯然不合適,那如何真正的實(shí)現(xiàn) 小程序工程化 呢? 我們可能需要一個(gè)簡(jiǎn)單的腳手架,腳手架需要支持的功能:
微信小程序的框架包含兩部分 View 視圖層、App Service邏輯層。View 層用來(lái)渲染頁(yè)面結(jié)構(gòu),AppService 層用來(lái)邏輯處理、數(shù)據(jù)請(qǐng)求、接口調(diào)用。
它們?cè)?nbsp;兩個(gè)線程里 運(yùn)行。
它們?cè)?nbsp;兩個(gè)線程里 運(yùn)行。
它們?cè)?nbsp;兩個(gè)線程里 運(yùn)行。
視圖層和邏輯層通過(guò)系統(tǒng)層的 JSBridage 進(jìn)行通信,邏輯層把數(shù)據(jù)變化通知到視圖層,觸發(fā)視圖層頁(yè)面更新,視圖層把觸發(fā)的事件通知到邏輯層進(jìn)行業(yè)務(wù)處理。
補(bǔ)充
在 Mac下 使用 js-beautify 對(duì)微信開(kāi)發(fā)工具 @v1.02.1808080代碼批量格式化:
cd /Applications/wechatwebdevtools.app/Contents/Resources/package.nw find . -type f -name '*.js' -not -path "./node_modules/*" -not -path -exec js-beautify -r -s 2 -p -f '{}' \; 復(fù)制代碼
在 js/extensions/appservice/index.js 中找到:
267: function(a, b, c) { const d = c(8), e = c(227), f = c(226), g = c(228), h = c(229), i = c(230); var j = window.__global.navigator.userAgent, k = -1 !== j.indexOf('game'); k || i(), window.__global.getNewWeixinJSBridge = (a) => { const { invoke: b } = f(a), { publish: c } = g(a), { subscribe: d, triggerSubscribeEvent: i } = h(a), { on: j, triggerOnEvent: k } = e(a); return { invoke: b, publish: c, subscribe: d, on: j, get __triggerOnEvent() { return k }, get __triggerSubscribeEvent() { return i } } }, window.WeixinJSBridge = window.__global.WeixinJSBridge = window.__global.getNewWeixinJSBridge('global'), window.__global.WeixinJSBridgeMap = { __globalBridge: window.WeixinJSBridge }, __devtoolsConfig.online && __devtoolsConfig.autoTest && setInterval(() => { console.clear() }, 1e4); try { var l = new window.__global.XMLHttpRequest; l.responseType = 'text', l.open('GET', `http://${window.location.host}/calibration/${Date.now()}`, !0), l.send() } catch (a) {} } 復(fù)制代碼 |
在 js/extensions/gamenaitveview/index.js 中找到:
299: function(a, b, c) { 'use strict'; Object.defineProperty(b, '__esModule', { value: !0 }); var d = c(242), e = c(241), f = c(243), g = c(244); window.WeixinJSBridge = { on: d.a, invoke: e.a, publish: f.a, subscribe: g.a } }, 復(fù)制代碼 |
在 js/extensions/pageframe/index.js 中找到:
317: function(a, b, c) { 'use strict'; function d() { window.WeixinJSBridge = { on: e.a, invoke: f.a, publish: g.a, subscribe: h.a }, k.a.init(); let a = document.createEvent('UIEvent'); a.initEvent('WeixinJSBridgeReady', !1, !1), document.dispatchEvent(a), i.a.init() } Object.defineProperty(b, '__esModule', { value: !0 }); var e = c(254), f = c(253), g = c(255), h = c(256), i = c(86), j = c(257), k = c.n(j); 'complete' === document.readyState ? d() : window.addEventListener('load', function() { d() }) }, 復(fù)制代碼
我們都看到了 WeixinJSBridge 的定義。分別都有 on 、 invoke 、 publish 、 subscribe 這個(gè)幾個(gè)關(guān)鍵方法。
拿 invoke 舉例,在 js/extensions/appservice/index.js 中發(fā)現(xiàn)這段代碼:
f (!r) p[b] = s, f.send({ command: 'APPSERVICE_INVOKE', data: { api: c, args: e, callbackID: b } }); 復(fù)制代碼 |
在 js/extensions/pageframe/index.js 中發(fā)現(xiàn)這段代碼:
g[d] = c, e.a.send({ command: 'WEBVIEW_INVOKE', data: { api: a, args: b, callbackID: d } }) 復(fù)制代碼 |
簡(jiǎn)單的分析得知:字段 command 用來(lái)區(qū)分行為, invoke 用來(lái)調(diào)用 Native 的 Api。在不同的來(lái)源要使用不同的前綴。 data 里面包含 Api 名,參數(shù)。另外 callbackID 指定接受回調(diào)的方法句柄。Appservice 和 Webview 使用的通信協(xié)議是一致的。
我們不能在代碼里使用 BOM 和 DOM 是因?yàn)楦緵](méi)有,另一方面也不希望 JS 代碼直接操作視圖。
在開(kāi)發(fā)工具中 remote-helper.js 中找到了這樣的代碼:
const vm = require("vm"); const vmGlobal = { require: undefined, eval: undefined, process: undefined, setTimeout(...args) { //...省略代碼 return timerCount; }, clearTimeout(id) { const timer = timers[id]; if (timer) { clearTimeout(timer); delete timers[id]; } }, setInterval(...args) { //...省略代碼 return timerCount; }, clearInterval(id) { const timer = timers[id]; if (timer) { clearInterval(timer); delete timers[id]; } }, console: (() => { //...省略代碼 return consoleClone; })() }; const jsVm = vm.createContext(vmGlobal); // 省略大量代碼... function loadCode(filePath, sourceURL, content) { let ret; try { const script = typeof content === 'string' ? content : fs.readFileSync(filePath, 'utf-8').toString(); ret = vm.runInContext(script, jsVm, { filename: sourceURL, }); } catch (e) { // something went wrong in user code console.error(e); } return ret; } 復(fù)制代碼 |
這樣的分層設(shè)計(jì)顯然是有意為之的,它的中間層完全控制了程序?qū)τ诮缑孢M(jìn)行的操作, 同時(shí)對(duì)于傳遞的數(shù)據(jù)和響應(yīng)時(shí)間也能做到監(jiān)控。一方面程序的行為受到了極大限制, 另一方面微信可以確保他們對(duì)于小程序內(nèi)容和體驗(yàn)有絕對(duì)的控制。
這樣的結(jié)構(gòu)也說(shuō)明了小程序的動(dòng)畫(huà)和繪圖 API 被設(shè)計(jì)成生成一個(gè)最終對(duì)象而不是一步一步執(zhí)行的樣子, 原因就是 Json 格式的數(shù)據(jù)傳遞和解析相比與原生 API 都是損耗不菲的,如果頻繁調(diào)用很可能損耗過(guò)多性能,進(jìn)而影響用戶(hù)體驗(yàn)。
var context = wx.createCanvasContext('firstCanvas') context.setStrokeStyle("#00ff00") context.setLineWidth(5) context.rect(0, 0, 200, 200) context.stroke() context.setStrokeStyle("#ff0000") context.setLineWidth(2) context.moveTo(160, 100) context.arc(100, 100, 60, 0, 2 * Math.PI, true) context.moveTo(140, 100) context.arc(100, 100, 40, 0, Math.PI, false) context.moveTo(85, 80) context.arc(80, 80, 5, 0, 2 * Math.PI, true) context.moveTo(125, 80) context.arc(120, 80, 5, 0, 2 * Math.PI, true) context.stroke() context.draw() 復(fù)制代碼 |
Page({ data: { animationData: {} }, onShow: function(){ var animation = wx.createAnimation({ duration: 1000, timingFunction: 'ease', }) this.animation = animation animation.scale(2,2).rotate(45).step() this.setData({ animationData:animation.export() }) } }) 復(fù)制代碼 |
知識(shí)點(diǎn)考察
WXML(WeiXin Markup Language)
Wxml編譯器:Wcc 把 Wxml文件 轉(zhuǎn)為 JS
執(zhí)行方式:Wcc index.wxml
WXSS(WeiXin Style Sheets)
wxss編譯器:wcsc 把wxss文件轉(zhuǎn)化為 js
執(zhí)行方式: wcsc index.wxss
親測(cè)包含但不限于如下內(nèi)容:
建議 Css3 的特性都可以做一下嘗試。
rpx(responsive pixel): 可以根據(jù)屏幕寬度進(jìn)行自適應(yīng)。規(guī)定屏幕寬為 750rpx。公式: const dsWidth = 750 export const screenHeightOfRpx = function () { return 750 / env.screenWidth * env.screenHeight } export const rpxToPx = function (rpx) { return env.screenWidth / 750 * rpx } export const pxToRpx = function (px) { return 750 / env.screenWidth * px } |
復(fù)制代碼
可以了解一下 pr2rpx-loader 這個(gè)庫(kù)。
使用 @import 語(yǔ)句可以導(dǎo)入外聯(lián)樣式表, @import 后跟需要導(dǎo)入的外聯(lián)樣式表的相對(duì)路徑,用 ; 表示語(yǔ)句結(jié)束。
靜態(tài)的樣式統(tǒng)一寫(xiě)到 class 中。style 接收動(dòng)態(tài)的樣式,在運(yùn)行時(shí)會(huì)進(jìn)行解析, 請(qǐng)盡量避免將靜態(tài)的樣式寫(xiě)進(jìn) style 中,以免影響渲染速度 。
定義在 app.wxss 中的樣式為全局樣式,作用于每一個(gè)頁(yè)面。在 page 的 wxss 文件中定義的樣式為局部樣式,只作用在對(duì)應(yīng)的頁(yè)面,并會(huì)覆蓋 app.wxss 中相同的選擇器。
小程序未來(lái)有計(jì)劃支持字體。參考微信公開(kāi)課。
小程序開(kāi)發(fā)與平時(shí) Web開(kāi)發(fā)類(lèi)似,也可以使用字體圖標(biāo),但是 src:url() 無(wú)論本地還是遠(yuǎn)程地址都不行,base64 值則都是可以顯示的。
將 ttf 文件轉(zhuǎn)換成 base64。打開(kāi)這個(gè)平臺(tái) transfonter.org/。點(diǎn)擊 Add fonts 按鈕,加載ttf格式的那個(gè)文件。將下邊的 base64 encode 改為 on。點(diǎn)擊 Convert 按鈕進(jìn)行轉(zhuǎn)換,轉(zhuǎn)換后點(diǎn)擊 download 下載。
復(fù)制下載的壓縮文件中的 stylesheet.css 的內(nèi)容到 font.wxss ,并且將 icomoon 中的 style.css 除了 @font-face 所有的代碼也復(fù)制到 font.wxss 并將i選擇器換成 .iconfont,最后:
<text class="iconfont icon-home" style="font-size:50px;color:red"></text> 復(fù)制代碼
小程序提供了一系列組件用于開(kāi)發(fā)業(yè)務(wù)功能,按照功能與HTML5的標(biāo)簽進(jìn)行對(duì)比如下:
小程序的組件基于Web Component標(biāo)準(zhǔn)
使用Polymer框架實(shí)現(xiàn)Web Component
目前Native實(shí)現(xiàn)的組件有
cavnas
video
map
textarea
Native組件層在 WebView 層之上。這目前帶來(lái)了一些問(wèn)題:
包含但不限于:
小程序仍然使用 WebView 渲染,并非原生渲染。(部分原生)
服務(wù)端接口返回的頭無(wú)法執(zhí)行,比如:Set-Cookie。
依賴(lài)瀏覽器環(huán)境的 JS 庫(kù)不能使用。
不能使用 npm,但是可以自搭構(gòu)建工具或者使用 mpvue。(未來(lái)官方有計(jì)劃支持)
不能使用 ES7,可以自己用babel+webpack自搭或者使用 mpvue。
不支持使用自己的字體(未來(lái)官方計(jì)劃支持)。
可以用 base64 的方式來(lái)使用 iconfont。
小程序不能發(fā)朋友圈(可以通過(guò)保存圖片到本地,發(fā)圖片到朋友前。二維碼可以使用B接口)。
獲取二維碼/小程序接口的限制。
小程序推送只能使用“服務(wù)通知” 而且需要用戶(hù)主動(dòng)觸發(fā)提交 formId,formId 只有7天有效期。(現(xiàn)在的做法是在每個(gè)頁(yè)面都放入form并且隱藏以此獲取更多的 formId。后端使用原則為:優(yōu)先使用有效期最短的)
小程序大小限制 2M,分包總計(jì)不超過(guò) 8M
轉(zhuǎn)發(fā)(分享)小程序不能拿到成功結(jié)果,原來(lái)可以。鏈接(小游戲造的孽)
拿到相同的 unionId 必須綁在同一個(gè)開(kāi)放平臺(tái)下。開(kāi)放平臺(tái)綁定限制:
公眾號(hào)關(guān)聯(lián)小程序,鏈接
一個(gè)公眾號(hào)關(guān)聯(lián)的10個(gè)同主體小程序和3個(gè)非同主體小程序可以互相跳轉(zhuǎn)
品牌搜索不支持金融、醫(yī)療
小程序授權(quán)需要用戶(hù)主動(dòng)點(diǎn)擊
小程序不提供測(cè)試 access_token
安卓系統(tǒng)下,小程序授權(quán)獲取用戶(hù)信息之后,刪除小程序再重新獲取,并重新授權(quán),得到舊簽名,導(dǎo)致第一次授權(quán)失敗
開(kāi)發(fā)者工具上,授權(quán)獲取用戶(hù)信息之后,如果清緩存選擇全部清除,則即使使用了wx.checkSession,并且在session_key有效期內(nèi),授權(quán)獲取用戶(hù)信息也會(huì)得到新的session_key
為了驗(yàn)證小程序?qū)TTP的支持適配情況,我找了兩個(gè)服務(wù)器做測(cè)試,一個(gè)是網(wǎng)上搜索到支持HTTP2的服務(wù)器,一個(gè)是我本地起的一個(gè)HTTP2服務(wù)器。測(cè)試中所有請(qǐng)求方法均使用 wx.request。
網(wǎng)上支持HTTP2的服務(wù)器: HTTPs://www.snel.com:443
在Chrome上查看該服務(wù)器為 HTTP2
在模擬器上請(qǐng)求該接口, 請(qǐng)求頭 的HTTP版本為HTTP1.1,模擬器不支持HTTP2
由于小程序線上環(huán)境需要在項(xiàng)目管理里配置請(qǐng)求域名,而這個(gè)域名不是我們需要的請(qǐng)求域名,沒(méi)必要浪費(fèi)一個(gè)域名位置,所以打開(kāi)不驗(yàn)證域名,TSL 等選項(xiàng)請(qǐng)求該接口,通過(guò)抓包工具表現(xiàn)與模擬器相同
由上可以看出,在真機(jī)與模擬器都不支持 HTTP2,但是都是成功請(qǐng)求的,并且 響應(yīng)頭 里的 HTTP 版本都變成了HTTP1.1 版本,說(shuō)明服務(wù)器對(duì) HTTP1.1 做了兼容性適配。
本地新啟一個(gè) node 服務(wù)器,返回 JSON 為請(qǐng)求的 HTTP 版本
如果服務(wù)器只支持 HTTP2,在模擬器請(qǐng)求時(shí)發(fā)生了一個(gè) ALPN 協(xié)議的錯(cuò)誤。并且提醒使用適配 HTTP1
當(dāng)把服務(wù)器的 allowHTTP1 ,設(shè)置為 true ,并在請(qǐng)求時(shí)處理相關(guān)相關(guān)請(qǐng)求參數(shù)后,模擬器能正常訪問(wèn)接口,并打印出對(duì)應(yīng)的 HTTP 請(qǐng)求版本
session_key 有有效期,有效期并沒(méi)有被告知開(kāi)發(fā)者,只知道用戶(hù)越頻繁使用小程序,session_key 有效期越長(zhǎng)
用戶(hù)授權(quán)時(shí),開(kāi)放平臺(tái)使用舊的 session_key 對(duì)用戶(hù)信息進(jìn)行加密。調(diào)用 wx.login 重新登錄,會(huì)刷新 session_key,這時(shí)后端服務(wù)從開(kāi)放平臺(tái)獲取到新 session_key,但是無(wú)法對(duì)老 session_key 加密過(guò)的數(shù)據(jù)解密,用戶(hù)信息獲取失敗
代碼包的大小是最直接影響小程序加載啟動(dòng)速度的因素。代碼包越大不僅下載速度時(shí)間長(zhǎng),業(yè)務(wù)代碼注入時(shí)間也會(huì)變長(zhǎng)。所以最好的優(yōu)化方式就是減少代碼包的大小。
小程序加載的三個(gè)階段的表示。
在構(gòu)建小程序分包項(xiàng)目時(shí),構(gòu)建會(huì)輸出一個(gè)或多個(gè)功能的分包,其中每個(gè)分包小程序必定含有一個(gè)主包,所謂的主包,即放置默認(rèn)啟動(dòng)頁(yè)面/TabBar 頁(yè)面,以及一些所有分包都需用到公共資源/JS 腳本,而分包則是根據(jù)開(kāi)發(fā)者的配置進(jìn)行劃分。
在小程序啟動(dòng)時(shí),默認(rèn)會(huì)下載主包并啟動(dòng)主包內(nèi)頁(yè)面,如果用戶(hù)需要打開(kāi)分包內(nèi)某個(gè)頁(yè)面,客戶(hù)端會(huì)把對(duì)應(yīng)分包下載下來(lái),下載完成后再進(jìn)行展示。
優(yōu)點(diǎn):
限制:
原生分包加載的配置假設(shè)支持分包的小程序目錄結(jié)構(gòu)如下:
├── app.js ├── app.json ├── app.wxss ├── packageA │ └── pages │ ├── cat │ └── dog ├── packageB │ └── pages │ ├── apple │ └── banana ├── pages │ ├── index │ └── logs └── utils 復(fù)制代碼
開(kāi)發(fā)者通過(guò)在 app.json subPackages 字段聲明項(xiàng)目分包結(jié)構(gòu):
{ "pages":[ "pages/index", "pages/logs" ], "subPackages": [ { "root": "packageA", "pages": [ "pages/cat", "pages/dog" ] }, { "root": "packageB", "pages": [ "pages/apple", "pages/banana" ] } ] } |
官方即將推出分包預(yù)加載
獨(dú)立分包
每次 setData 的調(diào)用都是一次進(jìn)程間通信過(guò)程,通信開(kāi)銷(xiāo)與 setData 的數(shù)據(jù)量正相關(guān)。
setData 會(huì)引發(fā)視圖層頁(yè)面內(nèi)容的更新,這一耗時(shí)操作一定時(shí)間中會(huì)阻塞用戶(hù)交互。
在需要頻繁更新的場(chǎng)景下,自定義組件的更新只在組件內(nèi)部進(jìn)行,不受頁(yè)面其他部分內(nèi)容復(fù)雜性影響。
小程序的幾個(gè)頁(yè)面間,存在一些相同或是類(lèi)似的區(qū)域,這時(shí)候可以把這些區(qū)域邏輯封裝成一個(gè)自定義組件,代碼就可以重用,或者對(duì)于比較獨(dú)立邏輯,也可以把它封裝成一個(gè)自定義組件,也就是微信去年發(fā)布的自定義組件,它讓代碼得到復(fù)用、減少代碼量,更方便模塊化,優(yōu)化代碼架構(gòu)組織,也使得模塊清晰,后期更好地維護(hù),從而保證更好的性能。
但微信打算在原來(lái)的基礎(chǔ)上推出的自定義組件 2.0,它將擁有更高級(jí)的性能:
目前小程序開(kāi)發(fā)的痛點(diǎn)是:開(kāi)源組件要手動(dòng)復(fù)制到項(xiàng)目,后續(xù)更新組件也需要手動(dòng)操作。不久的將來(lái),小程序?qū)⒅С謓pm包管理,有了這個(gè)之后,想要引入一些開(kāi)源的項(xiàng)目就變得很簡(jiǎn)單了,只要在項(xiàng)目里面聲明,然后用簡(jiǎn)單的命令安裝,就可以使用了。
微信小程序團(tuán)隊(duì)表示,他們?cè)诳紤]推出一些官方自定義組件,為什么不內(nèi)置到基礎(chǔ)庫(kù)里呢?因?yàn)閮?nèi)置組件要提供給開(kāi)發(fā)者,這個(gè)組件一定是開(kāi)發(fā)者很難實(shí)現(xiàn)或者是無(wú)法實(shí)現(xiàn)的一個(gè)能力。所以他們更傾向于封裝成自定義組件,想基于這些內(nèi)置組件里,封裝一些比較常見(jiàn)的、交互邏輯比較復(fù)雜的組件給大家使用,讓大家更容易開(kāi)發(fā)。類(lèi)似彈幕組件,開(kāi)發(fā)者就不用關(guān)注彈幕怎么飄,可以節(jié)省開(kāi)發(fā)者的開(kāi)發(fā)成本。
同時(shí),他們也想給開(kāi)發(fā)者提供一些規(guī)范和一些模板,讓開(kāi)發(fā)者設(shè)計(jì)出好用的自定義組件,更好地被大家去使用。
當(dāng)小程序加載太慢時(shí),可能會(huì)導(dǎo)致用戶(hù)的流失,而小程序的開(kāi)發(fā)者可能會(huì)面臨著不知道如何定位問(wèn)題或不知如何解決問(wèn)題的困境。
為此,小程序即將推出一個(gè)體驗(yàn)評(píng)分的功能,這是為了幫助開(kāi)發(fā)者可以檢查出小程序有一些什么體驗(yàn)不好的地方,也會(huì)同時(shí)給出一份優(yōu)化的指引建議。
小程序在最初的技術(shù)選型時(shí),引入了原生組件的概念,因?yàn)樵M件可以使小程序的能力更加豐富,比如地圖、音視頻的能力,但是原生組件是由客戶(hù)端原生渲染的,導(dǎo)致了原生組件的層級(jí)是最高的,開(kāi)發(fā)者很容易遇到打開(kāi)調(diào)試的問(wèn)題,發(fā)現(xiàn)視頻組件擋在了 vConsole 上。
為了解決這個(gè)問(wèn)題,當(dāng)時(shí)微信做了一個(gè)過(guò)渡的方案:cover-view。cover-view可以覆蓋在原生組件之上,這一套方案解決了大部分的需求場(chǎng)景。比如說(shuō)視頻組件上很多的按鈕、標(biāo)題甚至還有動(dòng)畫(huà)的彈幕,這些都是用 cover-view 去實(shí)現(xiàn)的,但它還是沒(méi)有完全解決原生組件的開(kāi)發(fā)體驗(yàn)問(wèn)題,因?yàn)?cover-view 有一些限制:
因此微信決定將用同層渲染取代 cover-view,它能像普通組件一樣使用,原生組件的層級(jí)不再是最高,而是和其他的非原生組件在同一層級(jí)渲染,可完全由 z-index 控制,可完全支持觸摸事件。
微信表示,同層渲染在 iOS 平臺(tái)小程序上已經(jīng)開(kāi)始內(nèi)測(cè),會(huì)很快開(kāi)放給開(kāi)發(fā)者,Android 平臺(tái)已經(jīng)取得突破性進(jìn)展,目前正在做一輪封裝的工作,開(kāi)放指日可待。
相比傳統(tǒng)的小程序框架,這個(gè)一直是我們作為資深開(kāi)發(fā)者比較期望去解決的,在 Web 開(kāi)發(fā)中,隨著 Flux、Redux、Vuex 等多個(gè)數(shù)據(jù)流工具出現(xiàn),我們也期望在業(yè)務(wù)復(fù)雜的小程序中使用。
WePY 默認(rèn)支持 Redux,在腳手架生成項(xiàng)目的時(shí)候可以?xún)?nèi)置
Mpvue 作為 Vue 的移植版本,當(dāng)然支持 Vuex,同樣在腳手架生成項(xiàng)目的時(shí)候可以?xún)?nèi)置
如果你和我們一樣,經(jīng)歷了從無(wú)到有的小程序業(yè)務(wù)開(kāi)發(fā),建議閱讀【小程序的組件化開(kāi)發(fā)】章節(jié),進(jìn)行官方語(yǔ)法的組件庫(kù)開(kāi)發(fā)(從基礎(chǔ)庫(kù) 1.6.3 開(kāi)始,官方提供了組件化解決方案)。
export default class Index extends wepy.page {} 復(fù)制代碼
所有的小程序開(kāi)發(fā)依賴(lài)官方提供的開(kāi)發(fā)者工具。開(kāi)發(fā)者工具簡(jiǎn)單直觀,對(duì)調(diào)試小程序很有幫助,現(xiàn)在也支持騰訊云(目前我們還沒(méi)有使用,但是對(duì)新的一些開(kāi)發(fā)者還是有幫助的),可以申請(qǐng)測(cè)試報(bào)告查看小程序在真實(shí)的移動(dòng)設(shè)備上運(yùn)行性能和運(yùn)行效果,但是它本身沒(méi)有類(lèi)似前端工程化中的概念和工具。
先說(shuō)結(jié)論:選擇 mpvue。
wepy vs mpvue。
理由:
工程化原生開(kāi)發(fā)因?yàn)椴粠Чこ袒?,諸如NPM包(未來(lái)會(huì)引入)、ES7、圖片壓縮、PostCss、pug、ESLint等等不能用。如果自己要搭工程化,不如直接使用wepy或mpvue。mpvue和wepy都可以和小程序原生開(kāi)發(fā)混寫(xiě)。, 參考wepy 。 而問(wèn)題在于wepy沒(méi)有引入webpack(wepy@2.0.x依然沒(méi)有引入),以上說(shuō)的這些東西都要造輪子(作者造或自己造)。沒(méi)有引入 Webpack 是一個(gè)重大的硬傷。社區(qū)維護(hù)的成熟 Webpack 顯然更穩(wěn)定,輪子更多。
維護(hù)wepy 也是社區(qū)維護(hù)的,是官方的?其實(shí) wepy 的主要開(kāi)發(fā)者只有作者一人,附上一個(gè) contrubutors 鏈接。另外被官方招安了也是后來(lái)的事,再說(shuō)騰訊要有精力幫著一起維護(hù)好 wepy,為什么不花精力在小程序原生開(kāi)發(fā)上呢?再來(lái)看看 mpvue,是美團(tuán)一個(gè)前端小組維護(hù)的。
學(xué)習(xí)成本Vue 的學(xué)習(xí)曲線比較平緩。mpvue 是 Vue的子集。所以 mpvue 的學(xué)習(xí)成本會(huì)低于 wepy。尤其是之前技術(shù)棧有學(xué)過(guò)用過(guò) Vue 的。
未來(lái)規(guī)劃mpvue 已經(jīng)支持 web 和小程序。因?yàn)?mpvue 基于AST,所以未來(lái)可以支持支付寶小程序和快應(yīng)用。他們也是有這樣的規(guī)劃。
請(qǐng)?jiān)谛枨蟪叵旅孀约赫?/p>
坑兩者都有各自的坑。但是我覺(jué)得有一些wepy的坑是沒(méi)法容忍的。比如 repeat組建里面用computed得到的列表全是同一套數(shù)據(jù) 而且1.x是沒(méi)法解決的。 wepy和mpvue我都開(kāi)發(fā)過(guò)完整小程序的體驗(yàn)下,我覺(jué)得wepy的坑更多,而且wepy有些坑礙于架構(gòu)設(shè)計(jì)沒(méi)辦法解決。
Vue.js 小程序版, fork 自 vuejs/vue@2.4.1,保留了 vue runtime 能力,添加了小程序平臺(tái)的支持。 mpvue 是一個(gè)使用 Vue.js 開(kāi)發(fā)小程序的前端框架。框架基于 Vue.js 核心, mpvue 修改了 Vue.js 的 runtime 和 compiler 實(shí)現(xiàn),使其可以運(yùn)行在小程序環(huán)境中,從而為小程序開(kāi)發(fā)引入了整套 Vue.js 開(kāi)發(fā)體驗(yàn)。
mpvue mpvue-loader
要了解 mpvue 原理必然要了解 Vue 原理,這是大前提。但是要講清楚 Vue 原理需要花費(fèi)大量的篇幅,不如參考 learnVue 。
現(xiàn)在假設(shè)您對(duì) Vue 原理有個(gè)大概的了解。
由于 Vue 使用了 Virtual DOM,所以 Virtual DOM 可以在任何支持 JavaScript 語(yǔ)言的平臺(tái)上操作,譬如說(shuō)目前 Vue 支持瀏覽器平臺(tái)或 weex,也可以是 mp(小程序)。那么最后 Virtual DOM 如何映射到真實(shí)的 DOM 節(jié)點(diǎn)上呢?vue為平臺(tái)做了一層適配層,瀏覽器平臺(tái)見(jiàn) runtime/node-ops.js 、weex平臺(tái)見(jiàn) runtime/node-ops.js ,小程序見(jiàn) runtime/node-ops.js 。不同平臺(tái)之間通過(guò)適配層對(duì)外提供相同的接口,Virtual DOM進(jìn)行操作Real DOM節(jié)點(diǎn)的時(shí)候,只需要調(diào)用這些適配層的接口即可,而內(nèi)部實(shí)現(xiàn)則不需要關(guān)心,它會(huì)根據(jù)平臺(tái)的改變而改變。
所以思路肯定是往增加一個(gè) mp 平臺(tái)的 runtime 方向走。但問(wèn)題是小程序不能操作 DOM,所以 mp 下的 node-ops.js 里面的實(shí)現(xiàn)都是直接 return obj 。
新 Virtual DOM 和舊 Virtual DOM 之間需要做一個(gè) patch,找出 diff。patch完了之后的 diff 怎么更新視圖,也就是如何給這些 DOM 加入 attr、class、style 等 DOM 屬性呢? Vue 中有 nextTick 的概念用以更新視圖,mpvue這塊對(duì)于小程序的 setData 應(yīng)該怎么處理呢?
另外個(gè)問(wèn)題在于小程序的 Virtual DOM 怎么生成?也就是怎么將 template 編譯成 render function 。這當(dāng)中還涉及到 運(yùn)行時(shí)-編譯器-vs-只包含運(yùn)行時(shí) ,顯然如果要提高性能、減少包大小、輸出 wxml、mpvue 也要提供預(yù)編譯的能力。因?yàn)橐A(yù)輸出 wxml 且沒(méi)法動(dòng)態(tài)改變 DOM,所以動(dòng)態(tài)組件,自定義 render,和 <script type="text/x-template"> 字符串模版等都不支持(參考)。
另外還有一些其他問(wèn)題,最后總結(jié)一下
render function
platform/mp的目錄結(jié)構(gòu)
. ├── compiler //解決問(wèn)題1,mpvue-template-compiler源碼部分 ├── runtime //解決問(wèn)題3 4 5 6 7 ├── util //工具方法 ├── entry-compiler.js //mpvue-template-compiler的入口。package.json相關(guān)命令會(huì)自動(dòng)生成mpvue-template-compiler這個(gè)package。 ├── entry-runtime.js //對(duì)外提供Vue對(duì)象,當(dāng)然是mpvue └── join-code-in-build.js //編譯出SDK時(shí)的修復(fù) 復(fù)制代碼
mpvue-loader 是 vue-loader 的一個(gè)擴(kuò)展延伸版,類(lèi)似于超集的關(guān)系,除了 vue-loader 本身所具備的能力之外,它還會(huì)利用 mpvue-template-compiler 生成 render function 。
它會(huì)從 webpack 的配置中的 entry 開(kāi)始,分析依賴(lài)模塊,并分別打包。在entry 中 app 屬性及其內(nèi)容會(huì)被打包為微信小程序所需要的 app.js/app.json/app.wxss,其余的會(huì)生成對(duì)應(yīng)的頁(yè)面page.js/page.json/page.wxml/page.wxss,如示例的 entry 將會(huì)生成如下這些文件,文件內(nèi)容下文慢慢講來(lái):
// webpack.config.js { // ... entry: { app: resolve('./src/main.js'), // app 字段被識(shí)別為 app 類(lèi)型 index: resolve('./src/pages/index/main.js'), // 其余字段被識(shí)別為 page 類(lèi)型 'news/home': resolve('./src/pages/news/home/index.js') } } // 產(chǎn)出文件的結(jié)構(gòu) . ├── app.js ├── app.json ├──· app.wxss ├── components │ ├── card$74bfae61.wxml │ ├── index$023eef02.wxml │ └── news$0699930b.wxml ├── news │ ├── home.js │ ├── home.wxml │ └── home.wxss ├── pages │ └── index │ ├── index.js │ ├── index.wxml │ └── index.wxss └── static ├── css │ ├── app.wxss │ ├── index.wxss │ └── news │ └── home.wxss └── js ├── app.js ├── index.js ├── manifest.js ├── news │ └── home.js └── vendor.js 復(fù)制代碼
<template> <div class="my-component" @click="test"> <h1>{{msg}}</h1> <other-component :msg="msg"></other-component> </div> </template> <script> import otherComponent from './otherComponent.vue' export default { components: { otherComponent }, data () { return { msg: 'Hello Vue.js!' } }, methods: { test() {} } } </script> 復(fù)制代碼
這樣一個(gè) Vue 的組件的模版部分會(huì)生成相應(yīng)的 wxml
<import src="components/other-component$hash.wxml" /> <template name="component$hash"> <view class="my-component" bindtap="handleProxy"> <view class="_h1">{{msg}}</view> <template is="other-component$hash" wx:if="{{ $c[0] }}" data="{{ ...$c[0] }}"></template> </view> </template> 復(fù)制代碼
可能已經(jīng)注意到了 other-component(:msg="msg") 被轉(zhuǎn)化成了 。mpvue 在運(yùn)行時(shí)會(huì)從根組件開(kāi)始把所有的組件實(shí)例數(shù)據(jù)合并成一個(gè)樹(shù)形的數(shù)據(jù),然后通過(guò) setData 到 appData, $c 是 $children 的縮寫(xiě)。至于那個(gè) 0 則是我們的 compiler 處理過(guò)后的一個(gè)標(biāo)記,會(huì)為每一個(gè)子組件打一個(gè)特定的不重復(fù)的標(biāo)記。 樹(shù)形數(shù)據(jù)結(jié)構(gòu)如下:
// 這兒數(shù)據(jù)結(jié)構(gòu)是一個(gè)數(shù)組,index 是動(dòng)態(tài)的 { $child: { '0'{ // ... root data $child: { '0': { // ... data msg: 'Hello Vue.js!', $child: { // ...data } } } } } } 復(fù)制代碼
這個(gè)部分的處理同 web 的處理差異不大,唯一不同在于通過(guò)配置生成 .css 為 .wxss ,其中的對(duì)于 css 的若干處理,在 postcss-mpvue-wxss 和 px2rpx-loader 這兩部分的文檔中又詳細(xì)的介紹。
app.json/page.json 1.1.1 以上
推薦和小程序一樣,將 app.json/page.json 放到頁(yè)面入口處,使用 copy-webpack-plugin copy 到對(duì)應(yīng)的生成位置。
1.1.1 以下
這部分內(nèi)容來(lái)源于 app 和 page 的 entry 文件,通常習(xí)慣是 main.js,你需要在你的入口文件中 export default { config: {} },這才能被我們的 loader 識(shí)別為這是一個(gè)配置,需要寫(xiě)成 json 文件。
import Vue from 'vue'; import App from './app'; const vueApp = new Vue(App); vueApp.$mount(); // 這個(gè)是我們約定的額外的配置 export default { // 這個(gè)字段下的數(shù)據(jù)會(huì)被填充到 app.json / page.json config: { pages: ['static/calendar/calendar', '^pages/list/list'], // Will be filled in webpack window: { backgroundTextStyle: 'light', navigationBarBackgroundColor: '#455A73', navigationBarTitleText: '美團(tuán)汽車(chē)票', navigationBarTextStyle: '#fff' } } }; 復(fù)制代碼
同時(shí),這個(gè)時(shí)候,我們會(huì)根據(jù) entry 的頁(yè)面數(shù)據(jù),自動(dòng)填充到 app.json 中的 pages 字段。 pages 字段也是可以自定義的,約定帶有 ^ 符號(hào)開(kāi)頭的頁(yè)面,會(huì)放到數(shù)組的最前面。
style scoped 在 vue-loader 中對(duì) style scoped 的處理方式是給每個(gè)樣式加一個(gè) attr 來(lái)標(biāo)記 module-id,然后在 css 中也給每條 rule 后添加 [module-id],最終可以形成一個(gè) css 的“作用域空間”。
在微信小程序中目前是不支持 attr 選擇器的,所以我們做了一點(diǎn)改動(dòng),把 attr 上的 [module-id] 直接寫(xiě)到了 class 里,如下:
<!-- .vue --> <template> <div class="container"> // ... </div> </template> <style scoped> .container { color: red; } </style> <!-- vue-loader --> <template> <div class="container" data-v-23e58823> // ... </div> </template> <style scoped> .container[data-v-23e58823] { color: red; } </style> <!-- mpvue-loader --> <template> <div class="container data-v-23e58823"> // ... </div> </template> <style scoped> .container.data-v-23e58823 { color: red; } </style> 復(fù)制代碼 |
生產(chǎn)出的內(nèi)容是:
(function(module, __webpack_exports__, __webpack_require__) { "use strict"; // mpvue-template-compiler會(huì)利用AST預(yù)編譯生成一個(gè)render function用以生成Virtual DOM。 var render = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h; // _c創(chuàng)建虛擬節(jié)點(diǎn),參考https://github.com/Meituan-Dianping/mpvue/blob/master/packages/mpvue/index.js#L3606 // 以及https://github.com/Meituan-Dianping/mpvue/blob/master/packages/mpvue/index.js#L3680 return _c('div', { staticClass: "my-component" }, [_c('h1', [_vm._v(_vm._s(_vm.msg))]), _vm._v(" "), _c('other-component', { attrs: { "msg": _vm.msg, "mpcomid": '0' } })], 1) } // staticRenderFns的作用是靜態(tài)渲染,在更新時(shí)不會(huì)進(jìn)行patch,優(yōu)化性能。而staticRenderFns是個(gè)空數(shù)組。 var staticRenderFns = [] render._withStripped = true var esExports = { render: render, staticRenderFns: staticRenderFns } /* harmony default export */ __webpack_exports__["a"] = (esExports); if (false) { module.hot.accept() if (module.hot.data) { require("vue-hot-reload-api").rerender("data-v-54ad9125", esExports) } } /***/ }) 復(fù)制代碼 |
compiler相關(guān),也就是template預(yù)編譯這塊,可以參考《 聊聊Vue的template編譯 》來(lái)搞明白。原理是一樣的。
mpvue自己實(shí)現(xiàn)了 export { compile, compileToFunctions, compileToWxml } ( 鏈接 )其中 compileToWxml 是用來(lái)生成wxml,具體代碼 在這 。
另外mpvue是不需要提供運(yùn)行時(shí)-編譯器的,雖然理論上是能夠做到的。因?yàn)樾〕绦虿荒懿僮鱀OM,即便提供了運(yùn)行時(shí)-編譯器也產(chǎn)生不了界面。
詳細(xì)講解compile過(guò)程:
1.將vue文件解析成模板對(duì)象
// mpvue-loader/lib/loader.js var parts = parse(content, fileName, this.sourceMap) 復(fù)制代碼
假如vue文件源碼如下:
<template> <view class="container-bg"> <view class="home-container"> <home-quotation-view v-for="(item, index) in lists" :key="index" :reason="item.reason" :stockList="item.list" @itemViewClicked="itemViewClicked" /> </view> </view> </template> <script lang="js"> import homeQuotationView from '@/components/homeQuotationView' import topListApi from '@/api/topListApi' export default { data () { return { lists: [] } }, components: { homeQuotationView }, methods: { async loadRankList () { let {data} = await topListApi.rankList() if (data) { this.dateTime = data.dt this.lists = data.rankList.filter((item) => { return !!item }) } }, itemViewClicked (quotationItem) { wx.navigateTo({ url: `/pages/topListDetail/main?item=${JSON.stringify(quotationItem)}` }) } }, onShow () { this.loadRankList() } } </script> <style lang="stylus" scoped> .container-bg width 100% height 100% background-color #F2F4FA .home-container width 100% height 100% overflow-x hidden </style> 復(fù)制代碼 |
調(diào)用 parse(content, fileName, this.sourceMap) 函數(shù)得到的結(jié)果大致如下:
{ template: { type: 'template', content: '\n<view class="container-bg">\n <view class="home-container">\n <home-quotation-view v-for="(item, index) in lists" :key="index" :reason="item.reason" :stockList="item.list" @itemViewClicked="itemViewClicked" />\n </view>\n</view>\n', start: 10, attrs: {}, end: 251 }, script: { type: 'script', content: '\n\n\n\n\n\n\n\n\nimport homeQuotationView from \'@/components/homeQuotationView\'\nimport topListApi from \'@/api/topListApi\'\n\nexport default {\n data () {\n return {\n lists: []\n }\n },\n components: {\n homeQuotationView\n },\n methods: {\n async loadRankList () {\n let {data} = await topListApi.rankList()\n if (data) {\n this.dateTime = data.dt\n this.lists = data.rankList.filter((item) => {\n return !!item\n })\n }\n },\n itemViewClicked (quotationItem) {\n wx.navigateTo({\n url: `/pages/topListDetail/main?item=${JSON.stringify(quotationItem)}`\n })\n }\n },\n onShow () {\n this.loadRankList()\n }\n}\n', start: 282, attrs: { lang: 'js' }, lang: 'js', end: 946, ... }, styles: [{ type: 'style', content: '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n.container-bg\n width 100%\n height 100%\n background-color #F2F4FA\n\n.home-container\n width 100%\n height 100%\n overflow-x hidden\n\n', start: 985, attrs: [Object], lang: 'stylus', scoped: true, end: 1135, ... }], customBlocks: [] } 復(fù)制代碼 |
2.調(diào)用mpvue-loader/lib/template-compiler/index.js導(dǎo)出的接口并傳入上面得到的html模板:
var templateCompilerPath = normalize.lib('template-compiler/index') ... var defaultLoaders = { html: templateCompilerPath + templateCompilerOptions, css: options.extractCSS ? getCSSExtractLoader() : styleLoaderPath + '!' + 'css-loader' + cssLoaderOptions, js: hasBuble ? ('buble-loader' + bubleOptions) : hasBabel ? babelLoaderOptions : '' } // check if there are custom loaders specified via // webpack config, otherwise use defaults var loaders = Object.assign({}, defaultLoaders, options.loaders) 復(fù)制代碼 調(diào)用mpvue/packages/mpvue-template-compiler/build.js的compile接口: // mpvue-loader/lib/template-compiler/index.js var compiled = compile(html, compilerOptions) 復(fù)制代碼 compile方法生產(chǎn)下面的ast(Abstract Syntax Tree)模板,render函數(shù)和staticRenderFns { ast: { type: 1, tag: 'view', attrsList: [], attrsMap: { class: 'container-bg' }, parent: undefined, children: [{ type: 1, tag: 'view', attrsList: [], attrsMap: { class: 'home-container' }, parent: { type: 1, tag: 'view', attrsList: [], attrsMap: { class: 'container-bg' }, parent: undefined, children: [ [Circular] ], plain: false, staticClass: '"container-bg"', static: false, staticRoot: false }, children: [{ type: 1, tag: 'home-quotation-view', attrsList: [{ name: ':reason', value: 'item.reason' }, { name: ':stockList', value: 'item.list' }, { name: '@itemViewClicked', value: 'itemViewClicked' }], attrsMap: { 'v-for': '(item, index) in lists', ':key': 'index', ':reason': 'item.reason', ':stockList': 'item.list', '@itemViewClicked': 'itemViewClicked', 'data-eventid': '{{\'0-\'+index}}', 'data-comkey': '{{$k}}' }, parent: [Circular], children: [], for: 'lists', alias: 'item', iterator1: 'index', key: 'index', plain: false, hasBindings: true, attrs: [{ name: 'reason', value: 'item.reason' }, { name: 'stockList', value: 'item.list' }, { name: 'eventid', value: '\'0-\'+index' }, { name: 'mpcomid', value: '\'0-\'+index' }], events: { itemViewClicked: { value: 'itemViewClicked', modifiers: undefined } }, eventid: '\'0-\'+index', mpcomid: '\'0-\'+index', static: false, staticRoot: false, forProcessed: true }], plain: false, staticClass: '"home-container"', static: false, staticRoot: false }], plain: false, staticClass: '"container-bg"', static: false, staticRoot: false }, render: 'with(this){return _c(\'view\',{staticClass:"container-bg"},[_c(\'view\',{staticClass:"home-container"},_l((lists),function(item,index){return _c(\'home-quotation-view\',{key:index,attrs:{"reason":item.reason,"stockList":item.list,"eventid":\'0-\'+index,"mpcomid":\'0-\'+index},on:{"itemViewClicked":itemViewClicked}})}))])}', staticRenderFns: [], errors: [], tips: [] } 復(fù)制代碼 |
其中的render函數(shù)運(yùn)行的結(jié)果是返回 VNode 對(duì)象,其實(shí) render 函數(shù)應(yīng)該長(zhǎng)下面這樣:
(function() { with(this){ return _c('div',{ //創(chuàng)建一個(gè) div 元素 attrs:{"id":"app"} //div 添加屬性 id },[ _m(0), //靜態(tài)節(jié)點(diǎn) header,此處對(duì)應(yīng) staticRenderFns 數(shù)組索引為 0 的 render 函數(shù) _v(" "), //空的文本節(jié)點(diǎn) (message) //三元表達(dá)式,判斷 message 是否存在 //如果存在,創(chuàng)建 p 元素,元素里面有文本,值為 toString(message) ?_c('p',[_v("\n "+_s(message)+"\n ")]) //如果不存在,創(chuàng)建 p 元素,元素里面有文本,值為 No message. :_c('p',[_v("\n No message.\n ")]) ] ) } }) 復(fù)制代碼
其中的 _c 就是vue對(duì)象的 createElement 方法 (創(chuàng)建元素), _m 是 renderStatic (渲染靜態(tài)節(jié)點(diǎn)), _v 是 createTextVNode (創(chuàng)建文本dom), _s 是 toString (轉(zhuǎn)換為字符串)
// src/core/instance/render.js export function initRender (vm: Component) { ... // bind the createElement fn to this instance // so that we get proper render context inside it. // args order: tag, data, children, normalizationType, alwaysNormalize // internal version is used by render functions compiled from templates vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false) // normalization is always applied for the public version, used in // user-written render functions. vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true) ... } ... Vue.prototype._s = toString ... Vue.prototype._m = renderStatic ... Vue.prototype._v = createTextVNode ... 復(fù)制代碼
// mpvue-loader/lib/template-compiler/index.js compileToWxml.call(this, compiled, html) 復(fù)制代碼
目錄結(jié)構(gòu)
. ├── events.js //解答問(wèn)題5 ├── index.js //入口提供Vue對(duì)象,以及$mount,和各種初始化 ├── liefcycle //解答問(wèn)題6、7 ├── node-ops.js //操作真實(shí)DOM的相關(guān)實(shí)現(xiàn),因?yàn)樾〕绦虿荒懿僮鱀OM,所以這里都是直接返回 ├── patch.js //解答問(wèn)題3 └── render.js //解答問(wèn)題4 復(fù)制代碼
patch.js
和vue使用的 createPatchFunction 保持一致,任然是舊樹(shù)和新樹(shù)進(jìn)行patch產(chǎn)出diff,但是多了一行this.$updateDataToMP()用以更新。
render.js
兩個(gè)核心的方法 initDataToMP 、 updateDataToMP 。
initDataToMP 收集vm上的data,然后調(diào)用小程序Page示例的 setData 渲染。
updateDataToMP 在每次patch,也就是依賴(lài)收集發(fā)現(xiàn)數(shù)據(jù)改變時(shí)更新(參考patch.js代碼),這部分一樣會(huì)使用 nextTick 和隊(duì)列。最終使用了節(jié)流閥 throttleSetData 。50毫秒用來(lái)控制頻率以解決頻繁修改Data,會(huì)造成大量傳輸Data數(shù)據(jù)而導(dǎo)致的性能問(wèn)題。
其中 collectVmData 最終也是用到了 formatVmData 。尤其要注意的是一句注釋?zhuān)?/p>
getVmData 這兒獲取當(dāng)前組件內(nèi)的所有數(shù)據(jù),包含 props、computed 的數(shù)據(jù)
我們又知道,service到view是兩個(gè)線程間通信,如果Data含有大量數(shù)據(jù),增加了傳輸數(shù)據(jù)量,加大了傳輸成本,將會(huì)造成性能下降。
events.js
正如官網(wǎng)所說(shuō)的,這里使用 eventTypeMap 做了各事件的隱射
import { getComKey, eventTypeMap } from '../util/index' 復(fù)制代碼
// 用于小程序的 event type 到 web 的 event export const eventTypeMap = { tap: ['tap', 'click'], touchstart: ['touchstart'], touchmove: ['touchmove'], touchcancel: ['touchcancel'], touchend: ['touchend'], longtap: ['longtap'], input: ['input'], blur: ['change', 'blur'], submit: ['submit'], focus: ['focus'], scrolltoupper: ['scrolltoupper'], scrolltolower: ['scrolltolower'], scroll: ['scroll'] } 復(fù)制代碼 |
使用了 handleProxyWithVue 方法來(lái)代理小程序事件到vue事件。
另外看下作者自己對(duì)這部分的思路
事件代理機(jī)制:用戶(hù)交互觸發(fā)的數(shù)據(jù)更新通過(guò)事件代理機(jī)制完成。在 Vue.js 代碼中,事件響應(yīng)函數(shù)對(duì)應(yīng)到組件的 method, Vue.js 自動(dòng)維護(hù)了上下文環(huán)境。然而在小程序中并沒(méi)有類(lèi)似的機(jī)制,又因?yàn)?Vue.js 執(zhí)行環(huán)境中維護(hù)著一份實(shí)時(shí)的虛擬 DOM,這與小程序的視圖層完全對(duì)應(yīng),我們思考,在小程序組件節(jié)點(diǎn)上觸發(fā)事件后,只要找到虛擬 DOM 上對(duì)應(yīng)的節(jié)點(diǎn),觸發(fā)對(duì)應(yīng)的事件不就完成了么;另一方面,Vue.js 事件響應(yīng)如果觸發(fā)了數(shù)據(jù)更新,其生命周期函數(shù)更新將自動(dòng)觸發(fā),在此函數(shù)上同步更新小程序數(shù)據(jù),數(shù)據(jù)同步也就實(shí)現(xiàn)了。
getHandle 這個(gè)方法應(yīng)該就是作者思路當(dāng)中所說(shuō)的:找到對(duì)應(yīng)節(jié)點(diǎn),然后找到handle。
lifecycle.js
在 initMP 方法中,自己創(chuàng)建小程序的App、Page。實(shí)現(xiàn)生命周期相關(guān)方法,使用 callHook代理兼容小程序App、Page的生命周期。
官方文檔生命周期中說(shuō)到了:
同 vue,不同的是我們會(huì)在小程序 onReady 后,再去觸發(fā) vue mounted 生命周期
這部分查看, onReady 之后才會(huì)執(zhí)行 next ,這個(gè) next 回調(diào)最終是vue的 mountComponent??梢栽?nbsp;index.js 中看到。這部分代碼也就是解決了"小程序生命周期中觸發(fā)vue生命周期"。
export function initMP (mpType, next) { // ... global.Page({ // 生命周期函數(shù)--監(jiān)聽(tīng)頁(yè)面初次渲染完成 onReady () { mp.status = 'ready' callHook(rootVueVM, 'onReady') next() }, }) // ... } 復(fù)制代碼
在小程序onShow時(shí),使用$nextTick去第一次渲染數(shù)據(jù),參考上面提到的render.js。
export function initMP (mpType, next) { // ... global.Page({ // 生命周期函數(shù)--監(jiān)聽(tīng)頁(yè)面顯示 onShow () { mp.page = this mp.status = 'show' callHook(rootVueVM, 'onShow') // 只有頁(yè)面需要 setData rootVueVM.$nextTick(() => { rootVueVM._initDataToMP() }) }, }) // ... } 復(fù)制代碼 |
在mpvue-loader生成template時(shí),比如點(diǎn)擊事件 @click 會(huì)變成 bindtap="handleProxy" ,事件綁定全都會(huì)使用 handleProxy 這個(gè)方法。
可以查看上面回顧一下。
最終handleProxy調(diào)用的是event.js中的 handleProxyWithVue 。
export function initMP (mpType, next) { // ... global.Page({ handleProxy (e) { return rootVueVM.$handleProxyWithVue(e) }, }) // ... } 復(fù)制代碼 |
index.js
最后index.js就負(fù)責(zé)各種初始化和mount。
原因:目前的組件是使用小程序的 template 標(biāo)簽實(shí)現(xiàn)的,給組件指定的class和style是掛載在template標(biāo)簽上,而template 標(biāo)簽不支持 class 及 style 屬性。
解決方案: 在自定義組件上綁定class或style到一個(gè)props屬性上。
// 組件ComponentA.vue <template> <div class="container" :class="pClass"> ... </div> </template> 復(fù)制代碼 |
<script> export default { props: { pClass: { type: String, default: '' } } } </script> 復(fù)制代碼
<!--PageB.vue--> <template> <component-a :pClass="cusComponentAClass" /> </template> 復(fù)制代碼
<script> data () { return { cusComponentAClass: 'a-class b-class' } } </script> 復(fù)制代碼
<style lang="stylus" scoped> .a-class border red solid 2rpx .b-class margin-right 20rpx </style> 復(fù)制代碼
但是這樣會(huì)有問(wèn)題就是style加上scoped之后,編譯模板生成的代碼是下面這樣的:
.a-class.data-v-8f1d914e { border: #f00 solid 2rpx; } .b-class.data-v-8f1d914e { margin-right 20rpx } 復(fù)制代碼
所以想要這些組件的class生效就不能使用scoped的style,改成下面這樣,最好自己給a-class和b-class加前綴以防其他的文件引用這些樣式:
<style lang="stylus"> .a-class border red solid 2rpx .b-class margin-right 20rpx </style> <style lang="stylus" scoped> .other-class border red solid 2rpx ... </style> 復(fù)制代碼
<!--P組件ComponentA.vue--> <template> <div class="container" :style="pStyle"> ... </div> </template> 復(fù)制代碼
<script> export default { props: { pStyle: { type: String, default: '' } } } </script> 復(fù)制代碼
<!--PageB.vue--> <template> <component-a :pStyle="cusComponentAStyle" /> </template> 復(fù)制代碼
<script> const cusComponentAStyle = 'border:red solid 2rpx; margin-right:20rpx;' data () { return { cusComponentAStyle } } </script> 復(fù)制代碼
<style lang="stylus" scoped> ... </style> 復(fù)制代碼
也可以通過(guò)定義styleObject,然后通過(guò)工具函數(shù)轉(zhuǎn)化為styleString,如下所示:
const bstyle = { border: 'red solid 2rpx', 'margin-right': '20rpx' } let arr = [] for (let [key, value] of Object.entries(bstyle)) { arr.push(`${key}: ${value}`) } const cusComponentAStyle = arr.join('; ') 復(fù)制代碼
<!--組件ComponentA.vue--> <template> <div class="container" :style="{'background-color': backgroundColor}"> ... </div> </template> 復(fù)制代碼
<script> export default { props: { backgroundColor: { type: String, default: 'yellow' } } } </script> 復(fù)制代碼
<!-- PageB.vue --> <template> <component-a backgroundColor="red" /> </template> 復(fù)制代碼
package.json修改
注意事項(xiàng)
移動(dòng)src/main.js中config相關(guān)內(nèi)容到同級(jí)目錄下main.json(新建)中
export default { // config: {...} 需要移動(dòng) } 復(fù)制代碼
to
{ "pages": [ "pages/index/main", "pages/logs/main" ], "subPackages": [ { "root": "pages/packageA", "pages": [ "counter/main" ] } ], "window": {...} } 復(fù)制代碼 |
build/webpack.base.conf.js
+var CopyWebpackPlugin = require('copy-webpack-plugin') +var relative = require('relative') function resolve (dir) { return path.join(__dirname, '..', dir) } -function getEntry (rootSrc, pattern) { - var files = glob.sync(path.resolve(rootSrc, pattern)) - return files.reduce((res, file) => { - var info = path.parse(file) - var key = info.dir.slice(rootSrc.length + 1) + '/' + info.name - res[key] = path.resolve(file) - return res - }, {}) +function getEntry (rootSrc) { + var map = {}; + glob.sync(rootSrc + '/pages/**/main.js') + .forEach(file => { + var key = relative(rootSrc, file).replace('.js', ''); + map[key] = file; + }) + return map; } plugins: [ - new MpvuePlugin() + new MpvuePlugin(), + new CopyWebpackPlugin([{ + from: '**/*.json', + to: 'app.json' + }], { + context: 'src/' + }), + new CopyWebpackPlugin([ // 處理 main.json 里面引用的圖片,不要放代碼中引用的圖片 + { + from: path.resolve(__dirname, '../static'), + to: path.resolve(__dirname, '../dist/static'), + ignore: ['.*'] + } + ]) ] } 復(fù)制代碼
build/webpack.dev.conf.js
module.exports = merge(baseWebpackConfig, { devtool: '#source-map', output: { path: config.build.assetsRoot, - filename: utils.assetsPath('js/[name].js'), - chunkFilename: utils.assetsPath('js/[id].js') + filename: utils.assetsPath('[name].js'), + chunkFilename: utils.assetsPath('[id].js') }, plugins: [ new webpack.DefinePlugin({ module.exports = merge(baseWebpackConfig, { // copy from ./webpack.prod.conf.js // extract css into its own file new ExtractTextPlugin({ - filename: utils.assetsPath('css/[name].wxss') + filename: utils.assetsPath('[name].wxss') }), module.exports = merge(baseWebpackConfig, { } }), new webpack.optimize.CommonsChunkPlugin({ - name: 'vendor', + name: 'common/vendor', minChunks: function (module, count) { // any required modules inside node_modules are extracted to vendor return ( module.exports = merge(baseWebpackConfig, { } }), new webpack.optimize.CommonsChunkPlugin({ - name: 'manifest', - chunks: ['vendor'] + name: 'common/manifest', + chunks: ['common/vendor'] }), - // copy custom static assets - new CopyWebpackPlugin([ - { - from: path.resolve(__dirname, '../static'), - to: config.build.assetsSubDirectory, - ignore: ['.*'] - } - ]), 復(fù)制代碼
build/webpack.prod.conf.js
var webpackConfig = merge(baseWebpackConfig, { devtool: config.build.productionSourceMap ? '#source-map' : false, output: { path: config.build.assetsRoot, - filename: utils.assetsPath('js/[name].js'), - chunkFilename: utils.assetsPath('js/[id].js') + filename: utils.assetsPath('[name].js'), + chunkFilename: utils.assetsPath('[id].js') }, plugins: [ var webpackConfig = merge(baseWebpackConfig, { }), // extract css into its own file new ExtractTextPlugin({ - // filename: utils.assetsPath('css/[name].[contenthash].css') - filename: utils.assetsPath('css/[name].wxss') + // filename: utils.assetsPath('[name].[contenthash].css') + filename: utils.assetsPath('[name].wxss') }), // Compress extracted CSS. We are using this plugin so that possible // duplicated CSS from different components can be deduped. var webpackConfig = merge(baseWebpackConfig, { new webpack.HashedModuleIdsPlugin(), // split vendor js into its own file new webpack.optimize.CommonsChunkPlugin({ - name: 'vendor', + name: 'common/vendor', minChunks: function (module, count) { // any required modules inside node_modules are extracted to vendor return ( var webpackConfig = merge(baseWebpackConfig, { // extract webpack runtime and module manifest to its own file in order to // prevent vendor hash from being updated whenever app bundle is updated new webpack.optimize.CommonsChunkPlugin({ - name: 'manifest', - chunks: ['vendor'] - }), + name: 'common/manifest', + chunks: ['common/vendor'] + }) - // copy custom static assets - new CopyWebpackPlugin([ - { - from: path.resolve(__dirname, '../static'), - to: config.build.assetsSubDirectory, - ignore: ['.*'] - } - ]) ] }) 復(fù)制代碼
config/index.js
module.exports = { env: require('./prod.env'), index: path.resolve(__dirname, '../dist/index.html'), assetsRoot: path.resolve(__dirname, '../dist'), - assetsSubDirectory: 'static', // 不將資源聚合放在 static 目錄下 + assetsSubDirectory: '', assetsPublicPath: '/', productionSourceMap: false, // Gzip off by default as many popular static hosts such as @@ -26,7 +26,7 @@ module.exports = { port: 8080, // 在小程序開(kāi)發(fā)者工具中不需要自動(dòng)打開(kāi)瀏覽器 autoOpenBrowser: false, - assetsSubDirectory: 'static', // 不將資源聚合放在 static 目錄下 + assetsSubDirectory: '', assetsPublicPath: '/', proxyTable: {}, // CSS Sourcemaps off by default because relative paths are "buggy" 復(fù)制代碼
工作日 8:30-12:00 14:30-18:00
周六及部分節(jié)假日提供值班服務(wù)