干货 | Islands Architecture(孤岛架构)在携程新版首页的实践

作者简介

携程前端框架团队,为携程集团各业务线在PC、H5、小程序等各阶段提供优秀的Web解决方案。当前主要专注方向包括:新一代研发模式探索,Rust构建工具链路升级、Serverless应用框架开发、在线文档系统开发、低代码平台搭建、适老化与无障碍探索等。

一、项目背景

2022,携程PC版首页终于迎来了首次改版,完成了用户体验与技术栈的全面升级。

作为与用户连接的重要入口,旧版PC首页已经陪伴携程走过了22年,承担着重要使命的同时,也遇到了很多问题:

维护/更新困难

祖传代码黑盒逻辑过多,产品也难以推动新需求的上线,旧版首页已经不能满足高速发展的业务需求。

技术栈陈旧且不统一

互联网技术日新月异,旧版首页的整体架构设计和技术栈都相对落后,且大首页中各个组件的研发涉及多事业部合作,存在技术选型差异的问题,增加了维护成本。

用户体验有待改善

旧版携程首页的设计风格沿用至今,在视觉和交互层面上,都已经难以满足用户不断提升的互联网体验和审美需求。

综合上述情况,为了给用户提供更好的服务,携程首页的整体改造迫在眉睫。

二、需求分析

携程首页改造需要考虑的核心问题包括以下几个方面:

技术选型

为了优化首屏性能,提升用户体验,携程新版首页采用服务端渲染模式。在技术选型上,考虑到我们希望应用层是轻量的,只做页面HTML拼接和响应两件事情,最终决定基于Node.js构建应用载体,客户端则统一使用公司主流的React技术栈。

跨团队合作

首页作为携程的重要门户,涉及多业务线的流量入口。如图1所示,我们可以将整个页面进行切割,按业务线划分成多个组件模块。

干货 | Islands Architecture(孤岛架构)在携程新版首页的实践_第1张图片

图1 携程首页业务模块切分图

可以看到,整个页面的研发是需要框架部门和各个事业部业务团队紧密合作才能完成的,这就需要一整套完善的跨团队合作模式。其中,我们希望业务团队只需要关注业务逻辑的实现,完成组件模块的开发。框架团队则负责提供:

  • 组件模块服务端/客户端构建方案

  • 组件模块服务端渲染方案

  • 应用层面,实现页面组装及响应

  • 组件模块开发环境

监控及维护

上线后,我们需要时刻关注应用状态,及时响应异常情况。因此,需要对应用及组件进行埋点监控。除此之外,由于需要跨团队合作,对于业务组件,我们希望各个业务团队不仅可以实现开发/构建自由,彼此独立互不影响,在监控及版本管理上也能实现自控。因此,我们将各个业务组件包装成Node.js应用,开发人员可以直接在发布系统查看组件版本,完成发布/回退,也可以通过应用ID在埋点管理平台查看组件的相关埋点。

三、整体架构设计

干货 | Islands Architecture(孤岛架构)在携程新版首页的实践_第2张图片

图2 携程首页架构设计图

基于上述需求分析,携程新版首页的整体架构设计如图2所示,可以分为四个部分:

业务模块开发

我们将携程首页拆分为多个业务模块,由各业务团队负责完成相应组件的开发。与常规React组件开发不同的是,首先,开发人员需要在配置文件中设置好模块相关配置,如组件唯一ID;其次,组件开发需遵循一些规则,如为防止出现样式污染,我们强制使用CSS Modules;最后,我们支持服务端渲染组件,可以在服务端生命周期中拉取数据,然后在服务端/客户端使用。为了更好的辅助业务团队完成组件开发,框架团队会提供脚手架帮助创建组件模版,搭建开发环境,模拟完整首页场景。

业务模块构建

业务模块开发完成后,就需要构建/发布至生产环境。整个构建过程会在Pipeline中完成,开发人员git push代码后会自动触发。基于不同的entry及配置,我们会使用webpack分别完成客户端及服务端代码的生产态构建,并将客户端构建产物(js+css)上传至静态资源管理系统。

之后,我们会将服务端构建产物(js)连同组件及静态资源版本相关信息包装成一个Job应用,该应用中会有一个定时任务负责推送当前版本信息,触发组件完成服务端渲染,这里我们是使用定时器来实现定时任务的管理。最后,需要由开发人员在发布系统中将构建好的应用镜像部署到生产环境,完成组件的发布。

业务模块服务端渲染

业务模块的服务端渲染主要包括两部分:

  • 在沙盒中完成服务端渲染

  • 将组件相关信息及渲染生成的html存到Redis中

我们将相关功能实现封装成云函数,作为服务提供出去。由于部分组件对服务端渲染具有数据更新的需求。因此,上文我们提到过,Job应用中会有一个定时任务,负责触发组件进行服务端渲染,这里也就是会触发云函数的调用。

应用页面组装

最后,我们就需要在应用中将所有的业务模块拼装起来,定时从Redis中获取组件相关信息,组装成首页html返回到客户端。

四、整体架构的核心功能实现

对应上述首页架构设计,我们简要介绍下部分核心功能的实现:

4.1 搭建组件开发环境,模拟首页场景

我们会在开发阶段提供脚手架辅助业务团队开发组件,其中一项重要功能就是搭建组件开发环境。常规的webpack搭建React开发环境我们这里就不赘述了,为了实现开发环境的统一标准化,我们还做了以下事情:

  • 将webpack,babel的相关配置封装到cli中,有选择的提供可配置项,规范化组件开发环境

  • 对entry进行收口。这里需要注意的是,服务端和客户端的entry是不同的,对于客户端entry,需要获取服务端传过来的数据,并通过调用ReactDOM.render()完成渲染:

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(, root)
    } else {
        ReactDOM.hydrate(, 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()
}


export default render()

搭建首页场景。我们希望开发人员在组件开发时,就可以看到其嵌入在整个首页中的效果,而不是只能看到自己的组件。因此,我们在服务端处理页面请求时,通过以下方式搭建了首页场景:

  • 读取首页html文件(首次从线上拉取)

  • 解析/处理首页html,移除当前组件相关的线上script/link标签,添加开发态构建产物

  • 在沙盒中服务端渲染组件,替换首页html中的组件html

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等等前端框架。这里的问题是各个事业部的技术栈的错综复杂,如果分开维护会带来不同的版本及很高的维护成本。

所有页面中的公共组件有变更时能否统一热更新

当公共组件的改动或有问题需要修复时,不能让所有的页面都去变更公共组件,而是应该我们变更后,所有页面上的公共组件会静默生效,各个事业部无需关心公共组件的变更。

公共组件的样式如何不对页面造成巨大影响

由于各个业务方的样式风格不同并且还存在一些全局的公共样式,如何才能保证每个接入方为下图的页面布局方式,其页面组成的方式为阴影部分是事业部所维护的组件,其他是公共组件。

干货 | Islands Architecture(孤岛架构)在携程新版首页的实践_第3张图片

由于历史原因,旧版的公共组件已经使用了很多年了,新版头尾和旧版的头尾布局构造不同,要如何设计,才能使其改动最小,而不是去做很大的改动去适配公共组件。新旧版大首页页面布局变化如下图:

干货 | Islands Architecture(孤岛架构)在携程新版首页的实践_第4张图片

公共组件的渲染性能问题

在背景中提到的不同形态的公共组件(比如有些不需要左侧菜单或者头部样式的不同),如何在客户端能第一时间展示给用户相应组件形态并且支持搜索引擎优化(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 结果。

干货 | Islands Architecture(孤岛架构)在携程新版首页的实践_第5张图片

CSR(客户端渲染)的情况

CSR如何保持为最新公共组件的?

需要一台机器同多语言技术栈SDK一样,定时从 Redis 里读取数据,对外暴露一个接口,供客户端的JS调用。这样,每次用户访问页面的时候,客户端JS会发起请求,保证用户所看到的的内容永远是最新的。

干货 | Islands Architecture(孤岛架构)在携程新版首页的实践_第6张图片

问题三:样式问题

目前新版的相比之前旧版的公共组件在样式和交互上更加复杂。由于左侧菜单的存在,使得布局构造不同,而且各个事业部的页面样式可能五花八门,很难保证不会影响自身样式和事件等问题。

比如:如果使用flex的布局,需要在最外层套用一个div,如果不套用的话则需要在body元素上添加flex样式,但是不能保证其他的事业部的页面的 body 是否有其他的样式,甚至body 内是否存在其他的div元素等。还有很多事业部的页面的类似滚动等事件监听都是在body上进行监听的,所以如果外层套取div,这种形式会让原来页面的事件监听滚动非常麻烦,各个事业部原来监听body的事件,需要一一进行改动。

干货 | Islands Architecture(孤岛架构)在携程新版首页的实践_第7张图片

观察老项目发现,之前的公共组件骨架有个最外层的div元素,并且有一个名为"container"的id,我们要做的就是将左侧的菜单 fixed 在左侧就好了.关于css的fixed的兼容性:

干货 | Islands Architecture(孤岛架构)在携程新版首页的实践_第8张图片

(样式属性兼容情况)

但是此时有个问题是,我们的左菜单是可以展开或收起的。所以在展开和收起的时候需要一个全局的通信机制,当左侧的组件变化时,在组件的内部应该触发全局的通信钩子,通知 id 为container 的div元素跟随左菜单变化,达到 flex 布局的效果。

干货 | Islands Architecture(孤岛架构)在携程新版首页的实践_第9张图片

(左侧菜单展开)

干货 | Islands Architecture(孤岛架构)在携程新版首页的实践_第10张图片

(左侧菜单收起)

问题四:性能问题

基于问题 1/2/3 大概已经拟定了技术的方向,并且已经能在各个事业部行的通了,证明思路是没有问题的,但是还有些个琐碎的问题需要考虑:

  • 因为是定时从服务端里进行拉取,那么第一次没有拉取时或者在客户端渲染的情况下请求server是需要时间的,这样请求回来HTML再进行异步渲染,是否时间过长?

为了解决上面的问题,我们考虑了先准备一个预渲染的HTML占位,类似骨架屏的意思,此时就可以先进行骨架屏的渲染,之后再异步拉取渲染,来解决异步渲染白屏等待时间的问题。

干货 | Islands Architecture(孤岛架构)在携程新版首页的实践_第11张图片

  • 多个公共组件的客户端 JS 资源是否能够合并,将Preact公共包也一起合并打包。

为了解决这个问题,我们的那台跑沙盒JOB机器就可以继续做这件事情。因为每个组件构建后有资源的版本,我们需要将版本存储一份,一旦新的组件构建后,拉取其他公共组件的资源版本,将多个JS组装在一起。同时因为我们用了 Preact ,抽取了 Preact 为一个共同依赖,将它放在最前面,保证它的最先执行。

干货 | Islands Architecture(孤岛架构)在携程新版首页的实践_第12张图片

六、公共组件的数据动态配置系统

介绍完携程新版大首页的公共组件的渲染原理及技术细节,接下来就是公共组件中的数据如何支持动态配置。

6.1 为什么需要组件数据动态配置系统

携程PC版首页进行改版的过程中,按业务线将整个页面划分成了多个组件模块,每个组件模块内都有需要展示的业务数据。而页面上线之后,随着产品需求的变更,业务数据会被频繁的更新,如果每次更新数据都发布一次模块代码的话,这个成本和风险很大。

因此,将代码和数据分开发布是很有必要的,当组件数据有改动时无需发布组件,搭建一个专门用于发布大首页数据配置的管理系统势在必行。

6.2 组件数据动态配置系统的需求分析

携程大首页数据配置管理系统的核心功能是完成数据配置的发布,并保证发布的可靠性和安全性,为了实现这个目标,此管理系统应制定一套完整的数据检验规范和发布流程,其中主要功能包括:

  • 规范数据配置上传格式,本地配置数据与线上配置数据差异对比

  • 制定不同组件模块的数据校验规则,并以此校验数据合法性

  • 数据配置发布前效果预览,确保与线上其他组件模块之间相互不影响

  • 更新线上页面

6.3 组件数据动态配置系统架构设计

干货 | Islands Architecture(孤岛架构)在携程新版首页的实践_第13张图片

图1 大首页数据配置管理系统架构设计

数据配置管理系统的架构设计 (如图1 所示),为了实现需求分析中的四块主要功能,整个管理系统主要搭建了两个应用:

前端应用:以可视化界面的形式提供本地上传配置文件,预览数据效果以及更新页面等功能,同时完成了数据校验和预览检测。

Node服务:主要负责数据配置的处理及发布,将前端应用上传的数据配置保存到QConfig系统中。

其中,前端应用提供的预览功能的架构设计如下图2 所示:

干货 | Islands Architecture(孤岛架构)在携程新版首页的实践_第14张图片

图2 预览功能架构设计

预览功能的实现主要依赖三部分 (如图2所示):

前端应用:负责提供数据配置和展示页面效果。

服务端渲染应用:调用组件渲染函数,根据数据配置渲染出当前组件HTML,并从Redis拉取其他组件的HTML,而后组装成一个完整页面的HTML吐给前端应用。

Redis:存储所有组件模块的HTML。

6.4 数据配置管理系统的核心功能实现

前面部分介绍了数据配置管理系统的架构设计,这里就架构中核心功能部分的实现进行详细介绍,主要包括:

  • 数据配置规范及校验

  • 组件及页面预览

数据配置规范及数据校验

本地上传的数据配置最终要传给组件渲染出来,而数据配置的上传者不一定是组件的开发者,上传者并不一定清楚组件所需数据的类型和结构,那么如何保证上传的数据与组件要求的数据结构保持一致呢?

这就需要管理系统制定一套数据配置规范来约束上传的数据,然而不同的组件,其数据结构是不同的,那么每个组件都应有一套自己的规范。管理系统提供了两种制定数据规范的方式:

  • 录入组件的基本信息,其中包括详细的数据结构:数据名称,数据类型,必传或可选等。

  • 组件使用TypeScript(推荐的组件开发语言)语言开发时,可以上传.d.ts声明文件,系统会根据此文件解析出具体的组件信息及数据结构。

规范制定完成之后管理系统会将其存储起来,每次有上传者上传某一组件的数据配置后(为方便上传者修改数据,管理系统规定数据配置以JSON文件的形式提供),系统会根据组件的数据规范校验上传的数据配置,如果校验通过则会展示上传数据与线上数据的差别,上传者可进行预览操作;如果校验未通过,则提示未通过原因及具体的不规范数据,上传者不可进行后续的预览操作,需重新上传数据配置,直到校验通过。

组件及页面预览

此部分功能的核心实现在SSR Service 服务端渲染组件中(上文中有详细介绍,这里不赘述),主要分为以下几个步骤完成:

  • 应用的组件渲染函数在接收到符合组件数据规范的配置数据后,将数据通过Props传给组件,进而render出当前组件的HTML。

  • 从Redis中取出其他模块的HTML 与当前组件HTML拼接在一起,为了保证预览的可靠性(减少其他模块出错时对当前组件的影响),其他模块均使用生产态的HTML进行拼接。为什么一定要将其他模块的HTML拼接在一起预览呢?为了测试配置数据发布之后对其他组件模块的影响,若有影响则不能发布,从而保证线上页面的安全性。

七、总结

本文通过携程新版首页项目系统的介绍了其整体架构设计,组件开发,数据配置的整个流程及实现原理,是对岛屿式架构的一次实践。希望能对大家今后跨团队组件式开发的项目有所收获。

【推荐阅读】

  • 上线效率提升8倍,携程门票活动直连平台实践

  • 每分钟写入6亿条数据,携程监控系统Dashboard存储升级实践

  • 携程海外MySQL数据复制实践

  • 支持10X增长,携程机票订单库Sharding实践

干货 | Islands Architecture(孤岛架构)在携程新版首页的实践_第15张图片

 “携程技术”公众号

  分享,交流,成长

你可能感兴趣的:(架构,低代码)