前端工程化 → 深入SDK架构设计

9,920 阅读17分钟

前置知识

SDK 的全称是 Software Development Kit,翻译过来是软件开发工具包,这是一种被用来辅助开发某类软件而编写的特定软件包,其主要是为宿主系统提供服务的。

可以参考的优化点 前端性能优化.png

常用的JS安全库.png

主要围绕的话题

SDk架构的设计主要需要考虑到易用性可拓展性性能等方面,常见的SDK架构设计是模块化架构:将不同功能模块拆分成独立的组件,实现热插拔机制;同时可以配合主线程的生命周期进行精准控制插件的相关执行逻辑

SDK初始化
数据上报

数据上报需要考虑的点比较多,常见的如下:

  • 是否支持异步操作和高可靠性
    • 不会影响页面的其他操作
    • 不会太依赖于浏览器,当浏览器关闭时也会继续发送数据,从而保证了数据的可靠性
    • 推荐API:navigator.sendBeacon(ur,data)
      • 注意点:是将数据以表单的形式或JSON格式进行单独封装后post到服务端,因此需要服务端进行相对应的解析和处理操作
      class StatisticSDK {
        constructor(productID, baseURL) {
          this.productID = productID;
          this.baseURL = baseURL;
        }
        send(query = {}) {
          query.productID = this.productID; 
          let data = new URLSearchParams();
          for (const [key, value] of Object.entries(query)) {
            data.append(key, value);
          } 
          navigator.sendBeacon(this.baseURL, data);
        }
        // 用户行为日志上报
        event(key, value = {}) {
          this.send({ event: key, ...value })
        }
        pv() {
          this.event('pv')
        }
        
        //
        initPerformance() {
          this.send({ event: 'performance', ...performance.timing })
        }
      }
      
自定义错误上报
  • 运行时报错
    • JS逻辑错误
    • DOM操作错误
    • 通过addEventListener('error',()=>{})进行检测和上报
// ...
error(err, errInfo = {}) {
    const {
        message,
        stack
    } = err;
    this.send({
        event: 'error',
        message,
        stack,
        ...errInfo
    })
}
initErrorListenner() {
    window.addEventListener('error', event = >{
        this.error(error);
    }) 
    window.addEventListener('unhandledrejection', event = >{
        this.error(new Error(event.reason), {
            type: 'unhandledrejection'
        })
    })
}
初始化错误上报
自定义日志上报

接口设计原则

  • 单一职责原则(Single Responsibility Principle:SRP)
    • 定义:应该有且仅有一个原因引起类的变化
    • 应该根据实际业务情况而定,关注变化点,在实际使用时,类很难做到职责单一,但是接口的职责应该尽量单一
  • 里氏替换原则(Liskov Substitution Principle:LSP)
    • 定义:所有引用基类的地方必须可以透明的使用其子类的对象。其目的是为良好的继承定义了一个规范
    • 规范:
      • 在类中调用其他类时务必要使用父类或接口,如果不能使用父类或接口,则说明了类的设计已经违背开LSP原则
      • 子类必须完全实现父类的方法
      • 子类可以拓展自身的属性和方法
      • 覆盖或重写父类的方法时输出的结果可以被缩小,输入的参数可以被放大
  • 依赖倒置原则(Dependence Inversion Principle:DIP)
    • 定义:High level modules should not depend upon low level modules.Both should depend upon abstractions.Abstractions should not depend upon details.Details should depend upon abstractions:面向接口编程
    • 具体含义拆分
      • 高层模块不应该依赖于低层模块。两者都应该依赖其抽象
      • 抽象不应该依赖于细节
      • 细节应该依赖于抽象
  • 接口隔离原则
    • 定义:
      • 客户端不应该依赖其不需要的接口
      • 类间的依赖关系应该建立在最小的接口之上
      • 即:接口尽量细化,要建立单一的接口,同时接口中的方法尽量少
    • 具体实践
      • 一个接口只服务于一个子模块或业务逻辑
      • 已经被污染的接口要尽量去修改,同时在必要的时候采用适配模式进行转化和处理
  • 开闭原则
    • 定义:一个软件实体如类、模块和函数应该对齐拓展开放,对修改关闭

设计初衷

  • 可以减少人力成本和开发时间
  • 共享一套代码,便于后期维护
    • 最牛批的实现方式是开发一套可以支持所有前端框架使用的通用SDK(如React、Vue、angular、小程序等)(可以采用不同语言的SDK分别管理和发版),同时在此SDK的基础上,可以快速地根据框架的语法特性进行上层封装,是JavaScript SDK的核心要求之一; image.png

涉及职责需要明确

与数据提供的服务端明确职责

一般数据的提供方都是服务端,但是某些前端的文案就需要通过「前端写死文案」或者「业务接入方可配置」等方案进行设计了,一般有明确可能会变动的都会采用后者;

与业务接入方明确职责

一般为了使得SDK更加的轻量,SDK内使用的一些非强关联的功能(如登录、数据请求、错误日志监控等)通常都由宿主环境进行提供,然后在SDK内部通过相关逻辑进行数据与功能的介入即可,如数据配置化传递、登录态cookie同步等;

  • 具体分类
    • 运行时业务方提供的方法和数据
      • 可以通过props或初始化配置参数进行传递给SDK
    • 业务方依赖的库提供的能力
      • SDK需要进行特殊处理,需要将这些库定义为peerDependencies,但是需要注意SDK自身和接入方的库版本的同步
      • 脚手架设计中的 lerna就可以实现将依赖进行配置化管理,从而实现SDK间的依赖充分利用和隔离

设计理念

设计SDK的方式主要取决于SDK的最终用途,如提供给网页调用和提供给服务端调用是不一样的,当然也是有一些明显的共用原则,主要有两个基本的原则:

  • 最小可用性原则:即用最少的代码,尽量减少不需要的逻辑
  • 最少依赖原则:即用最低额度的外部依赖,减少没必要的额外依赖
  • 前端SDK一般需要考虑的问题
    • 需要兼容的浏览器类型和版本(原因和理由)
    • 代码在用户的html文件中是如何引入的
    • 在html的什么地方引入
      • 需要提前了解html的渲染方式、浏览器对资源的加载方式
      • script是否需要asyncdefercharset等标记
  • 服务端交互的SDK需要考虑到
    • api要限流、限制次数、防止盗刷
    • 日志监控和数据上报

设计原则

  • 安全与稳定
    • 网络请求方面可以使用HTTPS,既可以提高安全性,还可以防止国内运营商常见的DNS劫持等问题
    • 配置、DB数据存储方面
      • SDK的配置、相关数据以及用户数据都是SDK的核心内容,一定要使用有效的合适的加密方案来存储;而对应的性能问题可以通过尽可能少的加解密次数以及不同级别的加解密算法等来解决
      • 加解密使用的密钥以及加解密的额算法本身的安全性也需要高度的重视,建议放在SO上,同时再通过SO的加固增加安全性
  • 少依赖与易拓展
    • 支持插件化:最大限度支持拓展
    • 支持Hook机制:可以满足个性化需求

SDK内容包括

  • 功能模块
    • 交付给客户接入、安装的产物
    • 按照功能模块主要分为两大类:生命周期方法实例方法
      • 生命周期方法
        • 初始化SDK,实例化SDK核心类
        • SDK对应功能的打开/关闭的回调
      • 实例方法:负责具体的业务实现
        • 注意📢:在设计对外API时,每个API都要有方法执行完成后的回调,便于用户执行后续的逻辑
  • API
    • SDK的核心内容,提供给开发者的API包,是一切功能的入口
  • 文档
    • 标准统一、结构化展示形式,接入指引
    • 这里的文档包括商业接入流程、接入指引、架构介绍、更新方法、API说明、测试报告、常见问题、版本历史、接入验证方法或验证工具等,尤其有的优秀的SDK文档是包含新旧版本文档记录的,有重大迭代时对应的SDK文档也会有新版本的迭代更新,并且有旧版本的维护与升级说明
  • Demo
    • 可以直接运行的Demo,直观体验
  • 监控和告警
    • 数据上报可以简单分为关键日志上报异常数据上报,前者主要是为了SDK的开发者方便定位问题;后者主要是建立一套客户端的监控和告警机制,从而提前开启关键日志分析问题
    • 监控和告警主要是为SDK开发者自身服务的
    • 一方面通过监控和告警可以了解到SDK的版本、接口调用量、接口失败率等数据
    • 另一方面可以尽早的发现问题,比业务更早、更快的响应
  • SDK中的多线程
    • 为了减少SDK对应用本身的影响,尽可能减少SDK引起的ANR(超时程序无响应)等问题,需要遵循以下两点
      • SDK非必须,不要使用应用的主线程,就算非要使用也需要是一些简单的操作,不可以长时间占用
      • SDK应该有一个专门的线程来处理SDK相关的操作
    • 进程间通信相关
      • Handle大法
        • 所有耗时、异步操作都通过handle扔给SDK的线程去处理,处理结束后再将结果通过handle发送给主线程
        • 任何时候主线程只做一件事即UI调整;所有耗时的操作(如读取文件、读取DB、网络数据读取、网络请求等都不可占用主线程)
  • 📢:SDK的相关数据有哪些
    • 大前提:SDK的接入量足够多、使用用户要足够大,是一个前人栽树后人乘凉的过程
    • 接口调用数据
      • 目的:主要用来监控接口的稳定性、及时发现问题、以及进行数据的推广等
      • 数据包括:接口调用的成功率、失败率、调用次数等
    • 开发者、接入应用相关数据
      • 目的:有助于了解当前开发者的活跃地带、为服务提供支持,了解目前的接入应用的分布、了解应用市场的走向等
      • 数据包括:开发者的地域分布、接入应用的类型、单开发者接入应用的数据等
    • JSSDK常用分类
      • UI组件库:封装一系列的组件,通过配置帮助开发者调用,实现UI效果,如Antd、ELement UI、Vant、Bootstrap等
      • JS类库:封装一些通用的处理方法,便于开发者不纠结于兼容性、边缘条件等;如lodash、moment、axios、JQuery等
      • 监控统计工具:通过API来实现数据统计上报、错误监控等,如Sentry、百度统计等
      • 第三方SDK:如微信SDK、支付宝SDK等
    • SDK的用户数据
      • 用户个人数据:
        • 目的:使得运营更加了解用户的实际情况
        • 数据包括:用户的地域分布、性别、职业等
      • 用户设备数据:
        • 目的:使得开发者更好的做兼容和后续方向的规划
        • 数据包括:用户设备机型、系统、系统版本、内存、CPU、网络、分辨率、DPI、传感器等数据
      • 用户使用习惯数据:
        • 目的:使得运营更加了解用户的额使用习惯、更容易掌握推广的时机
        • 数据包括:使用时段、使用时长、打开次数、打开评率、留存、活跃等数据
      • 用户和应用的相关性数据:
        • 目的:有助于市场的开拓
        • 数据包括:单一用户手机上同类型APP的安装数量、每天的使用APP的数据等数据

SDK架构设计

  • SDK架构分解
    • API层
      • 是对整个SDK的对外封装,经过这一层后仅仅将需要暴露给外部的接口、结构体声明出来,向开发者隐藏具体的实现
    • Framework层
      • 是最核心层,也是SDK的最底层;完成了SDK的初始化、模块管理以及一些公共基础工具(如数据存储、网络请求处理等)
      • 也可以再细分出一层libware,主要承载一些通用的方法(如字符编码、文件读取、包信息读取等)的实现和对通用库的封装(如AsyncHttp等)
    • Module层
      • 应该称为是中间层,这一层一般是具体模块的业务逻辑,主要是具体的功能实现
      • 一般情况下对于Module也可以又按照上述的三层结构来划分,做到各个模块之间的功能独立
  • 基础架构设计
    • 主要考虑方面是:可读性、可拓展性和可维护性三方面
    • 基础架构分为两个层次:
      • 业务层
        • 可以独立成业务模块:包括开放API接口和业务功能实现
      • 通用功能层
        • 可以分为通用功能模块和基础工具模块
  • 开放API接口设计
    • 建议遵循以下规则
      • API接口命名规范,且通俗易懂
        • 同时需要注意命名空间的问题,避免与其他库冲突,推荐使用(function (){...})()将SDK代码包起来,如jQuery、NodeJS等类库通常使用的一个方法是将创建的私有命名空间的整个文件用闭包包起来,这样可以避免与其他库冲突
      • 接口职责单一:一个接口只做一件事
        • 接口是SDK和用户沟通的桥梁,每一个接口对应着一个独立的SDK功能,并且应该有明确的输入和输出
        • 即使有两个接口有比较接近的功能,但是用一个接口又比较麻烦,那就不进行合并处理
      • 接口参数要尽量少,且做好相应的参数校验和逻辑保护
        • 参数尽量少,但是也要实现可拓展的功能
        • 参数尽可能使用Object封装,当有多个同一类型的参数时要使用对象的方式进行传递
        • 此外所有的接口参数必须要提前第一时间做好合法性校验,且当需要进行转义、需要进行类型转换的参数时一定要提前去进行处理
      • 最小可用性和最少依赖原则
        • 能简单实现就不炫技
        • 能自己实现就不依赖第三方库
      • 接口尽量保证是非阻塞的,不影响开发者正常业务逻辑
        • 设计的SDK需要足够稳定、向后兼容、合适的单元测试进行质量保证
        • 尽量使用try-catch去捕获错误和检测对应语法是否支持,如检测cookie、session、localStorage、sessionStorage等是否支持
        let checkCanSessionStorage = function () {
          let mod = "modernizr";
          try {
            sessionStorage.setItem(mod, mod);
            sessionStorage.removeItem(mod);
            return true;
          } catch (e) {
            return false;
          }
        };
        
      • 接口结果最好是直接返回,尽量减少使用回调
      • 错误边界处理,提高SDK的稳定性

        如果 SDK 组件抛出错误,导致接入的页面崩溃了,妥妥的 p0 级 bug 。

        • 所以,一定要将 SDK 的错误 catch 在组件内部。
        • 对于 React 组件,用 ErrorBoundary 包裹是必不可少的
        • 维护稳定性一般关注点如下
          • JS异常
          • 资源加载异常
          • API请求异常
          • 白屏异常
  • 业务功能框架设计与开发
    • 避免过度设计,主要考虑某一类具体的业务需求即可,不要太过于纠结概率性问题,SDK要有自己的具体业务需求;
    • 要考虑到业务方「treeShaking」的问题
      • 当前业界比较通用的方式是:将不同组件编译到不同目录,业务方通过组件目录的形式引用
    import SDKForA from 'SDK/dist/modern/components/SDKForA';
    // 组件导出的npm包/编译后产物打包的目录/ESM 规范的打包路径/要引入的组件
    
  • 基础核心库设计与开发
    • 需要保证功能间相互独立,降低耦合度
  • 除了核心的偏领域的模块外,SDK还需要有更基础的与领域无关的模块,包括SDK内核(构造方法、插件机制、与上下游服务器的额交互、上报队列机制、不同环境的管理等)和工具类库
  • 构建
    • webpack工具:
      • 可以使用ES6模块化方式进行构建,这样可以使得业务在引入SDK时通过解构的方式减少最终业务代码的体积
      • 尽量使用node的方式进行调用(即webpack.run的方式执行),原因是SDK的构建需要应对不同的参数变化,node方式比纯配置方式更加灵活的调整输入和输出的参数
        • 模块应该是职责单一、相互独立、低耦合的、高度内聚且可替换的离散功能块
        • 浏览器端不能兼容CommonJS的根本原因是缺少NodeJS中的四个环境变量:moduleexportsrequireglobal,因此就出现了AMD(依赖前置)规范;
      • webpack.run使用回调函数的方式进行构建,当然开发者也可以封装成Promise
    • Rollup工具
      • 可以使用ES6模块化方式进行构建,这样可以使得业务在引入SDK时通过解构的方式减少最终业务代码的体积
      • rollup.rollup会返回一个Promise,可以通过async的方式来进行构建
  • 打包与发布
    • SDK版本管理机制

      • 较成熟的版本管理机制是语义化版本号,具体表现为{主版本}.{次版本}.{补丁版本},实现简单易记好管理;
        • 主版本:一般涉及到重大的更新时才会更替主版本号,而且很大概率会出现新旧版本不兼容的问题出现
        • 次版本:应用于新特性或较大的调整,因此可能会出现breakchange
        • 补丁版本:较小的优化或者一些fixed可以通过更新补丁版本号实现更新迭代
    • SDK的引用方式

      • 大体分为CDN引用和NPM两种,当然还有其他的如ES Module、CommonJS、AMD/CMD/UMD等,可以采用第三方库如webpack实现自动适配所有形式的模块,同时提供最基本的CDN和NPM两种引用方式,供用户多重选择,但最终都是需要通过CDN或NPM的方式进行提供的;

      UMD是希望提供一个前后端跨平台的解决方案(支持AMD、CommonJS模块化方式和全局变量的方式来导入和导出模块),是一种通用的模块化规范,旨在兼容不同环境;
      对于「调用方」来说,在安装直接依赖库时也会安装其dependencies中的所有SDK,一般情况下会和其他依赖同级,当有「同名且冲突、或版本不同时」的依赖会直接安装到「SDK/node_modules下」,即所有依赖会平级设置;完全理解各种dependencies

      在SDK设计时不可能将几种模块化都分别实现一遍,一般采用rollup来编译SDK源码,从而满足三种规范的代码;

      // package.json
      {
        "name": "@lbxin/build-laugh",
        "version": "1.0.0",
        // "main": "src/index.js",
        "main": "dist/cjs/index.js",
        "module": "dist/es/index.js",
        "files": ["dist"],
        "scripts": {
          "build":"rm -rf ./dist && rollup -c build/config/rollup.config.js",
          "test": "echo \"Error: no test specified\" && exit 1"
        },
        "devDependencies": {
          "rollup": "^2.55.1"
        }
      }
      
      
      // src/config/rollup.config.js
      
      import { resolve } from 'path'
      import { name,dependencies } from '../package.json'
      
      const FORMAT = {
        'ES': 'es',
        'CJS': 'cjs',
        'UMD': 'umd'
      }
      
      const base = {
        input: resolve(__dirname, '../src/index.js'),
        external: Object.keys(dependencies), //不用处理dependencies中相关的import逻辑
      };
      
      const output = function (format) {
        return {
          name,
          dir: resolve(__dirname, `../dist/${format}`),
          // format 参数,决定输出需要满足哪一种模块化规范
          format,
        }
      }
      
      export default [
        {
          ...base,
          output: output(FORMAT.ES),
        },
        {
          ...base,
          output: output(FORMAT.CJS),
        },
        {
          ...base,
          output: output(FORMAT.UMD),
        }
      ]
      
      • UND内部逻辑浅析
        • 先判断是否支持Node模块格式(exports是否存在)
        • 再判断是否支持AMD(define是否存在)
        • 前两个都不存在则将模块公开到全局(window或global)

      image.png

      //静态资源引入
      <script src="/sdk/v1/wpkReporter"></script>
      
      // ES Module(使用import和export关键字来导入和导出模块,支持静态分析(在编译时进行模块依赖的静态分析,有更好的性能和可靠性))
      import wpkReporter from 'wpkReporter'
      
      // CommonJS(是同步加载的,主要用于服务端开发,通过module.exports导出模块、require函数加载模块)
      const wpkReporter = require('wpkReporter')
      
      // AMD,requireJS引用
      //AMD(异步模块定义,主用于浏览器,遵循依赖前置原则,使用define函数定义模块、require函数加载模块) 
      define([jquery.js, lodash.js], function($, _){ 
          console.log("jquery and lodash", $, _) 
      })
      //CMD(通用模块定义,用于浏览器或node中,遵循依赖就近原则,使用define函数定义模块、require函数加载模块)
      define(function(require){ 
          const lodash = require('./a.js') 
          console.log("lodash", lodash) 
      })
      
      
      require.config({
        paths: {
          "wpk": "https://g.alicdn.com/woodpeckerx/jssdk/wpkReporter.js",
        }
      })
      require(['wpk', 'test'], function (wpk) {
        // do your business
      })
      
  • 性能方面应该考虑的问题点
    • 白屏时间
    • 可交互时间(TTI)
    • 首屏时间
    • FP/FMP/FCP等
  • 关于同步和异步接口
    • 可以同步的就不用异步
    • 能不用全局回调的就不用全局回调,不然后续会吭哧吭哧的改为独立的局部模块回调(弃用全局回调,改为直接在接口调用时让同步添加对应的接口回调)
    • 同一个回调里的接口尽可能的少,可以合并的尽量合并
  • 使用建议
    • 异步语法
      • 应该使用异步语法去加载脚本,改善用户体验,SDK类库不应该影响主页面的加载
      • 即在进行CDN引入JS脚本文件时,需要添加async配置从而实现异步加载SDK文件
      • 异步脚本未必会按照指定的额顺序执行,且不应该使用document.write,因此如果脚本有依赖于执行顺序或者需要访问或修改网页的额DOM或CSSOM,那么您可能需要重新编写此类脚本
      • 另外为了解决脚本引入引发的「网络请求」问题,建议将较小的脚本进行内嵌实现;不是初始化必须得代码应该异步或延迟执行

JS-SDK在前端中的案例

  • UI组件库
  • 性能监控工具,如阿里的arms,岳鹰前端监控SDK等
  • 统计分析工具
  • 智能验证工具SDK
  • TRTC Web SDK新架构设计

SDK优劣分析

  • SDK优点
    • 实现简单,可以在不允许跨域的情况下实现分析数据
    • 实现业务逻辑更加灵活,可以定制化实现各种需求
    • 可以实现模块化编程
    • 可以提高程序的可维护性和可拓展性
  • SDK缺点
    • 体积较大:SDK通常含有大量的代码,可能会影响应用程序的下载速度和安装体验,一般可以尝试将SDK进行模块化编程,方便后续的Tree-Shaking操作,从而减小包体积
    • 版本更新:版本更新可能会带来业务方更新与迭代
    • 后续维护不方便:SDK内部逻辑复杂,后续维护有成本
    • 限制自定义:由于开发者的应用程序与SDK进行了绑定,后续的自定义拓展可能会受限

JS-SDK实现

  • JS-SDK常用类型
    • Web的api集合(类似微信官方的JS-SDK工具)
    • 分析与统计工具(类似百度统计的JS-SDK工具)
    • 嵌入式类如widget
  • 常用的设计模式
    • 单例模式:一个类只返回一个实例,一旦创建再次调用就直接返回(如jQuery、lodash、moment等)
    • 构造函数模式
    • 混合模式(原型模式+构造函数模式)
    • 工厂模式
    • 发布订阅模式
      • 常用于事件驱动的插件模式中,从而实现插件间的联动、解耦和灵活的通信效果
  • API接口

    前端SDK的核心就是API接口,该方式以函数的形式提供所需的功能,开发人员可以通过调用接口函数来实现相应的功能

var SDK = {
    // 接口函数1
    showMessage: function(message) {
        alert(message);
    },
    // 接口函数2
    showConfirm: function(message) {
        return confirm(message);
    }
};
  • 代码封装

前端SDK可以根据功能模块进行模块化封装,使得代码更易于管理和维护;通过将SDK代码进行重构,让SDK可以更加容易拓展、尽量减少模块之间的耦合,使得各个功能模块更加独立,甚至可以做到轻松的添加和删除模块而不会对其他的模块有影响。
模块化主要是为了方便SDK的开发者适应各种需求的变化,而插件化则主要是为了更方便SDK的使用者;插件化就是指SDK开发者可以根据使用者需求提供接入相对应功能的差异化的SDK包,开发者可以自由动态生成SDK包来适应不同的需求;

// 封装消息框模块
SDK.Dialog = {};
SDK.Dialog.Message = function(message) {
    alert(message);
};

// 封装确认框模块
SDK.Dialog.Confirm = function(message) {
    return confirm(message);
};
  • 依赖注入

前端SDK中的不同模块之间可能会有依赖关系,可以通过依赖注入的方式实现依赖关系管理

// 声明依赖模块
SDK.Dialog = {};
SDK.Dialog.Message = function(message) {
    alert(message);
};

SDK.Dialog.Confirm = function(message) {
    return confirm(message);
};

// 依赖模块
SDK.Action = function(dialog) {
    this.dialog = dialog;
}

// 注入依赖模块
var action = new SDK.Action(SDK.Dialog);
action.dialog.Message("Hello World");
  • 使用封装的SDK
    • SDK的引用
    // 在HTML页面中引入SDK库
    <script type="text/javascript" src="sdk.js"></script>
    
    • API使用
    // 调用SDK中的接口函数来实现相应功能
    <button onclick="SDK.Dialog.Message('Hello World!')">
        点我显示消息框
    </button>
    
    <button onclick="SDK.Dialog.Confirm('确定要删除吗?')">
        点我显示确认框
    </button>
    
    • 模块使用
    // 通过依赖注入的方式使用SDK模块
    var dialog = new SDK.Dialog();
    var action = new SDK.Action(dialog);
    action.ShowMessage('Hello World');
    

插件架构的SDK设计实现

SDK架构的设计需要考虑到易用性、可拓展性和性能等方面,一种常见的SDK架构设计就是模块化架构,将不同的功能模块拆分成独立的组件,提供给开发者使用;通过将插件和SDK的相关实现拆分开实现了插件的热插拔等目的,其具有以下优点

  • 代码组织和封装
    • 将插件的相关功能和行为封装在一个独立的类中,使得代码结构更加清晰,易于理解和维护。
    • 不同的插件可以遵循相同的接口规范,提高了代码的一致性和可预测性。
  • 可拓展性
    • 方便添加新的插件,通过创建新类和继承基类
  • 功能拆分
    • 可以将复杂的系统功能分解为多个独立的插件,每个插件专注于特定的任务,降低了单个模块的复杂度。
  • 易于管理和调用
  • 缺点是增加了代码层测和复杂性

具体实现

插件接口定义

// 定义一个插件接口
class Plugin {
  constructor() {
    // 在构造函数中设置插件的名称为 'Plugin'
    this.name = 'Plugin';
  }

  // 插件的初始化方法
  init() {
    // 打印日志表示插件已初始化
    console.log('Plugin initialized');
  }

  // 插件的执行方法
  execute() {
    // 打印日志表示插件已执行
    console.log('Plugin executed');
  }
}

SDK定义

// 定义一个SDK对象
class SDK {
  constructor() {
    // 在构造函数中初始化一个空的插件数组
    this.plugins = [];
  }

  // 注册插件的方法,接收一个插件对象作为参数
  registerPlugin(plugin) {
    // 将传入的插件添加到插件数组中
    this.plugins.push(plugin);
  }

  // 初始化所有已注册插件的方法
  initPlugins() {
    // 遍历插件数组,对每个插件调用其 init 方法
    this.plugins.forEach((plugin) => {
      plugin.init();
    });
  }

  // 执行所有已注册插件的方法
  executePlugins() {
    // 遍历插件数组,对每个插件调用其 execute 方法
    this.plugins.forEach((plugin) => {
      plugin.execute();
    });
  }
}

创建SDK实例

const sdk = new SDK();

创建插件实例

const plugin1 = new Plugin();
const plugin2 = new Plugin();

向SDK注册插件

sdk.registerPlugin(plugin1);
sdk.registerPlugin(plugin2);

初始化已注册的插件

sdk.initPlugins();

执行已注册的插件

sdk.executePlugins();

拓展 → 如何实现插件的定制化和拓展的功能

// 定义插件接口
interface Plugin {
	init(options: any): void;
	execute(): void;
  }
  
  // 具体插件实现
  class MyPlugin implements Plugin {
	init(options: any) {
	  // 根据配置进行初始化操作
	  console.log('Plugin initialized with options:', options);
	}
  
	execute() {
	  // 执行具体的插件逻辑
	  console.log('Plugin Executed');
	}
  }
  
  // 使用插件
  const plugin = new MyPlugin();
  plugin.init({ name: 'My Plugin' });
  plugin.execute();

重构上述的插件化SDK

// 定义插件接口
interface Plugin {
  init(options: any): void;  // 接收初始化选项
  execute(): void;
  getState(): any;  // 新增:获取插件状态的方法
  setConfig(config: any): void;  // 新增:设置插件配置的方法
  handleError(error: any): void;  // 新增:错误处理方法
  releaseResources(): void;  // 新增:资源释放方法
}

// 基础插件类,包含一些公共逻辑
class BasePlugin implements Plugin {
  constructor(public name: string) {}

  init(options: any) {
    console.log(`Plugin ${this.name} initialized with options:`, options);
  }

  execute() {
    console.log(`Plugin ${this.name} executed`);
  }

  getState() {
    console.log(`Getting state of Plugin ${this.name}`);
    return {};  // 这里根据实际情况返回状态
  }

  setConfig(config: any) {
    console.log(`Setting config for Plugin ${this.name}:`, config);
  }

  handleError(error: any) {
    console.error(`Error in Plugin ${this.name}:`, error);
  }

  releaseResources() {
    console.log(`Releasing resources of Plugin ${this.name}`);
  }
}

// 具体插件 1,继承自基础插件类并实现定制化逻辑
class Plugin1 extends BasePlugin {
  constructor() {
    super('Plugin1');
    // 1、作为函数使用并在子类的构造函数中调用时,它代表的是父类的构造函数,其内部的`this`指向的是当前子类的实例。
    // 2、调用父类 `BasePlugin` 的构造函数,并传递 `'Plugin1'` 作为参数,为父类中定义的属性进行初始化。
    // 3、确保父类的构造函数中的逻辑能够正确执行。
  }

  init(options: any) {
    // `super`用于指代父类的相关内容。在类的继承中,子类可以继承父类的属性和方法。当需要在子类中调用父类的方法时,就需要使用`super`关键字。
    // 在子类的普通方法中,`super`作为对象使用时指向父类的原型对象,通过它调用父类方法时,方法内部的`this`指向子类的实例。
    super.init(options);
    // 插件 1 特有的初始化逻辑
    console.log('Plugin1 specific initialization');
  }

  execute() {
    super.execute();
    // 插件 1 特有的执行逻辑
    console.log('Plugin1 specific execution');
  }
}

// 具体插件 2,继承自基础插件类并实现定制化逻辑
class Plugin2 extends BasePlugin {
  constructor() {
    super('Plugin2');
  }

  init(options: any) {
    super.init(options);
    // 插件 2 特有的初始化逻辑
    console.log('Plugin2 specific initialization');
  }

  execute() {
    super.execute();
    // 插件 2 特有的执行逻辑
    console.log('Plugin2 specific execution');
  }
}

// 定义 SDK 对象
class SDK {
  constructor() {
    this.plugins = [];
  }

  // 注册插件的方法,接收一个插件对象作为参数
  registerPlugin(plugin: Plugin) {
    this.plugins.push(plugin);
  }

  // 初始化所有已注册插件的方法
  initPlugins() {
    this.plugins.forEach((plugin) => {
      plugin.init({ someCommonOption: 'common' });  // 传递一些公共的初始化选项
    });
  }

  // 执行所有已注册插件的方法
  executePlugins() {
    // 这里需要单独处理下 避免在初始化完插件后又有新的插件注册进来然后调用执行插件的方法,避免影响到之前已经完成的插件  →  添加一个标识符来标识是否已完成注册和执行 后续的逻辑将屏蔽掉该标识符的插件
    this.plugins.forEach((plugin) => {
      plugin.execute();
    });
  }
}

// 创建 SDK 实例
const sdk = new SDK();

// 创建插件实例
const plugin1 = new Plugin1();
const plugin2 = new Plugin2();

// 向 SDK 注册插件
sdk.registerPlugin(plugin1);
sdk.registerPlugin(plugin2);

// 初始化已注册的插件
sdk.initPlugins();

// 执行已注册的插件
sdk.executePlugins();

拓展

请求通用SDK封装

class netWorkQueue{
    constructor(maxCountRequest,maxCacheCount,maxRetryTimes = 0){
        // 请求最大并发数量
        this.maxCountRequest = maxCountRequest
        // 缓存池最大限制数量
        this.maxCacheCount = maxCacheCount
        // 最大重试次数
        this.maxRetryTimes = maxRetryTimes

        // 当前正在进行的请求数量  需要注意在请求完成(失败/成功/出错)时都减一
        this.currentRunCount = 0

        // 请求缓存池
        this.queueList = []

        // 访问的顺序集合 保证清除数据时清除的是旧的数据(最先推入的且不是近期访问过的)
        this.queueOrder = []

        // 请求缓存对象 缓存结果
        this.cache = {}
        
    }

    // 交换访问顺序
    exchangeQueueOrder(url){
        this.queueOrder.splice(this.queueOrder.findIndex(url),1)
        this.queueOrder.push(url)
    }

    // 发起请求的方法
    async request(url,options,shouldCache){
        if(this.cache[url] && shouldCache){
            // 命中缓存且允许缓存的前提下进行直接返回  且改变访问顺序
            this.exchangeQueueOrder(url)
            return this.cache[url]
        }

        // 边界处理  超限后直接入队排队
        if(this.currentRunCount > this.maxCountRequest){
            return new Promise((resolve) => {
                this.queueList.push({url,options,shouldCache,resolve})
            })
        }

        // 可以进行请求发送
        this.currentRunCount++

        // 添加重试次数逻辑
        let retryTimes = 0
        while(retryTimes < this.maxRetryTimes){
            try {
                const {data} = await this.fetch(url,options)
                if(shouldCache){
                    this.addToCache(url,data)
                }
                // 减少请求数量
                this.currentRunCount--
                // 处理缓存池
                this.processQueue()
                return data
            } catch (error) {
                retryTimes++
                if(retryTimes === this.maxRetryTimes){
                    // 减少请求数量
                    this.currentRunCount--
                    // 处理缓存池
                    this.processQueue()
                    throw error
                }
            } finally{
                // 由于需要兼容retryTimes 因此需要try-catch单独实现
                // // 减少请求数量
                // this.currentRunCount--
                // // 处理缓存池
                // this.processQueue()
            }
        }
    }

    // 添加缓存的方法
    addToCache(url,data){
        // 判断缓存池是否上限  上限则清除最早的缓存
        if(Object.keys(this.cache).length > this.maxCacheCount){
            // const oldKey = Object.keys(this.cache)[0]
            const oldKey = this.queueOrder[0] // 优先选择queueOrder的首位数据进行清除
            delete this.cache[oldKey]
        }
        this.queueOrder.push(url)
        this.cache[url] = data
    }

    // 处理请求队列的方法
    processQueue(){
        if(this.queueList.length>0 && this.currentRunCount < this.maxCountRequest){
            const { url,options,shouldCache,resolve } = this.queueList.shift()
            this.request(url,options,shouldCache).then(resolve)
        }
    }
}

// 使用示例
// 创建一个实例,限制最大并发请求数量为3,最大缓存大小为5
const networkSDK = new netWorkQueue(3, 5);

// 发起第一个请求,不进行缓存
networkSDK.request('https://example.com/api/endpoint1', { method: 'GET' })
  .then((data) => console.log('请求1结果:', data))
  .catch((error) => console.error('请求1错误:', error));

// 发起第二个请求,进行缓存
networkSDK.request('https://example.com/api/endpoint2', { method: 'GET' }, true)
  .then((data) => console.log('请求2结果:', data))
  .catch((error) => console.error('请求2错误:', error));

// 发起第三个请求,进行缓存
networkSDK.request('https://example.com/api/endpoint3', { method: 'GET' }, true)
  .then((data) => console.log('请求3结果:', data))
  .catch((error) => console.error('请求3错误:', error));

// 发起第四个请求,不进行缓存,由于并发限制,它将被添加到队列中等待处理
networkSDK.request('https://example.com/api/endpoint4', { method: 'GET' })
  .then((data) => console.log('请求4结果:', data))
  .catch((error) => console.error('请求4错误:', error));

// 发起第五个请求,进行缓存,由于并发限制,它将被添加到队列中等待处理
networkSDK.request('https://example.com/api/endpoint5', { method: 'GET' }, true)
  .then((data) => console.log('请求5结果:', data))
  .catch((error) => console.error('请求5错误:', error));

axios请求封装部分拓展

请求拦截

自定义域名拦截设置

// 自定义域名集合
// 其他域名集合
// const.js 
const lbxinHostMainDomains = ['lbxin.cn']

// utils.js 
function isFromThirdDomain() {
  // 获取主域名
  const mainDomain = document.domain.split('.').slice(-2).join('.');
  let isThirdDomainFlag = true;
  lbxinHostMainDomains.filter(item => {
    if(!isThirdDomainFlag) return;
    isThirdDomainFlag = mainDomain == item ? false : true
  });
  return isThirdDomainFlag;
}
const isThirdDomain = isFromThirdDomain();
const otherHosts = {};
if (process.env.DATA_ENV !== 'production') {
  if(isThirdDomain) {
    host = location.protocol + "//server.lbxin.net";
    otherHosts['ws'] = location.protocol + '//ws.lbxin.net'
  } else {
    otherHosts['ws'] = location.protocol + '//t-ws.lbxin.cn'
    host = location.protocol + "//t-xhm-server.lbxin.cn"
  }
} else {
  if(isThirdDomain) {
    host = location.protocol + "//server.lbxin.net";
    otherHosts['ws'] = location.protocol + '//ws.lbxin.net'
  }else {
    host = location.protocol + "//lbxin.cn/server";
    otherHosts['ws'] = location.protocol + '//ws.lbxin.cn'
  }
}

// 请求方法中拦截方式
// ......
if (otherHost && otherHosts[otherHost]) {
  url = `${otherHosts[otherHost]}${url}`;
} else {
  url = `${host}${url}`;
}
// ......

基础头信息拦截设置

const handleRequestHeader = (config) => {
  config['xxxx'] = 'xxx'
  return config
}

const handleAuth = (config) => {
  config.header['token'] = localStorage.getItem('token') || token || ''
  return config
}

axios.interceptors.request.use((config) => {
  config = handleChangeRequestHeader(config)
  config = handleConfigureAuth(config)
  return config
})

请求方法拦截

响应拦截

响应拦截一般由三类组成:网络错误处理、授权错误处理、普通错误处理,视项目具体情况来定是否细化与删除部分处理 网络错误处理base封装

const handleNetworkError = (errStatus) => {
  let errMessage = '未知错误'
  if (errStatus) {
    switch (errStatus) {
      case 400:
        errMessage = '错误的请求'
        break
      case 401:
        errMessage = '未授权,请重新登录'
        break
      case 403:
        errMessage = '拒绝访问'
        break
      case 404:
        errMessage = '请求错误,未找到该资源'
        break
      case 405:
        errMessage = '请求⽅法未允许'
        break
      case 408:
        errMessage = '请求超时'
        break
      case 500:
        errMessage = '服务器端出错'
        break
      case 501:
        errMessage = '⽹络未实现'
        break
      case 502:
        errMessage = '⽹络错误'
        break
      case 503:
        errMessage = '服务不可⽤'
        break
      case 504:
        errMessage = '⽹络超时'
        break
      case 505:
        errMessage = 'http版本不⽀持该请求'
        break
      default:
        errMessage = `其他连接错误 --${errStatus}`
    }
  } else {
    errMessage = `⽆法连接到服务器!`
  }
}

授权错误处理

const handleAuthError = (errno) => {
  const authErrMap: any = {
    '10031': '登录失效,需要重新登录', // token 失效
    '10032': '您太久没登录,请重新登录~', // token 过期
    '10033': '账⼾未绑定⻆⾊,请联系管理员绑定⻆⾊',
    '10034': '该⽤⼾未注册,请联系管理员注册⽤⼾',
    '10035': 'code ⽆法获取对应第三⽅平台⽤⼾',
    '10036': '该账⼾未关联员⼯,请联系管理员做关联',
    '10037': '账号已⽆效',
    '10038': '账号未找到',
  }

  if (authErrMap.hasOwnProperty(errno)) {
    message.error(authErrMap[errno])
    // 授权错误,登出账⼾
    logout()
    return false
  }

  return true
}

普通错误处理

const handleGeneralError = (errno, errmsg) => {
  if (err.errno !== '0') {
    meessage.error(err.errmsg)
    return false
  }

  return true
}

引用到axios相应拦截中

axios.interceptors.response.use(
  (response) => {
    if (response.status !== 200) return Promise.reject(response.data)

    handleAuthError(response.data.errno)
    handleGeneralError(response.data.errno, response.data.errmsg)

    return response
  },
  (err) => {
    handleNetworkError(err.response.status)
    Promise.reject(err.response)
  }
)

推荐文献

清晰详细的前端埋点SDK入门实现
前端SDK开发用法介绍
前端资源共享方案对比-笔记:iframe/JS-SDK/微前端