Islands Architecture(孤島架構)在攜程新版首頁的實踐

作者簡介

攜程前端框架團隊,爲攜程集團各業務線在 PC、H5、小程序等各階段提供優秀的 Web 解決方案。當前主要專注方向包括:新一代研發模式探索,Rust 構建工具鏈路升級、Serverless 應用框架開發、在線文檔系統開發、低代碼平臺搭建、適老化與無障礙探索等。

一、項目背景

2022,攜程 PC 版首頁終於迎來了首次改版,完成了用戶體驗與技術棧的全面升級。

作爲與用戶連接的重要入口,舊版 PC 首頁已經陪伴攜程走過了 22 年,承擔着重要使命的同時,也遇到了很多問題:

維護 / 更新困難

祖傳代碼黑盒邏輯過多,產品也難以推動新需求的上線,舊版首頁已經不能滿足高速發展的業務需求。

技術棧陳舊且不統一

互聯網技術日新月異,舊版首頁的整體架構設計和技術棧都相對落後,且大首頁中各個組件的研發涉及多事業部合作,存在技術選型差異的問題,增加了維護成本。

用戶體驗有待改善

舊版攜程首頁的設計風格沿用至今,在視覺和交互層面上,都已經難以滿足用戶不斷提升的互聯網體驗和審美需求。

綜合上述情況,爲了給用戶提供更好的服務,攜程首頁的整體改造迫在眉睫。

二、需求分析

攜程首頁改造需要考慮的核心問題包括以下幾個方面:

技術選型

爲了優化首屏性能,提升用戶體驗,攜程新版首頁採用服務端渲染模式。在技術選型上,考慮到我們希望應用層是輕量的,只做頁面 HTML 拼接和響應兩件事情,最終決定基於 Node.js 構建應用載體,客戶端則統一使用公司主流的 React 技術棧。

跨團隊合作

首頁作爲攜程的重要門戶,涉及多業務線的流量入口。如圖 1 所示,我們可以將整個頁面進行切割,按業務線劃分成多個組件模塊。

圖片

圖 1 攜程首頁業務模塊切分圖

可以看到,整個頁面的研發是需要框架部門和各個事業部業務團隊緊密合作才能完成的,這就需要一整套完善的跨團隊合作模式。其中,我們希望業務團隊只需要關注業務邏輯的實現,完成組件模塊的開發。框架團隊則負責提供:

監控及維護

上線後,我們需要時刻關注應用狀態,及時響應異常情況。因此,需要對應用及組件進行埋點監控。除此之外,由於需要跨團隊合作,對於業務組件,我們希望各個業務團隊不僅可以實現開發 / 構建自由,彼此獨立互不影響,在監控及版本管理上也能實現自控。因此,我們將各個業務組件包裝成 Node.js 應用,開發人員可以直接在發佈系統查看組件版本,完成發佈 / 回退,也可以通過應用 ID 在埋點管理平臺查看組件的相關埋點。

三、整體架構設計

圖片

圖 2 攜程首頁架構設計圖

基於上述需求分析,攜程新版首頁的整體架構設計如圖 2 所示,可以分爲四個部分:

業務模塊開發

我們將攜程首頁拆分爲多個業務模塊,由各業務團隊負責完成相應組件的開發。與常規 React 組件開發不同的是,首先,開發人員需要在配置文件中設置好模塊相關配置,如組件唯一 ID;其次,組件開發需遵循一些規則,如爲防止出現樣式污染,我們強制使用 CSS Modules;最後,我們支持服務端渲染組件,可以在服務端生命週期中拉取數據,然後在服務端 / 客戶端使用。爲了更好的輔助業務團隊完成組件開發,框架團隊會提供腳手架幫助創建組件模版,搭建開發環境,模擬完整首頁場景。

業務模塊構建

業務模塊開發完成後,就需要構建 / 發佈至生產環境。整個構建過程會在 Pipeline 中完成,開發人員 git push 代碼後會自動觸發。基於不同的 entry 及配置,我們會使用 webpack 分別完成客戶端及服務端代碼的生產態構建,並將客戶端構建產物(js+css)上傳至靜態資源管理系統。

之後,我們會將服務端構建產物(js)連同組件及靜態資源版本相關信息包裝成一個 Job 應用,該應用中會有一個定時任務負責推送當前版本信息,觸發組件完成服務端渲染,這裏我們是使用定時器來實現定時任務的管理。最後,需要由開發人員在發佈系統中將構建好的應用鏡像部署到生產環境,完成組件的發佈。

業務模塊服務端渲染

業務模塊的服務端渲染主要包括兩部分:

我們將相關功能實現封裝成雲函數,作爲服務提供出去。由於部分組件對服務端渲染具有數據更新的需求。因此,上文我們提到過,Job 應用中會有一個定時任務,負責觸發組件進行服務端渲染,這裏也就是會觸發雲函數的調用。

應用頁面組裝

最後,我們就需要在應用中將所有的業務模塊拼裝起來,定時從 Redis 中獲取組件相關信息,組裝成首頁 html 返回到客戶端。

四、整體架構的核心功能實現

對應上述首頁架構設計,我們簡要介紹下部分核心功能的實現:

4.1 搭建組件開發環境,模擬首頁場景

我們會在開發階段提供腳手架輔助業務團隊開發組件,其中一項重要功能就是搭建組件開發環境。常規的 webpack 搭建 React 開發環境我們這裏就不贅述了,爲了實現開發環境的統一標準化,我們還做了以下事情:

import React from 'react'
import ReactDOM from 'react-dom'
import Comp from '__COMP_PATH__'
const render = async() => {
    let data
    // 獲取服務端傳遞到客戶端數據
    const container = document.getElementById('__MFE___MODULE___DATA__')
    if (container && container.textContent) {
        try {
            data = JSON.parse(container.textContent)
        } catch(e) {
            console.log(e)
        }
    }
    const root = document.getElementById('__MODULE__')
    // 客戶端渲染組件
    if (module.hot) {
        ReactDOM.render(<Comp serverData={data} />, root)
    } else {
        ReactDOM.hydrate(<ErrorBoundary><Comp serverData={data} /></ErrorBoundary>, root)
    }
}
render()

對於服務端 entry,則需要調用服務端生命週期拉取數據,並調用 renderToString() 完成渲染:

import React from 'react'
import { renderToString } from 'react-dom/server'
import Comp from '__COMP_PATH__'
const render = async() => {
    let data
    // 執行服務端生命週期
    if (Comp.getInitialProps) {
        data = await Comp.getInitialProps(_ctx)
    }
    // 沙盒中傳入setMfeData方法,見下文中服務端渲染組件實現
    setMfeData(data)
    // 服務端渲染組件,返回html
    return renderToString(<Comp serverData={data} />)
}
export default render()

搭建首頁場景。我們希望開發人員在組件開發時,就可以看到其嵌入在整個首頁中的效果,而不是隻能看到自己的組件。因此,我們在服務端處理頁面請求時,通過以下方式搭建了首頁場景:

4.2 SSR-Service 服務端渲染組件

我們會在沙盒中運行服務端構建生成的代碼(可結合上文中服務端 entry 看),完成組件渲染,得到服務端生命週期中返回的數據及組件 html。

const vm = require('vm')
const render = async ({content, request}) => {
    // content即爲服務端構建生成的代碼
    const script = new vm.Script(content)
    let moduleObj = {
        exports: {}
    }
    let mfeEnv = 'prod'
    let mfeData
    // 基於雲函數中的request模擬req
    const _req = {
        url: request.rawPath,
        query: request.queryStringParameters,
        headers: request.headers
    }
    let sandBox = {
        ...global,
        process,
        require,
        module: moduleObj,
        console,
        _ctx: {
            req: _req,
            env: mfeEnv,
        },
        setMfeData: (data) => {
            mfeData = data
        }
    }
    // 沙盒中運行,執行服務端渲染
    const ctx = vm.createContext(sandBox)
    script.runInContext(ctx)
    const comp = await sandBox.module.exports.default
    return {
        comp, 
        mfeData
    }
}

4.3 整體頁面組裝

在首頁應用中,我們會定時從 redis 中獲取組件相關信息,拼裝首頁 html,在有客戶端請求進入時,直接返回緩存中的最新 html。

let indexCache = ''
const renderPage = async (content) => {
    // 加載首頁html
    const $ = cheerio.load(content)
    // 更新組件
    for (let module of modules) {
        try {
            // moduleData爲從redis取到的數據
            let data = moduleData[module] || ''
            if (!data) {
                continue
            }
            data = typeof data == 'string' ? JSON.parse(data) : data
            const {comp, version, mfeData, style} = data
            // 更新組件相關的html,link,script標籤
            parse(module, comp, $, version, mfeData, style)
        } catch(e) {
            console.log(e)
        }
    }
    // 生成html
    const payload = $.html()
    if (!payload) {
        throw Error('renderPage error - html is null')
    }
    // 更新緩存
    indexCache = payload
}

五、公共組件的渲染原理及技術細節

前面說的是島嶼式架構之首頁的整體架構和獨立組件渲染的核心實現,其中有些獨立組件(左側菜單欄,頭部等)除了在大首頁中使用,還會在其他的頁面中使用,這裏就稱爲公共組件。

5.1 公共組件需求點和痛點分析

在開始開發公共組件前,需要整理一下目前各個事業部的接入需求、成本及痛點。所以總結了以下問題點:

各個事業部的站點技術架構不同

由於各個事業部的站點技術架構不同。有的事業部可能是服務端渲染,有的可能爲客戶端渲染 。在服務端渲染中,技術棧又可能出現 JAVA 和 NODE 。而在客戶端渲染中,各個事業部技術棧也不統一,有 React、JQuery 或者 Vue 等等前端框架。這裏的問題是各個事業部的技術棧的錯綜複雜,如果分開維護會帶來不同的版本及很高的維護成本。

所有頁面中的公共組件有變更時能否統一熱更新

當公共組件的改動或有問題需要修復時,不能讓所有的頁面都去變更公共組件,而是應該我們變更後,所有頁面上的公共組件會靜默生效,各個事業部無需關心公共組件的變更。

公共組件的樣式如何不對頁面造成巨大影響

由於各個業務方的樣式風格不同並且還存在一些全局的公共樣式,如何才能保證每個接入方爲下圖的頁面佈局方式,其頁面組成的方式爲陰影部分是事業部所維護的組件,其他是公共組件。

圖片

由於歷史原因,舊版的公共組件已經使用了很多年了,新版頭尾和舊版的頭尾佈局構造不同,要如何設計,才能使其改動最小,而不是去做很大的改動去適配公共組件。新舊版大首頁頁面佈局變化如下圖:

圖片

公共組件的渲染性能問題

在背景中提到的不同形態的公共組件(比如有些不需要左側菜單或者頭部樣式的不同),如何在客戶端能第一時間展示給用戶相應組件形態並且支持搜索引擎優化 (SEO)。當多個公共組件在頁面中如何能快速進行加載及渲染。

5.2 解決公共組件問題和痛點

問題一:各個事業部的站點技術架構不同

前面提到了各個業務支線的技術棧不統一的問題,並且還存在服務端和客戶端渲染的情況,如果爲了多個技術棧去維護多個公共組件維護成本極高,且沒有辦法做到一套代碼多端使用。這裏就從服務端和客戶端渲染分析,提供的相應解決的方案

CSR(客戶端渲染)

在 CSR 中,技術棧也不同。由於有 React、Vue、jQuery,所以我們需要提供的應該是一個原生 JS 的公共組件,這樣能保證維護成本。但是大首頁的首屏技術棧已經爲 React,再去開發及維護一套原生 JS 組件顯得冗餘。所以需要一個方案來支持多技術棧運行,並且能夠兼容我們大首頁首屏的技術棧。

最終的方案是使用 Preact,它很輕量,重點是它可以幫助我們解決多技術棧運行並且能夠兼容 React。可萬一有頁面同樣在使用 Preact 和我們衝突怎麼辦? 這裏將 Preact 單獨打包出來 common 包並且重名了全局的變量。這樣即使頁面使用了 Preact 也不會和我們有衝突,在 webpack 的 externals 的選項中可以配置組件需要的包名。

{
    //...
    externals: {
        preact: 'xxxxxx'
    }
    // ...
}

SSR(服務端渲染)

在 SSR 中,在技術棧上選擇了 Preact,Preact 它同樣支持 SSR,可以構建一個服務端的 JS 來支持 SSR。因此我們的問題就迎刃而解了,我們在組件構建的時候多生成一份 Preact 的 SSR 的 JS,用沙盒執行服務端渲染輸出 HTML 並存儲。我們調研了以前的老的公共組件,全部攜程的業務線存在的技術棧只有兩種:JAVA、NODE,這樣就提供兩個接入方式的外殼即可——兩套不同語言的 SDK 及接入方式,其內部都是獲取統一的公共組件 HTML 字符串供頁面使用。

{
        // ...
        resolve: {
            extensions: ['.ts', '.tsx', '.js'],
            alias: isPreact ? {
                "react": "preact/compat",
                "react-dom": "preact/compat",     // Must be below test-utils
                "react/jsx-runtime": "preact/jsx-runtime"
            } : {},
        }
        // ...
}

(React 輕鬆轉換 Preact)

問題二:所有頁面中的公共組件有變更時能否統一熱更新

當公共組件更新或者修復緊急的某些問題,不應該影響業務方頁面,應該是自動進行更新,當用戶訪問頁面時,看到就是最新的公共組件,因此我們沒有做類似 npm 包多版本的方式進行管理。

基於問題一的基礎上:

SSR(服務端渲染) 的情況

SSR 的服務端的 HTML 從哪裏來?HTML 怎麼樣才能是最新的?

我們需要構建出來一份服務端的 JS 在沙盒中輸出 HTML,存儲在了 Redis 中,將多個公共組件統一構建出了多個 HTML,分別存放在 Redis 裏。業務方接入 JAVA、NODE 的 SDK 其實要做的只有一件事:守護進程定時的去 Redis 裏拿到最新的 HTML 結果。

圖片

CSR(客戶端渲染) 的情況

CSR 如何保持爲最新公共組件的?

需要一臺機器同多語言技術棧 SDK 一樣,定時從 Redis 裏讀取數據,對外暴露一個接口,供客戶端的 JS 調用。這樣,每次用戶訪問頁面的時候,客戶端 JS 會發起請求,保證用戶所看到的的內容永遠是最新的。

圖片

問題三:樣式問題

目前新版的相比之前舊版的公共組件在樣式和交互上更加複雜。由於左側菜單的存在,使得佈局構造不同,而且各個事業部的頁面樣式可能五花八門,很難保證不會影響自身樣式和事件等問題。

比如:如果使用 flex 的佈局,需要在最外層套用一個 div,如果不套用的話則需要在 body 元素上添加 flex 樣式,但是不能保證其他的事業部的頁面的 body 是否有其他的樣式,甚至 body 內是否存在其他的 div 元素等。還有很多事業部的頁面的類似滾動等事件監聽都是在 body 上進行監聽的,所以如果外層套取 div,這種形式會讓原來頁面的事件監聽滾動非常麻煩,各個事業部原來監聽 body 的事件,需要一一進行改動。

圖片

觀察老項目發現,之前的公共組件骨架有個最外層的 div 元素,並且有一個名爲 "container" 的 id,我們要做的就是將左側的菜單 fixed 在左側就好了. 關於 css 的 fixed 的兼容性:

圖片

(樣式屬性兼容情況)

但是此時有個問題是,我們的左菜單是可以展開或收起的。所以在展開和收起的時候需要一個全局的通信機制,當左側的組件變化時,在組件的內部應該觸發全局的通信鉤子,通知 id 爲 container 的 div 元素跟隨左菜單變化,達到 flex 佈局的效果。

圖片

(左側菜單展開)

圖片

(左側菜單收起)

問題四:性能問題

基於問題 1/2/3 大概已經擬定了技術的方向,並且已經能在各個事業部行的通了,證明思路是沒有問題的,但是還有些個瑣碎的問題需要考慮:

爲了解決上面的問題,我們考慮了先準備一個預渲染的 HTML 佔位,類似骨架屏的意思,此時就可以先進行骨架屏的渲染,之後再異步拉取渲染,來解決異步渲染白屏等待時間的問題。

圖片

爲了解決這個問題,我們的那臺跑沙盒 JOB 機器就可以繼續做這件事情。因爲每個組件構建後有資源的版本,我們需要將版本存儲一份,一旦新的組件構建後,拉取其他公共組件的資源版本,將多個 JS 組裝在一起。同時因爲我們用了 Preact ,抽取了 Preact 爲一個共同依賴,將它放在最前面,保證它的最先執行。

圖片

六、公共組件的數據動態配置系統

介紹完攜程新版大首頁的公共組件的渲染原理及技術細節,接下來就是公共組件中的數據如何支持動態配置。

6.1 爲什麼需要組件數據動態配置系統

攜程 PC 版首頁進行改版的過程中,按業務線將整個頁面劃分成了多個組件模塊,每個組件模塊內都有需要展示的業務數據。而頁面上線之後,隨着產品需求的變更,業務數據會被頻繁的更新,如果每次更新數據都發布一次模塊代碼的話,這個成本和風險很大。

因此,將代碼和數據分開發布是很有必要的,當組件數據有改動時無需發佈組件,搭建一個專門用於發佈大首頁數據配置的管理系統勢在必行。

6.2 組件數據動態配置系統的需求分析

攜程大首頁數據配置管理系統的核心功能是完成數據配置的發佈,並保證發佈的可靠性和安全性,爲了實現這個目標,此管理系統應制定一套完整的數據檢驗規範和發佈流程,其中主要功能包括:

6.3 組件數據動態配置系統架構設計

圖片

圖 1 大首頁數據配置管理系統架構設計

數據配置管理系統的架構設計 (如圖 1 所示),爲了實現需求分析中的四塊主要功能,整個管理系統主要搭建了兩個應用:

前端應用:以可視化界面的形式提供本地上傳配置文件,預覽數據效果以及更新頁面等功能,同時完成了數據校驗和預覽檢測。

Node 服務:主要負責數據配置的處理及發佈,將前端應用上傳的數據配置保存到 QConfig 系統中。

其中,前端應用提供的預覽功能的架構設計如下圖 2 所示:

圖片

圖 2 預覽功能架構設計

預覽功能的實現主要依賴三部分 (如圖 2 所示):

前端應用:負責提供數據配置和展示頁面效果。

服務端渲染應用:調用組件渲染函數,根據數據配置渲染出當前組件 HTML,並從 Redis 拉取其他組件的 HTML,而後組裝成一個完整頁面的 HTML 吐給前端應用。

Redis:存儲所有組件模塊的 HTML。

6.4 數據配置管理系統的核心功能實現

前面部分介紹了數據配置管理系統的架構設計,這裏就架構中核心功能部分的實現進行詳細介紹,主要包括:

數據配置規範及數據校驗

本地上傳的數據配置最終要傳給組件渲染出來,而數據配置的上傳者不一定是組件的開發者,上傳者並不一定清楚組件所需數據的類型和結構,那麼如何保證上傳的數據與組件要求的數據結構保持一致呢?

這就需要管理系統制定一套數據配置規範來約束上傳的數據,然而不同的組件,其數據結構是不同的,那麼每個組件都應有一套自己的規範。管理系統提供了兩種制定數據規範的方式:

規範制定完成之後管理系統會將其存儲起來,每次有上傳者上傳某一組件的數據配置後(爲方便上傳者修改數據,管理系統規定數據配置以 JSON 文件的形式提供),系統會根據組件的數據規範校驗上傳的數據配置,如果校驗通過則會展示上傳數據與線上數據的差別,上傳者可進行預覽操作;如果校驗未通過,則提示未通過原因及具體的不規範數據,上傳者不可進行後續的預覽操作,需重新上傳數據配置,直到校驗通過。

組件及頁面預覽

此部分功能的核心實現在 SSR Service 服務端渲染組件中(上文中有詳細介紹,這裏不贅述),主要分爲以下幾個步驟完成:

七、總結

本文通過攜程新版首頁項目系統的介紹了其整體架構設計,組件開發,數據配置的整個流程及實現原理,是對島嶼式架構的一次實踐。希望能對大家今後跨團隊組件式開發的項目有所收穫。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/2EzL1ZrPCe_ZFGPjsrcp9Q