淺析微信小程式中自定義元件的方法

2022-03-25 13:00:13
微信小程式中怎麼自定義元件?下面本篇文章給大家介紹一下微信小程式中自定義元件的方法,希望對大家有所幫助!

在微信小程式開發過程中,對於一些可能在多個頁面都使用的頁面模組,可以把它封裝成一個元件,以提高開發效率。雖然說我們可以引入整個元件庫比如 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 引數通訊

1.png

在電商/物流等微信小程式中,會存在這樣的使用者故事,有一個「下單頁面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.navigateBackwx.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 的一些元件原始碼,看看有贊是如何實踐的。

感謝閱讀,如有錯誤的地方請指出

【相關學習推薦:】

以上就是淺析微信小程式中自定義元件的方法的詳細內容,更多請關注TW511.COM其它相關文章!