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