帶你玩轉小程式開發實踐|含直播回顧視訊

iKcamp發表於2019-03-04

作者:張利濤,視訊課程《微信小程式教學》、《基於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
...
複製程式碼

## 小程式的執行過程

  1. 我們在微信上開啟一個小程式
    微信客戶端在開啟小程式之前,會把整個小程式的程式碼包下載到本地。

  2. 微信 App 從微信伺服器下載小程式的檔案包
    為了流暢的使用者體驗和效能問題,小程式的檔案包不能超過 2M。另外要注意,小程式目錄下的所有檔案上傳時候都會打到一個包裡面,所以儘量少用圖片和第三方的庫,特別是圖片。

  3. 解析 app.json 配置資訊初始化導航欄,視窗樣式,包含的頁面列表

  4. 載入執行 app.js 初始化小程式,建立 app 例項

  5. 根據 app.json,載入執行第一個頁面初始化第一個 Page

  6. 路由切換
    以棧的形式維護了當前的所有頁面。最多 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 的原始碼就會發現,上述的過程可以簡化描述如下:

  1. 訂閱:監聽狀態————儲存對應的回撥
  2. 釋出:狀態變化————執行回撥函式
  3. 同步檢視:回撥函式同步資料到檢視

第三步:同步檢視,在 React 中,State 發生變化後會觸發 Render 來更新檢視。

而小程式中,如果我們通過 setData 改變 data,同樣可以更新檢視。

所以,我們實現小程式元件通訊的思路如下:

  1. 觀察者模式/釋出訂閱模式
  2. 裝飾者模式/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實戰專案教學(含視訊)| 課程大綱介紹

滬江iKcamp出品微信小程式教學共5章16小節彙總(含視訊)


帶你玩轉小程式開發實踐|含直播回顧視訊

2019年,iKcamp原創新書《Koa與Node.js開發實戰》已在京東、天貓、亞馬遜、噹噹開售啦!

相關文章