攜程Web組件在跨端場景的實踐
作者簡介
Iris,攜程前端開發(fā)經(jīng)理,專注于前端組件庫和工程化領(lǐng)域。
Abert,攜程高級研發(fā)經(jīng)理,關(guān)注跨端解決方案。
一、背景
我們在開發(fā) H5 營銷活動后,通常會將營銷活動的入口投放到多端,包括 App、小程序。常見的投放形式有:Native 原生頁面、React Native 頁面和小程序頁面的內(nèi)嵌彈窗。那么此時,就需要 Native、RN、小程序端的人力投入。由此,整個流程從僅需 H5 開發(fā)演變成需要多端開發(fā)、溝通,從 H5 營銷活動靈活上線演變成受制于 App 和小程序的版本發(fā)布。
為了優(yōu)化此流程,我們引入了一種全新的方案——跨端共享 Web 組件。這一方案秉承“一套 Web 代碼,多端共享”的理念,旨在縮短上線周期、降低人力成本、并快速響應(yīng)迭代。采用跨端共享 Web 組件,我們能夠高效地實現(xiàn)多端共享,同時也能夠更加豐富地展示 Web 組件,從而為我們的業(yè)務(wù)帶來更多的價值。
二、方案介紹
那么如何做到“一套 Web 代碼,多端共享”——
我們的小程序使用 Taro 框架和 React 框架進行開發(fā),Taro 支持渲染 HTML 標簽,鑒于此,我們選擇了 React 作為 Web 組件的開發(fā)技術(shù)棧,這樣,一方面,我們能直接運行在小程序端,另一方面可以用 React 的強大功能來創(chuàng)建可復(fù)用的自定義 HTML 元素。
在小程序端,Web 組件以 NPM 包的形式存在。在 Native 和 RN 端,使用 WebView,加載一個包含 Web Components 的 H5 鏈接。不管是 NPM 包的形式,還是 Web Components 的形式,都是同一套 Web 代碼的產(chǎn)物。
在介紹實踐過程之前,先簡單介紹一下 Web Components。Web Components 是 Web 標準的一部分,是 W3C 提出的一套組件模型。由三個主要技術(shù)組成:
a. Custom Elements:允許開發(fā)者創(chuàng)建自定義 HTML 元素,這些元素可以擁有自己的屬性和方法。
b. Shadow DOM:允許開發(fā)者創(chuàng)建封裝的 DOM 樹,將其附加到自定義元素上,從而實現(xiàn)樣式和行為的隔離。
c. HTML Templates:允許開發(fā)者定義可重用的 HTML 模板,這些模板可以在不同的 Web 應(yīng)用程序中使用。
瀏覽器基于此標準實現(xiàn)了一套 API,Web Components 作者可以用這些 API 去封裝組件功能,然后把它應(yīng)用到任何地方,不必擔(dān)心有任何沖突。
React 或 Vue 都提供了相應(yīng) API,讓開發(fā)者能以 React 組件或 Vue 組件的形式書寫 Web Components。而這里,我們正是用的 React 組件的形式書寫 Web 組件,然后將其打包為 Web Components。
假設(shè)彈窗組件名為 zt-dialog,我們提供給 Native 和 RN 端的 H5 鏈接內(nèi)容形似:
<html>
<head>
<script src="https://static.tripcdn.com/zt-dialog.umd.js"></script>
</head>
<body>
<zt-dialog></zt-dialog>
</body>
</html>
這段代碼表明,zt-dialog 組件的自定義 HTML 元素是 `zt-dialog` ,其功能邏輯被打包到一個 UMD 格式的 JavaScript 文件中。這意味著,Web 組件可以被應(yīng)用到任何其他 H5 中。
我們給小程序端提供的內(nèi)容則是一個 NPM 包 @ctrip/zt-dialog,主要內(nèi)容則是:
import Dialog from '@ctrip/zt-dialog'
import '@ctrip/zt-dialog/dist/styles/mini.css'
三、Web組件與宿主環(huán)境
我們的 Web 組件相較于普通的 React 組件,需要考慮哪些問題呢?可以從 Web 組件寄宿于不同環(huán)境這個角度進行思考,在這個場景下,Native 端、RN 端、小程序端都是宿主環(huán)境。
因此我們要思考三個核心問題是:如何識別不同宿主環(huán)境,如何使用宿主環(huán)境的能力以及如何與宿主環(huán)境通信。
3.1 識別宿主環(huán)境
其實方法有很多種,比如各端可以傳一個特殊參數(shù),或者利用 WebView 區(qū)別于小程序的全局變量等等,來做宿主環(huán)境的識別判斷。但最終我們選擇了一種更優(yōu)解,利用環(huán)境變量,在構(gòu)建時僅打包所需代碼。
環(huán)境變量是在應(yīng)用程序運行時根據(jù)不同環(huán)境提供不同值的一種機制。我們的 Web 組件使用 Vite 進行構(gòu)建,它支持在項目中使用環(huán)境變量。在應(yīng)用程序中,通過 `import.meta.env` 對象來訪問這些環(huán)境變量,根據(jù)值不同,來執(zhí)行不同的邏輯。在構(gòu)建時,這些環(huán)境變量會被靜態(tài)替換。
比如下面這段源代碼,根據(jù)`VITE_COMP_TYPE` 變量的值來處理不同的宿主環(huán)境下的 onClose 事件和 onJump 事件:
const onClose = () => {
if (import.meta.env.VITE_COMP_TYPE === 'mini') {
console.log("mini")
} else {
console.log("webview")
}
});
const onJump = () => {
if (import.meta.env.VITE_COMP_TYPE === 'mini') {
console.log("mini jump")
} else {
console.log("webview jump")
}
}
通過這段構(gòu)建命令:
cross-env VITE_COMP_TYPE=mini vite build
最終小程序端使用的 NPM 包結(jié)果輸出如下圖:
const u = () => {
console.log("mini")
},
p = () => {
console.log("mini jump")
};
可以看出我們這里只會有`mini` 的代碼。從另一個角度講,小程序端引入 Web 組件,其 Size 是很敏感的,所以我們用這種方式也可以盡可能打包更小 Size 的代碼。
3.2 使用宿主環(huán)境的能力
Web 組件需要使用的能力一般來說,有發(fā)送請求、導(dǎo)航、分享、埋點。在 Native 和RN 端,我們使用 WebView 加載 Web 組件,那么發(fā)送請求,可以利用瀏覽器發(fā)送請求的能力;至于埋點,我們也可以使用瀏覽器加載埋點腳本,從而自行處理埋點邏輯;而導(dǎo)航和分享則使用橋方法即可。在小程序端,我們考慮得則要多一些,下面展開講講。
一般來說原生小程序都會對請求進行封裝,帶一些特定的請求參數(shù),并且對請求返回值做預(yù)先的處理,因此發(fā)送請求只能由小程序端以組件參數(shù)的形式傳給 Web 組件。導(dǎo)航、埋點同理。
分享則有一些特殊,微信小程序規(guī)定,喚起分享有兩個條件:
條件一:通過給 button 組件設(shè)置屬性`open-type=share`;
條件二:在用戶點擊按鈕后觸發(fā)`Page.onShareAppMessage`事件獲取到分享相關(guān)信息。
條件一經(jīng)測試,Web 組件用這樣的寫法即可滿足:
<button openType="share">
<p>分享</p>
</button>
條件二則不行,如果你是小程序開發(fā)人員,那么你一定知道`Page.onShareAppMessage`是一個頁面處理函數(shù),它是用于監(jiān)聽用戶點擊頁面分享按鈕的事件,并不能被主動調(diào)用。解決這個問題的思路如下
a. Web 組件從小程序端提供的注冊中心拿到一個唯一分享源 ID
b. Web 組件將分享源 ID 給到 button 標簽
c. Web 組件向分享源信息中心注冊這個 ID 對應(yīng)的分享信息
最終,用戶在點擊分享的時候,小程序端可從分享源信息中心拿到當(dāng)前分享源 ID 對應(yīng)的分享信息。圖示:
3.3 與宿主環(huán)境通信
思考一個問題,Web 組件是否需要與宿主環(huán)境通信?如果是,那通信場景有哪些?在實踐過程中,我們發(fā)現(xiàn)有這兩種場景:用戶點擊關(guān)閉組件、在合適的時機顯示組件。
通信方式如圖:
就實際場景來看下對應(yīng)代碼,以“用戶點擊關(guān)閉按鈕”場景為例:
const closePopUp = () => {
if (import.meta.env.VITE_COMP_TYPE === 'mini') {
props.close(); // 小程序端傳遞的關(guān)閉事件參數(shù)
} else if (isRNWebView() {
window.postMessage(JSON.stringify({
closeModal: true // RN端使用postMessage發(fā)送closeModal事件
}));
} else if (isNativeWebView()) {
window.Bridge.insideClose(() => {}); // APP端使用橋方法關(guān)閉當(dāng)前WebView
}
};
由此,不管什么場景下,我們都可以用類似的方式實現(xiàn)與宿主環(huán)境的通信。
再看下“在合適的時機顯示組件”這種場景,首先我們理解下什么是“合適的時機”,也許你會想,在符合特定業(yè)務(wù)邏輯的前提下,讓 Web 組件正常顯示不就是“合適的時機”嗎?實際實踐后,我們發(fā)現(xiàn),在小程序端,我們采用了 NPM 包形式嵌入、打包分離、公共樣式抽離、webp 等方式盡可能優(yōu)化其性能,Web 組件確實能正常顯示,準確說做到了讓用戶對組件加載無感知。
但是在 Native 和 RN 端,我們使用了 WebView 加載 H5 鏈接的方式,一旦使用了大圖+顯示動畫,那么 Web 組件的呈現(xiàn)方式就有一些不盡如人意,主要體現(xiàn)在用戶能明顯感知到大圖的加載過程、大圖未顯示完成動畫就已經(jīng)開始。因此,需要把這種場景處理得更細致些。
處理思路如下:
a. Native 加載一個 WebView 容器,此時 WebView 不顯示
b. WebView 加載完成后,加載一個 H5,這個 H5 會加載耗時較多的資源
c. 待資源加載完成后,H5 通知到 Native 顯示 WebView
d. H5 顯示 Web 組件,此時開始 Web 組件的動畫
圖示:
等資源加載完成后,“通知Native顯示W(wǎng)ebView”這個過程則使用橋方法通信機制。
由此,在 Native 和 RN 端,能夠更加細致化地控制 Web 組件的顯示,從而更加優(yōu)雅地顯示 Web 組件。
至此,Web 組件和宿主環(huán)境之間的核心問題就解決了。在這時,我們還在小程序端遇到一個樣式的小問題。Taro 在進行 px 尺寸單位的換算時,默認以 750px 作為換算標準,而我們編寫 Web 組件時,通常以 375px 為標準。這導(dǎo)致在小程序端顯示時,整體樣式會比小程序的樣式小一倍,最后的解決方案是編譯小程序樣式時利用插件對尺寸*2。
另外為了優(yōu)化圖片加載性能,Web 組件的圖片會使用 webp 格式。在小程序端,支持 webp,因此可以直接使用,而 Native 和 RN 端則需要根據(jù)瀏覽器支持情況做一下判斷。
四、對Web組件的支持
在了解了“一套 Web 代碼,多端共享”的正確打開方式之后,再來看下各端對 Web 組件需要做怎樣的支持。畢竟在換位思考之后,我們才能從“旁觀者清”的角度去完善 Web 組件。
首先,Native 端為 Web 組件開啟了一個透明的 WebView。這個 WebView 要區(qū)分于非透明的 WebView。因此約定 H5 鏈接里添加特定 query 參數(shù)。如果 Web 組件想要指定 WebView 的寬高,也是同樣地添加特定 query 參數(shù)。
假設(shè)約定的 query 參數(shù)是 `insidepop=1`,zt-dialog 組件的 H5 鏈接形式如下:
https://m.ctrip.com/demo/zt-dialog.html?insidepop=1
以 Android 為例,在 Native 端被使用:
Intent intent = new Intent(); // 初始化一個通用Intent
Activity activity = new Activity();
intent.setClass(activity, H5Container.class)
intent.putExtra(H5Container.URL_LOAD, 'https://m.ctrip.com/demo/zt-dialog.html?insidepop=1'); // 加載包含Web組件的H5鏈接
AppUtil.startActivity(activity, intent);
再者,在 RN 端,我們使用 WebView 控件開啟一個透明的 WebView。由于需要處理關(guān)閉彈窗、導(dǎo)航、分享等功能,RN 端基于 WebView 控件再次做了封裝。
同樣是 zt-dialog 組件的 H5 鏈接形式,在 RN 端被使用:
import React from 'react';
import { ViewPort, Text, TouchableHighlight } from 'react-native';
import { WebViewModal } from 'react-native-webview';
export default class Demo {
render() {
return (
<ViewPort>
<TouchableHighlight onPress={() => {this.webviewRef.showModal()}}>
<Text>show modal</Text>
</TouchableHighlight>
<WebViewModal
position='bottom'
webViewUrl={'https://m.ctrip.com/demo/zt-dialog.html'}
/>
</ViewPort>
)
}
}
最后,小程序端使用的是 NPM 包的形式,基于上述的一些思考,在小程序端,其很多能力都依賴于參數(shù)傳遞的方式,因此小程序端封裝了一個 React Hoc 組件,將我們約定好的請求、導(dǎo)航、分享等等能力都封裝到這個 Hoc 組件中。這個 Hoc 組件類似:
import React from "react"
import Taro from "@tarojs/taro"
const webBridgeHoc = (WebComp)=>{
return (props)=>{
return <WebComp
_ubtTrace={_ubtTrace} // 埋點
_request={requestFunc} // 小程序原生request
_navigateTo={Taro.navigateTo} // 跳轉(zhuǎn)
_redirectTo={Taro.redirectTo} // 重定向跳轉(zhuǎn)
_reLaunch={Taro.reLaunch} // 關(guān)閉所有頁面,打開到應(yīng)用內(nèi)的某個頁面
_switchTab={Taro.switchTab} // 切換tab頁
...
/>
}
}
export default webBridgeHoc
zt-dialog 組件在小程序端被使用時:
import Dialog from '@ctrip/zt-dialog'
import '@ctrip/zt-dialog/dist/styles/mini.css'
import webBridgeHoc from '@/components/webBridgeHoc'
export default webBridgeHoc(Dialog)
總的來說,各端對 Web 組件的支持是相對簡單的。在做了一定的封裝之后,實際應(yīng)用過程中,我們還在 Native 端的首頁彈窗進一步做了服務(wù)端收口下發(fā) Web 組件的 H5 鏈接。因此 Native 端的首頁彈窗甚至無需再有 Native 端的人力介入,也可以完成一個完整閉環(huán)的需求交付周期。而這樣的過程是可以完全復(fù)制到小程序端和 RN 端的。至此,完全釋放 Native、RN、小程序的人力。
五、總結(jié)與展望
其實,從各端對 Web 組件的支持就可以看出,跨端共享 Web 組件一方面是整合了各端現(xiàn)有的能力,另一方面是發(fā)揮自己的優(yōu)勢如豐富的動畫吸引用戶。換句話說,在實踐前期,投入的成本并不大,但初期的效益卻是直觀的——釋放了多端人力,而是否能夠最大化地發(fā)揮優(yōu)勢產(chǎn)生收益則是我們 Web 組件開發(fā)需要繼續(xù)關(guān)注的課題。
后續(xù)我們將持續(xù)關(guān)注,豐富的 Web 組件表現(xiàn)形式是否有效提高了用戶的點擊率以及 Web 組件在各端的性能表現(xiàn)。
最后,讓我們看下 Web 組件的效果:
Native 端:
小程序端: