動態

詳情 返回 返回

一套代碼構建B端企業管理系統跨端方案——從原理到落地解決方案 - 動態 詳情

前言

大家好,這些年來設計系統一直停留在歷史的某個時間點,缺乏創新,缺乏活力,難以有讓人眼前一亮的東西展現,但它並沒有停滯不前,我們一直致力於從一些獨特的角度重新審視和認識設計系統,通過梳理和理解組件的本質,抽象原子化組件,嚴格執行展示層與交互層的分離方式,使組件能夠更好的融合跨端設計,同時深度結合設計語義與前端代碼,確保整個系統的交互和代碼唯一性,目前我們已有一套基礎設施對外開源,歡迎大家的持續關注。

image.png

今天這篇文章的主題主要是與大家探討一下我們的跨端設計實現。

問題是什麼?

回想一下工作中你是否也遇到過如下場景

當我們開發了一套PC端的管理系統提供給客户使用,客户因為一些不可抗拒因素產生了一點點想法:

  1. 客户因為辦公地點不方便配備電腦,要求一套適配手機端的管理系統。
  2. 銷售團隊給老闆現場演示,使用平板電腦,要求我們適配平板電腦。
  3. 總部使用電腦端開單,而門店使用小程序入庫。
  4. 前端同時維護多端,設計一次改動需要同步多端。

當你遇到這樣的問題,你該怎麼辦?

  1. 放棄這類場景或用户?這基本是不可行的,難以説服,並且你同意老闆也不會同意。
  2. 選擇獨立維護多端代碼?同樣的功能需要實現多遍,不管是開發量還是後期的維護量,都足以摧毀研發最後一根頭髮。
  3. 在同一套代碼上簡單的做響應式適配?既達不到很好的效果,同時也增加了代碼的維護難度,複雜系統堪稱地獄級難度。

那麼是否有一套技術方案可以解決上面的問題?答案是有,在我們長期服務於B端管理系統建設的踩坑過程中,已經梳理並驗證了一套行之有效的解決方案,也就是我們正在構建跨端組件設計系統。

什麼是跨端組件設計系統?

我將通過一個組件(Select)案例對此有一個初步的理解,試着思考一下組件在不同端的交互區別?在 PC 電腦端,一個下拉組件通常是由一個觸發器和一個下拉麪板組成的交互組件,當我們來到手機端,同樣功能的下拉組件交互形式發生了變化,最主要的區別在於下拉麪板轉變成了彈出面板,以及一些細節上的變化,但組件的本質沒有變,依舊是一個觸發器結合選擇面板。

而我們的跨端組件設計系統最原本的目標是提供一套統一的組件定義,同時滿足多個端的交互差異,實現開發人員不用在適配多端,寫一遍代碼,多端去使用的目的,就如同下面代碼所展示的編寫規則以及它在不同的端所呈現出來的交互方式:

function () {
    return (
        <Form>
          <FormItem label="Select" name="select">
            <Select data={types} />
          </FormItem>
        </Form>
    )
}

image.png

為什麼之前沒有?

那麼回過頭來,既然存在這樣的問題,為什麼截止目前市面上沒有相關的解決方案?出於自身現有的認知,我梳理了幾點可能的原因:

  1. 這樣的場景並非普遍性,可能僅僅是極小部分業務需要,並沒有痛到那麼痛?
  2. 當我們實現了多端的交互體驗,如何能做到資源不浪費,每個端都各自僅加載端所必要的資源?
  3. 一套代碼實現多端呈現,如何才能讓開發沒有端的感知,如同僅編寫 PC 一樣?
  4. 跨端的規則應該如何制定才能達到普適性?這是一個巨大的挑戰。

這些問題就是我們這套解決方案所需要面臨和解決的問題。

解決方案是什麼?

我們有了前面的基礎理解,我將由組件(Select)使用方角度出發,介紹整個解決方案。

跨端基礎設施的落地

首先,我們期望的理想的組件使用方式如下:

// 引入下拉組件
import { Select } from "@/components/select";

function Example() {
    const data = [
        { label: 'Option1', value: 'option1' },
        { label: 'Option2', value: 'option2' },
    ];

    // 使用組件並且無端的感知
    return <Select defaultValue="option1" data={data} />
}

整個使用過程中,用户不會感知到自己正在完成一個業務跨端項目,就如同以前一樣,以為自己只是在完成某個產品,這很好,也是我們持續朝向的一個方向。

我們設想,它一定擁有着一套針對多端的統一使用規範,對應着代碼中的組件屬性定義:

interface Option {
    label: string;
    value: string;
}

// 這裏的定義只是為了闡述方案寫的一個簡化版本,實際組件會有更多的定義。
interface SelectProps {
    // 值
    value?: string;
    // 默認值
    defaultValue?: string;
    // 下拉框數據
    data?: Option[];
}

同時一個 Select 組件的背後需要承載多個端的交互規則,考慮到每個端都擁有自己獨一無二的交互體驗,期望在一套代碼中實現多套交互,這已經相當的不合理,不僅會導致資源增大,同時還會使組件過於臃腫難以維繫,因此它一定是滿足由多套代碼獨立實現,統一使用的規則,基於這層規則,一個組件的目錄結構也就清晰明瞭了:

src/components
├── select 組件根目錄,基於約定方式構建跨端組件
| ├── pc 約定為PC端的組件實現
| | ├── Select.tsx  
| | ├── Select.scss
| | └── index.ts 統一的資源導出文件
| ├── mobile 約定為Mobile端的組件實現
| | ├── Select.tsx  
| | ├── Select.scss
| | └── index.ts 統一的資源導出文件
| └── button.ts 組件的統一定義,不同端我們都需要遵循統一的組件定義,以保證業務使用的統一性

接下來我們需要考慮的是如何在運行時根據用户的瀏覽器環境判斷端類型,從而返回對應的組件資源,通過直覺能夠快速想到的方式是在組件根目錄下新增 index.ts 文件,通過條件選擇導出不同的端資源:

import { exportCrossComponent } from "@/src/core";

import PCSelect from "./pc";
import MobileSelect from "./mobile";

// 導出跨端組件
export const Select = exportCrossComponent(PCSelect, MobileSelect);

咋一看,這樣是滿足了先前所期望的用户使用行為,但是仔細一想會發現,它觸犯了之前章節所列出的問題,如何能做到資源不浪費?

上面的方案在打包過程中 PC 和移動端的資源都會被打成一個 Bundle 資源包,因此也沒辦法做到根據端去加載對應的資源,我們需要將組件的資源包按照端緯度獨立打包,回顧一下現有社區方案發現 Module Federation 與我們的方案契合,如果對這項技術沒有基本概念的,請先自行學習一下。

基於這個思路,我們開始將跨端組件獨立成一個共享組件項目,這個項目命名為 wis , 然後在業務項目 application 中註冊遠程項目 wis 並導入,並且我們簡化了一部分 Module Federation 的配置,具體的細節不在本文章討論範圍內,不做細化,大致如下:

// 業務應用提煉的配置文件
// application/wis.config.ts
import type { WisConfig } from "@wisdesign/wis-plugin";

export default const config: WisConfig = {
    remotes: {
        // 註冊wis項目,這樣我們就可以在本項目中使用
        // 這裏假設wis項目監聽在4000端口上
        wis: "http://localhost:4000",
    },
};  

因此相應的之前的使用方式產生了變化,如下所示:

// 主要是這裏產生了變化,導入的方式也變化了,通過共享的項目中導入
import { Select } from "wis/select";

function Example() {
    const data = [
        { label: 'Option1', value: 'option1' },
        { label: 'Option2', value: 'option2' },
    ];
    return <Select defaultValue="option1" data={data} />
}

接下來就順利了起來,我們來看看 wis 項目中組件是如何導出的:

// 業務應用提煉的配置文件
// wis/wis.config.ts
import type { WisConfig } from "@wisdesign/wis-plugin";

export default const config: WisConfig = {
    // 對外共享的名稱
    name: "wis",
    
    // 配置導出的組件
    exposes: {
        "./select/pc": "./src/components/select/pc",
        "./select/mobile": "./src/components/select/mobile",
    },
}; 

這樣已經達到了將組件按照端的緯度進行打包導出,但這樣的註冊方式不完美,存在割裂感,不像是一個完整的組件體系,只是正好兩個導出名都包含了組件名 ./select/pc ./select/mobile 用户完全可以很隨意的改成 /selectPc ./selectMobile 這並不存在規則,不利於在運行時檢查端類型,匹配對應的端資源。

因此我們繼續探索,通過配置解析功能,形成了最終的導出格式:

// 業務應用提煉的配置文件
// wis/wis.config.ts
import type { WisConfig } from "@wisdesign/wis-plugin";

export default const config: WisConfig = {
    // 對外共享的名稱
    name: "wis",
    
    // 配置導出的組件
    exposes: {
        "./select": {
            pc: "./src/components/select/pc",
            mobile: "./src/components/select/mobile",
        },
    },
};

通過配置轉換器,我們依舊可以獲得如下配置,然後交由 Module Federation 進行解析處理:

{
    "./select/pc": "./src/components/select/pc",
    "./select/mobile": "./src/components/select/mobile",
}

這樣就確保了配置的關聯性和一致,一切就緒,只欠東風,而這裏的東風指的是運行時如何才能確保加載正確的端資源,要實現這個能力,我們需要藉助 Module Federation 中的一個運行時插件機制,在模塊解析完成後,獲取模塊 Id,同時獲取當前用户運行的瀏覽器端信息,也就是瀏覽器 UA 標識,主動拼接跨端模塊 id,在模塊列表中查找,存在則修改當前模塊 id 為對應的端模塊 id,否則什麼也不做,大致的代碼流程如下:

import type { FederationRuntimePlugin } from "@module-federation/enhanced/runtime";

interface RemoteModule {
    modulePath: string;
    moduleName: string;
}

type RuntimePlugin = () => FederationRuntimePlugin;

const crossPlugin: RuntimePlugin = () => {
    return {
        name: "cross-plugin",
        afterResolve(data) {
            // 獲取當前所有模塊的列表
            const modules: RemoteModule[] = data.remoteSnapshot?.modules || [];
            
            // 獲取UA標識,如pc / mobile / pad
            // 這個函數的實現在這裏就省略了
            const agent = getBrowerAgent();
            
            // 端模塊id 比如 ./select/pc
            const crossModuleExpose = `${data.expose}/${agent}`;
            // 匹配當前模塊是否存在端模塊
            const isMatched = modules.some(mod => {
                return mod.modulePath === crossModuleExpose 
            });
            
            // 匹配到端模塊,修改為當前端的模塊
            if (isMathced) {
                data.expose = moduleCrossExpose;
            }

            return data;
    },
  };
};

到這裏,一套完整的跨端解決方案基礎設施有了,我們只剩下最後一個問題。

跨端組件一定要單獨起一個項目麼,不能和業務代碼放在一個項目中麼?

跨端組件支持自引用模式,你可以像下面一樣使用自己導出的跨端組件:

// 業務應用提煉的配置文件
// application/wis.config.ts
import type { WisConfig } from "@wisdesign/wis-plugin";

export default const config: WisConfig = {
    // 配置共享的名稱
    name: "application",
    
    // 配置導出的組件
    exposes: {
        "./com": {
            pc: "./src/components/com/pc",
            mobile: "./src/components/com/mobile",
        }
    },
}; 

使用方式:

// application/pages/example/Example.page.tsx

// 從自身項目中引入
import { Com } from "application/com";

function Example() {
    return <Com />
}

跨端的規則應該如何制定才能達到普適性?

這是一個相對比較寬泛的問題,這裏面所涉及到的細節非常多,牽涉到每一個組件本質抽象以及規則定製,在整個生態組件的構建過程中我們一直遵循着一套基本原則,基於本質理解,組件的規則和屬性不在是基於表象,也就是説不應該基於樣式定義,而是更加底層的原則,落在每一個組件中理解組件實際是什麼,它在整個系統中所起到的作用和定位,這個將會貫穿在我們整個 wis 組件庫的開發過程中,最終通過結果反過來看它的定義是否達到普適性,也是我們與其它社區組件庫之間的一個巨大差異點,這一部分不在這篇文章中詳細討論,後期考慮單獨寫一篇文章做介紹。

為了幫助大家更好的理解,我們起了一個項目,目的是通過一個簡單的 Todo List 應用演示跨端組件的構建過程以及所遵循的原則,同時也是快速理解這套方案一個有效方法。

Demo 訪問地址:https://demo.wis.design/#/todo 訪問該地址,並嘗試在 PC 和手機端訪問以查看不同的交互效果。
Demo 源碼地址:https://github.com/wisdesignsystem/wis-example

總結

感謝你能靜下心來讀完整個方案,即使該方案可能對你來説還沒有場景,我相信該方案也會對你產生一些啓發。我們目前已經完成了基礎設施的建設,正在逐步完成跨端組件庫的建設,現階段里程碑目標主要集中在打造 PC 端的組件規則使其能夠滿足現有業務單據的需求,移動端的建設還未開始,目前已經將現有的代碼開源出來,如果你對我們的方案和目標感興趣,請關注我們,大家的每一個支持和 star 都將給予我們巨大的動力前行,我們也一直在尋找設計師、工程師來幫助我們修復錯誤、構建新組件、書寫項目文檔,想要了解更多信息

你可以訪問我們的官網 https://wis.design
或者關注我們的項目倉庫 https://github.com/wisdesignsystem/wis

Add a new 評論

Some HTML is okay.