作者:張利濤,視訊課程《微信小程式教學》、《基於Koa2搭建Node.js實戰專案教學》主編,滬江前端架構師
本文原創,轉載請註明作者及出處
小程式和 H5 區別
我們不一樣,不一樣,不一樣。
執行環境 runtime
首先從官方文件可以看到,小程式的執行環境並不是瀏覽器環境:
小程式框架提供了自己的檢視層描述語言 WXML 和 WXSS,以及基於 JavaScript 的邏輯層框架,並在檢視層與邏輯層間提供了資料傳輸和事件系統,可以讓開發者可以方便的聚焦於資料與邏輯上。
小程式的檢視層目前使用 WebView 作為渲染載體,而邏輯層是由獨立的 JavascriptCore 作為執行環境。在架構上,WebView 和 JavascriptCore 都是獨立的模組,並不具備資料直接共享的通道。當前,檢視層和邏輯層的資料傳輸,實際上通過兩邊提供的 evaluateJavascript 所實現。即使用者傳輸的資料,需要將其轉換為字串形式傳遞,同時把轉換後的資料內容拼接成一份 JS 指令碼,再通過執行 JS 指令碼的形式傳遞到兩邊獨立環境。
而 evaluateJavascript 的執行會受很多方面的影響,資料到達檢視層並不是實時的。同一程式內的 WebView 實際上會共享一個 JS VM,如果 WebView 內 JS 執行緒正在執行渲染或其他邏輯,會影響 evaluateJavascript 指令碼的實際執行時間,另外多個 WebView 也會搶佔 JS VM 的執行許可權;另外還有 JS 本身的編譯執行耗時,都是影響資料傳輸速度的因素。
複製程式碼
而所謂的執行環境,對於任何語言的執行,它們都需要有一個環境——runtime。瀏覽器和 Node.js 都能執行 JavaScript,但它們都只是指定場景下的 runtime,所有各有不同。而小程式的執行環境,是微信定製化的 runtime。
大家可以做一個小實驗,分別在瀏覽器環境和小程式環境開啟各自的控制檯,執行下面的程式碼來進行一個 20 億次的迴圈:
var k
for (var i = 0; i < 2000000000; i++) {
k = i
}
複製程式碼
瀏覽器控制檯下執行時,當前頁面是完全不能動,因為 JS 和檢視共用一個執行緒,相互阻塞。
小程式控制臺下執行時,當前檢視可以動,如果繫結有事件,也會一樣觸發,只不過事件的回撥需要在 『迴圈結束』 之後。
檢視層和邏輯層如果共用一個執行緒,優點是通訊速度快(離的近就是好),缺點是相互阻塞。比如瀏覽器。
檢視層和邏輯層如果分處兩個環境,優點是相互不阻塞,缺點是通訊成本高(異地戀)。比如小程式的 setData
,通訊一次就像是寫情書!
所以,嚴格來說,小程式是微信定製的混合開發模式。
在 JavaScript 的基礎上,小程式做了一些修改,以方便開發小程式。
- 增加 App 和 Page 方法,進行程式和頁面的註冊。【增加了 Component】
- 增加 getApp 和 getCurrentPages 方法,分別用來獲取 App 例項和當前頁面棧。
- 提供豐富的 API,如微信使用者資料,掃一掃,支付等微信特有能力。【呼叫原生元件:Cordova、ReactNative、Weex 等】
- 每個頁面有獨立的作用域,並提供模組化能力。
- 由於框架並非執行在瀏覽器中,所以 JavaScript 在 web 中一些能力都無法使用,如 document,window 等。【小程式的 JsCore 環境】
- 開發者寫的所有程式碼最終將會打包成一份 JavaScript,並在小程式啟動的時候執行,直到小程式銷燬。類似 ServiceWorker,所以邏輯層也稱之為 App Service。
與傳統的 HTML 相比,WXML 更像是一種模板式的標籤語言
從實踐體驗上看,我們可以從小程式檢視上看到 Java FreeMarker 框架、Velocity、smarty 之類的影子。
小程式檢視支援如下
資料繫結 {{}}
列表渲染 wx:for
條件判斷 wx:if
模板 tempalte
事件 bindtap
引用 import include
可在檢視中應用的指令碼語言 wxs
...
複製程式碼
Java FreeMarker 也同樣支援上述功能。
資料繫結 ${}
列表渲染 list指令
條件判斷 if指令
模板 FTL
事件 原生事件
引用 import include 指令
內建函式 比如『時間格式化』
可在檢視中應用的指令碼語言 巨集 marco
...
複製程式碼
## 小程式的執行過程
-
我們在微信上開啟一個小程式
微信客戶端在開啟小程式之前,會把整個小程式的程式碼包下載到本地。 -
微信 App 從微信伺服器下載小程式的檔案包
為了流暢的使用者體驗和效能問題,小程式的檔案包不能超過 2M。另外要注意,小程式目錄下的所有檔案上傳時候都會打到一個包裡面,所以儘量少用圖片和第三方的庫,特別是圖片。 -
解析 app.json 配置資訊初始化導航欄,視窗樣式,包含的頁面列表
-
載入執行 app.js 初始化小程式,建立 app 例項
-
根據 app.json,載入執行第一個頁面初始化第一個 Page
-
路由切換
以棧的形式維護了當前的所有頁面。最多 5 個頁面。出棧入棧
## 解決小程式介面不支援 Promise 的問題
小程式的所有介面,都是通過傳統的回撥函式形式來呼叫的。回撥函式真正的問題在於他剝奪了我們使用 return 和 throw 這些關鍵字的能力。而 Promise 很好地解決了這一切。
那麼,如何通過 Promise 的方式來呼叫小程式介面呢?
檢視一下小程式的官方文件,我們會發現,幾乎所有的介面都是同一種書寫形式:
wx.request({
url: "test.php", //僅為示例,並非真實的介面地址
data: {
x: "",
y: ""
},
header: {
"content-type": "application/json" // 預設值
},
success: function(res) {
console.log(res.data)
},
fail: function(res) {
console.log(res)
}
})
複製程式碼
所以,我們可以通過簡單的 Promise 寫法,把小程式介面裝飾一下。程式碼如下:
wx.request2 = (option = {}) => {
// 返回一個 Promise 例項物件,這樣就可以使用 then 和 throw
return new Promise((resolve, reject) => {
option.success = res => {
// 重寫 API 的 success 回撥函式
resolve(res)
}
option.fail = res => {
// 重寫 API 的 fail 回撥函式
reject(res)
}
wx.request(option) // 裝飾後,進行正常的介面請求
})
}
複製程式碼
上述程式碼簡單的展現瞭如何把一個請求介面包裝成 Promise 形式。但在實戰專案中,可能有多個介面需要我們去包裝處理,每一個都單獨包裝是不現實的。這時候,我們就需要用一些技巧來處理了。
其實思路很簡單:我們把需要 Promise 化的『介面名字』存放在一個『陣列』中,然後對這個陣列進行迴圈處理。
這裡我們利用了 ECMAScript5 的特性 Object.defineProperty 來重寫介面的取值過程。
let wxKeys = [
// 儲存需要Promise化的介面名字
"showModal",
"request"
]
// 擴充套件 Promise 的 finally 功能
Promise.prototype.finally = function(callback) {
let P = this.constructor
return this.then(
value => P.resolve(callback()).then(() => value),
reason =>
P.resolve(callback()).then(() => {
throw reason
})
)
}
wxKeys.forEach(key => {
const wxKeyFn = wx[key] // 將wx的原生函式臨時儲存下來
if (wxKeyFn && typeof wxKeyFn === "function") {
// 如果這個值存在並且是函式的話,進行重寫
Object.defineProperty(wx, key, {
get() {
// 一旦目標物件訪問該屬性,就會呼叫這個方法,並返回結果
// 呼叫 wx.request({}) 時候,就相當於在呼叫此函式
return (option = {}) => {
// 函式執行後,返回 Promise 例項物件
return new Promise((resolve, reject) => {
option.success = res => {
resolve(res)
}
option.fail = res => {
reject(res)
}
wxKeyFn(option)
})
}
}
})
}
})
複製程式碼
注: Object.defineProperty() 方法會直接在一個物件上定義一個新屬性,或者修改一個物件的現有屬性,並返回這個物件。
用法也很簡單,我們把上述程式碼儲存在一個 js 檔案中,比如 utils/toPromise.js,然後在 app.js 中引入就可以了:
import "./util/toPromise"
App({
onLoad() {
wx
.request({
url: "http://www.weather.com.cn/data/sk/101010100.html"
})
.then(res => {
console.log("come from Promised api, then:", res)
})
.catch(err => {
console.log("come from Promised api, catch:", err)
})
.finally(res => {
console.log("come from Promised api, finally:")
})
}
})
複製程式碼
小程式元件化開發
小程式從 1.6.3 版本開始,支援簡潔的元件化程式設計
官方支援元件化之前的做法
// 元件內部實現
export default class TranslatePop {
constructor(owner, deviceInfo = {}) {
this.owner = owner;
this.defaultOption = {}
}
init() {
this.applyData({...})
}
applyData(data) {
let optData = Object.assign(this.defaultOption, data);
this.owner && this.owner.setData({
translatePopData: optData
})
}
}
// index.js 中呼叫
translatePop = new TranslatePop(this);
translatePop.init();
複製程式碼
實現方式比較簡單,就是在呼叫一個元件時候,把當前環境的上下文 content 傳遞給元件,在元件內部實現 setData 呼叫。
應用官方支援的方式來實現
官方元件示例:
Component({
properties: {
// 這裡定義了innerText屬性,屬性值可以在元件使用時指定
innerText: {
type: String,
value: "default value"
}
},
data: {
// 這裡是一些元件內部資料
someData: {}
},
methods: {
// 這裡是一個自定義方法
customMethod: function() {}
}
})
複製程式碼
結合 Redux 實現元件通訊
在 React 專案中 Redux 是如何工作的
-
單一資料來源
整個應用的 state 被儲存在一棵 object tree 中,並且這個 object tree 只存在於唯一一個 store 中。
-
State 是隻讀的
惟一改變 state 的方法就是觸發 action,action 是一個用於描述已發生事件的普通物件
-
使用純函式來執行修改
為了描述 action 如何改變 state tree ,你需要編寫 reducers。
-
Props 傳遞 —— Render 渲染
如果你有看過 Redux 的原始碼就會發現,上述的過程可以簡化描述如下:
- 訂閱:監聽狀態————儲存對應的回撥
- 釋出:狀態變化————執行回撥函式
- 同步檢視:回撥函式同步資料到檢視
第三步:同步檢視,在 React 中,State 發生變化後會觸發 Render 來更新檢視。
而小程式中,如果我們通過 setData 改變 data,同樣可以更新檢視。
所以,我們實現小程式元件通訊的思路如下:
- 觀察者模式/釋出訂閱模式
- 裝飾者模式/Object.defineProperty (Vuejs 的設計路線)
在小程式中實現元件通訊
先預覽下我們的最終專案結構:
├── components/
│ ├── count/
│ ├── count.js
│ ├── count.json
│ ├── count.wxml
│ ├── count.wxss
│ ├── footer/
│ ├── footer.js
│ ├── footer.json
│ ├── footer.wxml
│ ├── footer.wxss
├── pages/
│ ├── index/
│ ├── ...
│ ├── log/
│ ├── ...
├── reducers/
│ ├── counter.js
│ ├── index.js
│ ├── redux.min.js
├── utils/
│ ├── connect.js
│ ├── shallowEqual.js
│ ├── toPromise.js
├── app.js
├── app.json
├── app.wxss
複製程式碼
1. 實現『釋出訂閱』功能
首先,我們從 cdn 或官方網站獲取 redux.min.js,放在結構裡面
建立 reducers 目錄下的檔案:
// /reducers/index.js
import { createStore, combineReducers } from './redux.min.js'
import counter from './counter'
export default createStore(combineReducers({
counter: counter
}))
// /reducers/counter.js
const INITIAL_STATE = {
count: 0,
rest: 0
}
const Counter = (state = INITIAL_STATE, action) => {
switch (action.type) {
case "COUNTER_ADD_1": {
let { count } = state
return Object.assign({}, state, { count: count + 1 })
}
case "COUNTER_CLEAR": {
let { rest } = state
return Object.assign({}, state, { count: 0, rest: rest+1 })
}
default: {
return state
}
}
}
export default Counter
複製程式碼
我們定義了一個需要傳遞的場景值 count
,用來代表例子中的『點選次數』,rest
代表『重置次數』。
然後在 app.js 中引入,並植入到小程式全域性中:
//app.js
import Store from './reducers/index'
App({
Store,
})
複製程式碼
2. 利用 『裝飾者模式』,對小程式的生命週期進行包裝,狀態發生變化時候,如果狀態值不一樣,就同步 setData
// 引用了 react-redux 中的工具函式,用來判斷兩個狀態是否相等
import shallowEqual from './shallowEqual'
// 獲取我們在 app.js 中植入的全域性變數 Store
let __Store = getApp().Store
// 函式變數,用來過濾出我們想要的 state,方便對比賦值
let mapStateToData
// 用來補全配置項中的生命週期函式
let baseObj = {
__observer: null,
onLoad() { },
onUnload() { },
onShow() { },
onHide() { }
}
let config = {
__Store,
__dispatch: __Store.dispatch,
__destroy: null,
__observer() {
// 物件中的 super,指向其原型 prototype
if (super.__observer) {
super.__observer()
return
}
const state = __Store.getState()
const newData = mapStateToData(state)
const oldData = mapStateToData(this.data || {})
if (shallowEqual(oldData, newData)) {// 狀態值沒有發生變化就返回
return
}
this.setData(newData)
},
onLoad() {
super.onLoad()
this.__destroy = this.__Store.subscribe(this.__observer)
this.__observer()
},
onUnload() {
super.onUnload()
this.__destroy && this.__destroy() & delete this.__destroy
},
onShow() {
super.onShow()
if (!this.__destroy) {
this.__destroy = this.__Store.subscribe(this.__observer)
this.__observer()
}
},
onHide() {
super.onHide()
this.__destroy && this.__destroy() & delete this.__destroy
}
}
export default (mapState = () => { }) => {
mapStateToData = mapState
return (options = {}) => {
// 補全生命週期
let opts = Object.assign({}, baseObj, options)
// 把業務程式碼中的 opts 配置物件,指定為 config 的原型,方便『裝飾者呼叫』
Object.setPrototypeOf(config, opts)
return config
}
}
複製程式碼
呼叫方法:
// pages/index/index.js
import connect from "../../utils/connect"
const mapStateToProps = (state) => {
return {
counter: state.counter
}
}
Page(connect(mapStateToProps)({
data: {
innerText: "Hello 點我加1哦"
},
bindBtn() {
this.__dispatch({
type: "COUNTER_ADD_1"
})
}
}))
複製程式碼
最終效果展示:
專案原始碼地址: github.com/ikcamp/xcx-…
直播視訊地址: www.cctalk.com/v/151373616…
iKcamp官網:www.ikcamp.com
iKcamp新課程推出啦~~~~~開始免費連載啦~每週2更共11堂iKcamp課|基於Koa2搭建Node.js實戰專案教學(含視訊)| 課程大綱介紹
2019年,iKcamp原創新書《Koa與Node.js開發實戰》已在京東、天貓、亞馬遜、噹噹開售啦!