序言 本文就如何设计多端容器做出实现方案。本文指的多端并非是跨端,多端容器泛指容器提供统一能力,以供下游应用在多种环境下的容器加载并正常运行。各端容器提供的能力可能不同,下游也无需关心上层实现及依赖,只依赖于抽象接口,遵循依赖反转原则。
小伙伴应该接触过以下这种需求场景:应用既可以在web browser运行,也可以以Hybrid的方式内嵌 webview、三方应用(alipay,wx,微应用等)浏览器。可能有些同学会问:“同是 Browser Environment,提供的上下文能力不都一样吗”,但实际上,会有一些容器对加载内容的能力做一些限制和添加特色功能,端容器需要做的就是将它们对齐。当处于可以访问DOM的环境调用社区的动画库,处于原生应用中时调用JSBridge使用原生应用的交互能力,处于其他端例如小程序时使用三方生态提供的能力 —— 当无法最优解时,可以自动降级到兜底方案。
又比如wxH5和dingding微应用,会有默认的导航栏,部分应用会开放应用导航栏的定制能力,而当环境处于Web Browser 时自带导航栏应该展现出来。这只是部分差异,当维护的应用涉及多个环境时,问题被放大也就棘手了起来。
总之,多端容器解决的问题是:
提供多端统一API,抽象api约定,下游无感。
多端逻辑隔离,利于维护管理。
依赖反转,解耦提高可替代性。
目标 容器能力
常用指令API(toast/alert/confirm)和常用工具类(剪切板/本地存储/预览图片/环境区分)。
Services,服务网关(根据环境调用对应API调整参数) e.g. fetch/jsonp/自定义注入网关。
ComponentLoader,组件加载器,具备预加载功能。
NavgationAPI,导航及路由缓存。
ErrorBoundary,可定制错误兜底视图自动上报。
Suspense,定制 React.lazy、componentLoader 加载过渡动画。
使用方式 import { containerContext, Container } from '@iron-man/container-api' ;function App ( ) { return ( <Container {...options}> <AnyChild /> </Container> ) } function AnyChild() { const containerAPI = useContext(containerContext); useEffect(() => { / / use container ability }, []) return / /... }
可能有些人会有疑问,为什么要使用Context?经常在某些文章里看到性能和组件间可预测的数据复杂性两方面问题,首先它是跨组件共享数据的首选方式,定义后属性的不可重写代表也不会让跨组件层级之间组件关系变得复杂且不可预测,还有一些文章指出它的诟病是容易造成性能损耗,例如不拆分 Context 充当 Redux 构建单一数据树使用,使组件强行脱离 bailout 逻辑, 然而 React 性能优化的一大关键在于减少不必要的render,这一点可以通过 memo 规避掉。
设计分层 整块分为基础容器层、路由层、ErrorBoundary层、Suspense层。所有“层”说白了都是容器,大体来说两意思相近。
其他层依赖于基础容器提供的基础能力,除基础容器外,每种功能无耦合可组合拆分不影响其他能力,还有像埋点容器、Profiler容器、动画过渡容器等自由发挥。
基础容器层
1.2.3 偏向于容器api,没有其他依赖,适合放在基础容器层内或为下游层提供能力。
通用API提供上下文常用的指令工具方法。
服务网关提供服务调用能力。
ComponentLoader 过渡动画依赖 Suspense, 容错依赖 ErrorBoundary,这里并不是直接依赖容器层的 ErrorBoundary,而是复用基础组件,它只作为一个基础组件为加载器单独提供功能。
路由层
依赖基础容器的 ComponentLoader, 导航路由在上篇文章里已经介绍了实现 传送门 ,本章不重复介绍。
ErrorBoundary 层
复用 ErrorBoundary 组件,目的是将 ComponentLoader 加载发生的错误和运行时的错误隔离区别展示。
Suspense 层
与 ErrorBoundary 相同,也是为了将 ComponentLoader 加载时的过渡动画和 React.lazy 触发的区分开来。
api 约定 为了保证多端实现的API统一,需要定义一个抽象接口中心,把抽象API接口暴露出来,由各端容器的真正实现引入作为核心规范约束。
最终应用入口根据判断环境通过DI (Dependency Injection) 方式注入到下游应用,完成对下游应用的多端兼容。
和钢铁侠很像,他能够根据敌人的特性去装载适合对战的机甲,也能够适应各种环境,故取名@iron-man/container-api
,各端实现则为 @iron-man/container-${platform}-impl
。 然后小程序限制太大,即便有好的跨端框架,方案也并非完全适用,主要体现在路由和脚本加载方面。
产物最终将提供两种方式接入,两种方式的差别不大,取决于平台的架构模式。
DI,类 requirejs,各端统一引入@iron-man/container-api
,平台容器根据环境判断自动注入对应平台的依赖文件。
二方包,类 npm 模块,各端直接引入对应端环境的包。
为了避免在npm上发布测试包,本文章容器抽象&web-impl部分使用 Learn 管理,demo 使用 webpack external + requirejs 完成对各端业务mock实现。
接口定义 根据设计分层可以得到以下结构,基础容器层细分归纳为 Layer,而其他层分类到 Wrapper。
Layers
Basic // 基础层
Scheme // 协议层
Service // 网关层
Wrappers
Navigation // 路由层
ErrorBoundary // 容错层
Suspense // 加载层
Basic 一些常用的工具方法
export interface TipUtils { alert: () => void ; confirm: () => void ; toast: () => void ; } export interface StorageApi { get : (key: string ) => Promise <string | null >; set : (key: string , value: string ) => Promise <void >; del: (key: string ) => Promise <void >; getJSON: <T = unknown>(key: string ) => Promise <T | null >; setJSON: <T = unknown>(key: string , value: T ) => Promise <void >; } export interface PracticalUtils { previewImage: (text: string ) => void , copyToClipboard: (text: string ) => Promise <void > }; export interface BasicLayerAbility extends TipUtils { }
Schema 协议层,主要通过 componentURI 解析生成实际组件/脚本引用地址提供组件渲染和预加载功能。
import { ComponentType } from "react" ;import { SuspenseAbility } from '../wrapper/suspense' ;export interface ComponentLoaderProps extends SuspenseAbility { componentURI: string ; props?: Record<string , any >; } export type SchemaLayerAbility = { ComponentLoader: ComponentType<ComponentLoaderProps>; preLoadComponent: (componentName: string ) => Promise <any >; }
Suspense & ErrorBoundary 提供自定义加装方法和自定义错误边界,两者共有 onError、renderError 方法,由容器侧传入以支持动态配置。
export type ErrorBoundaryAbility = { onError?: (error: any , type : 'load' | 'render' ) => void ; renderError?: (error: any , type : 'load' | 'render' ) => React.ReactNode; } export interface SuspenseAbility extends ErrorBoundaryAbility { renderLoading?: () => ReactElement; onLoad?: (componentClass: any ) => void ; }
Navigator 运行时的实现思路是使用 history API 模拟一个页面栈,所有导航方法的操作都是基于 PageStack,搭配 popstate event 响应返回事件,路由导航能力基于 ComponentLoader。并在其基础上添加了单路由match多副本和路由回调功能,这块内容上篇文章叙述过了,传送门 。
export interface NavigationAbility { navAPI: { navigateTo(page: string , params?: Record<string , any >): void ; back(): void ; navigateAndWaitBack(page: string , params?: Record<string , any >): Promise <any >; backWithResponse(data: any ): void ; replace(page: string , params?: Record<string , any >): void ; open(page: string , params?: Record<string , any >): void ; reload(): void ; } }
容器侧配置 部分配置, 具体配置请查看 container-api 。
export interface ContainerProps { children?: ReactNode; envType?: EnvTypeEnum; cacheOptions?: Array <RegExp >; customResolveModuleRule?: (componentURI: string ) => ModuleType | null ; modules?: { readonly [moduleURI: string ]: RemoteModule | LazyModule | LocalModule; }; readonly withSuspense?: boolean | React.ComponentType<any >; readonly withErrorBoundary?: boolean | React.ComponentType<any >; }
Mock实现 除了要定义接口,还需要对部分功能做初步实现,这样就可以直接打 container-api 的包,在 demo 里引入该声明文件。
import type * as ContainerAPI from '../../context/index' ;declare module '@iron-man/container-api' { export = ContainerAPI; }
Web-implement 初始化上下文 web端实现,可以使用各种开放能力,但在实现之前,首先需要初始化Context对各API提供初步的mock实现。
import { BasicLayerAbility, ServiceLayerAbility, SchemaLayerAbility, NavigationAbility, ContainerAbility } from '@iron-man/container-api' ;import { unimplementedAsyncFunction, unimplementedFunction, unimplementedComponent } from './utils/initialFunction' ;const defaultBasicAbility = { alert: unimplementedFunction, } const defaultSchema = { componentLoader: unimplementedComponent, } export const containerContext: Context<ContainerAbility> = createContext({ ...defaultBasicAbility, ...defaultSchema, ...defaultService, ...defaultNavigation });
Basic 下一步是初始化基础容器, createContainer
负责整合基础容器层内的layer,提供迭代方法初始化。
工具层 import { ContainerOptions, ContainerAbility } from '@iron-man/container-api' ;import BasicLayer from './basic' ;import SchemaLayer from './schema/index' ;import ServiceLayer from './service/index' ;import { LayerIteration } from './interface' ;const layers: LayerIteration[] = [BasicLayer, SchemaLayer, ServiceLayer];function createContainer (originContainer: ContainerAbility, options: ContainerOptions ) { return layers.reduce((preContainer, layer ) => layer(preContainer, options), originContainer); } import { ContainerProps } from '@iron-man/container-api' ;import { containerContext as ContainerContext } from '@/containerContext' ;export default function BasicContainer (props: ContainerProps ) { const { children, ...restProps } = props; const propsRef = useSafeTrackingRef(restProps); const originContext = useContext(ContainerContext); const value = useMemo(() => createContainer(originContext, propsRef.current), []); return <ContainerContext.Provider value={value}>{children}</ContainerContext.Provider>; }
function BasicLayer (origin: ContainerAbility, options: ContainerOptions ): ContainerAbility { const basic = createBasicLayer(origin, options) return { ...origin, ...basic }; } function createBasicLayer ( ) { return { alert: AM.alert, toast: AM.toast, ... } }
项目需求里无线端 antd-mobile 用的比较多,这里就提供它的实现了。需要注意的是适配接口参数类型。再照猫画虎定义好其他工具API,基础容器层工具部分就实现完了。
服务网关层 主要包含服务调用和服务注入,服务调用包含基本的request/jsonp,每个公司都可能有自己的一套网关,也可以根据 natty-fetch 去定制团队协同规范,注入服务提供自定义服务,但必须固化参数。
function ServiceLayer (origin: ContainerAbility, options: ContainerOptions ) { return { ...origin, service: { ...createServicePortal(origin, options), ...createCustomServicePortal(origin, options), }, }; }
根据上下文注入的domain&判断环境加载服务,这里request就用fetch做简单实现。createCustomServicePortal
包装自定义服务。
function createServicePortal ( origin: ContainerAbility, options: ContainerOptions ): Pick <ServiceLayerAbility ['service '], 'jsonp ' | 'request ' | 'getService '> { const Request = createRequestService(options.envType || 'online' ); const Jsonp = createJsonpService(options.envType || 'online' ); const presetServices = { request: Request, jsonp: Jsonp, }; const getService = (name: string ): TypeRequest | CustomFetcher | null => { if (Object .prototype.hasOwnProperty.call(presetServices, name)) { return presetServices[name as keyof typeof presetServices]; } else if (typeof name === 'string' ) { return origin.service.getCustomService(name) }else { return null } }; return { ...presetServices, getService, }; }
协议层(组件/模块加载器) 比较麻烦的是协议层,它支持三种模块引入,远程模块、本地模块、懒加载模块,本地模块很容器理解,懒加载模块大致上和远程模块类似,不同点是额外提供懒加载能力,远程模块可以引入AMD、CMD、UMD、ESM模块。
除此之外,还需要考虑几个问题
协议解析,如何根据协议解析判断你到底是想要哪种模块,并且可以自定义解析模块
远程模块 => URL
本地模块 => 内置/预置组件
协议缓存与组件预加载。
自定义加装动画和错误边界
协议规范 无论是任何类型的组件,都会有其自身的“身份信息”,这些信息在加载过程中仅提供给加载器使用,除此之外,对外有统一的加装方法供调用。加载和渲染区分开来,预加载实际意义上即是只调用了加载方法。
如何生成组件的“身份信息”,componentLoader
接受一个 componentURI 参数,该参数根据协议解析中的协议头、协议体、协议参数,默认协议支持如下三种格式。流程如下
"group://chat.list#default" "internal://Loading" "https://xxx.com/group/repo_name/version/index.js#Home"
通过解析协议拿到模块的地址,这个地址可能是外链也可能是本地,也有可能是本地+外链资源的混合 —— lazyModule,拿到组件身份信息后,根据模块类型加载脚本,加装动画和错误边界后渲染出来,过程中还需要考虑缓存和避免重复加载的问题。预加载的区别只是没有最后Render那一步。
export const createComponentLoader = (origin: ContainerAbility, options: ContainerOptions): Pick<ContainerAbility, 'ComponentLoader' | 'preLoadComponent' > => { const { getComponentInfo } = createComponentRegister(options, INTERNAL_MODULES); const componentLoader = (loaderProps: ComponentLoaderProps ) => { return <ComponentRender componentURI={componentURI} innerProps={innerProps} /> }; const ComponentRender: React.FC<ComponentRenderProps> = (props ) => { const { componentURI, innerProps = {} } = props; const componentInfo = useMemo(() => getComponentInfo(componentURI), [componentURI]); if (!componentInfo) { throw new Error (`unknown componentURI ${componentURI} ` ); } return <>{componentInfo.render(innerProps)}</>; }; return { componentLoader, preLoadComponent: (componentURI: string) => { const componentInfo = getComponentInfo(componentURI); if (!componentInfo) { throw new CantGetModuleInfo(componentURI); } / / 仅调用 load 方法 return componentInfo.load(); } } }
getComponentInfo(componentURI)
主要做三件事情
解析协议,提供对外加载的API。
提供协议解析与组件的缓存。
提供自定义协议解析流程。
const createComponentRegister = (options: ContainerOptions, internalModule: ContainerOptions['modules'] ) => { const registerMap = useMemo(() => new Map(), []); function getComponentInfo (componentURI: string ) { if (registerMap.has(componentURI)) { return registerMap.get(componentURI); } const info: ModuleType = options.customResolveModuleRule?.call(null , componentURI) || internalModule && internalModule[componentURI] || resolveModule(componentURI, options); if (!info) { throw new CantGetModuleInfo(componentURI); } return registerComponentFromModuleInfo(componentURI, info); } return { registerMap, getComponentInfo } }
协议解析 协议解析就比较好处理了,只可能存在三种类型。
export type RemoteModule = { type : 'remote' ; url: string ; format?: 'AMD' | 'UMD' | 'CMD' ; exportName?: string ; }; export type LocalModule = { type : 'local' ; module : unknown; }; export type LazyModule = { type : 'lazy' ; module : () => Promise<unknown>; }; export type ModuleType = LocalModule | RemoteModule | LazyModule;
然后通过parseURI
解析协议URI,得到以下几种字段
type moduleURIInfo = { protocol: string ; path: string ; subPath: string ; componentURI: string ; };
根据 protocol 字段命中对应的协议体解析逻辑
enum ProtocolEnum { http = 'http' , https = 'https' , group = 'group' , internal = 'internal' } export default function resolveModule (componentURI: string , options: ContainerOptions ): ModuleType { const uriInfo = parseURI(componentURI, options); switch (uriInfo.protocol) { case ProtocolEnum.http: case ProtocolEnum.https: { return parseRemoteModule(uriInfo, options); } case ProtocolEnum.group: { return parseGroupModule(uriInfo, options); } case ProtocolEnum.internal: { return Reflect.get(options.modules || {}, uriInfo.path) } default : { throw new InvalidComponentURIProtocol(uriInfo.protocol); } } } function parseGroupModule (uriInfo: moduleURIInfo, options: ContainerOptions ): ModuleType { return { type : 'remote' , url: `//xxx.com/${uriInfo.protocol} /${uriInfo.path} .js` , exportName: uriInfo.subPath, format: 'UMD' , }; }
其他命中逻辑类似,此处还应该细化组件的版本信息和协议头控制,例如在模板内注入全局组件版本映射表,以及更为灵动的协议头match规则,group => hotline-group。。。
协议加载 接下来就是加载过程,可自行注入全局的事件钩子,e.g. registed、beforeLoad 、loaded。
function registerComponentFromModuleInfo (componentURI: string , info: ModuleType ) { let _component: any = null ; function createComponentInfo (fetchComponent: Fetcher<any > ): ComponentInfo { const load = () => { const Component = fetchComponent(); if (!Component) { throw new InvalidComponentError(componentURI, getModuleName(info)); } return _component = Component; } return { id: componentURI, render: (props: any ) => { const Component = load(); return React.createElement(Component, props); }, load, get component() { return _component; } }; } if (info.type === 'local' ) { return register(componentURI, createComponentInfo(() => info.module )); } if(info.type === 'lazy') { return register(componentURI, createComponentInfo(createFetcher(info.module ))); } if(info.type === 'remote') { const fetcher = createFetcher(async () => loadModule({ name: info.name, url: info.url, exportName: info.exportName || '' , format: info.format || 'UMD' }) ); return register(componentURI, createComponentInfo(fetcher)); } throw new InvalidModuleURIError(componentURI); }
都知道除了 hooks 之外的函数在函数组件里都会执行多次,为了组件只加载一次,需要利用闭包函数。loadModule
是脚本加载函数,屏蔽了部分细节,例如处理可能存在的沙盒、requirejs、环节限制不能注入脚本(fetch内容eval掉,注意跨域),web直接使用了 url-package-loader ,这个包内置了AMD兼容方案。如果环境不支持 amd,需要额外配置 libraryName 降低到 UMD。
export async function loadModule <T = any >(config: LoadModuleOptions ): Promise <T > { const { url, name, format = 'UMD' , exportName } = config; let module ; // PackageLoader 内置了 amd 判断,暂时先写成一样的 if (format === 'UMD' || format === 'AMD') { module = await new PackageLoader({ name, url }).loadScript(); } if (module && exportName) { return get (module , exportName); } if (module && module .__esModule && !exportName) { return module .default ; } return module ; }
到此,基础容器就可以跑起来了。
const App: FC<ContainerProps> = (props) => { <BasicContainer {...props}> <Demo /> </BasicContainer> } const Demo = () => { const container = useContext(containerContext); const { ComponentLoader } = container; useEffect(() => { // container.toast('xxx') }, []) return ( <div> <ComponentLoader componentURI="https://0.0.0.0:8082/todoList" props={{ title: '蔬菜', itemList: ['🥒', '🥔', '🎃'], }} /> <ComponentLoader componentURI="group://todoList#default" props={{ title: '水果', itemList: ['🍌', '🍊', '🍐', '🍉'], }} /> </div> ) }
Navigator 虽然之前文章已经详细介绍过,还是要唠叨一嘴,路由层解决的问题是
无刷新切换路由,与上层路由不耦合、不冲突,可并用。
路由多副本缓存,例如多个聊天界面。
静默路由不更新。
提供路由钩子和路由回调。
数据管控路由实现状态持久化
ErrorBoundary 本质是控制错误的边界,不至于让整个应用崩溃,同时也提供兜底视图,除了提升用户体感外还可以附加其他功能,比如“点击重试”、“错误展示/上报”等,在此基础上,提供了自定义边界的接口。
const ErrorBoundaryContainer: React.FC<ContainerProps> = props => { const { children, withErrorBoundary } = props; if (withErrorBoundary === true || withErrorBoundary === undefined) { return <ErrorBoundaryWrapper>{children}</ErrorBoundaryWrapper>; } if (typeof withErrorBoundary === 'function') { return createElement(withErrorBoundary, {}, children); } return children as ReactElement; };
Suspense 它的原理是通过捕捉子组件抛出的 Promise 状态来判断完成加载渲染,配合 React.lazy + dynamic import 可以达到 code-splitting 的效果,与 ErrorBoundary 同样要为其提供自定义 Suspense 的接口。注意某些不支持Suspense的情况,e.g. 没有 动态import,自定义Suspense 需要借助 ErrorBoundaryWrapper 来完成。
const MockSuspense = () => { // ...other const handleError = useCallback((boundaryError: any) => { // Error or not Promise, continue throw if (boundaryError instanceof Error || !isPromiseAlike(boundaryError)) { throw boundaryError; } const thePromise = boundaryError; promiseIdRef.current = Date.now() + Math.random(); const thePromiseId = promiseIdRef.current; // promise only thePromise.then(() => { // success }, (err: any) => { if (thePromiseId === promiseIdRef.current) { // fail } } ); }, []); return ( <ErrorBoundaryWrapper onError={handleError} renderError={renderFallback}> {children} </ErrorBoundaryWrapper> ) } // SuspenseContainer.tsx const SuspenseContainer: React.FC<ContainerProps> = (props: ContainerProps) => { const { children, withSuspense } = props; if (withSuspense === true || withSuspense === undefined) { return <Suspense fallback={<Loading />}>{children}</Suspense>; } if (typeof withSuspense === 'function') { return createElement(withSuspense, { fallback: <Loading /> }, children); } return children as ReactElement; };
至此,完成了web-impl,使用起来非常简单,因为是使用 Context API,只需要在应用最外层包裹即可,像 react-route 一样,提供了 withContext
的注入方式和 Context 自带的 Hooks API。
https://webpack.js.org/configuration/externals/#externals
搭配 requirejsrequirejs.config({ paths: { "@iron-man/container-web-api": "//0.0.0.0:7105/index" } })
const Container: FC<ContainerProps> = ({ children, ...props }) => ( <BasicContainer {...props}> <NavigatorContainer {...props}> <ErrorBoundaryContainer {...props}> <SuspenseContainer {...props}>{children}</SuspenseContainer> </ErrorBoundaryContainer> </NavigatorContainer> </BasicContainer> ); const App = () => ( <Container {...options}> <Demo /> </Container> ) const Demo = () => { const container = useContext(containerContext); } // or const Demo = withContext<DemoProps>((props) => { const { navAPI } = props; })
其他端容器大部分实现相似,只需要对不同业务体系和端环境区别实现即可。