测试在项目流程中的那些事儿

前言

测试作为整个项目中的一环,在项目流程中起着不可或缺的作用。部分团队是缺少项目管理角色的,这个时候,测试对项目流程的推进、项目质量的保证显得尤为重要。好的测试,能在整个项目流程中以QA角度做好项目管理和及时的风险预警,让项目如期上线且保障质量。业界一直强调测试前置,那么测试在项目中,如何根据项目情况做前置工作推进项目流程,让大家都开心工作呢?本文以自己所在的项目组为例讲述项目流程中的一些事,希望可以与大家一同探讨~

一、QA在项目中扮演的角色

【why】明确目标是什么:明确做这个项目的目标是什么,可适当根据目标对需求实现、项目质量、研发提测时间点等做一些调节。

【when】项目的deadline:考虑项目组的特殊性,我们需要知道项目需要什么时候上线,明确项目deadline,根据时间节点制定合适的测试计划

【what】各阶段我们需要做什么:可以重点关注项目流程中,QA参与与输出的环节。有输入才会有输出,所以输出的环节往往是需要QA花费时间去思考的地方。

【how】遇到风险点时怎么做:测试阶段,除了QA环节的风险点需要及时暴露和push外,这个阶段研发和产品也在做一些工作。在项目流程管理中,作为最下游的参与者,需要关注这些风险点,及时暴露和push解决。

【who】QA、RD、PM

二、我们面临的挑战

2.1挑战点

1.发版频率在排名第二,2021全年发版71次,相当于每周都有一个版本在进行迭代,快速迭代的节奏, 对人效和团队协同效率要求高。

2.整个2021年,研发人均bug数为123个,bug较多, 提测质量不高。为了不拉长项目周期, 保障较短的bugfix时间非常关键,同时要考虑如何提高提测质量。
在这里插入图片描述

3.整个2021年,测试人均提bug量最多,在项目节奏紧张的情况下,发现和提bug的效率必须提升。
在这里插入图片描述

2.2关于提测质量

针对上述挑战的内容,我们可以看到提测质量上,我们存在不足之处。我们之前做过提高冒烟用例比例、冒烟交叉执行、时间预估增加冒烟时间等尝试,最后发现收获的效果有限。主要原因如下:

  1. 多方合作、项目有固定deadline:由于项目特殊性,部分需求是多方合作的模式且有固定的deadline,就需要项目尽快上线,在对项目效率有极高要求的情况下,我们允许带一些层级深的bug上线,针对上线情况做hot
    fix。
  2. 项目节奏紧张,需快速迭代更新:现有研发团队是串行的节奏,能持续高效迭代,为保证项目节奏的稳定性,避免出现因一个项目周期拉的过长导致节奏紊乱,我们接受分步提测的形式,就有可能出现冒烟功能不完整的情况,导致提测质量不如预期。

基于以上原因,我们可以看到在质量与效率之间需要做一定的选择时,需要向项目效率倾斜,所以我们既然无法更好地改变提测质量,那就去改变我们能改变的。

三、面对这些挑战,QA可以做什么

QA可以做什么让整个迭代周期变短,在bug很多的情况下还能快速迭代且线上问题较少呢?先来看下我们的项目流程:
在这里插入图片描述

从整个项目流程上看,可能与很多团队如出一辙。在流程上,QA作为下游的一个部分,可以看到QA参与输出的内容其实有很多,这些部分就是我们可以尝试去改变提升的点。
那么我们从这些输出内容看下,面对上述挑战,QA都做了哪些改变以及还有哪些困境。

3.1项目排期计划

项目排期计划模板:
在这里插入图片描述

【when】项目排期一般是需求评审完后,根据需求拆分需求模块和开发模块。
排期计划中,QA的工作:熟悉需求,拆分需求模块,制定测试计划
QA同学加入进模块拆解,能更好的了解需求,拆分的开发模块也能更快的知道当有bug时,bug是属于哪个端的,提给哪位对应的开发。
根据各模块的提测时间和大致开发周期,QA同学也能制定对应的测试计划。

【what】– QA具体需要做什么

  1. 协助开发拆分功能模块,确保模块都有对应的开发负责人
  2. 确认项目deadline、开发总预计时间和提测时间

3.2测试计划制定

项目测试计划模板:
在这里插入图片描述

【when】测试计划一般在项目排期给出后1天内提供,后续根据排期动态调整

测试计划中,QA的工作:根据需求预估时间和人力,明确测试环境与策略,制定合理的测试计划,预估风险

【what】– QA具体需要做什么

1.拆分功能模块,模块明确好对应的测试。(包含用例编写安排、一、二轮测试安排和兼容测试安排)

2.预估好项目的总体测试时间和各轮次的测试时间

3.一轮接近尾声时,与开发明确好上预发时间;二轮接近尾声时,与开发明确好上online环境的时间

4.如有数据配置项,二轮测试开始前与产品明确好配置所需内容和完成时间节点

以上1、2两点尽早提供,其余可在对应时间点给出。当然,如遇到需求变更、人力变更等需要及时提出和调整。 

【how】– 具体怎么做

根据开发排期,动态制定和调整合理测试的计划。

  • 根据提测时间,决定用例执行顺序与分配:
    如下图拆分的测试计划,后台配置(星火)与用户端提测时间不一致,结合两个提测时间点,我们利用用户端提测前的时间,先执行后台配置的用例,这样即使是分步提测,我们也能确保每次提测时测试资源能跟上。
    在这里插入图片描述

  • 根据功能制定测试轮次
    对于主干功能:需要多次执行测试用例,一般制定三轮的测试,一轮在测试环境,二轮预发环境,三轮线上环境
    对于对内的、不影响用户使用的功能:制定一轮测试,在测试环境测一轮。比如星火等配置后台是给运营使用的,做一轮测试,上预发后产品走查验证+配置内容即可
    活动类的功能:依据活动的复杂程度和使用频次,制定测试轮次。比如新年活动,是一次性的活动且活动时间紧,评估后我们在预发做了一轮测试就上线了,上线质量也一样较好。具体测试流程:活动类测试流程尝试

  • 按照模块、用例量与难度划分,制定每人每天用例执行目标
    一轮测试模块划分根据用例编写与熟悉度划分

  • 实行交叉测试,避免因不熟悉导致遗漏或效率降低
    二轮进测试进行交叉,利用TC平台的任务指派,也可以清楚看到组员的任务数量与完成情况。

如下图,测试计划的拆解与人员分配,细致划分到每人每日的工作目标,且各模块的分配会进行交叉,一轮测试人员发现用例不完善或测试不方便的地方也即使提供了文档以便二轮人员尽快上手测试。
在这里插入图片描述

【小结】:我们可以看到,调整测试计划的4种方式,主要目的都是通过这些办法去更高效地去完成测试任务,保障项目如期上线;更完善、全面地去发现bug,提升项目质量。测试计划的合理调整分配,是面对项目过程中各种挑战的有效方式之一。

3.3jira定制化流程

  1. 定制化的jira项目流程:
    在这里插入图片描述

版本发布管理三部曲:
在这里插入图片描述

  • jira版本发布管理:从产品建立版本开始,到最终复盘,整个流程和数据统计都体现在jira看板中,方便统一管理
  • 项目进度自动同步:如下,项目组成员能很清晰的知道当前项目进度,且版本进度每天都会自动在大群同步;完结的项目,也会根据项目情况自动同步复盘信息
    在这里插入图片描述

【小结】:

1.定制化的流程,让流程更加统一规范和智能化。

2.关键信息的及时同步,能减少每日站会、信息同步会等重复会议,节约了时间。
各团队之前的协作更加顺畅,那团队协同效率和人效也就自然而然能进一步提高。

  1. QA高效提bug、研发快速修bug秘诀:

2021Q1 效率工具的需求收集提效讨论中,提bug流程的优化建议一一实现了,每个人提bug 的速度大幅提升,主要汇总如下:

  • bug区分问题类型 —— 使bug分类更精准,能更好地分析数据,push对应人员
  • bug状态展示优化 —— 各状态一目了然,更快找到需要处理的bug
  • bug描述预置版本、步骤、设备等信息 —— 减少重复内容输入,提bug效率更高


\[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jr4sSSa1-1647325340880)(http://pfp.ps.netease.com/kmspvt/file/6229b793cd05488340940b7dYcR67yhq01?sign=gL7e5CD91maZDjs7XkFpJTX3Oa4=&expire=1646902939)\]

  • jira移动版接入使用 —— 附件内容更方便上传,bug描述更准确,减少因无法复现、描述不清等原因带来的重复沟通成本

  • bug流程新增:一轮漏测、fix bug引入选项、bug描述不清的状态 —— 当然这些指标目的不是为了追究是开发或是测试的责任,是为了分析bug,总结原因,从中找出不足的地方(比如用例设计不完善、开发修复bug未自测等问题),大家共同进步,提升项目质量,从而让项目进行更流畅与高效。

  • 自动提醒开发QAfix和验收bug:—— 精准找到需要处理bug,处理效率大大提升
    项目流程复盘中,我们约定p1bug当天需要fix,p2bug原则上fix周期不超过T+1天,验收不超过T+2天。如下图,就是根据形成的规范自动提醒研发、测试的内容:

    在这里插入图片描述

【小结】:

1.即使是预置的一些提bug信息和界面优化,也让测试更“优雅”地工作,提bug和验bug也更有劲儿了。

2.T+1修复周期的约定与消息推送,给了研发一个心里预期,正如我们根据项目情况调整测试策略一般,研发也根据我们给的预期调整了工作模式,从而使研发fix bug周期保障到最短,高效且有质量地修复了bug。 

工作流程的调整与满满预期的加持,让整个团队的工作效率极大提高。            

3.4测试报告

  1. 测试日报

【when】一般项目提测后,需要每日下班前发送日报

【what】– QA具体需要做什么

汇总其他QA的进度,根据项目情况发送日报or周报。
日报中风险项一环节可根据项目情况修改,同步计划、提醒事项等都可以写入。
push开发fix bug:p1 修复周期不超过T+1天,bug数量较多时,可根据测试情况适当催开发修改(比如一轮测试接近尾声,还有很多服务端前端bug,就需要催一下了)

【how】– 具体怎么做

在galaxy平台工具上,实现了日报自动生成工具,每日可自动生成日报内容,方便大家看进度,且日报中还有当前bug状态和链接,研发也能更快找到自己的bug。
日报一键生成效果如下:

【小结】:

日报的自动生成,节省了测试每日汇总进度的时间,更是直接大幅减少了关键信息的沟通同步成本,是人效和团队协同效率提升的又一次加成buff。

  1. 质量报告(测试报告)

【when】项目上线后,对项目进行总结梳理

【how】– 具体怎么做
结合jira的使用流程,可一键生成测试报告并同步质量平台。

生成的测试报告示例:

3.5项目复盘

【when】项目上线后的一周内,小型项目如有必要可合并组织

【why】复盘的目的:针对项目中不足之处,共同讨论对策,争取下次做的更好

【what】– QA具体需要做什么

1.数据文档准备:形式其实不做限制,需要的数据、文档等准备好即可,也可以与开发轮流组织。

2.会议上形成的todo list需要进行跟进处理

【how】– 具体怎么做

复盘例子:

复盘提效jira看板:如下图 — ps:催bug或者发日报的时候也可以使用,比较清晰

【小结】:定期做项目复盘,让团队意识到我们当前存在的问题,推进项目流程一次比一次做的更好。

【不足】:
当然,在复盘过程中,各团队虽然达成一些共识共同改进,也遇到了一些列问题。

问题一:项目节奏已经很紧张的情况下,大家可能都在赶项目进度,没有余力去做复盘总结工作,追求效率从而忽视了质量。

问题二:复盘形成的todolist也没时间去跟进,导致复盘的总结内容最后不了了之,复盘失去意义。

问题三:一些复盘改进点,往往由于各种特殊原因而不能按规定执行 。

基于以上原因,复盘收获的效果是比较有限的,也是我们今后需要探讨与改进的一个命题。

四、项目风险

4.1风险评估

项目流程中,我们关注各个阶段需要做什么事的同时也会做项目管理与把控,关注项目风险,守住deadline。

风险可以分为两类:质量风险和进度风险

举个例子:
用例编写的时间不够,影响测试时间和上线时间,我们称之为进度风险;而用例编写时,编写用例人员不熟该功能,用例覆盖不足,我们可以称之为质量风险。

这里我们主要关注的是项目进度,所以着重关注进度风险一项。进度风险,就是在项目进度中出现的风险从而影响了整个项目的时间点。

在测试计中,我们设计了风险一栏放于第一位,目的就是让QA在项目流程中,及时从测试角度去观测和记录风险。

比如:

在这里插入图片描述

4.2风险对策

面对风险出现时,需要case by case讨论。在进度风险出现时,首要原则就是及时暴露风险、寻找方法去尽可能降低风险。

项目组很多项目因与其他部门配合,有固定deadline并且允许有部分已知问题带上线,那么我们一般从测试开发角度去商议的解决办法如下:


以上方案如果还不能守住deadline,就要考虑项目延期。

结语

上述内容是作者所在项目组结合已有的测试流程,针对项目遇到的挑战进行流程推进以及推进后的总结介绍。
鉴于不同项目组的特殊和差异性,文中提到的方法和手段可能只是冰山一角,不一定完全适用各类项目。根据项目情况做前置工作推进项目流程,其实是一个很大的命题,不同项目组有时存在的问题也不尽相同,测试在项目流程中还能做哪些更 nice 的事,还是需要靠大家在现有情况下去进行探索和总结。也欢迎大家留言与我们交流讨论~

阅读全文
程序设计优化之管道数据流

摘要

计算机基础的同学估计对管道这个词都不陌生了,尤其是在Linux系统当中,管道操作符已经被广泛的使用,并给我们的变成带来了极大的便利。前端领域比较注明的脚手架“gulp”也是以其管道操作著称。
今天我们就来一步步抽丝剥茧,看看在前端领域的“管道数据流”要如何设计。

一、前言

有计算机基础的同学估计对管道这个词都不陌生了,尤其是在Linux系统当中,管道操作符已经被广泛的使用,并给我们的变成带来了极大的便利。管道操作通常分为单向管道和双向管道,当数据从上一节管道流向下一节管道时,我们的数据将会被这节管道进行一定的加工处理,处理完毕后送往下一节管道,依次类推,这样就可以对一些原始的数据在不断的管道流动中进行不断的加工,最后得到我们想要的目标数据。
在我们日常编程开发过程中,也可以尝试使用管道数据的概念,对我们的程序架构进行一定的优化,让我们程序的数据流动更加清晰明了,并可以让我们像是流水线一样,每个管道专门负责各自的工作对数据源进行一次粗加工,达到职责分明与程序解耦的目的。

二、程序设计

现在我们使用Typescript实现一个基础的管道类的设计,我们今天使用的管道是单向管道。
在这里插入图片描述

2.1 Pipe-转接头

顾名思义,转接头就是需要将不同的多节管道连接在一起成为一整条管道的连接口,通过这个连接头,我们可以控制数据的流向,让数据流向他真正该去的的地方。
首先,我们来设计一下我们的转接头的类型结构:

type Pipeline = {
  /**
   * 将多节管道链接起来
   * e.g.
   * const app = new BaseApp();
   * app.pipe(new TestApp1()).pipe(new TestApp2()).pipe(new TestApp3()).pipe(new Output()).pipe(new End())
   * @param _next
   */
  pipe(_next: Pipeline): Pipeline;
};

上述代码描述了一个支持管道数据的类需要有怎样的一个转接头,在程序设计中,我们的转接头其实就是一个函数,用于将多节管道相互链接。
从上面的代码大家可以看出,为了程序的高复用,我们选择对管道中传输的数据类型进行泛型化,这样,我们再具体实现某一个程序时,便可更加灵活的使用其中类型,例如:

// 时间类型的管道
type DatePipeline = Pipeline
// 数组类型的管道
type ArrayPipeLine = Pipeline
// 自定义数据类型的管道
type CustomPipeLine = Pipeline<{name: string, age: number}>

除此之外,我们这个函数的传入参数和返回值也是有讲究的,从上面的代码可以看出,我们接收一个管道类型的数据,又返回一个管道类型的数据。其中,参数中传入的便是下一节管道,这样,我们就把两节管道连接到了一起。之所以要返回一个管道类型的数据,是为了让我们使用时可以链式调用,更符合管道数据的设计理念,如:

const app = new AppWithPipleline();
app.pipe(new WorkerPipeline1())
   .pipe(new WorkerPipeline2())
   .pipe(new WorkerPipeline3())
   .pipe(new WorkerPipeline4())

也就是说,我们返回的,其实也是下一节管道的引用。

2.2 Push-水泵

有了转接头之后,我们还需要一个“水泵”将我们的数据源源不断地推送到不同的管道,最终到达目标点。

type Pipeline = {
  /**
   * 实现该方法可以将数据通过管道一层层传递下去
   * @param data
   */
  push(data: T[]): Promise;
  /**
   * 将多节管道链接起来
   * e.g.
   * const app = new BaseApp();
   * app.pipe(new TestApp1()).pipe(new TestApp2()).pipe(new TestApp3()).pipe(new Output()).pipe(new End())
   * @param _next
   */
  pipe(_next: Pipeline): Pipeline;
};

为了适应更多场景,我们设计这个水泵接受一个T[]类型的数组,在第一节管道当中,当我们拿到了初始的数据源时,我们就可以利用这个水泵(方法)将数据推送出去,让后面的每一个加工车间处理数据。

2.3 resolveData – 加工车间

当我们的数据被推送到某一节管道时,会有一个加工车间对推送过来的数据根据各自不同的工序进行粗加工。
注意:我们每一个加工车间应该尽可能保证职责分离,每个加工车间负责一部分的工作,对数据进行一次粗加工,而不是把所有的工作都放到一个加工车间当中,否则就失去了管道数据的意义。

type Pipeline = {
  /**
   * 实现该方法可以将数据通过管道一层层传递下去
   * @param data
   */
  push(data: T[]): Promise;
  /**
   * 将多节管道链接起来
   * e.g.
   * const app = new BaseApp();
   * app.pipe(new TestApp1()).pipe(new TestApp2()).pipe(new TestApp3()).pipe(new Output()).pipe(new End())
   * @param _next
   */
  pipe(_next: Pipeline): Pipeline;
  /**
   * 用于接受从上一节管道传递下来的数据,可进行加工后传递到下一节管道
   * @param data
   */
  resolveData(data: T[]): T[] | Promise;
};

​加工车间依旧是接收一个T[]类型的数据数组,拿到这个数据后,按照各自的工序对数据进行加工处理,加工好之后,重新放回流水线的传送带上(返回值),送往下一节管道的加工车间继续加工。

三、具体实现

上面我们只是定义了一个管道应该有的最基本的行为,只有具备以上行为能力的类我们才认为它是一节合格的管道。那么,接下来,我们就来看看一个管道类需要如何实现。

3.1 基础管道模型类

class BaseApp

implements Pipeline

{ constructor(data?: P[]) { data && this.push(data); } /** * 仅内部使用,下一节管道的引用 */ protected next: Pipeline

| undefined; /** * 接受到数据后,使用 resolveData 处理获得新书局后,将新数据推送到下一节管道 * @param data */ async push(data: P[]): Promise { data = await this.resolveData(data); this.next?.push(data); } /** * 链接管道 * 让 pipe 的返回值始终是下一节管道的引用,这样就可以链式调用 * @param _next * @returns */ pipe(_next: Pipeline

): Pipeline

{ this.next = _next; return _next; } /** * 数据处理,返回最新的数据对象 * @param data * @returns */ resolveData(data: P[]): P[] | Promise { return data; } }

我们定义了一个实现了Pipleline接口的基础类,用来描述所有管道的样子,我们所有的管道都需要继承到这个基础类。
在构造函数中,我们接受一个可选参,这个参数代表我们的初始数据源,只有第一节管道需要传入这个参数为整个管道注入初始数据,我们拿到这个初始数据后,会使用水泵(push)将这个数据推送出去。

3.2 管道统一数据对象

通常在程序实现时,我们会定义一个统一的数据对象作为管道中流动的数据,这样更好维护与管理。

type PipeLineData = {
    datasource: {
        userInfo: {
            firstName: string;
            lastName: string;
            age: number,
        }
    }
}

3.3 第一节管道

由于第一节管道之前没有任何管道了,我们想要让数据流动起来,就需要在第一节管道处使用水泵给予数据一个初始动能,让他可以流动起来,因此,第一节管道的实现会与其他管道略有不同。

export class PipelineWorker1 extends BaseApp {
  constructor(data: T[]) {
    super(data);
  }
}

第一节管道主要的功能就是接受原始数据源,并使用水泵将数据发送出去,所以实现起来比较简单,只需要继承我们的基类BaseApp,并将初始数据源提交给基类,基类再用水泵将数据推送出去即可。

3.4 其他管道

其他管道每个管道都会有一个数据处理车间,用来处理流向当前管道的数据,因此我们还需要重写基类的resolveData方法。

export class PipelineWorker2 extends BaseApp {
  constructor() {
    super();
  }
  resolveData(data: PipeLineData[]): PipeLineData[] | Promise {
    // 在这里我们可以对数据进行一些特定的处理
    // 注意我们尽可能在传入的 data 上进行操作,保持引用
    data.forEach(item => {
        item.userInfo.name = ${item.userInfo.firstName} · ${item.userInfo.lastName}
    });
    // 最后,我们再调用基类的 resolveData 方法,把处理好的数据传进去,
    // 这样就完成了一道工序的加工了
    return super.resolveData(data);
  }
}

export class PipelineWorker3 extends BaseApp {
  constructor() {
    super();
  }
  resolveData(data: PipeLineData[]): PipeLineData[] | Promise {
    // 在这里我们可以对数据进行一些特定的处理
    // 注意我们尽可能在传入的 data 上进行操作,保持引用
    data.forEach(item => {
        item.userInfo.age += 10;
    });
    // 最后,我们再调用基类的 resolveData 方法,把处理好的数据传进去,
    // 这样就完成了一道工序的加工了
    return super.resolveData(data);
  }
}

export class Output extends BaseApp {
  constructor() {
    super();
  }
  resolveData(data: PipeLineData[]): PipeLineData[] | Promise {
    // 在这里我们可以对数据进行一些特定的处理
    // 注意我们尽可能在传入的 data 上进行操作,保持引用
    console.log(data);
    // 最后,我们再调用基类的 resolveData 方法,把处理好的数据传进去,
    // 这样就完成了一道工序的加工了
    return super.resolveData(data);
  }
}
// 我们还可以利用管道组装灵活的特性开发出各种各样的插件,可随时插拔
export class Plugin1 extends BaseApp {
  constructor() {
    super();
  }
  resolveData(data: PipeLineData[]): PipeLineData[] | Promise {
    // 在这里我们可以对数据进行一些特定的处理
    // 注意我们尽可能在传入的 data 上进行操作,保持引用
    console.log("这是一个插件");
    // 最后,我们再调用基类的 resolveData 方法,把处理好的数据传进去,
    // 这样就完成了一道工序的加工了
    return super.resolveData(data);
  }
}

3.5 组装管道

上面我们已经将每一节管道都准备好了,现在要把他们组装起来,投入使用了。

const datasource = {
    userInfo: {
        firstName: "kiner",
        lastName: "tang",
        age: 18
    }
};
const app = new PipelineWorker1(datasource);
// 管道可以随意组合
app.pipe(new Output())
   .pipe(new PipelineWorker2())
   .pipe(new Output())
   .pipe(new PipelineWorker3())
   .pipe(new Output())
   .pipe(new Plugin1());

四、结语

至此,我们就已经完成了一个管道架构的设计了。是不是觉得,使用了管道数据之后,我们的整个程序代码的数据流向更加清晰,每个模块之前的分工更加分明,模块与模块之前的项目配合更加灵活了呢?
使用管道设计,还能让我们可以额外扩充一个插件库,用户可以随意定制符合各个业务场景的插件,让我们的程序的扩展性变得极强。

阅读全文
js几种网络请求方式梳理——摆脱回调地狱

摘要
本文介绍了基于 XMLHttpRequest、Promise、async/await 等三种异步网络请求的写法,其中async/await 写法允许我们以类似于同步的方式编写异步程序,摆脱繁琐的回调函数。

一、背景

为了应对越来越多的测试需求,减少重复性的工作,有道智能硬件测试组基于 electron 开发了一系列测试提效工具。

electron 的编程语言是js,因为大家都不是专业的前端,对js不太熟悉,在编写程序时踩了不少坑。尤其是js中的事件和网络请求,这些涉及到异步编程的地方很容易出错。

随着工具的快速开发迭代,代码中出现了越来越多的嵌套的回调函数,工具崩溃的几率也越来越大。为了解决这些问题,我们用 async/await 对这些回调函数进行了重构,使得代码量下降,代码的可读性和可理解性都有了大幅度提高。

本文介绍了基于 XMLHttpRequest、Promise、async/await 等三种异步网络请求的写法,其中 async/await 写法允许我们以类似于同步的方式编写异步程序,摆脱繁琐的回调函数。

二、前言

在js中如果只是发起单个网络请求还不算复杂,用fetch、axios或者直接用XMLHttpRequest就能满足要求。

但若是多个请求按顺序拉取数据那写起来就很麻烦了,因为js中的网络请求都是异步的,想要顺序执行最常见写法就是在回调函数中发起下一个请求,如下面这些代码:

const requestOptions = {
    method: 'GET',
    redirect: 'follow'
};

fetch('https://xxx.yyy.com/api/zzz/', requestOptions)
    .then(response => response.json())
    .then(data => {
        fetch('https://xxx.yyy.com/api/aaa/'+data.id, requestOptions)
            .then(response => response.json())
            .then(data => {
                console.log(data)
            })
            .catch(error => console.error('error', error));
    })
    .catch(error => console.error('error', error));

假设我需要经过两步获取一个数据,如从https://xxx.yyy.com/api/zzz/获取一个数据对象data,通过data.id得到我要获取数据的序号,之后再发一次请求得到想要的数据。

用回调函数的方式就类似于上面这样,太繁琐了,而且容易出错,并且一旦逻辑复杂就不好改啦。

接下来梳理一下js的几种网络请求方式,摆脱回调地狱,希望对遇到类似问题的小伙伴有所帮助。

(一)XMLHttpRequest

首先是XMLHttpRequest,初学前端时大名鼎鼎的Ajax主要指的就是它。通过XMLHttpRequest对象创建网络请求的套路如下:

// 假设访问http://localhost:3000/user返回json对象{"name":"YouDao"}
const xhr = new XMLHttpRequest();
const url = 'http://localhost:3000/user'

xhr.onreadystatechange = function(){
  if (this.readyState == 4 && this.status == 200){
    const json=JSON.parse(xhr.responseText)
    const name=json.name
    console.log(name)
  }
}
xhr.open('GET',url)
xhr.send()

这段代码首先创建一个XMLHttpRequest对象xhr,然后给xhr.onreadystatechange添加readystatechange事件的回调函数,之后xhr.open(‘GET’,url)初始化请求,最后由xhr.send()发送请求。

请求发送后,程序会继续执行不会阻塞,这也是异步调用的好处。当浏览器收到响应时就会进入xhr.onreadystatechange的回调函数中去。在整个请求过程中,xhr.onreadystatechange会触发四次,每次readyState都会自增,从1一直到4,只有到了最后阶段也就是readyState为4时才能得到最终的响应数据。到达第四阶段后还要根据status判断响应的状态码是否正常,通常响应码为200说明请求没有遇到问题。这段代码最终会在控制台上会打出YouDao。

可以看出,通过XMLHttpRequest处理请求的话,首先要针对每个请求创建一个XMLHttpRequest对象,然后还要对每个对象绑定readystatechange事件的回调函数,若是多个请求串起来,想想就很麻烦。

(二)Promise

Promise是在 ECMAScript 2015 引入的,如果一个事件依赖于另一个事件返回的结果,那么使用回调会使代码变得很复杂。Promise对象提供了检查操作失败或成功的一种模式。如果成功,则会返回另一个Promise。这使得回调的书写更加规范。

通过Promise处理的套路如下:

const promise = new Promise((resolve,reject)=>{
  let condition = true;
  if (condition) {
    resolve("ok")
  } else {
    reject("failed")
  }
}).then( msg => console.log(msg))
  .catch( err => console.error(err))
  .finally( _ =>console.log("finally"))

上面这段代码把整个处理过程串起来了,首先创建一个Promise对象,它的构造器接收一个函数,函数的第一个参数是没出错时要执行的函数resolve,第二个参数是出错后要执行的函数reject。

resolve指执行成功后then里面的回调函数,reject指执行失败后catch里执行的回调函数。最后的finally是不论成功失败都会执行的,可以用来做一些收尾清理工作。

基于Promise的网络请求可以用axios库或浏览器自带的fetch实现。

axios库创建请求的套路如下:

import axios from 'axios'
const url = 'http://xxx.yyy.com/'
axios.get(url)
  .then(data => console.log(data))
  .catch(err => console.error(err))

我比较喜欢用fetch,fetch是用来代替XMLHttpRequest的浏览器API,它不需要导库,fetch创建请求的方式和axios类似,在开头已经展示过了就不重复写了。

虽然Promise把回调函数的编写方式简化了一些,但还是没有摆脱回调地狱,多个请求串起来的话就会像我开头写的那样,在then里面创建新的Promise,最终变成Promise地狱。

(三)async/await

async/await是在 ECMAScript 2017 引入的,可以简化Promise的写法,使得代码中的异步函数调用可以按顺序执行,易于理解。

下面就用开头的那个例子说明吧:

直接用fetch获取数据:

const requestOptions = {
    method: 'GET',
    redirect: 'follow'
};

fetch('https://xxx.yyy.com/api/zzz/', requestOptions)
    .then(response => response.json())
    .then(data => {
        fetch('https://xxx.yyy.com/api/aaa/'+data.id, requestOptions)
            .then(response => response.json())
            .then(data => {
                console.log(data)
            })
            .catch(error => console.error('error', error));
    })
    .catch(error => console.error('error', error));

用async/await改写后:

async function demo() {
​  const requestOptions = {
    method: 'GET',
    redirect: 'follow'
  };

  const response = await fetch('https://xxx.yyy.com/api/zzz/', requestOptions);
  const data = await response.json()
  const response1 = await fetch('https://xxx.yyy.com/api/aaa/'+data.id, requestOptions)
  const data1 = await response1.json()
  console.log(data1)
}

demo().catch(error => console.error('error',error))

改写后的代码是不是就很清楚了,没有那么多的then跟在后面了,这样如果有一连串的网络请求也不用怕了。

当async放在一个函数的声明前时,这个函数就是一个异步函数,调用该函数会返回一个Promise。
await用于等待一个Promise对象,它只能在异步函数中使用,await表达式会暂停当前异步函数的执行,等待 Promise 处理完成。

这样如果想让一连串的异步函数调用顺序执行,只要把被调用的这些函数放到一个用async修饰的函数中,调用前加上await就能让这些函数乖乖地顺序执行了。

结语
通过本文的梳理,相信你已经知道怎样避免回调地狱了。不过需要注意的是 Promise 是2015年加入语言规范的,而 async/await 是2017年才加入到语言规范的,如果你的项目比较老或者是必须要兼容老版本的浏览器(如IE6),那就需要用别的方式来解决回调地狱了。
对于 electron 只要你用的是近几年的版本都是支持的,electron 可以当成是 chromium 和 node.js 的结合体,特别适合用来写跨平台的工具类桌面应用程序。

阅读全文
剖析react核心设计原理–异步执行调度

JS的执行通常在单线程的环境中,遇到比较耗时的代码时,我们首先想到的是将任务分割,让它能够被中断,同时在其他任务到来的时候让出执行权,当其他任务执行后,再从之前中断的部分开始异步执行剩下的计算。所以关键是实现一套异步可中断的方案。那么我们将如何实现一种具备任务分割、异步执行、而且还能让出执行权的解决方案呢。React给出了相应的解决方案。

背景

React起源于 Facebook 的内部项目,用来架设 Instagram 的网站,并于 2013 年 5 月开源。该框架主要是一个用于构建用户界面的 JavaScript 库,主要用于构建 UI,对于当时双向数据绑定的前端世界来说,可谓是独树一帜。更独特的是,他在页面刷新中引入了局部刷新的机制。优点有很多,总结后react的主要特性如下:

1. 1 变换

框架认为 UI 只是把数据通过映射关系变换成另一种形式的数据。同样的输入必会有同样的输出。这恰好就是纯函数。

1.2 抽象

​实际场景中只需要用一个函数来实现复杂的 UI。重要的是,你需要把 UI 抽象成多个隐藏内部细节,还可以使用多个函数。通过在一个函数中调用另一个函数来实现复杂的用户界面,这就是抽象。

1.3 组合

为了达到可重用的特性,那么每一次组合,都只为他们创造一个新的容器是的。你还需要“其他抽象的容器再次进行组合。”就是将两个或者多个容器。不同的抽象合并为一个。

React 的核心价值会一直围绕着目标来做更新这件事,将更新和极致的用户体验结合起来,就是 React 团队一直在努力的事情。

变慢==>升级

随着应用越来越复杂,React15 架构中,dom diff 的时间超过 16.6ms,就可能会让页面卡顿。那么是哪些因素导致了react变慢,并且需要重构呢。

React15之前的版本中协调过程是同步的,也叫stack reconciler,又因为js的执行是单线程的,这就导致了在更新比较耗时的任务时,不能及时响应一些高优先级的任务,比如用户在处理耗时任务时输入页面会产生卡顿。页面卡顿的原因大概率由CPU占用过高产生,例如:渲染一个 React 组件时、发出网络请求时、执行函数时,都会占用 CPU,而CPU占用率过高就会产生阻塞的感觉。如何解决这个问题呢?

在我们在日常的开发中,JS的执行通常在单线程的环境中,遇到比较耗时的代码时,我们首先想到的是将任务分割,让它能够被中断,同时在其他任务到来的时候让出执行权,当其他任务执行后,再从之前中断的部分开始异步执行剩下的计算。所以关键是实现一套异步可中断的方案。

那么我们将如何实现一种具备任务分割、异步执行、而且还能让出执行权的解决方案呢。React给出了相应的解决方案。

2.1 任务划分

如何单线程的去执行分割后的任务,尤其是在react15中更新的过程是同步的,我们不能将其任意分割,所以react提供了一套数据结构让他既能够映射真实的dom也能作为分割的单元。这样就引出了我们的Fiber。

Fiber

Fiber是React的最小工作单元,在React中,一切皆为组件。HTML页面上,将多个DOM元素整合在一起可以称为一个组件,HTML标签可以是组件(HostComponent),普通的文本节点也可以是组件(HostText)。每一个组件就对应着一个fiber节点,许多fiber节点互相嵌套、关联,就组成了fiber树(为什么要使用链表结构:因为链表结构就是为了空间换时间,对于插入删除操作性能非常好),正如下面表示的Fiber树和DOM的关系一样:

Fiber树 DOM树

   div#root div#root
      | |
     div
      | / \
     div p a
    / ↖
   / ↖
  p ----> 
             |
             a

​一个 DOM 节点一定要着一个光纤节点节点,但一个光纤节点却非常有匹配的 DOM 节点节点。fiber作为工作单元的结构如下:

export type Fiber = {
  // 识别 fiber 类型的标签。
  tag: TypeOfWork,

  // child 的唯一标识符。
  key: null | string,

  // 元素的值。类型,用于在协调 child 的过程中保存身份。
  elementType: any,

  // 与该 fiber 相关的已解决的 function / class。
  type: any,

  // 与该 fiber 相关的当前状态。
  stateNode: any,

  // fiber 剩余的字段

  // 处理完这个问题后要返回的 fiber。
  // 这实际上就是 parent。
  // 它在概念上与堆栈帧的返回地址相同。
  return: Fiber | null,

  // 单链表树结构。
  child: Fiber | null,
  sibling: Fiber | null,
  index: number,

  // 最后一次用到连接该节点的引用。
  ref:
    | null
    | (((handle: mixed) => void) & { _stringRef: ?string, ... })
    | RefObject,

  // 进入处理这个 fiber 的数据。Arguments、Props。
  pendingProps: any, // 一旦我们重载标签,这种类型将更加具体。
  memoizedProps: any, // 用来创建输出的道具。

  // 一个状态更新和回调的队列。
  updateQueue: mixed,

  // 用来创建输出的状态
  memoizedState: any,

  mode: TypeOfMode,

  // Effect
  effectTag: SideEffectTag,
  subtreeTag: SubtreeTag,
  deletions: Array | null,

  // 单链表的快速到下一个 fiber 的副作用。
  nextEffect: Fiber | null,

  // 在这个子树中,第一个和最后一个有副作用的 fiber。
  // 这使得我们在复用这个 fiber 内所做的工作时,可以复用链表的一个片断。
  firstEffect: Fiber | null,
  lastEffect: Fiber | null,

  // 这是一个 fiber 的集合版本。每个被更新的 fiber 最终都是成对的。
  // 有些情况下,如果需要的话,我们可以清理这些成对的 fiber 来节省内存。
  alternate: Fiber | null,
};

了解完光纤的结构,那么光纤与光纤之间是如何并创建的链表树链接的呢。这里我们引出双缓冲机制

​在页面中被刷新用来渲染用户界面的树,被称为 current,它用来渲染当前用户界面。每当有更新时,Fiber 会建立一个 workInProgress 树(占用内存),它是由 React 元素中已经更新数据创建的。React 在这个 workInProgress 树上执行工作,并在下次渲染时使用这个更新的树。一旦这个 workInProgress 树被渲染到用户界面上,它就成为 current 树。

在这里插入图片描述
2.2 异步执行

那么fiber是如何被时间片异步执行的呢,提供一种思路,示例如下

let firstFiber
let nextFiber = firstFiber
let shouldYield = false
//firstFiber->firstChild->sibling
function performUnitOfWork(nextFiber){
  //...
  return nextFiber.next
}

function workLoop(deadline){
  while(nextFiber && !shouldYield){
          nextFiber = performUnitOfWork(nextFiber)
          shouldYield = deadline.timeReaming < 1
        }
  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)

我们知道浏览器有一个api叫做requestIdleCallback,它可以在浏览器空闲的时候执行一些任务,我们用这个api执行react的更新,让高优先级的任务优先响应。对于requsetIdleCallback函数,下面是其原理。

​const temp = window.requestIdleCallback(callback[, options]);

对于普通的用户交互,上一帧的渲染到下一帧的渲染时间是属于系统空闲时间,Input输入,最快的单字符输入时间平均是33ms(通过持续按同一个键来触发),相当于,上一帧到下一帧中间会存在大于16.4ms的空闲时间,就是说任何离散型交互,最小的系统空闲时间也有16.4ms,也就是说,离散型交互的最短帧长一般是33ms。

requestIdleCallback回调调用时机是在回调注册完成的上一帧渲染到下一帧渲染之间的空闲时间执行

callback 是要执行的回调函数,会传入 deadline 对象作为参数,deadline 包含:

timeRemaining:剩余时间,单位 ms,指的是该帧剩余时间。

didTimeout:布尔型,true 表示该帧里面没有执行回调,超时了。

options 里面有个重要参数 timeout,如果给定 timeout,那到了时间,不管有没有剩余时间,都会立刻执行回调
callback。

但事实是requestIdleCallback存在着浏览器的兼容性和触发不稳定的问题,所以我们需要用js实现一套时间片运行的机制,在react中这部分叫做scheduler。同时React团队也没有看到任何浏览器厂商在正向的推动requestIdleCallback的覆盖进程,所以React只能采用了偏hack的polyfill方案。

requestIdleCallback polyfill 方案( Scheduler )

上面说到requestIdleCallback存在的问题,在react中实现的时间片运行机制叫做scheduler,了解时间片的前提是了解通用场景下页面渲染的整个流程被称为一帧,浏览器渲染的一次完整流程大致为

执行JS--->计算Style--->构建布局模型(Layout)--->绘制图层样式(Paint)--->组合计算渲染呈现结果(Composite)

**帧的特性:**

帧的渲染过程是在JS执行流程之后或者说一个事件循环之后

帧的渲染过程是在一个独立的UI线程中处理的,还有GPU线程,用于绘制3D视图

帧的渲染与帧的更新呈现是异步的过程,因为屏幕刷新频率是一个固定的刷新频率,通常是60次/秒,就是说,渲染一帧的时间要尽可能的低于16.6毫秒,否则在一些高频次交互动作中是会出现丢帧卡顿的情况,这就是因为渲染帧和刷新频率不同步造成的
用户通常的交互动作,不要求一帧的渲染时间低于16.6毫秒,但也是需要遵循谷歌的RAIL模型的

那么Polyfill方案是如何在固定帧数内控制任务执行的呢,究其根本是借助requestAnimationFrame让一批扁平的任务恰好控制在一块一块的33ms这样的时间片内执行。

Lane

以上是我们的异步调度策略,但是仅有异步调度,我们怎么确定应该调度什么任务呢,哪些任务应该被先调度,哪些应该被后调度,这就引出了类似于微任务宏任务的Lane

有了异步调度,我们还需要细粒度的管理各个任务的优先级,让高优先级的任务优先执行,各个Fiber工作单元还能比较优先级,相同优先级的任务可以一起更新

关于lane的设计可以看下这篇:

https://github.com/facebook/react/pull/18796github.com/facebook/react/pull/18796

应用场景

有了上面所介绍的这样一套异步可中断分配机制,我们就可以实现batchUpdates批量更新等一系列操作:
在这里插入图片描述
更新fiber前
在这里插入图片描述
更新fiber后

以上除了cpu的瓶颈问题,还有一类问题是和副作用相关的问题,比如获取数据、文件操作等。不同设备性能和网络状况都不一样,react怎样去处理这些副作用,让我们在编码时最佳实践,运行应用时表现一致呢,这就需要react有分离副作用的能力。

设计serve computer

我们都写过获取数据的代码,在获取数据前展示loading,数据获取之后取消loading,假设我们的设备性能和网络状况都很好,数据很快就获取到了,那我们还有必要在一开始的时候展示loading吗?如何才能有更好的用户体验呢?

看下下面这个例子

function getSomething(id) {
  return fetch(${host}?id=${id}).then((res)=>{
    return res.param
  })
}

async function getTotalSomething(id1, id2) {
  const p1 = await getSomething(id1);
  const p2 = await getSomething(id2);

  return p1 + p2;
}

async function bundle(){
  await getTotalSomething('001', '002');
}

我们通常可以用async+await的方式获取数据,但是这会导致调用方法变成异步函数,这就是async的特性,无法分离副作用。

分离副作用,参考下面的代码

function useSomething(id) {
  useEffect((id)=>{
      fetch(${host}?id=${id}).then((res)=>{
       return res.param
      })
  }, [])
}

function TotalSomething({id1, id2}) {
  const p1 = useSomething(id1);
  const p2 = useSomething(id2);

  return 
}

这就是hook解耦副作用的能力。

解耦副作用在函数式编程的实践中非常常见,例如redux-saga,将副作用从saga中分离,自己不处理副作用,只负责发起请求。

function* fetchUser(action) {
   try {
      const user = yield call(Api.fetchUser, action.payload.userId);
      yield put({type: "USER_FETCH_SUCCEEDED", user: user});
   } catch (e) {
      yield put({type: "USER_FETCH_FAILED", message: e.message});
   }
}

严格意义上讲react是不支持Algebraic Effects的,但是借助fiber执行完更新之后交还执行权给浏览器,让浏览器决定后面怎么调度,Suspense也是这种概念的延伸。

const ProductResource = createResource(fetchProduct);

​const Proeuct = (props) => {
    const p = ProductResource.read( // 用同步的方式来编写异步代码!
          props.id
    );
  return 

{p.price}

; } function App() { return (
Loading...
}>
); }

可以看到ProductResource.read是同步的写法,把获取数据的部分分离出了Product组件之外,原理是ProductResource.read在获取数据之前会throw一个特殊的Promise,由于scheduler的存在,scheduler可以捕获这个promise,暂停更新,等数据获取之后交还执行权。这里的ProductResource可以是localStorage甚至是redis、mysql等数据库等。这就是我理解的server componet的雏形。

本文作为react16.5+版本后的核心源码内容,浅析了异步调度分配的机制,了解了其中的原理使我们在系统设计以及模型构建的情况下会有较好的大局观。对于较为复杂的业务场景设计也有一定的辅助作用。这只是react源码系列的第一篇,后续会持续更新,希望可以帮到你。

happy hacking~~

阅读全文
LiveVideoStackCon | 面向在线教育业务的流媒体分发演进

几年前,很多人对在线网课还非常陌生。随着移动设备的普及和音视频技术的发展,如今在线教育产品百花齐放。而在线教育产品能服务千万学子离不开流媒体分发技术的支撑。本次LiveVideoStackCon
2021 音视频技术大会北京站邀请到了网易有道研发工程师周晓天,为我们分享网易有道在线教育业务的流媒体分发相关内容。

文 | 周晓天
整理 | LiveVideoStack

大家好,我来自网易有道精品课研发团队。如今音视频被各界广泛关注,“直播+”成为一个热点,大厂也纷纷推出了一系列音视频的相关服务。

网易有道是一家以成就学习者“高效学习”为使命的智能学习公司,依托强大的互联网AI等技术手段,围绕学习场景,打造了一系列深受用户喜欢的学习产品和服务。除了面向多种场景的在线教育平台,还有有道词典、有道词典笔等领先市场的软硬件学习工具。

在这里插入图片描述
其中在线教育业务就是依托音视频技术的不断成熟应运而生的一项重要业务。

音视频技术内容广、链条长、每个点又会很深。所以今天分享的内容以有道的在线教育业务为主题,聚焦在有道团队流媒体分发服务端的部分。

在这里插入图片描述

今天的内容分为三个部分,分别是有道在线教育业务介绍、分发系统架构的演进和对分发难点的思考与实践。

1.在线教育业务介绍

首先通过在线教育直播业务形态理解需求,明确媒体分发服务端要考虑什么。

在这里插入图片描述

不同班型对应着不同需求。2013年左右最先出现的是1V1课程、普通小班课。本质上是借助RTC实时通信模式构建的教育产品。后来游戏直播和娱乐直播被大家熟悉,而这个阶段被熟知的在线学习的主要形式是视频点播模式,比如网易公开课。随着音视频领域技术成熟,以及用户对在线教育需求的升级,直播网课迅速发展。直播课大约出现在2014年,在疫情后得到了空前的关注。

在这里插入图片描述

传统大班直播课是老师的单向推流,在互动大班课中,学生可以和老师进一步互动,获得更好的上课体验。学生连麦、屏幕/白板、老师视频和互动消息构成一节课的主要内容。

互动小班进一步优化产品的互动性,提升学员课堂参与感、学习体验与学习效果。音视频+H5互动组件+灵活的布局需求也带来额外复杂性。

在这里插入图片描述

面向业务设计服务,需要理解不同业务的差异再去采取相应的技术。这里提供一种思考的方式:以互动大班课为例,一个老师和一个学生正在连麦,再将连麦的过程分发给其他学生。对于流媒体分发,右侧列出一些考虑的要素:需要什么程度的延迟和流畅性?多大的规模?需要多高的媒体质量?当前业务线对方案成本的敏感度?

在这里插入图片描述

进一步可以用这种方式横向对比不同课程形态,通过它们的区别获得更精细的需求。

比如,对比大班直播课和互动大班课:对于规模为M的会话,大班直播课要把一个人的信息分发给M-1个人,这可以通过基于CDN的视频直播方式做到。如果进一步想要给产品增增加连麦互动性,成为互动大班课。连麦的增加会让简化模型变为两个部分,如何在一个教室内同时满足这两个需求?最简单的思路是在原有CDN分发的基础上,让连麦内容通过RTC方式交换,再将它们的信息通过原有CDN系统分发,但这么做会带来内容延迟和用户切换延迟等问题。

对比互动大班和(线上、线下)双师班级,虽然模型类似,但具体到场景中双师班级中的一个“学生端”可能对应一个线下教室的全体学生,这会增加单路分发异常的代价,这样的差异也就要求系统能对不同场景配置不同策略。

在这里插入图片描述

除了在线教育,横向对比的思路同样可以用来分析其他场景的业务线,例如普通小班和游戏开黑。开黑看似和只发送语音的普通小班课程类似,但是在性能和网络占用方面要求更严格。在尽量不占用游戏带宽的同时,还需要尽量减少CPU的操作,为游戏提供充足的算力。如果直接用小班课程的RTC接口用于游戏,保证通话质量的同时反而会影响游戏。如果期望使用一套系统支持多种业务,那么在系统设计早期就要明确业务差异和设计需求。

在这里插入图片描述

通过以上的分析,可以列出了在线教育业务对媒体分发系统的一些主要需求点。第一要满足分发低延迟、上麦低延迟。第二点要做大规模分发。相对一些娱乐场景,要做到高稳定以及高可用。第四点要对成本进行控制。最后,不同学生、不同教室对于上课场景的需求是不同的,所以一定要支持多端接入。

2.分发架构的演进

在这里插入图片描述

当多个业务线同时铺开的过程中,从1v1到小班、到大班直播、再到互动大班以及互动小班等课程,这会影响分发系统的演进过程。一种思路是随着业务的演变,分发架构逐渐复杂,不断支持越来越多的特性。有道并没有采用该思路,而是经历了从基于CDN的分发,到全部业务使用实时通信网络(RTN)的切换,没有架构上的中间过渡状态。

下面我们简单回顾一些分发架构作为普及内容。

在这里插入图片描述

基于CDN网络的直播内容分发的树状架构十分清晰,架构本身决定数据的路由,同时易于维护、风险和成本可控。当一个用户选定一个边缘接入,媒体数据的分发路由就已经规划好了。同时它有自身的缺点,比如:只支持单向分发、协议带来的固定延迟等。

在这里插入图片描述

早期通过CDN模式部署的直播为了增加互动性和降低延迟,在CDN架构的基础上做了两个优化。一方面在边缘拉流节点支持RTC的方式接入(图中也写为RTN边缘节点),从而屏蔽掉媒体封装协议带来的延迟、增加IM互动效果,同时还能增加弱网抗性。另一方面为了进一步增加互动性,增加了RTC旁路系统以支持双向连麦,再将连麦内容转推到CDN网络中完成直播。一些“低延时CDN直播”产品就采用这样的原理。

在这里插入图片描述

刚刚提到用于连麦的旁路RTC系统需要转推内容到CDN分发网络,那是否能让这个系统把CDN大规模分发的任务也一起做了呢?于是就有了纯RTN的架构。该架构不再有鲜明的树状分发结构,而是用一个网状拓扑分发所有内容。任意单向拉流客户端可以随时切换为双向通信,不需要先做系统的切换。

在这里插入图片描述

通过上述的分析,我们可以大致总结出业内直播流媒体分发演进的方向——音视频直播CDN和RTC网络边界模糊,逐步融为一体。直播CDN厂商逐渐从单向大规模分发支持低延迟接入、连麦。之前的RTC产品,从面向小型会议的架构逐步为了能够同时服务千人、万人,也开始将分发网络变复杂。所以现在我们能看到网易的WE-CAN分布式传输网、阿里云GRTN 流媒体总线、以及其它“X-RTN”都是该演进过程的结果。

在这里插入图片描述

刚刚提到的架构主要是ToB厂商的产品,在ToC服务的场景中也会有如上图所示的架构,通过一个媒体服务器融合两个分发网络提供服务,特别是对于同时有自研和三方接入时。该结构在带来新的非功能特性的同时,也有很大的风险。有道没有选择使用类似的架构进行过度,而是直接用RTN分发网络对原有功能进行替代。

有道当前架构示意图

该架构能满足多种场景的需求,也支持多种推拉流客户端接入。例如当同学上公开课时,通过微信小程序或者浏览器直接看是最为便捷的。已经使用课程APP、已经参加系列课程的用户,使用APP接入以获得最优体验。

相比CDN架构自身的拓扑结构决定了数据分发路由,RTN网状拓扑在带来灵活性的同时也增加复杂性。比如路由无法从拓扑直接获取,而是需要一个额外的调度中心去计算、规划路由,完成对应转发资源的调度,这也凸显了RTN架构下调度中心的重要性。

图中也有一个CDN旁路的部分,他的主要作用是做一些突发接入量过大的课程的负载均衡,增加系统的弹性。

有道在设计网络节点拓扑的时候更偏向于灵活性。一方面,分发节点没有分层、分级,采用扁平拓扑。另一方面,通过配置不同的属性、角色可以实现对网络分发特性的改变。

3.分发难点思考与实践

在这里插入图片描述

对于流媒体分发系统有以下四个要点——接入问题、网络连通性、路由建立以及转发。除此之外还想分享一下关于分层设计和通道的概念。

在这里插入图片描述

解决接入问题的核心理念是“就近”接入——网络质量最好的接入为“最近”的接入。(不同类型的业务可能会有不同思路:有道的教学场景中力求现有每个用户体验尽可能最优,类似于贪心算法;但在别的业务中,思路可能会是在达到QoS最低限制的情况下选择全局成本最优的接入、路由方式)最直观的方法是使用基于IP、位置的接入推荐。进一步利用对不同网关网络探测、连接历史数据优化推荐的结果。除了利用线上、线下数据统计获得的先验的知识进行接入推荐,考虑到这样的方法无法涵盖所有特殊形况,有道还引入人工配置的支持。支持手工热配对部分ToC场景非常有效

右下角是一个大班课老师上行丢包率打点图,可以看到存在有规律的、平均在9%左右的丢包。该老师长期在固定地点使用固定设备进行直播,而且早期还有技术支持同学进行过网络检查,网络一直很好。按照之前的算法,他的位置没有变、网络没有变,使用的推荐数据库也变化不大,所以根据算法每次会给出相同的推荐结果。突然出现的有规律丢包推测是流量行为被运营商识别、分类,并对其进行了策略限制。

面对这种情况,修改算法是行不通的。通过有道热配置的方式,在发现问题进行上报的同时就可以人工修改配置,下一次老师接入会避开对应接入节点,解决丢包问题。

我们通过“过滤器”机制实现该操作:假如所有可接入节点构成一个池子,那么最终“过滤”出的结果构成推荐给客户端进行接入的列表。所以把过滤规则的计算过程作为算法写入系统,将算法执行要使用的参数作为可以热更新的数据写在数据库来实现。

在这里插入图片描述

接入只解决了分发网络的入口问题,那么分发网络究竟是怎样的拓扑形态呢?这就涉及到网络节点的连通性设计问题。有道的网络是一个扁平的拓扑,每个机房都是拓扑中扁平的点。理论上可以给所有节点之间都建立连接,成为一个mesh网络,那么这样的网络将会无比灵活,任意一条通路都可以被规划出来,完全依赖算法进行实际路由的选择。有道并没有采用这样的方式。

我们还是引入了一些人工经验,比如根据经验将一些机房的连通性删除,成为非Full mesh的结构。可以认为是借助人工的方式进行了剪枝、组织。除了连通性,在路由计算时还需要解决权重的获取问题,也就需要对节点连接情况差异进行量化描述。这种量化是基于规律性的QoS探测完成的,类似前面接入选择的问题,算法可能没法精细地满足所有case或者一些特殊情况,那么在量化差异外,我们也通过可配置的属性描述定性的差异来增加拓扑的灵活性。

之所以这样提高灵活性、支持人工配置,是为了能满足不同业务的差异化需求。同时也有代价,就是复杂性的提高。所以或许没有最好的架构,只有更合适的架构。

在这里插入图片描述

在确定了接入位置(明确了分发的起点和终点)、建立了分发网络的连通性后,要解决的就是路由规划或者说调度问题。这里可以为大家分享的实践和思考有三点:一条路由的规划、多路径还有成本控制。规划单条路由是完成数据分发的基础,我们根据动态探测、刷新的网络QoS量化质量和基于当前节点状况、节点配置共同完成路由权重的计算。有了无向带权图、有了终点和起点,就可以计规划一条最短分发路由。

解决了接入问题,又完成分发网络连通性定义,现在解决了媒体数据分发路由的规划,看似就可以完成分发任务了。但对于有道的业务要求这还不够,想进一步保障用户体验就需要提升分发网络对抖动、丢包的抗性。多路径分发是一种保障方式。有道分发网络有三种路径——主要路径、备选路径、实时路径。主要路径直接用于业务分发;备选路径是主要路径的备份,在规划主要路径时生成,当主要路径异常时切换。实时路径是在主要路径之外额外建立的多路冗余分发路径,以提供更加强大的分发抖动、丢包抗性,这对一些重点任务、大规模分发任务有很高价值。

以图上橙色线路为例。边缘是移动、联通和电信三个单线机房,除了主路径之外,可以在两个边缘的联通运营商之间建立实时路径,在实现实时备份的情况下降低备份线路成本。

在这里插入图片描述

控制中心完成数据分发路径的规划后,就需要沿途节点执行转发任务。这涉及到高性能流媒体分发服务器的设计。上图显示了有道的转发服务器线程模型。协议、端口对应不同的线程,从而在有限端口情况下尽可能利用多核资源。

除了每个协议-端口对会绑定一个IO线程,还有一个core线程,完成来自不同接入的数据包路由。比如一个推流用户从协议A端口A1接入(如使用UDP,从3000端口推流),同会话另一个拉流用户采用协议B端口B1接入(如使用TCP,从4000端口拉流),这两个用户根据IO线程模型不可能分配到同一个线程,所以需要进行跨线程数据转发。此时core线程会根据会话发布订阅的关系,将接收队列的内容向对应IO线程的队列进行转发。

该线程模型的设计和业务类型、比例也是相关的。当时系统负载以大班课为主,即推流人数大大小于拉流人数。如果业务类型发生变化,例如班型越来越小、课程每个成员都进行推流,而服务器总用户量如果不变,这会让core线程的转发负载相对大班课大大增加。这也是小班课业务带来的一项挑战,需要架构能随业务变化灵活应对。

在这里插入图片描述

除了上面四个关键问题外,借本次机会想额外分享、探讨两个细节:分层设计和通道的概念。

分层设计相当于转发问题的延伸。服务器拿到来自一个连接的数据以后,通过core线程分发。逻辑结构上可以理解为三层:链接层解决不同协议连入的问题;路由层负责处理数据在内部的分发、转移;会话层维护了发布订阅关系,指导路由进行分发,将数据发到正确的连接。该分层思想不仅用在单机线程模型中,也用在整个分发网络中。

在这里插入图片描述

当业务方接入一个实时通信SDK时,关于“通道”不同ToB厂商会有不同定义,简单理解就是对实时媒体传输资源的一种抽象。比如一些厂商所服务的业务场景的主要数据是人脸和屏幕共享,对应SDK可能就只提供两个通道资源,其中人脸通道支持大小流的同时推送。

上图以互动大班课为例介绍有道在“通道”设计方面的思考。左下角图片展示了互动大班的典型教师上课效果:右上角是主讲的老师,正在和左边的学生进行连麦,那么如何进一步把当前界面所有信息传递给其它学生?有道实时通信SDK提供了Live、RTC、Group等多个通道资源。SDK向外暴露的通道资源数量可以定义,同时可以差异化配置,虽然名字不同但是底层资源属于同一类。一个通道对应一路同步的音视频的分发能力。

仍以刚刚的场景为例:示意图左侧是教师,右侧是学生。橙色是RTC通道,这部分完成老师和学生的连麦。随后教师在端上进行混流——将连麦内容、课程白板等内容混为一路音视频通过Live通道向其它听课的学生发送。比如可以通过获取当前屏幕内容来做端上的混流。在互动大班型的业务场景下,所有学生需要获得信息都在这一张图里,都是视频和音频的媒体信息,这样就可以采取两个通道组合的方式,一个连麦、一个直播,从而完成整个业务。

不同的通道之所以有不同的名字而不是使用一个通道对象数组,是为了进一步降低客户端接入门槛。比如Live通道概念上相比RTC更强调流畅性,这可以对应一个更大的视频最小缓冲区来提升网络抖动抗性。

业务中发现SDK提供通道这种资源的方式可能会影响业务方的思考方式:如果只有“人脸通道”和“屏幕通道”,这可能会限制业务产品对新课程形式的思考。

4.互动小班课为例

在这里插入图片描述

借本次机会可以和大家分享有道关于互动小班的尝试,在以下两个方面和大家交流:小班的“互动”到底是怎样的?以及互动课程的录制问题。

在这里插入图片描述

在小班课中,多位学生和老师全程可以连麦。不同的同学可以随时被拉到台上进行分享、答题。除了音视频、白板这些基本内容之外,我们还加入了一些互动元素:本地媒体元素播放、多人实时互动棋盘等。这样的互动元素带来什么影响呢?

前面提到的互动大班课可以在端上混再发送到Live通道,这样流既可以省去需要单独服务端混流带来的视频延迟和同步问题,同时完整地传递了所有课程信息。但是对于互动小班课,如果老师端通过这种截取屏幕将内容分发给其他学生的方式,就会丢失互动元素的可互动性、布局也无法改变。当一个学生回头看录播的时候无法进行参与,只能作为旁观者看到别的同学的互动过程。这也是互动小班课第一个难点——互动元素如何处理?如何进行录制?回放的时候如何保持同步?实际中是有很多坑点和挑战。

5.关于自研

在这里插入图片描述

最后想和大家探讨一些关于自研实时通信系统的问题。

这里的部分内容截取自 ToB 厂商对痛点的分析,自研所遇到的问题可以分为以下几点:

  1. 成本:除了人力、资源覆盖、动态扩缩容的运维等,还有与之对应的机会成本。前两点都比较重要。另外不同业务带宽峰值位置不同,复用一套基础设施和带宽资源可以降低资源、能源的消耗。
  2. 风险:比如上文提到用一套MCU结合两套架构,可能会引入额外的风险。
  3. 边界:比如是否加入特殊配置解决业务问题,团队内做自研对于业务需求的边界如何把握的问题?
  4. 系统优化门槛:当跑通上文提到的所有内容后,业务可以跑起来。但如果想要进一步压缩成本,就需要对更深技术栈的理解,比如数据驱动的全链路传输优化,编解码的优化,难度和所需的人力可能都会更高。

在这里插入图片描述

但自研的优势也是很明显的:

  1. 对音视频基建的理解:音视频逐步成为一种基建,但如果团队只通过三方SDK的方式接入音视频能力可能无法深刻理解音视频技术的难点、无法正确评估风险、无法把握潜在的机会。
  2. 更多原子能力:自研技术可以根据复杂的业务需要按照业务线进行更灵活的配置,用合理的方式暴露更深的接口,这会让业务层获得更大的灵活性。
  3. 对产品、研发、技术支持提供帮助:音视频技术涉及广泛且复杂,让客户端研发同学、技术支持同学对业务出现的异常准确排错、根据埋点数据分析问题原因是很困难的。依赖音视频自研团队对业务中遇到的问题进行积累、理解更深层的原因、排查未来可能出现的隐患是一种行之有效的方法。通过音视频自研团队可以辅助产品进行设计、加速研发对音视频技术的落地,还能辅助技术支持在业务中确定用户问题原因、提早发现更深的隐患。毕竟再快的工单系统可能也无法比隔壁工位的支持来的更快。
  4. 成本控制、面向业务优化:当能操控的技术越底层,针对特定业务能做的优化空间也就越大,进一步优化体验的同时也有更多成本压缩的空间。

感谢阅读,以上是本次的分享内容,谢谢!

阅读全文
前端技术分享:页面性能优化问题复盘

项目背景

在 code_pc 项目中,前端需要使用 rrweb 对老师教学内容进行录制,学员可以进行录制回放。为减小录制文件体积,当前的录制策略是先录制一次全量快照,后续录制增量快照,录制阶段实际就是通过 MutationObserver 监听 DOM 元素变化,然后将一个个事件 push 到数组中。

为了进行持久化存储,可以将录制数据压缩后序列化为 JSON 文件。老师会将 JSON 文件放入课件包中,打成压缩包上传到教务系统中。学员回放时,前端会先下载压缩包,通过 JSZip 解压,取到 JSON 文件后,反序列化再解压后,得到原始的录制数据,再传入 rrwebPlayer 实现录制回放。

发现问题

在项目开发阶段,测试录制都不会太长,因此录制文件体积不大(在几百 kb),回放比较流畅。但随着项目进入测试阶段,模拟长时间上课场景的录制之后,发现录制文件变得很大,达到 10-20 M,QA 同学反映打开学员回放页面的时候,页面明显卡顿,卡顿时间在 20s 以上,在这段时间内,页面交互事件没有任何响应。

页面性能是影响用户体验的主要因素,对于如此长时间的页面卡顿,用户显然是无法接受的。

问题排查

经过组内沟通后得知,可能导致页面卡顿的主要有两方面因素:前端解压 zip 包,和录制回放文件加载。同事怀疑主要是 zip 包解压的问题,同时希望我尝试将解压过程放到 worker 线程中进行。那么是否确实如同事所说,前端解压 zip 包导致页面卡顿呢?

3.1 解决 Vue 递归复杂对象引起的耗时问题

对于页面卡顿问题,首先想到肯定是线程阻塞引起的,这就需要排查哪里出现长任务。

所谓长任务是指执行耗时在 50ms 以上的任务,大家知道 Chrome 浏览器页面渲染和 V8 引擎用的是一个线程,如果 JS 脚本执行耗时太长,就会阻塞渲染线程,进而导致页面卡顿。

对于 JS 执行耗时分析,这块大家应该都知道使用 performance 面板。在 performance 面板中,通过看火焰图分析 call stack 和执行耗时。火焰图中每一个方块的宽度代表执行耗时,方块叠加的高度代表调用栈的深度。

按照这个思路,我们来看下分析的结果:
在这里插入图片描述
可以看到,replayRRweb 显然是一个长任务,耗时接近 18s ,严重阻塞了主线程。

而 replayRRweb 耗时过长又是因为内部两个调用引起的,分别是左边浅绿色部分和右边深绿色部分。我们来看下调用栈,看看哪里哪里耗时比较严重:
在这里插入图片描述
熟悉 Vue 源码的同学可能已经看出来了,上面这些耗时比较严重的方法,都是 Vue 内部递归响应式的方法(右边显示这些方法来自 vue.runtime.esm.js)。

为什么这些方法会长时间占用主线程呢?在 Vue 性能优化中有一条:不要将复杂对象丢到 data 里面,否则会 Vue 会深度遍历对象中的属性添加 getter、setter(即使这些数据不需要用于视图渲染),进而导致性能问题。

那么在业务代码中是否有这样的问题呢?我们找到了一段非常可疑的代码

export default {
  data() {
    return {
      rrWebplayer: null
    }
  },
  mounted() {
    bus.$on("setRrwebEvents", (eventPromise) => {
      eventPromise.then((res) => {
        this.replayRRweb(JSON.parse(res));
      })
    })
  },
  methods: {
    replayRRweb(eventsRes) {
      this.rrWebplayer = new rrwebPlayer({
        target: document.getElementById('replayer'),
        props: {
          events: eventsRes,
          unpackFn: unpack,
          // ...
        }
      })
    }
  }
}

在上面的代码中,创建了一个 rrwebPlayer 实例,并赋值给 rrWebplayer 的响应式数据。在创建实例的时候,还接受了一个 eventsRes 数组,这个数组非常大,包含几万条数据。

这种情况下,如果 Vue 对 rrWebplayer 进行递归响应式,想必非常耗时。因此,我们需要将 rrWebplayer 变为 Non-reactive data(避免 Vue 递归响应式)。

转为 Non-reactive data,主要有三种方法

数据没有预先定义在 data 选项中,而是在组件实例 created 之后再动态定义 this.rrwebPlayer (没有事先进行依赖收集,不会递归响应式);

数据预先定义在 data 选项中,但是后续修改状态的时候,对象经过 Object.freeze 处理(让 Vue 忽略该对象的响应式处理);

数据定义在组件实例之外,以模块私有变量形式定义(这种方式要注意内存泄漏问题,Vue 不会在组件卸载的时候销毁状态);

这里我们使用第三种方法,将 rrWebplayer 改成 Non-reactive data 试一下:

let rrWebplayer = null;export default {
  //...
  methods: {
    replayRRweb(eventsRes) {
      rrWebplayer = new rrwebPlayer({
        target: document.getElementById('replayer'),
        props: {
          events: eventsRes,
          unpackFn: unpack,
          // ...
        }
      })
    }
  }
}

重新加载页面,可以看到这时候页面虽然还卡顿,但是卡顿时间明显缩短到5秒内了。观察火焰图可知,replayRRweb 调用栈下,递归响应式的调用栈已经消失不见了:
在这里插入图片描述

3.2 使用时间分片解决回放文件加载耗时问题

但是对于用户来说,这样仍然是不可接受的,我们继续看一下哪里耗时严重:
图片
可以看到问题还是出在 replayRRweb 这个函数里面,到底是哪一步呢:
在这里插入图片描述
那么 unpack 耗时的问题怎么解决呢?

由于 rrweb 录制回放 需要进行 dom 操作,必须在主线程运行,不能使用 worker 线程(获取不到 dom API)。对于主线程中的长任务,很容易想到的就是通过 时间分片,将长任务分割成一个个小任务,通过事件循环进行任务调度,在主线程空闲且当前帧有空闲时间的时候,执行任务,否则就渲染下一帧。方案确定了,下面就是选择哪个 API 和怎么分割任务的问题。

这里有同学可能会提出疑问,为什么 unpack 过程不能放到 worker 线程执行,worker
线程中对数据解压之后返回给主线程加载并回放,这样不就可以实现非阻塞了吗?

如果仔细想一想,当 worker 线程中进行 unpack,主线程必须等待,直到数据解压完成才能进行回放,这跟直接在主线程中 unpack
没有本质区别。worker 线程只有在有若干并行任务需要执行的时候,才具有性能优势。

提到时间分片,很多同学可能都会想到 requestIdleCallback 这个 API。requestIdleCallback 可以在浏览器渲染一帧的空闲时间执行任务,从而不阻塞页面渲染、UI 交互事件等。目的是为了解决当任务需要长时间占用主进程,导致更高优先级任务(如动画或事件任务),无法及时响应,而带来的页面丢帧(卡死)情况。因此,requestIdleCallback 的定位是处理不重要且不紧急的任务。

requestIdleCallback 不是每一帧结束都会执行,只有在一帧的 16.6ms
中渲染任务结束且还有剩余时间,才会执行。这种情况下,下一帧需要在 requestIdleCallback 执行结束才能继续渲染,所以
requestIdleCallback 每个 Tick 执行不要超过
30ms,如果长时间不将控制权交还给浏览器,会影响下一帧的渲染,导致页面出现卡顿和事件响应不及时。

requestIdleCallback 参数说明:

// 接受回调任务
type RequestIdleCallback = (cb: (deadline: Deadline) => void, options?: Options) => number
// 回调函数接受的参数
type Deadline = {
 timeRemaining: () => number // 当前剩余的可用时间。即该帧剩余时间。
 didTimeout: boolean // 是否超时。
}

我们可以用 requestIdleCallback 写个简单的 demo:

// 一万个任务,这里使用 ES2021 数值分隔符
const unit = 10_000;
// 单个任务需要处理如下
const onOneUnit = () => {
    for (let i = 0; i <= 500_000; i++) {}
}
// 每个任务预留执行时间
1msconst FREE_TIME = 1;
// 执行到第几个任务
let _u = 0;

function cb(deadline) {
// 当任务还没有被处理完 & 一帧还有的空闲时间 > 1ms
    while (_u < unit && deadline.timeRemaining() >FREE_TIME) {
        onOneUnit();
        _u ++;
    }
    // 任务干完
    if (_u >= unit) return;
    // 任务没完成, 继续等空闲执行
    window.requestIdleCallback(cb)
}

window.requestIdleCallback(cb)

这样看来 requestIdleCallback 似乎很完美,能否直接用在实际业务场景中呢?答案是不行。我们查阅 MDN 文档就可以发现,requestIdleCallback 还只是一个实验性 API,浏览器兼容性一般:
在这里插入图片描述
查阅 caniuse 也得到类似的结论,所有 IE 浏览器不支持,safari 默认情况下不启用:
在这里插入图片描述
而且还有一个问题,requestIdleCallback 触发频率不稳定,受很多因素影响。经过实际测试,FPS 只有 20ms 左右,正常情况下渲染一帧时长控制在16.67ms 。

为了解决上述问题,在 React Fiber 架构中,内部自行实现了一套 requestIdleCallback 机制:

  • 使用 requestAnimationFrame 获取渲染某一帧的开始时间,进而计算出当前帧到期时间点;
  • 使用 performance.now() 实现微秒级高精度时间戳,用于计算当前帧剩余时间;
  • 使用 MessageChannel 零延迟宏任务实现任务调度,如使用 setTimeout() 则有一个最小的时间阈值,一般是 4ms;

按照上述思路,我们可以简单实现一个 requestIdleCallback 如下:

// 当前帧到期时间点
let deadlineTime;
// 回调任务
let callback;
// 使用宏任务进行任务调度
const channel = new MessageChannel();
const port1 = channel.port1;
const port2 = channel.port2;
// 接收并执行宏任务
port2.onmessage = () => {
  // 判断当前帧是否还有空闲,即返回的是剩下的时间
  const timeRemaining = () => deadlineTime - performance.now();
  const _timeRemain = timeRemaining();
  // 有空闲时间 且 有回调任务
  if (_timeRemain > 0 && callback) {
    const deadline = {
      timeRemaining,
      didTimeout: _timeRemain < 0,
    };
    // 执行回调
    callback(deadline);
  }
};
window.requestIdleCallback = function (cb) {
  requestAnimationFrame((rafTime) => {
    // 结束时间点 = 开始时间点 + 一帧用时16.667ms
    deadlineTime = rafTime + 16.667;
    // 保存任务
    callback = cb;
    // 发送个宏任务
    port1.postMessage(null);
  });
};

在项目中,考虑到 api fallback 方案、以及支持取消任务功能(上面的代码比较简单,仅仅只有添加任务功能,无法取消任务),最终选用 React 官方源码实现。

那么 API 的问题解决了,剩下就是怎么分割任务的问题。

查阅 rrweb 文档得知,rrWebplayer 实例上提供一个 addEvent 方法,用于动态添加回放数据,可用于实时直播等场景。按照这个思路,我们可以将录制回放数据进行分片,分多次调用 addEvent 添加。

import {
  requestHostCallback, cancelHostCallback,
}
 from "@/utils/SchedulerHostConfig";
export default {
  // ...
  methods: {
    replayRRweb(eventsRes = []) {
      const PACKAGE_SIZE = 100;
      // 分片大小
      const LEN = eventsRes.length;
      // 录制回放数据总条数
      const SLICE_NUM = Math.ceil(LEN / PACKAGE_SIZE);
      // 分片数量
      rrWebplayer = new rrwebPlayer({
        target: document.getElementById("replayer"),
        props: {
          // 预加载分片
          events: eventsRes.slice(0, PACKAGE_SIZE),
          unpackFn: unpack,
        },
      });
      // 如有任务先取消之前的任务
      cancelHostCallback();
      const cb = () => {
        // 执行到第几个任务
        let _u = 1;
        return () => {
          // 每一次执行的任务
          // 注意数组的 forEach 没办法从中间某个位置开始遍历
          for (let j = _u * PACKAGE_SIZE; j < (_u + 1) * PACKAGE_SIZE; j++) {
            if (j >= LEN) break;
            rrWebplayer.addEvent(eventsRes[j]);
          }
          _u++;
          // 返回任务是否完成
          return _u < SLICE_NUM;
        };
      };
      requestHostCallback(cb(), () => {
        // 加载完毕回调
      });
    },
  },
};

注意最后加载完毕回调,源码中不提供这个功能,是本人自行修改源码加上的。

按照上面的方案,我们重新加载学员回放页面看看,现在已经基本察觉不到卡顿了。我们找一个 20M 大文件加载,观察下火焰图可知,录制文件加载任务已经被分割为一条条很细的小任务,每个任务执行的时间在 10-20ms 左右,已经不会明显阻塞主线程了:
在这里插入图片描述
优化后,页面仍有卡顿,这是因为我们拆分任务的粒度是 100 条,这种情况下加载录制回放仍有压力,我们观察 fps 只有十几,会有卡顿感。我们继续将粒度调整到 10 条,这时候页面加载明显流畅了,基本上 fps 能达到 50 以上,但录制回放加载的总时间略微变长了。使用时间分片方式可以避免页面卡死,但是录制回放的加载平均还需要几秒钟时间,部分大文件可能需要十秒左右,我们在这种耗时任务处理的时候加一个 loading 效果,以防用户在录制文件加载完成之前就开始播放。

有同学可能会问,既然都加 loading 了,为什么还要时间分片呢?假如不进行时间分片,由于 JS 脚本一直占用主线程,阻塞 UI 线程,这个 loading 动画是不会展示的,只有通过时间分片的方式,把主线程让出来,才能让一些优先级更高的任务(例如 UI 渲染、页面交互事件)执行,这样 loading 动画就有机会展示了。

进一步优化

使用时间分片并不是没有缺点,正如上面提到的,录制回放加载的总时间略微变长了。但是好在 10-20M 录制文件只出现在测试场景中,老师实际上课录制的文件都在 10M 以下,经过测试录制回放可以在 2s 左右就加载完毕,学员不会等待很久。

假如后续录制文件很大,需要怎么优化呢?之前提到的 unpack 过程,我们没有放到 worker 线程执行,这是因为考虑到放在 worker 线程,主线程还得等待 worker 线程执行完毕,跟放在主线程执行没有区别。但是受到时间分片启发,我们可以将 unpack 的任务也进行分片处理,然后根据 navigator.hardwareConcurrency 这个 API,开启多线程(线程数等于用户 CPU 逻辑内核数),以并行的方式执行 unpack ,由于利用多核 CPU 性能,应该能够显著提升录制文件加载速率。

总结

这篇文章中,我们通过 performance 面板的火焰图分析了调用栈和执行耗时,进而排查出两个引起性能问题的因素:Vue 复杂对象递归响应式,和录制回放文件加载。

对于 Vue 复杂对象递归响应式引起的耗时问题,本文提出的解决方案是,将该对象转为非响应式数据。对于录制回放文件加载引起的耗时问题,本文提出的方案是使用时间分片。

由于 requestIdleCallback API 的兼容性及触发频率不稳定问题,本文参考了 React 17 源码分析了如何实现 requestIdleCallback 调度,并最终采用 React 源码实现了时间分片。经过实际测试,优化前页面卡顿 20s 左右,优化后已经察觉不到卡顿,fps 能达到 50 以上。但是使用时间分片之后,录制文件加载时间略微变长了。后续的优化方向是将 unpack 过程进行分片,开启多线程,以并行方式执行 unpack,充分利用多核 CPU 性能。

参考

· vue-9-perf-secrets

· React Fiber很难?六个问题助你理解

· requestIdleCallback – MDN

· requestIdleCallback – caniuse

· 实现React requestIdleCallback调度能力

详情可点击这里查看

if,size_20,color_FFFFFF,t_70,g_se,x_16)

阅读全文