首頁 > 軟體

一步步教你實現微信小程式自定義元件

2022-03-21 13:00:11

前言

在微信小程式開發過程中,對於一些可能在多個頁面都使用的頁面模組,可以把它封裝成一個元件,以提高開發效率。雖然說我們可以引入整個元件庫比如 weui、vant 等,但有時候考慮微信小程式的包體積限制問題,通常封裝為自定義的元件更為可控。

並且對於一些業務模組,我們就可以封裝為元件複用。本文主要講述以下兩個方面:

  • 元件的宣告與使用
  • 元件通訊

元件的宣告與使用

微信小程式的元件系統底層是通過 Exparser 元件框架實現,它內建在小程式的基礎庫中,小程式內的所有元件,包括內建元件和自定義元件都由 Exparser 組織管理。

自定義元件和寫頁面一樣包含以下幾種檔案:

  • index.json
  • index.wxml
  • index.wxss
  • index.js
  • index.wxs

以編寫一個 tab 元件為例: 編寫自定義元件時需要在 json 檔案中講 component 欄位設為 true:

{
    "component": true
}

在 js 檔案中,基礎庫提供有 Page 和 Component 兩個構造器,Page 對應的頁面為頁面根元件,Component 則對應:

Component({
    options: { // 元件設定
        addGlobalClass: true,
        // 指定所有 _ 開頭的資料欄位為純資料欄位
        // 純資料欄位是一些不用於介面渲染的 data 欄位,可以用於提升頁面更新效能
        pureDataPattern: /^_/, 
        multipleSlots: true // 在元件定義時的選項中啟用多slot支援
    },
    properties: {
        vtabs: {type: Array, value: []},
    },
    data: {
        currentView: 0,
    },
    observers: { // 監測
        activeTab: function(activeTab) {
            this.scrollTabBar(activeTab);
        }
    }, 
    relations: {  // 關聯的子/父元件
        '../vtabs-content/index': {
            type: 'child', // 關聯的目標節點應為子節點
            linked: function(target) {
                this.calcVtabsCotentHeight(target);
            },
            unlinked: function(target) {
                delete this.data._contentHeight[target.data.tabIndex];
            }
        }
    },
    lifetimes: { // 元件宣告週期
        created: function() {
            // 元件範例剛剛被建立好時
        },
        attached: function() {
            // 在元件範例進入頁面節點樹時執行
        },
        detached: function() {
            // 在元件範例被從頁面節點樹移除時執行
        },
    },
    methods: { // 元件方法
        calcVtabsCotentHeight(target) {}
    } 
});

如果有了解過 Vue2 的小夥伴,會發現這個宣告很熟悉。

在小程式啟動時,構造器會將開發者設定的properties、data、methods等定義段,

寫入Exparser的元件登入檔中。這個元件在被其它元件參照時,就可以根據這些註冊資訊來建立自定義元件的範例。

模版檔案 wxml:

<view class='vtabs'>
    <slot />
</view>

樣式檔案:

.vtabs {}

外部頁面元件使用,只需要在頁面的 json 檔案中引入

{
  "navigationBarTitleText": "商品分類",
  "usingComponents": {
    "vtabs": "../../../components/vtabs",
  }
}

在初始化頁面時,Exparser 會建立出頁面根元件的一個範例,用到的其他元件也會響應建立元件範例(這是一個遞迴的過程):

元件建立的過程大致有以下幾個要點:

  • 根據元件註冊資訊,從元件原型上建立出元件節點的 JS 物件,即元件的 this;
  • 將元件註冊資訊中的 data 複製一份,作為元件資料,即 this.data;
  • 將這份資料結合元件 WXML,據此建立出 Shadow Tree(元件的節點樹),由於 Shadow Tree 中可能參照有其他元件,因而這會遞迴觸發其他元件建立過程;
  • 將 ShadowTree 拼接到 Composed Tree(最終拼接成的頁面節點樹)上,並生成一些快取資料用於優化元件更新效能;
  • 觸發元件的 created 生命週期函數;
  • 如果不是頁面根元件,需要根據元件節點上的屬性定義,來設定元件的屬性值;
  • 當元件範例被展示在頁面上時,觸發元件的 attached 生命週期函數,如果 Shadow Tree 中有其他元件,也逐個觸發它們的生命週期函數。

元件通訊

由於業務的負責度,我們常常需要把一個大型頁面拆分為多個元件,多個元件之間需要進行資料通訊。

對於跨代元件通訊可以考慮全域性狀態管理,這裡只討論常見的父子元件通訊:

方法一 WXML 資料繫結

用於父元件向子元件的指定屬性設定資料。

子宣告 properties 屬性

Component({
    properties: {
        vtabs: {type: Array, value: []}, // 資料項格式為 `{title}`
    }
})

父元件呼叫:

    <vtabs vtabs="{{ vtabs }}"</vtabs>

方法二 事件

用於子元件向父元件傳遞資料,可以傳遞任意資料。

子元件派發事件,先在 wxml 結構繫結子元件的點選事件:

   <view bindtap="handleTabClick">

再在 js 檔案中進行派發事件,事件名可以自定義填寫, 第二個引數可以傳遞資料物件,第三個引數為事件選項。

 handleClick(e) {
     this.triggerEvent(
         'tabclick', 
         { index }, 
         { 
             bubbles: false,  // 事件是否冒泡
             // 事件是否可以穿越元件邊界,為 false 時,事件只在參照元件的節點樹上觸發,
             // 不進入其他任何元件的內部
             composed: false,  
             capturePhase: false // 事件是否擁有捕獲階段 
         }
     );
 },
 handleChange(e) {
     this.triggerEvent('tabchange', { index });
 },

最後,在父元件中監聽使用:

<vtabs 
    vtabs="{{ vtabs }}"
    bindtabclick="handleTabClick" 
    bindtabchange="handleTabChange" 
>

方法三 selectComponent 獲取元件範例物件

通過 selectComponent 方法可以獲取子元件的範例,從而呼叫子元件的方法。

父元件的 wxml

<view>
    <vtabs-content="goods-content{{ index }}"></vtabs-content>
</view>

父元件的 js

Page({
    reCalcContentHeight(index) {
        const goodsContent = this.selectComponent(`#goods-content${index}`);
    },
})

selector類似於 CSS 的選擇器,但僅支援下列語法。

  • ID選擇器:#the-id(筆者只測試了這個,其他讀者可自行測試)
  • class選擇器(可以連續指定多個):.a-class.another-class
  • 子元素選擇器:.the-parent > .the-child
  • 後代選擇器:.the-ancestor .the-descendant
  • 跨自定義元件的後代選擇器:.the-ancestor >>> .the-descendant
  • 多選擇器的並集:#a-node, .some-other-nodes

方法四 url 引數通訊

在電商/物流等微信小程式中,會存在這樣的使用者故事,有一個「下單頁面A」和「貨物資訊頁面B」

  • 在「下單頁面 A」填寫基本資訊,需要下鑽到「詳細頁面B」填寫詳細資訊的情況。比如一個寄快遞下單頁面,需要下鑽到貨物資訊頁面填寫更詳細的資訊,然後返回上一個頁面。
  • 在「下單頁面 A」下鑽到「貨物頁面B」,需要回顯「貨物頁面B」的資料。

微信小程式由一個 App() 範例和多個 Page() 組成。小程式框架以棧的方式維護頁面(最多10個) 提供了以下 API 進行頁面跳轉,頁面路由如下

  • wx.navigateTo(只能跳轉位於棧內的頁面)
  • wx.redirectTo(可跳轉位於棧外的新頁面,並替代當前頁面)
  • wx.navigateBack(返回上一層頁面,不能攜帶引數)
  • wx.switchTab(切換 Tab 頁面,不支援 url 引數)
  • wx.reLaunch(小程式重啟)

可以簡單封裝一個 jumpTo 跳轉函數,並傳遞引數:

export function jumpTo(url, options) {
    const baseUrl = url.split('?')[0];
    // 如果 url 帶了引數,需要把引數也掛載到 options 上
    if (url.indexof('?') !== -1) {
        const { queries } = resolveUrl(url);
        Object.assign(options, queries, options); // options 的優先順序最高
    } 
    cosnt queryString = objectEntries(options)
        .filter(item => item[1] || item[0] === 0) // 除了數位 0 外,其他非值都過濾
        .map(
            ([key, value]) => {
                if (typeof value === 'object') {
                    // 物件轉字串
                    value = JSON.stringify(value);
                }
                if (typeof value === 'string') {
                    // 字串 encode
                    value = encodeURIComponent(value);
                }
                return `${key}=${value}`;
            }
        ).join('&');
    if (queryString) { // 需要組裝引數
        url = `${baseUrl}?${queryString}`;
    }
    
    const pageCount = wx.getCurrentPages().length;
    if (jumpType === 'navigateTo' && pageCount < 5) {
        wx.navigateTo({ 
            url,
            fail: () => { 
                wx.switch({ url: baseUrl });
            }
        });
    } else {
        wx.navigateTo({ 
            url,
            fail: () => { 
                wx.switch({ url: baseUrl });
            }
        });
    } 
}

jumpTo 輔助函數:

export const resolveSearch = search => {
    const queries = {};
    cosnt paramList = search.split('&');
    paramList.forEach(param => {
        const [key, value = ''] = param.split('=');
        queries[key] = value;
    });
    return queries;
};

export const resolveUrl = (url) => {
    if (url.indexOf('?') === -1) {
        // 不帶引數的 url
        return {
            queries: {},
            page: url
        }
    }
    const [page, search] = url.split('?');
    const queries = resolveSearch(search);
    return {
        page,
        queries
    };
};

在「下單頁面A」傳遞資料:

jumpTo({ 
    url: 'pages/consignment/index', 
    { 
        sender: { name: 'naluduo233' }
    }
});

在「貨物資訊頁面B」獲得 URL 引數:

const sender = JSON.parse(getParam('sender') || '{}');

url 引數獲取輔助函數

// 返回當前頁面
export function getCurrentPage() {
    const pageStack = wx.getCurrentPages();
    const lastIndex = pageStack.length - 1;
    const currentPage = pageStack[lastIndex];
    return currentPage;
}

// 獲取頁面 url 引數
export function getParams() {
    const currentPage = getCurrentPage() || {};
    const allParams = {};
    const { route, options } = currentPage;
    if (options) {
        const entries = objectEntries(options);
        entries.forEach(
            ([key, value]) => {
                allParams[key] = decodeURIComponent(value);
            }
        );
    }
    return allParams;
}

// 按欄位返回值
export function getParam(name) {
    const params = getParams() || {};
    return params[name];
}

引數過長怎麼辦?路由 api 不支援攜帶引數呢?

雖然微信小程式官方檔案沒有說明可以頁面攜帶的引數有多長,但還是可能會有引數過長被截斷的風險。

我們可以使用全域性資料記錄引數值,同時解決 url 引數過長和路由 api 不支援攜帶引數的問題。

// global-data.js
// 由於 switchTab 不支援攜帶引數,所以需要考慮使用全域性資料儲存
// 這裡不管是不是 switchTab,先把資料掛載上去
const queryMap = {
    page: '',
    queries: {}
};

更新跳轉函數

export function jumpTo(url, options) {
    // ...
    Object.assign(queryMap, {
        page: baseUrl,
        queries: options
    });
    // ...
    if (jumpType === 'switchTab') {
        wx.switchTab({ url: baseUrl });
    } else if (jumpType === 'navigateTo' && pageCount < 5) {
        wx.navigateTo({ 
            url,
            fail: () => { 
                wx.switch({ url: baseUrl });
            }
        });
    } else {
        wx.navigateTo({ 
            url,
            fail: () => { 
                wx.switch({ url: baseUrl });
            }
        });
    }
}

url 引數獲取輔助函數

// 獲取頁面 url 引數
export function getParams() {
    const currentPage = getCurrentPage() || {};
    const allParams = {};
    const { route, options } = currentPage;
    if (options) {
        const entries = objectEntries(options);
        entries.forEach(
            ([key, value]) => {
                allParams[key] = decodeURIComponent(value);
            }
        );
+        if (isTabBar(route)) {
+           // 是 tab-bar 頁面,使用掛載到全域性的引數
+           const { page, queries } = queryMap; 
+           if (page === `${route}`) {
+               Object.assign(allParams, queries);
+           }
+        }
    }
    return allParams;
}

輔助函數

// 判斷當前路徑是否是 tabBar
const { tabBar} = appConfig;
export isTabBar = (route) => tabBar.list.some(({ pagePath })) => pagePath === route); 

按照這樣的邏輯的話,是不是都不用區分是否是 isTabBar 頁面了,全部頁面都從 queryMap 中獲取?這個問題目前後續探究再下結論,因為我目前還沒試過從 頁面範例的 options 中拿到的值是缺少的。所以可以先保留讀取 getCurrentPages 的值。

方法五 EventChannel 事件派發通訊

前面我談到從「當前頁面A」傳遞資料到被開啟的「頁面B」可以通過 url 引數。那麼想獲取被開啟頁面傳送到當前頁面的資料要如何做呢?是否也可以通過 url 引數呢?

答案是可以的,前提是不需要儲存「頁面A」的狀態。如果要保留「頁面 A」的狀態,就需要使用 navigateBack 返回上一頁,而這個 api 是不支援攜帶 url 引數的。

這樣時候可以使用 頁面間事件通訊通道 EventChannel。

pageA 頁面

// 
wx.navigateTo({
    url: 'pageB?id=1',
    events: {
        // 為指定事件新增一個監聽器,獲取被開啟頁面傳送到當前頁面的資料
        acceptDataFromOpenedPage: function(data) {
          console.log(data) 
        },
    },
    success: function(res) {
        // 通過eventChannel向被開啟頁面傳送資料
        res.eventChannel.emit('acceptDataFromOpenerPage', { data: 'test' })
    }
});

pageB 頁面

Page({
    onLoad: function(option){
        const eventChannel = this.getOpenerEventChannel()
        eventChannel.emit('acceptDataFromOpenedPage', {data: 'test'});
   
        // 監聽acceptDataFromOpenerPage事件,獲取上一頁面通過eventChannel傳送到當前頁面的資料
        eventChannel.on('acceptDataFromOpenerPage', function(data) {
          console.log(data)
        })
      }
})

會出現資料無法監聽的情況嗎?

小程式的棧不超過 10 層,如果當前「頁面A」不是第 10 層,那麼可以使用 navigateTo 跳轉保留當前頁面,跳轉到「頁面B」,這個時候「頁面B」填寫完畢後傳遞資料給「頁面A」時,「頁面A」是可以監聽到資料的。

如果當前「頁面A」已經是第10個頁面,只能使用 redirectTo 跳轉「PageB」頁面。結果是當前「頁面A」出棧,新「頁面B」入棧。這個時候將「頁面B」傳遞資料給「頁面A」,呼叫 navigateBack 是無法回到目標「頁面A」的,因此資料是無法正常被監聽到。

不過我分析做過的小程式中,棧中很少有10層的情況,5 層的也很少。因為呼叫 wx.navigateBack 、wx.redirectTo 會關閉當前頁面,呼叫 wx.switchTab 會關閉其他所有非 tabBar 頁面。

所以很少會出現這樣無法回到上一頁面以監聽到資料的情況,如果真出現這種情況,首先要考慮的不是資料的監聽問題了,而是要保證如何能夠返回上一頁面。

比如在「PageA」頁面中先呼叫 getCurrentPages 獲取頁面的數量,再把其他的頁面刪除,之後在跳轉「PageB」頁面,這樣就避免「PageA」呼叫 wx.redirectTo導致關閉「PageA」。但是官方是不推薦開發者手動更改頁面棧的,需要慎重。

如果有讀者遇到這種情況,並知道如何解決這種的話,麻煩告知下,感謝。

使用自定義的事件中心 EventBus

除了使用官方提供的 EventChannel 外,我們也可以自定義一個全域性的 EventBus 事件中心。 因為這樣更加靈活,不需要在呼叫 wx.navigateTo 等APi裡傳入引數,多平臺的遷移性更強。

export default class EventBus {
 private defineEvent = {};
 // 註冊事件
 public register(event: string, cb): void { 
  if(!this.defineEvent[event]) {
   (this.defineEvent[event] = [cb]); 
  }
  else {
   this.defineEvent[event].push(cb); 
  } 
 }
 // 派遣事件
 public dispatch(event: string, arg?: any): void {
  if(this.defineEvent[event]) {{
            for(let i=0, len = this.defineEvent[event].length; i<len; ++i) { 
                this.defineEvent[event][i] && this.defineEvent[event][i](arg); 
            }
        }}
 }
 // on 監聽
 public on(event: string, cb): void {
  return this.register(event, cb); 
 }
 // off 方法
    public off(event: string, cb?): void {
        if(this.defineEvent[event]) {
            if(typeof(cb) == "undefined") { 
                delete this.defineEvent[event]; // 表示全部刪除 
            } else {
                // 遍歷查詢 
                for(let i=0, len=this.defineEvent[event].length; i<len; ++i) { 
                    if(cb == this.defineEvent[event][i]) {
                        this.defineEvent[event][i] = null; // 標記為空 - 防止dispath 長度變化 
                        // 延時刪除對應事件
                        setTimeout(() => this.defineEvent[event].splice(i, 1), 0); 
                        break; 
                    }
                }
            }
        } 
    }

    // once 方法,監聽一次
    public once(event: string, cb): void { 
        let onceCb = arg => {
         cb && cb(arg); 
         this.off(event, onceCb); 
        }
        this.register(event, onceCb); 
    }
    // 清空所有事件
    public clean(): void {
        this.defineEvent = {}; 
    }
}

export connst eventBus = new EventBus();

在 PageA 頁面監聽:

eventBus.on('update', (data) => console.log(data));

在 PageB 頁面派發

eventBus.dispatch('someEvent', { name: 'naluduo233'});

小結

本文主要討論了微信小程式如何自定義元件,涉及兩個方面:

  • 元件的宣告與使用
  • 元件的通訊

如果你使用的是 taro 的話,直接按照 react 的語法自定義元件就好。而其中的元件通訊的話,因為 taro 最終也是會編譯為微信小程式,所以 url 和 eventbus 的頁面元件通訊方式是適用的。後續會分析 vant-ui weapp 的一些元件原始碼,看看有贊是如何實踐的。

附:元件和頁面的區別

1、點選一個資料夾右鍵——新建Page 、新建Component

2、元件的js檔案

Component({  
      properties: {   //父元件傳過來的data
        num: Number,
        flag:Boolean
      },
  /**
   * 頁面的初始資料
   */
  data: {
  },
  methods:{   //元件的方法要寫在methods中
    jia(e) {
      let a
      if (this.properties.flag) {
        a = -1;
      } else {
        a = 1;
      }
      this.setData({
        flag: !this.properties.flag,
        num: this.properties.num + a
      })
    }
  },

3、頁面的js檔案

Page({
      data: {
        flag:false,
        number:1,
        motto: 'Hello World',
      },
      //事件處理常式,直接作為引數
      bindViewTap: function() {
        wx.navigateTo({
          url: '../logs/logs'
        })
      },
 })

總結

到此這篇關於微信小程式自定義元件的文章就介紹到這了,更多相關微信小程式自定義元件內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com