作者简介
Iris,携程前端开发经理,专注于前端组件库和工程化领域。
Abert,携程高级研发经理,关注跨端解决方案。
一、背景
我们在开发 H5 营销活动后,通常会将营销活动的入口投放到多端,包括 App、小程序。常见的投放形式有:Native 原生页面、React Native 页面和小程序页面的内嵌弹窗。那么此时,就需要 Native、RN、小程序端的人力投入。由此,整个流程从仅需 H5 开发演变成需要多端开发、沟通,从 H5 营销活动灵活上线演变成受制于 App 和小程序的版本发布。
为了优化此流程,我们引入了一种全新的方案——跨端共享 Web 组件。这一方案秉承“一套 Web 代码,多端共享”的理念,旨在缩短上线周期、降低人力成本、并快速响应迭代。采用跨端共享 Web 组件,我们能够高效地实现多端共享,同时也能够更加丰富地展示 Web 组件,从而为我们的业务带来更多的价值。
二、方案介绍
那么如何做到“一套 Web 代码,多端共享”——
我们的小程序使用 Taro 框架和 React 框架进行开发,Taro 支持渲染 HTML 标签,鉴于此,我们选择了 React 作为 Web 组件的开发技术栈,这样,一方面,我们能直接运行在小程序端,另一方面可以用 React 的强大功能来创建可复用的自定义 HTML 元素。
在小程序端,Web 组件以 NPM 包的形式存在。在 Native 和 RN 端,使用 WebView,加载一个包含 Web Components 的 H5 链接。不管是 NPM 包的形式,还是 Web Components 的形式,都是同一套 Web 代码的产物。
在介绍实践过程之前,先简单介绍一下 Web Components。Web Components 是 Web 标准的一部分,是 W3C 提出的一套组件模型。由三个主要技术组成:
a. Custom Elements:允许开发者创建自定义 HTML 元素,这些元素可以拥有自己的属性和方法。
b. Shadow DOM:允许开发者创建封装的 DOM 树,将其附加到自定义元素上,从而实现样式和行为的隔离。
c. HTML Templates:允许开发者定义可重用的 HTML 模板,这些模板可以在不同的 Web 应用程序中使用。
浏览器基于此标准实现了一套 API,Web Components 作者可以用这些 API 去封装组件功能,然后把它应用到任何地方,不必担心有任何冲突。
React 或 Vue 都提供了相应 API,让开发者能以 React 组件或 Vue 组件的形式书写 Web Components。而这里,我们正是用的 React 组件的形式书写 Web 组件,然后将其打包为 Web Components。
假设弹窗组件名为 zt-dialog,我们提供给 Native 和 RN 端的 H5 链接内容形似:
<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 组件可以被应用到任何其他 H5 中。
我们给小程序端提供的内容则是一个 NPM 包 @ctrip/zt-dialog,主要内容则是:
import Dialog from '@ctrip/zt-dialog'
import '@ctrip/zt-dialog/dist/styles/mini.css'
三、Web组件与宿主环境
我们的 Web 组件相较于普通的 React 组件,需要考虑哪些问题呢?可以从 Web 组件寄宿于不同环境这个角度进行思考,在这个场景下,Native 端、RN 端、小程序端都是宿主环境。
因此我们要思考三个核心问题是:如何识别不同宿主环境,如何使用宿主环境的能力以及如何与宿主环境通信。
3.1 识别宿主环境
其实方法有很多种,比如各端可以传一个特殊参数,或者利用 WebView 区别于小程序的全局变量等等,来做宿主环境的识别判断。但最终我们选择了一种更优解,利用环境变量,在构建时仅打包所需代码。
环境变量是在应用程序运行时根据不同环境提供不同值的一种机制。我们的 Web 组件使用 Vite 进行构建,它支持在项目中使用环境变量。在应用程序中,通过 `import.meta.env` 对象来访问这些环境变量,根据值不同,来执行不同的逻辑。在构建时,这些环境变量会被静态替换。
比如下面这段源代码,根据`VITE_COMP_TYPE` 变量的值来处理不同的宿主环境下的 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")
}
}
通过这段构建命令:
cross-env VITE_COMP_TYPE=mini vite build
最终小程序端使用的 NPM 包结果输出如下图:
const u = () => {
console.log("mini")
},
p = () => {
console.log("mini jump")
};
可以看出我们这里只会有`mini` 的代码。从另一个角度讲,小程序端引入 Web 组件,其 Size 是很敏感的,所以我们用这种方式也可以尽可能打包更小 Size 的代码。
3.2 使用宿主环境的能力
Web 组件需要使用的能力一般来说,有发送请求、导航、分享、埋点。在 Native 和RN 端,我们使用 WebView 加载 Web 组件,那么发送请求,可以利用浏览器发送请求的能力;至于埋点,我们也可以使用浏览器加载埋点脚本,从而自行处理埋点逻辑;而导航和分享则使用桥方法即可。在小程序端,我们考虑得则要多一些,下面展开讲讲。
一般来说原生小程序都会对请求进行封装,带一些特定的请求参数,并且对请求返回值做预先的处理,因此发送请求只能由小程序端以组件参数的形式传给 Web 组件。导航、埋点同理。
分享则有一些特殊,微信小程序规定,唤起分享有两个条件:
条件一:通过给 button 组件设置属性`open-type=share`;
条件二:在用户点击按钮后触发`Page.onShareAppMessage`事件获取到分享相关信息。
条件一经测试,Web 组件用这样的写法即可满足:
<button openType="share">
<p>分享</p>
</button>
条件二则不行,如果你是小程序开发人员,那么你一定知道`Page.onShareAppMessage`是一个页面处理函数,它是用于监听用户点击页面分享按钮的事件,并不能被主动调用。解决这个问题的思路如下
a. Web 组件从小程序端提供的注册中心拿到一个唯一分享源 ID
b. Web 组件将分享源 ID 给到 button 标签
c. Web 组件向分享源信息中心注册这个 ID 对应的分享信息
最终,用户在点击分享的时候,小程序端可从分享源信息中心拿到当前分享源 ID 对应的分享信息。图示:
3.3 与宿主环境通信
思考一个问题,Web 组件是否需要与宿主环境通信?如果是,那通信场景有哪些?在实践过程中,我们发现有这两种场景:用户点击关闭组件、在合适的时机显示组件。
通信方式如图:
就实际场景来看下对应代码,以“用户点击关闭按钮”场景为例:
const closePopUp = () => {
if (import.meta.env.VITE_COMP_TYPE === 'mini') {
props.close(); // 小程序端传递的关闭事件参数
} else if (isRNWebView() {
window.postMessage(JSON.stringify({
closeModal: true // RN端使用postMessage发送closeModal事件
}));
} else if (isNativeWebView()) {
window.Bridge.insideClose(() => {}); // APP端使用桥方法关闭当前WebView
}
};
由此,不管什么场景下,我们都可以用类似的方式实现与宿主环境的通信。
再看下“在合适的时机显示组件”这种场景,首先我们理解下什么是“合适的时机”,也许你会想,在符合特定业务逻辑的前提下,让 Web 组件正常显示不就是“合适的时机”吗?实际实践后,我们发现,在小程序端,我们采用了 NPM 包形式嵌入、打包分离、公共样式抽离、webp 等方式尽可能优化其性能,Web 组件确实能正常显示,准确说做到了让用户对组件加载无感知。
但是在 Native 和 RN 端,我们使用了 WebView 加载 H5 链接的方式,一旦使用了大图+显示动画,那么 Web 组件的呈现方式就有一些不尽如人意,主要体现在用户能明显感知到大图的加载过程、大图未显示完成动画就已经开始。因此,需要把这种场景处理得更细致些。
处理思路如下:
a. Native 加载一个 WebView 容器,此时 WebView 不显示
b. WebView 加载完成后,加载一个 H5,这个 H5 会加载耗时较多的资源
c. 待资源加载完成后,H5 通知到 Native 显示 WebView
d. H5 显示 Web 组件,此时开始 Web 组件的动画
图示:
等资源加载完成后,“通知Native显示WebView”这个过程则使用桥方法通信机制。
由此,在 Native 和 RN 端,能够更加细致化地控制 Web 组件的显示,从而更加优雅地显示 Web 组件。
至此,Web 组件和宿主环境之间的核心问题就解决了。在这时,我们还在小程序端遇到一个样式的小问题。Taro 在进行 px 尺寸单位的换算时,默认以 750px 作为换算标准,而我们编写 Web 组件时,通常以 375px 为标准。这导致在小程序端显示时,整体样式会比小程序的样式小一倍,最后的解决方案是编译小程序样式时利用插件对尺寸*2。
另外为了优化图片加载性能,Web 组件的图片会使用 webp 格式。在小程序端,支持 webp,因此可以直接使用,而 Native 和 RN 端则需要根据浏览器支持情况做一下判断。
四、对Web组件的支持
在了解了“一套 Web 代码,多端共享”的正确打开方式之后,再来看下各端对 Web 组件需要做怎样的支持。毕竟在换位思考之后,我们才能从“旁观者清”的角度去完善 Web 组件。
首先,Native 端为 Web 组件开启了一个透明的 WebView。这个 WebView 要区分于非透明的 WebView。因此约定 H5 链接里添加特定 query 参数。如果 Web 组件想要指定 WebView 的宽高,也是同样地添加特定 query 参数。
假设约定的 query 参数是 `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。由于需要处理关闭弹窗、导航、分享等功能,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 包的形式,基于上述的一些思考,在小程序端,其很多能力都依赖于参数传递的方式,因此小程序端封装了一个 React Hoc 组件,将我们约定好的请求、导航、分享等等能力都封装到这个 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} // 跳转
_redirectTo={Taro.redirectTo} // 重定向跳转
_reLaunch={Taro.reLaunch} // 关闭所有页面,打开到应用内的某个页面
_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 组件的支持是相对简单的。在做了一定的封装之后,实际应用过程中,我们还在 Native 端的首页弹窗进一步做了服务端收口下发 Web 组件的 H5 链接。因此 Native 端的首页弹窗甚至无需再有 Native 端的人力介入,也可以完成一个完整闭环的需求交付周期。而这样的过程是可以完全复制到小程序端和 RN 端的。至此,完全释放 Native、RN、小程序的人力。
五、总结与展望
其实,从各端对 Web 组件的支持就可以看出,跨端共享 Web 组件一方面是整合了各端现有的能力,另一方面是发挥自己的优势如丰富的动画吸引用户。换句话说,在实践前期,投入的成本并不大,但初期的效益却是直观的——释放了多端人力,而是否能够最大化地发挥优势产生收益则是我们 Web 组件开发需要继续关注的课题。
后续我们将持续关注,丰富的 Web 组件表现形式是否有效提高了用户的点击率以及 Web 组件在各端的性能表现。
最后,让我们看下 Web 组件的效果:
Native 端:
小程序端: