持续集成与持续部署

为什么需要CI/CD

传统的开发过程中的坑:

  • BUG总是在最后才发现
  • 越到项目后期,加班越严重
  • 交付无法保障
  • 变更频繁导致效率低下
  • 无效的等待多,用户满足度低

img

持续集成解决了什么问题?

  • 提高软件质量
  • 效率迭代
  • 便捷部署
  • 快速交付、便于管理

持续集成

核心概念

集成,就是一些孤立的事物或元素通过某种方式集中在一起,产生联系,从而构成一个有机整体的过程。知识经济的社会,集成已经成了很重要的一个名词。各行各业基本都会用到集成。比如汽车行业,那么复杂的一台跑车愣是通过一大堆零件组装起来。对于这些传统行业,它们在研发成功以后,可以通过流水线的方法批量生产进行集成。而在软件行业中,集成并不是一个简单的“搬箱子”的过程。因为软件工业是一个知识生产活动,其内在逻辑非常复杂,需求又很难一次性确定,完成的产品与最初的设计往往相差很远。敏捷宣言中就有一条是说响应变化重于遵循计划。而且由于软件行业的迅猛发展,软件变的越来越复杂,单靠个人是根本无法完成。大型软件为了重用及解耦,往往还需要分成好几个模块,这样集成就成了软件开发中不可或缺的一部分。

持续,不言而喻,就是指长期的对项目代码进行集成。

持续集成

持续集成(英文:Continuous Integration,简称CI)

在软件工程中,持续集成是指将所有开发者工作副本每天多次合并到主干的做法。

Grady Booch 在1991年的 Booch method 中首次命名并提出了 CI 的概念,尽管在当时他并不主张每天多次集成。而 XP(Extreme programming,极限编程)采用了 CI 的概念,并提倡每天不止一次集成。

在《持续集成》一书中,对持续集成的定义如下:持续集成是一种软件开发实践。在持续集成中,团队成员频繁集成他们的工作成果,一般每人每天至少集成一次,也可以多次。每次集成会经过自动构建(包括自动测试)的检验,以尽快发现集成错误。自从在团队中引入这样的实践之后,Martin Fowler发现这种方法可以显著减少集成引起的问题,并可以加快团队合作软件开发的速度。

img

持续集成强调开发人员提交了新代码之后,立刻进行构建、(单元)测试。根据测试结果,我们可以确定新代码和原有代码能否正确地集成在一起。

对于一天需要集成多少次数,并没有一个明确的定义。一般就是按照自己项目的实际需要来设置一定的频率,少则可能几次,多则可能达几十次。可以设置按照代码的变更来触发集成,或者设置一个固定时间周期来集成,也可以手工点击集成的按钮来“一键集成”。

持续交付

持续交付(英文:Continuous Delivery,简称CD)

完成 CI 中构建及单元测试和集成测试的自动化流程后,持续交付可自动将已验证的代码发布到存储库。为了实现高效的持续交付流程,务必要确保 CI 已内置于开发管道。持续交付的目标是拥有一个可随时部署到生产环境的代码库。

img

在持续交付中,每个阶段(从代码更改的合并,到生产就绪型构建版本的交付)都涉及测试自动化和代码发布自动化。在流程结束时,运维团队可以快速、轻松地将应用部署到生产环境中。

比如,我们完成单元测试后,可以把代码部署到连接数据库的 Staging 环境中更多的测试。如果代码没有问题,可以继续手动部署到生产环境中。

持续部署

持续部署(英文:Continuous Deployment,简称CD)

对于一个成熟的 CI/CD 管道来说,最后的阶段是持续部署。作为持续交付——自动将生产就绪型构建版本发布到代码存储库——的延伸,持续部署可以自动将应用发布到生产环境。由于在生产之前的管道阶段没有手动门控,因此持续部署在很大程度上都得依赖精心设计的测试自动化。

img

实际上,持续部署意味着开发人员对应用的更改在编写后的几分钟内就能生效(假设它通过了自动化测试)。这更加便于持续接收和整合用户反馈。总而言之,所有这些 CI/CD 的关联步骤都有助于降低应用的部署风险,因此更便于以小件的方式(而非一次性)发布对应用的更改。不过,由于还需要编写自动化测试以适应 CI/CD 管道中的各种测试和发布阶段,因此前期投资还是会很大。

持续部署则是在持续交付的基础上,把部署到生产环境的过程自动化

持续集成组成要素

一个最小化的持续集成系统需要包含以下几个要素:

  1. 版本管理系统:项目的源代码需要托管到适合的版本管理系统中,一般我们使用git作为版本控制库,版本管理软件可以使用github、gitlab、stash等。
  2. 构建脚本&工具:每个项目都需要有构建脚本来实现对整个项目的自动化构建。比如Java的项目就可以使用gradle作为构建工具。通过构建工具实现对编译、静态扫描、运行测试、样式检查、打包、发布等活动串起来,可以通过命令行自动执行。
  3. CI服务器:CI服务器可以检测项目中的代码变动,并及时的通过构建机器运行构建脚本,并将集成结果通过某种方式反馈给团队成员。

应用场景

  • 打包平台

    常见的打包,Java应用(Gradle/Maven)、Nodejs前端应用(npm/yarn)

    移动端打包:Android/iOS

  • 测试平台

    接口测试

    自动化测试Robotium、Testlink

    单元测试junit

    性能测试Jmeter

  • 自动部署

    FTP

    Shell

    Tomcat/Dokcer

    Kubernetes/Rancher/Cluster

  • 持续集成

    Git: gitlab github gitee等

    Jenkins/TravisCi/CircleCI

    Docker

工作流

传统的工作流

参与人员:开发、项目经理、测试

主要流程:

  • 项目一开始是先划分好模块,分配模块给相应的开发人员;
  • 开发人员开发好一个模块就进行单元测试
  • 等所有的模块都开发完成之后,由项目经理对所有代码进行集成
  • 集成后的项目由项目经理部署到测试服务器上,被交由测试人员进行集成测试;
  • 测试过程中出现 Bug 就提把问题记录进行 Bug 列表中;
  • 项目经理分配 Bug 给相应的责任人进行修改;
  • 修改完成后,项目经理再次对项目进行集成,并部署到测试服务器上;
  • 测试人员在下一次的集成测试中进行回归测试
  • 通过通过之后就部署到生产环境中;
  • 如果测试不通过,则重复上述“分配 Bug -> 修改 Bug -> 集成代码 -> 部署到测试服务器上 -> 集成测试”工作。

这也是传统的瀑布式开发模型,请参考:软件开发模式对比(瀑布、迭代、螺旋、敏捷)

带来的问题:

  1. 重复性劳动,无效的等待变多

    重复的进行发布部署。

    流程上:有可能开发在等集成其他人的模块;测试人员在等待开发人员修复 Bug;产品经理在等待新版本上线好给客户做演示;项目经理在等待其他人提交代码。不管怎么样,等待意味低效。

    自动化部署工作可以解放了集成、测试、部署等重复性劳动,而且机器集成的频率明显可以比手工的高很多。

  2. 很晚才发现缺陷,并且难以修复

    实践证明,缺陷发现的越晚,需要修复的时间和精力也就越大。从上一个可工作的软件到发现缺陷之间可能存在很多次提交,而要从这些提交中找出问题并修复的成本会很大,因为开发人员需要回忆每个提交的上下文来评估影响点。

  3. 低品质的软件,软件交付时机无法保障

    由于集成时每次包含的代码很多,所以大家的关注点主要都是如何保证编译通过、自动化测试通过,而往往很容易忽略代码是否遵守了编码规范、是否包含有重复代码、是否有重构的空间等问题。而这些问题又反过来会影响今后的开发和集成,久而久之集成变得越来越困难,软件的质量可想而知。

  4. 项目缺少可见性

    某些项目,程序会经常需要变更,特别是敏捷开发的实践者。由于产品经理在与客户交流过程中,往往实际的软件就是最好的原型,所以软件会被当作原型作为跟客户交流的工具。当然,客户最希望的当然是客户的想法能够马上反映到原型上,这会导致程序会经常被修改的。那么也就意味着“分配 Bug -> 修改 Bug -> 集成代码 -> 部署到测试服务器上 -> 集成测试”工作无形又爆增了。

常见的工作流

DevOps

该系统的各个组成部分是按如下顺序来发挥作用的:

  1. 开发者检入代码到源代码仓库。

  2. CI系统会为每一个项目创建了一个单独的工作区。当预设或请求一次新的构建时,它将把源代码仓库的源码存放到对应的工作区。

  3. CI系统会在对应的工作区内执行构建过程。

  4. 配置如果存在)构建完成后,CI系统会在一个新的构件中执行定义的一套测试。完成后触发通知(Email,RSS等等)给相关的当事人。

  5. 配置如果存在)如果构建成功,这个构件会被打包并转移到一个部署目标(如应用服务器)或存储为软件仓库中的一个新版本。软件仓库可以是CI系统的一部分,也可以是一个外部的仓库,诸如一个文件服务器或者像Java.net、SourceForge之类的网站。

  6. CI系统通常会根据请求发起相应的操作,诸如即时构建、生成报告,或者检索一些构建好的构件。

“You build it, you run it”,这是 Amazon 一年可以完成 5000 万次部署,平均每个工程师每天部署超过 50 次的核心秘籍。

解决的问题

  • 高效率

    高效率的发布,避免了重复性的劳动;

    更快的修复BUG,更快的交付成果,减少了等待时间。

  • 高质量

    只有在完成集成测试、系统测试后,才能得到可用的软件,整个过程中只有最后时刻才能拿到可运行软件。集成活动不一定在一个标准的构建机器上生成,而是在某个开发人员的机器上构建的,那么可能存在在其他机器上无法运行的问题。

    人与机器的一个最大的区别是,在重复性动作上,人容易犯错,而机器犯错的几率几乎为零。所以,当我们搭建完成集成服务器后,以后的事就交给集成服务器来打理吧。

  • 高产出

    快速开发和上市一个新产品,并快速取得预期的投资回报是每个企业孜孜以求的目标。

    便捷的部署+项目的可预期,使得团队的开发变成了一种开心的事情。

    持续集成可以让你在任何时间发布可以部署的软件。在外界看来,这是持续集成最明显的好处,对客户来说,可以部署的软件产品是最实际的资产。利用持续集成,你可以经常对源代码进行一些小改动,并将这些改动和其他代码进行集成。

常见问题

  1. 思维转变后,新技术抵触

    • 无法接受新事物:不管怎么样,求稳心态的人还是多。总是有人认为老的技术代表稳定,新的事物往往会带来问题。
    • 认为手工集成也没有多少工作量:不是所有的人都参与到了整个持续集成的环节,所以没有办法认识到问题全貌。

    针对这个问题,可以通过设置一定的持续集成技术培训、宣讲得到改观

  2. 管理层的抵触

    • 培训持续集成需要投入资金啊,没钱。
    • 持续集成服务器要增加软硬件成本啊,没钱。
    • 开发人员领了那么高的工资,多干活多加班应该啊。

    针对这一点,可以从开发人员的成本和持续集成的投入(软硬件)的成本上两者做下估算。

    硬件参考:

    Jenkins主服务器一般2C4G,slave服务器根据生产需要进行选购。

    git服务器一般2C4G(10人团队)

    Docker服务器8C32G(Rancher + harbor)

  3. 生产环境的复杂

    • 比如部署的生成环境是在政务外网,无法从互联网直接访问等。
    • 构建效率低下,任务多

    目前,这个是最麻烦的,还在研究中。初步设想是让政务外网开辟一个白名单,给持续集成服务器设置一个单独的通道。只是思路,未验证。

最佳实践

实施持续集成的开发人员可以尽早并经常提交。这允许他们尽早发现冲突。并且,如果存在任何问题,则使用较小的提交可以更轻松地对代码进行故障排除。每天或甚至更频繁地提交软件对于持续集成是必要的,但还不够。

要成功使用持续集成,团队必须:

  • 使测试成为开发过程中不可或缺的一部分。应该在创建代码时编写测试。

    公司成功持续整合所需的最重要因素是严格的测试文化。为了将新代码自信地集成到主线中,团队需要确信代码是健全的。这是通过测试来实现的,这应该定期进行。工程师应该在开发每个功能时编写测试。

  • 确保测试环境反映生产一致。

    为了支持您严格的测试文化,测试环境必须反映生产环境。否则,您无法保证您正在测试的内容将在生产中起作用。这意味着测试环境应使用相同版本的数据库,Web服务器配置,工件等。

  • 使用编码最佳实践,例如结对编程。

    软件开发的另一个最佳实践是在编码期间进行配对。对于更复杂的功能,团队在编写单行代码之前讨论体系结构方法。在将任何代码合并到生产环境之前,其他开发人员始终会检查代码。这有助于确保使用编码最佳实践,代码不会与其他开发人员正在处理的现有代码或代码冲突,并且新功能是可扩展的。

    Pair programming is an agile software development technique in which two programmers work together at one workstation. One, the driver, writes code while the other, the observer or navigator,[1] reviews each line of code as it is typed in. The two programmers switch roles frequently.

    While reviewing, the observer also considers the “strategic” direction of the work, coming up with ideas for improvements and likely future problems to address. This is intended to free the driver to focus all of their attention on the “tactical” aspects of completing the current task, using the observer as a safety net and guide.

  • 自动化部署工作流程。

    最后,为确保整个软件开发流程快速高效,构建需要快速,部署工作流程应自动化。代码构建的每一分钟都浪费了一分钟。通过自动化部署工作流程,团队可以更快地将完成的代码生成。因为,毕竟,如果没有接触到客户,那么快速开发软件有什么意义呢?

效率工具对比

点击查看效率工具

1. Jenkins

Jenkins,原名Hudson,2011年改为现在的名字,它 是一个开源的实现持续集成的软件工具。官方网站:http://jenkins-ci.org/

Jenkins 能实时监控集成中存在的错误,提供详细的日志文件和提醒功能,还能用图表的形式形象地展示项目构建的趋势和稳定性

Jenkins特点:

  • 易安装:Jenkins是一个独立的基于Java的程序,随时可以运行,包含Windows,Mac OS X和其他类Unix操作系统的软件包。仅仅一个 java -jar jenkins.war,从官网下载该文件后,直接运行,无需额外的安装,更无需安装数据库;
  • 易配置:提供友好的GUI配置界面;
  • 变更支持:Jenkins能从代码仓库(Subversion/CVS)中获取并产生代码更新列表并输出到编译输出信息中;
  • 支持永久链接:用户是通过web来访问Jenkins的,而这些web页面的链接地址都是永久链接地址,因此,你可以在各种文档中直接使用该链接;
  • 集成E-Mail/RSS/IM:当完成一次集成时,可通过这些工具实时告诉你集成结果(据我所知,构建一次集成需要花费一定时间,有了这个功能,你就可以在等待结果过程中,干别的事情);
  • JUnit/TestNG测试报告:也就是用以图表等形式提供详细的测试报表功能;
  • 支持分布式构建:Jenkins可以把集成构建等工作分发到多台计算机中完成
  • 文件指纹信息:Jenkins会保存哪次集成构建产生了哪些jars文件,哪一次集成构建使用了哪个版本的jars文件等构建记录;
  • 支持第三方插件:使得 Jenkins 变得越来越强大;凭借更新中心中的数百个插件,Jenkins几乎集成了持续集成和持续交付工具链中的所有工具。
  • Rest API - 可以访问控制您获取的数据量,获取/更新config.xml,删除作业,检索所有构建,获取/更新作业说明,执行构建,禁用/启用作业

Jenkins优点:

  • 价格(免费)
  • 定制
  • 插件系统
  • 完全控制系统

Jenkins缺点:

  • 需要专用服务器(或多个服务器)。这导致额外的费用。对于服务器本身,DevOps等…
  • 配置/定制所需的时间

2. Travis CI

Travis CI是一个托管的持续集成服务,用于构建和测试在GitHub上托管的软件项目。

Travis CI is a hosted continuous integration service used to build and test software projects hosted at GitHub

Travis CI的特点:

  • 基于云:TravisCI是一个基于云的系统 - 不需要专用服务器,您无需管理它。

  • 支持Docker运行测试

  • 使用YAML文件进行配置

  • 可选择Linux和Mac OSX上同时运行测试

  • 开箱即用的支持的语言

    Android,C,C#,C ++,Clojure,Crystal,D,Dart,Erlang,Elixir,F#,Go,Groovy,Haskell,Haxe,Java,JavaScript(使用Node.js),Julia,Objective-C,Perl,Perl6, PHP,Python,R,Ruby,Rust,Scala,Smalltalk,Visual Basic

  • 支持多环境构建矩阵:如Python 2.7 , 3.4, 3.5 + Django 1.8, 1.9, 1.10

    构建矩阵是一种工具,可以使用不同版本的语言和包运行测试。您可以以不同的方式自定义它。例如,某些环境的失败可以触发通知但不会使所有构建失败(这对包的开发版本有帮助)

Travis CI优点:

  • 开箱即用构建矩阵
  • 快速启动
  • 轻量级YAML配置
  • 开源项目的免费计划
  • 无需专用服务器

Travis CI缺点:

  • 与CircleCI相比,价格更高,没有免费的企业计划
  • 定制(对于某些你需要第三方的东西)

3. Circle CI

在GitHub或Bitbucket上的软件存储库被授权并作为项目添加到circleci.com之后,每个代码更改都会在干净的容器或VM中触发自动化测试。

CircleCI在2017年被Forrester评为持续集成领导者,并被命名为多个最佳DevOps工具列表。CircleCI成立于2011年,总部位于旧金山,拥有全球性的远程员工队伍,由Scale Venture Partners,DFJ,Baseline Ventures,Top Tier Capital,Industry Ventures,Heavybit和Harrison Metal Capital提供风险投资。

Circle CI的特点:

  • 云&本地化:CircleCI是一个基于云的系统 - 不需要专用服务器,您无需管理它。 但是,它还提供了一个本地解决方案,允许您在私有云或数据中心中运行它。
  • 商业&免费:即使是商业帐户,它也有免费计划
  • Rest API - 您可以访问项目,构建和工件(artifacts)。构建的结果将是工件或工件组。 工件可以是已编译的应用程序或可执行文件(例如,android APK)或元数据(例如,关于测试`成功的信息)
  • 按需安装:CircleCI 缓存必要的安装(requirements installation)。 它会检查第三方依赖项,而不是持续安装所需的环境
  • SSH模式:您可以触发SSH模式访问容器并进行自己的调查(如果出现任何问题)
  • 最小化配置:这是一个完整的开箱即用解决方案,需要最少的配置\调整

CircleCI优点:

  • 快速启动
  • CircleCI有一个免费的企业项目计划
  • 这很容易,也很快开始
  • 轻量级,易读的YAML配置
  • 您不需要任何专用服务器来运行CircleCI

CircleCI缺点:

  • CircleCI仅支持2个版本的Ubuntu免费(12.04和14.04)和MacOS作为付费部分

  • 尽管CircleCI可以使用并运行所有语言,但tt仅支持“开箱即用”的以下编程语言:Go(Golang),Haskell,Java,PHP,Python,Ruby / Rails,Scala

  • 如果您想进行自定义,可能会出现一些问题:您可能需要一些第三方软件来进行这些调整

  • 此外,虽然作为基于云的系统是一方的优势,它也可以停止支持任何软件,你将无法阻止

总结一下:

分类 Jenkins Travis CI Circle CI
本地部署 支持 不支持 支持
REST API 支持 支持 支持
配置 复杂,高度可配置 YAML文件 YAML文件
按需安装
跨平台支持 Linux + MacOS Linux + MacOS(付费)
多服务器 按需
快速构建 手动配置复杂 快(需要写配置文件) 最快
基本环境 Java 云环境 云环境
费用 免费 特定免费(69$/c) 特定免费(50$/c)

Travis CI的价格(非常感人):

image-20190611100501785

CirCle CI的价格:

image-20190611100534676

其他的一些持续集成的工具:CruiseControl,TeamCity,Continuum等

  • AnthillPro:商业的构建管理服务器,提供C功能
  • Bamboo:商业的CI服务器,对于开源项目免费
  • Build Forge:多功能商业构建管理工具,特点:高性能、分布式构建
  • Cruise Control:基于java实现的持续集成构建工具
  • CruiseControl.NET:基于C#实现的持续集成构建工具
  • Lunt build:开源的自动化构建工具
  • Para Build:商业的自动化软件构建管理服务器

Jenkins

使用简介

Jenkins是开源CI&CD软件领导者, 提供超过1000个插件来支持构建、部署、自动化, 满足任何项目的需要。

image-20190611103941326

相关概念:

  • 流水线:Jenkins Pipeline(或简称为 “Pipeline”)是一套插件,将持续交付的实现和实施集成到 Jenkins 中。

    Jenkins Pipeline 提供了一套可扩展的工具,用于将“简单到复杂”的交付流程实现为“持续交付即代码”。Jenkins Pipeline 的定义通常被写入到一个文本文件(称为 Jenkinsfile )中,该文件可以被放入项目的源代码控制库中。

  • 节点:节点是一个机器,主要用于执行jenkins任务

  • 阶段:定义不同的执行任务,比如:构建、测试、发布(部署)

  • 步骤:相当于告诉Jenkins现在要做些什么,比如shell命令。

image-20190611163108298

Jenkins的界面

image-20190611163727975

任务详情页面

image-20190611163855257

Jenkins任务日志

安装方式

  1. 环境要求

    • 机器要求:
      • 256 MB 内存,建议大于 512 MB
      • 10 GB 的硬盘空间(用于 Jenkins 和 Docker 镜像)
    • 需要安装以下软件:
      • Java 8 ( JRE 或者 JDK 都可以)
      • Docker (导航到网站顶部的Get Docker链接以访问适合您平台的Docker下载)
  2. 常规安装

    • 安装JDK

      官方地址

      下载对应的操作系统的JDK,然后解压进行安装。以Linux为例:

      下载最新版本,上传到Linux服务器

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      # 上传到 /opt/jdk8目录下

      # tar解压JDK安装包
      mkdir -p /opt/jdk8
      tar zxvf jdk-8u211-linux-x64.tar.gz -C /opt/jdk8 --strip-components 1

      # vi /etc/profile
      export JAVA_HOME=/opt/jdk8
      export JRE_HOME=${JAVA_HOME}/jre
      export CLASSPATH=.:${JAVA_HOME}/lib:${JRE_HOME}/lib
      export PATH=${JAVA_HOME}/bin:$PATH
    • 安装Jenkins

      下载Jenkins最新的war包:Latest

      1
      2
      3
      4
      5
      mkdir -p /opt/jenkins && cd /opt/jenkins

      wget -O /opt/jenkins/jenkins.war http://mirrors.jenkins.io/war-stable/latest/jenkins.war

      java -jar jenkins.war --httpPort=8080

      就嗯可以打开,http://localhost:8080了

      注意一段这样的话:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      *************************************************************
      *************************************************************
      *************************************************************

      Jenkins initial setup is required. An admin user has been created and a password generated.
      Please use the following password to proceed to installation:

      63196690ae7d47c49506480ee0e1af4a

      This may also be found at: /root/.jenkins/secrets/initialAdminPassword

      *************************************************************
      *************************************************************
      *************************************************************

      这里的63196690ae7d47c49506480ee0e1af4a就是初始的安装的管理员密码。

  3. 使用Docker安装

    • 安装Docker

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      # From https://get.docker.com:
      curl -fsSL https://get.docker.com -o get-docker.sh
      sh get-docker.sh

      #From https://test.docker.com:
      curl -fsSL https://test.docker.com -o test-docker.sh
      sh test-docker.sh

      # From the source repo (This will install latest from the test channel):
      sh install.sh
    • 配置Docker镜像加速,使用阿里云容器加速服务

      左侧的加速器帮助页面就会显示为你独立分配的加速地址

      1
      2
      例如:
      公网Mirror:[系统分配前缀].mirror.aliyuncs.com

      使用配置文件 /etc/docker/daemon.json(没有时新建该文件)

      1
      2
      3
      {
      "registry-mirrors": ["<your accelerate address>"]
      }

      重启Docker Daemon就可以了

      1
      2
      systemctl daemon-reload
      systemctl restart docker

      docker ps 查看容器运行状态

      docker logs 容器ID/容器名称 查看管理员初始密码

    • 安装Docker-compose.yml文件(可选)

      安装方法:

      1
      2
      3
      4
      5
      6
      #下载
      sudo curl -L https://github.com/docker/compose/releases/download/1.20.0/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
      #安装
      chmod +x /usr/local/bin/docker-compose
      #查看版本
      docker-compose --version
  • 安装Jenkins

    版本选择:

    Jenkins: https://hub.docker.com/r/jenkins/jenkins/

    Jenkins with Blue Ocean: https://hub.docker.com/r/jenkinsci/blueocean

    Blue Ocean 重新思考Jenkins的用户体验,从头开始设计Jenkins Pipeline, 但仍然与自由式作业兼容,Blue Ocean减少了混乱而且进一步明确了团队中每个成员 Blue Ocean 的主要特性包括:

    • 持续交付(CD)Pipeline的 复杂可视化 ,可以让您快速直观地理解管道状态。
    • Pipeline 编辑器 - 引导用户通过直观的、可视化的过程来创建Pipeline,从而使Pipeline的创建变得平易近人。
    • 个性化 以适应团队中每个成员不同角色的需求。
    • 在需要干预和/或出现问题时 精确定位 。 Blue Ocean 展示 Pipeline中需要关注的地方, 简化异常处理,提高生产力
    • 本地集成分支和合并请求, 在与GitHub 和 Bitbucket中的其他人协作编码时实现最大程度的开发人员生产力。****

    安装命令:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    # Jenkins
    docker run \
    -itd \
    -u root \
    -p 8080:8080 \
    -v jenkins-data:/var/jenkins_home \
    -v /var/run/docker.sock:/var/run/docker.sock \
    -v /usr/bin/docker:/usr/bin/docker \
    --name jenkins-master \
    jenkins/jenkins

    # Jenkins blueocean
    docker run \
    -itd \
    -u root \
    -p 8080:8080 \
    -v jenkins-data:/var/jenkins_home \
    -v /var/run/docker.sock:/var/run/docker.sock \
    -v /usr/bin/docker:/usr/bin/docker \
    --name jenkins-master \
    jenkinsci/blueocean
  1. 配置Jenkins插件加速

    进入jenkins系统管理->插件管理中->高级选项卡->升级站点,使用清华源:

    1
    https://mirrors.tuna.tsinghua.edu.cn/jenkins/updates/current/update-center.json

    系统管理

    插件管理

    关于官方所有的镜像列表:

    http://mirrors.jenkins-ci.org/status.html

  2. 环境配置

    • Jenkins 的URL路径
    • 全局工具的配置:Docker, JDK(JAVA)…
  3. 用户权限配置

    • 矩阵权限的配置
    • 添加管理员用户所有的权限
    • 添加Authorize Project插件,并且在系统管理中进行配置。配置逻辑,就给用户当前项目的矩阵权限!
  4. 与gitlab进行联接

    • 设置一个SSH Key,方便Jenkins去拉取Gitlab中的项目
    • GItlab项目中去配置SSH Key的Deploy权限。Settings -> Repository -> Deploy keys -> Public Deploy Keys
    • Jenkins添加SSH的私钥

    这样就完成了Jenkins可以访问gitlab的联接的过程。

    Jenkins层面,需要去安装Gitlab相关插件。

插件介绍

  • Publish over SSH

    这个是一个远程Shell工具,可以远程去执行一些shell命令

  • HTTP Request Plugin

    跨平台调用,在构建前后可以通过该插件以http形式调用各种api接口实现和内部系统的联动

  • Publish Over FTP

    用于远程使用FTP发布,比较合适于静态资源的发布。

    img

  • Performance Plugin

​ 该插件可以读取和解析测试框架输出的报告,并且在 Jenkins 上绘制性能和稳定性相关的图表。Performance Plugin 支持的测试框架有 JUnit、JMeter, Twitter 的 Lago 和 Taurus。下图是该插件输出的示例图:

img

https://plugins.jenkins.io/performance

  • Gitlab Merge Request Builder Plugin

​ Gitlab Merge Request Builder Plugin 可以方便的自动发起代码审查,它在创建 pull request 的时候,会自动带上关联任务的运行结果,以方便代码审查着确认改动的正确性。

​ 同时,这款插件还支持自动合并,既在代码审查通过后自动合并该 pull request 内容。

https://github.com/timols/jenkins-gitlab-merge-request-builder-plugin

  • JIRA Plugin

​ JIRA Plugin 可以让 Jenkins 任务和 JIRA 集成起来,这样项目管理者可以通过 JIRA 了解项目进度,开发者也可以通过该插件直接更改 JIRA 上的 issue 状态。

https://plugins.jenkins.io/jira

  • Kubernetes Plugin

​ 和最近大热的容器编排框架 Kubernetes 集成当然不能落下了。另外,Jenkins 对执行机的管理一直比较弱,无法做到快速的扩容和缩容。Kubernetes Plugin 通过引入 Kubernetes 的容器编排能力,让 Jenkins 执行机运行在 Kubernetes 环境中。

https://github.com/jenkinsci/kubernetes-plugin

  • Build Pipeline plugin

​ 对一个系统的持续集成会包含很多个方面,如果将它们都杂糅在一个 Jenkins 任务中,会提高排查成本,也不利于整个持续集成的运作。Build Pipeline plugin 可以让项目管理员针对系统持续集成步骤设置一系列关联的任务,任务之间可以设置不同的触发条件,以确认何时需要人工介入。该插件可以让整个持续集成流程变得非常直观:

img

https://github.com/jenkinsci/build-pipeline-plugin

配置自动化任务

两种执行方法:

  1. 配置自由风格的项目
  2. 配置Pipeline使用Jenkinsfile

需要注意的地方

  • SSH插件:

    SSH

    SSH Agent

    SSH Pipeline Steps

    Publish Over SSH

  • git相关插件:

    Gitlab

    Github

  • 管理员界面配置:

    Settings -> network -> Outbound requests

    Allow requests to the local network from hooks and services 进行勾选

其他的一些用法:

  • 使用Jenkins配合Docker Hub,把前端代码打包成镜像,再远程部署
  • Jenkins配合自建Docker容器服务,把前端代码打包,使用Kubernetes进行发布。
  • 使用Docker进行远程发布(远程服务器上有Docker服务,并且设置了远程连接)

把构建、打包的工作放在Docker容器里面,用于应对不同的开发环境需求(node8,node10等)

前端项目中的应用

插件推荐:

  • nodejs插件

    主要是用于不同版本的Node打包

    特别需要注意的是,使用jenkinsci/blueocean镜像的同学,需要重新运行新的容器,以便nodejs插件生效。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    docker run \
    -itd \
    -u root \
    -p 8080:8080 \
    -v /var/jenkins_home:/var/jenkins_home \
    -v /var/run/docker.sock:/var/run/docker.sock \
    -v /usr/bin/docker:/usr/bin/docker \
    --name jenkins-master \
    jenkins/jenkins

    注意:docker stop 容器名称 去停止之前的容器!

  • Publish Over SSH

    用于构建完成之后,推送到远程的web服务器

Jenkins发布到Nginx Docker容器:

1
2
3
4
5
6
7
8
9
10
# !bin/bash
node -v
cnpm install
npm run build
ls -la
if [ "$(docker inspect -f '{{.State.Running}}' nginx)" = "true" ]; then
docker stop nginx && docker rm nginx;
fi

docker run -itd --name nginx -v `pwd`/dist:/usr/share/nginx/html -p 20000:80 nginx

Pipleline演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pipeline {
agent {
docker {
image 'node:10'
args '-p 20000:8080'
}

}
stages {
stage('Build') {
steps {
sh 'yarn install'
}
}
stage('Deploy') {
steps {
sh './scripts/deploy.sh'
input 'Finished using the web site? (Click "Proceed" to continue)'
sh './scripts/kill.sh'
}
}
}
}

deploy.sh文件

1
2
3
4
5
6
7
8
9
#!/usr/bin/env sh
set -x
npm run serve &
sleep 1
echo $! > .pidfile
set +x

echo 'Now...'
echo 'Visit http://localhost:8080 to see your Node.js/Vue application in action.'

set命令用法:

set指令能设置所使用shell的执行方式,可依照不同的需求来做设置
 -a  标示已修改的变量,以供输出至环境变量。
 -b  使被中止的后台程序立刻回报执行状态。
 -C  转向所产生的文件无法覆盖已存在的文件。
 -d  Shell预设会用杂凑表记忆使用过的指令,以加速指令的执行。使用-d参数可取消。
 -e  若指令传回值不等于0,则立即退出shell。  
 -f   取消使用通配符。
 -h  自动记录函数的所在位置。
 -H Shell  可利用”!”加<指令编号>的方式来执行history中记录的指令。
 -k  指令所给的参数都会被视为此指令的环境变量。
 -l  记录for循环的变量名称。
 -m  使用监视模式。
 -n  只读取指令,而不实际执行。
 -p  启动优先顺序模式。
 -P  启动-P参数后,执行指令时,会以实际的文件或目录来取代符号连接。
 -t  执行完随后的指令,即退出shell。
 -u  当执行时使用到未定义过的变量,则显示错误信息。
 -v  显示shell所读取的输入值。
 -x  执行指令后,会先显示该指令及所下的参数。

+<参数>  取消某个set曾启动的参数。

kill.sh文件:

1
2
3
4
5
6
7
#!/usr/bin/env sh

echo 'The following command terminates the "npm run serve" process using its PID'
echo '(written to ".pidfile"), all of which were conducted when "deloy.sh"'
echo 'was executed.'
set -x
kill $(cat .pidfile)

思路:

  1. 使用一台单独的Nginx服务器发布,使用Publish Over SSH插件上传
  2. 使用Docker在本地发布或者远程发布
  3. 使用Dockerfile进行镜像内的构建,使用docker镜像进行发布

TravisCI

使用简介

Travis CI 只支持 Github,不支持其他代码托管服务。这意味着,你必须满足以下条件,才能使用 Travis CI。

  • 拥有 GitHub 帐号
  • 该帐号下面有一个项目
  • 该项目里面有可运行的代码
  • 该项目还包含构建或测试脚本

Travis简单的使用步骤:

  • github授权及面板
  • 获取github的Tokens
  • 配置项目.travis.yml
    • Node项目
    • Script脚本
    • 部署到github pages
    • 钩子用法
  • 其他

github授权及面板

首先,访问官方网站 travis-ci.org,点击右上角的个人头像,使用 Github 账户登入 Travis CI。

会进入到授权页面,这里跟微博、QQ是一回事,主要是读取你的用户信息。

image-20190612205411602

这里第二步在,Dashboard这个选项卡中,点击Activate这个按钮

image-20190612212459194

完了之后,Dashboard会列出所有Github中有.travis.yml配置文件的仓库:

image-20190612212731186

Travis 会列出 Github 上面你的所有仓库,以及你所属于的组织。此时,选择你需要 Travis 帮你构建的仓库,打开仓库旁边的开关。一旦激活了一个仓库,Travis 会监听这个仓库的所有变化。

image-20190612213022380

  • Settings中用于配置项目的构建条件

    image-20190612214434173

  • Requests可以查看构建记录

    image-20190612214519926

  • Caches主要是缓存文件

  • Trigger build手动触发构建

获取github的token

在settings->Developer settings->Personal access tokens->Generate new token

image-20190612225000955

配置项目的.travis.yml文件

  • 设置项目语言

    1
    2
    3
    language: node_js
    node_js:
    - "10"
  • Script脚本

    Travis 的运行流程很简单,任何项目都会经过两个阶段。

    • install 阶段:安装依赖
    • script 阶段:运行脚本

    配置文件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    # S: Build Lifecycle
    install:
    - yarn install

    // 钩子方法
    before_script:

    # 无其他依赖项所以执行npm run build 构建就行了
    script:
    - npm run build

    如果不需要安装,即跳过安装阶段,就直接设为true

    1
    install: true

    script字段用来指定构建或测试脚本。

    1
    script: bundle exec thor build

    如果有多个脚本,可以写成下面的形式。

    1
    2
    3
    script:
    - command1
    - command2
  • 部署到github pages

    1
    2
    3
    4
    5
    6
    7
    8
    9
    deploy:
    # 其他的一些配置项,可以参考:https://docs.travis-ci.com/user/deployment/pages/
    provider: pages
    skip_cleanup: true
    local_dir: dist/
    github_token: $GITHUB_TOKEN # Set in the settings page of your repository, as a secure variable
    keep_history: false
    on:
    branch: master
  • 钩子用法

    Travis 为上面这些阶段提供了7个钩子。

    • before_install:install 阶段之前执行
    • before_script:script 阶段之前执行
    • after_failure:script 阶段失败时执行
    • after_success:script 阶段成功时执行
    • before_deploy:deploy 步骤之前执行
    • after_deploy:deploy 步骤之后执行
    • after_script:script 阶段之后执行

完整的生命周期,从开始到结束是下面的流程。

  1. before_install
  2. install
  3. before_script
  4. script
  5. aftersuccess or afterfailure
  6. [OPTIONAL] before_deploy
  7. [OPTIONAL] deploy
  8. [OPTIONAL] after_deploy
  9. after_script

参考资料:

配置Node.js应用

配置一个Vue实例并发布到github pages

.travis.yml文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
language: node_js
node_js:
- "10"

# Travis-CI Caching
cache:
directories:
- node_modules

# S: Build Lifecycle
install:
- yarn install

before_script:

# 无其他依赖项所以执行npm run build 构建就行了
script:
- npm run build

deploy:
# 其他的一些配置项,可以参考:https://docs.travis-ci.com/user/deployment/pages/
provider: pages
skip_cleanup: true
local_dir: dist/
github_token: $GITHUB_TOKEN # Set in the settings page of your repository, as a secure variable
keep_history: false
on:
branch: master

CircleCI

使用简介

  1. 注册 CircleCI

    打开 CircleCI 官方网站,使用您的GitHub帐户登录。

    image-20190613093550553

    进行授权:

    image-20190613093732415

  2. 启动存储库

检查要在 CircleCI 上管理的存储库的开关按钮。

image-20190613094115350

  1. 编写 config.yml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    version: 2
    jobs:
    build:
    docker:
    ...
    branches:
    ...
    steps:
    ...
    environment:
    ...

    官方参考文档:https://circleci.com/docs/2.0/configuration-reference/#version

来看一个完事版的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
version: 2
jobs:
build:
docker:
- image: ubuntu:14.04

- image: mongo:2.6.8
command: [mongod, --smallfiles]

- image: postgres:9.4.1
# some containers require setting environment variables
environment:
POSTGRES_USER: root

- image: redis@sha256:54057dd7e125ca41afe526a877e8bd35ec2cdd33b9217e022ed37bdcf7d09673

- image: rabbitmq:3.5.4

environment:
TEST_REPORTS: /tmp/test-reports

working_directory: ~/my-project

steps:
- checkout

- run:
command: echo 127.0.0.1 devhost | sudo tee -a /etc/hosts

# Create Postgres users and database
# Note the YAML heredoc '|' for nicer formatting
- run: |
sudo -u root createuser -h localhost --superuser ubuntu &&
sudo createdb -h localhost test_db

- restore_cache:
keys:
- v1-my-project-{{ checksum "project.clj" }}
- v1-my-project-

- run:
environment:
SSH_TARGET: "localhost"
TEST_ENV: "linux"
command: |
set -xu
mkdir -p ${TEST_REPORTS}
run-tests.sh
cp out/tests/*.xml ${TEST_REPORTS}

- run: |
set -xu
mkdir -p /tmp/artifacts
create_jars.sh ${CIRCLE_BUILD_NUM}
cp *.jar /tmp/artifacts

- save_cache:
key: v1-my-project-{{ checksum "project.clj" }}
paths:
- ~/.m2

# Save artifacts
- store_artifacts:
path: /tmp/artifacts
destination: build

# Upload test results
- store_test_results:
path: /tmp/test-reports

deploy-stage:
docker:
- image: ubuntu:14.04
working_directory: /tmp/my-project
steps:
- run:
name: Deploy if tests pass and branch is Staging
command: ansible-playbook site.yml -i staging

deploy-prod:
docker:
- image: ubuntu:14.04
working_directory: /tmp/my-project
steps:
- run:
name: Deploy if tests pass and branch is Master
command: ansible-playbook site.yml -i production

workflows:
version: 2
build-deploy:
jobs:
- build:
filters:
branches:
ignore:
- develop
- /feature-.*/
- deploy-stage:
requires:
- build
filters:
branches:
only: staging
- deploy-prod:
requires:
- build
filters:
branches:
only: master
  1. 设置/查看任务

    image-20190613093327981

配置Node.js应用

.circleci/config.yml配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
version: 2
jobs:
build:
docker:
- image: circleci/node:10
branches:
only:
- master
steps:
- add_ssh_keys:
fingerprints:
- "c5:20:8e:79:81:19:fd:c1:6c:c4:fb:41:58:92:9d:4f"
- checkout
- restore_cache:
keys:
# fallback to using the latest cache if no exact match is found
- dependencies-
- run:
name: Install
command: yarn install
- save_cache:
paths:
- node_modules
key: dependencies-
- run:
name: build github pages
command: yarn build
- run:
name: Prepare shell commands
command: chmod +x scripts/deploy.sh
- run:
name: Run deploy scripts
command: ./scripts/deploy.sh

这里以发布到github page为示例:

deploy.sh文件的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#!/bin/sh
# ideas used from https://gist.github.com/motemen/8595451

# Based on https://github.com/eldarlabs/ghpages-deploy-script/blob/master/scripts/deploy-ghpages.sh
# Used with their MIT license https://github.com/eldarlabs/ghpages-deploy-script/blob/master/LICENSE

# abort the script if there is a non-zero error
set -e

# show where we are on the machine
pwd
remote=$(git config remote.origin.url)

echo 'remote is: '$remote

# make a directory to put the gp-pages branch
mkdir gh-pages-branch
cd gh-pages-branch
# now lets setup a new repo so we can update the gh-pages branch
git config --global user.email "$GH_EMAIL" > /dev/null 2>&1
git config --global user.name "$GH_NAME" > /dev/null 2>&1
git init
git remote add --fetch origin "$remote"

echo 'email is: '$GH_EMAIL
echo 'name is: '$GH_NAME
echo 'sitesource is: '$siteSource

# switch into the the gh-pages branch
if git rev-parse --verify origin/gh-pages > /dev/null 2>&1
then
git checkout gh-pages
# delete any old site as we are going to replace it
# Note: this explodes if there aren't any, so moving it here for now
git rm -rf .
else
git checkout --orphan gh-pages
fi

# copy over or recompile the new site
cp -a "../${siteSource}/." .

ls -la

# stage any changes and new files
git add -A
# now commit, ignoring branch gh-pages doesn't seem to work, so trying skip
git commit --allow-empty -m "Deploy to GitHub pages [ci skip]"
# and push, but send any output to /dev/null to hide anything sensitive
git push --force --quiet origin gh-pages
# go back to where we started and remove the gh-pages git repo we made and used
# for deployment
cd ..
rm -rf gh-pages-branch

echo "Finished Deployment!"

说明:

>/dev/null 2>&1的含义

文件描述符

当执行shell命令时,会默认打开3个文件,每个文件有对应的文件描述符来方便我们使用:

类型 文件描述符 默认情况 对应文件句柄位置
标准输入(standard input) 0 从键盘获得输入 /proc/slef/fd/0
标准输出(standard output) 1 输出到屏幕(即控制台) /proc/slef/fd/1
错误输出(error output) 2 输出到屏幕(即控制台) /proc/slef/fd/2

> 代表重定向到哪里?

例如:echo "123" > /home/123.txt
1 表示stdout标准输出,系统默认值是1,所以>/dev/null等同于1>/dev/null
2 表示stderr标准错误
& 表示等同于的意思,2>&1,表示2的输出重定向等同于1

参考资料:

Shell脚本———— /dev/null 2>&1详解

shell中>/dev/null 2>&1

Linux Shell 1>/dev/null 2>&1 含义

扩展知识

自动化流程的发展趋势

  1. 集中化

    以集群为基础,服务采用Saas方式进行交付。所有折构建、测试、发布全集中进行管理。

  2. 微服务+无服务的应用模式

    应用程序执行环境的管理被新的编程模型和平台取代后,团队的交付生产率得到了进一步的提升。一方面它免去了很多环境管理的工作,包括设备、网络、主机以及对应的软件和配置工作,使得软件运行时环境更加稳定。另一方面,它大大降低了团队采用DevOps的技术门槛。

    无服务器风格的架构(Serverless architecture)把DevOps技术在微服务领域的应用推向极致。当应用程序执行环境的管理被新的编程模型和平台取代后,团队的交付生产率得到了进一步的提升。一方面它免去了很多环境管理的工作,包括设备、网络、主机以及对应的软件和配置工作,使得软件运行时环境更加稳定。另一方面,它大大降低了团队采用DevOps的技术门槛。

    在微服务端到端交付流程上,Netflix开源了自家的Spinnaker,Netflix作为微服务实践的先锋,不断推出新的开源工具来弥补社区中微服务技术和最佳实践的缺失。而Spring Cloud则为开发者提供了一系列工具,以便他们在所熟悉的Spring技术栈下使用这些服务协调技术(coordination techniques),如服务发现、负载均衡、熔断和健康检查。

  3. 人工智能领域的应用

    DevOps的最早实践来自于互联网企业的Web应用,相应的思想被引入企业级应用并促进了一系列工具的发展。在人工智能领域,TensorFlow就是这样一个例子,它可以有多种DevOps友好的安装和部署方式 ,例如采用Docker进行部署。

    随着Python在大数据、人工智能、区块链、微服务以及Docker中的发展,可以预见Python在日后的领域仍然会发挥重要的作用。

  4. 安全推动DevOps的发展

    全是DevOps永远绕不开的话题,也往往是新技术在传统行业(例如金融和电信)应用中的最大阻碍。一方面,组织结构的转型迫使企业要打破原先的部门墙,这意味着很多原先的控制流程不再适用。另一方面,由于大量的DevOps技术来源于开源社区,缺乏强大技术实力的企业在应用相关技术时不免会有所担忧。

  5. Windows平台下.net的技术潜力巨大

    长期以来,Windows和.NET平台下的DevOps一直都是一个被低估的领域。一方面,社区缺乏对 Windows Server平台的兴趣。另一方面,Windows Server却有接近90%的市场占用率,在Web服务器领域则有33.5%的市场占有率

  6. 非功能性自动化测试工具逐渐完善

    自动化测试水平往往是衡量DevOps技术能力高低的重要指标,尤其是针对生产环境应用程序的非功能性自动化测试工具。一直以来,技术雷达都在尝试从不同的角度宣扬自动化测试的重要性,从软件的开发阶段延展到了整个应用生命周期甚至整体IT资产的管理上。

复杂的DevOps相关工具

DevOps

Jenkins的一些应用场景

打包平台:

使用Jenkins搭建iOS/Android

测试平台:

jenkins + python + selenium

Jmeter+maven+Jenkins构建云性能测试平台

Jenkins+PMD构建自动化静态代码检测

使用jenkins+Emma统计

客户端单元测试覆盖率

Jenkins+Ant+Java+Junit+SVN执行junit单元测试

jenkins+ant+jmeter搭建持续集成的接口测试平台

自动部署:

Jenkins+GitLab+蒲公英+FTP

jenkins结合ansible用shell实现自动化部署和回滚

持续集成:

Tomcat+Sonar搭建持续集成环境

Maven+Nexus+Jenkins+git/SVN

Jenkins的Docker-compose.yml创建文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
version: '3'
services:
jenkins:
container_name: 'jenkins'
image: jenkins/jenkins
restart: always
user: jenkins:<这里填Docker用户组的ID,见下面>
ports:
- "8080:8080"
- "50000:50000"
volumes:
- /home/jenkins/data:/var/jenkins_home
- /usr/bin/docker:/usr/bin/docker
- /var/run/docker.sock:/var/run/docker.sock

上面的脚本使用注意:

  1. 创建本地jenkins数据目录

    1
    mkdir -p /home/jenkins
  2. 查看docker用户组的ID

    1
    cat /etc/group |grep docker
  3. 执行docker-compose up -d

gitlab的docker启动配置文件

项目地址:https://github.com/sameersbn/docker-gitlab

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
version: '2'

services:
redis:
restart: always
image: sameersbn/redis:4.0.9-1
command:
- --loglevel warning
volumes:
- /srv/docker/gitlab/redis:/var/lib/redis:Z

postgresql:
restart: always
image: sameersbn/postgresql:10
volumes:
- /srv/docker/gitlab/postgresql:/var/lib/postgresql:Z
environment:
- DB_USER=gitlab
- DB_PASS=password
- DB_NAME=gitlabhq_production
- DB_EXTENSION=pg_trgm

gitlab:
restart: always
image: sameersbn/gitlab:11.11.2
depends_on:
- redis
- postgresql
ports:
- "10080:80"
- "10022:22"
volumes:
- /srv/docker/gitlab/gitlab:/home/git/data:Z
environment:
- DEBUG=false

- DB_ADAPTER=postgresql
- DB_HOST=postgresql
- DB_PORT=5432
- DB_USER=gitlab
- DB_PASS=password
- DB_NAME=gitlabhq_production

- REDIS_HOST=redis
- REDIS_PORT=6379

- TZ=Asia/Kolkata
- GITLAB_TIMEZONE=Kolkata

- GITLAB_HTTPS=false
- SSL_SELF_SIGNED=false

# 这里修改成服务器的IP或者域名
- GITLAB_HOST=localhost
- GITLAB_PORT=10080
- GITLAB_SSH_PORT=10022
- GITLAB_RELATIVE_URL_ROOT=
- GITLAB_SECRETS_DB_KEY_BASE=long-and-random-alphanumeric-string
- GITLAB_SECRETS_SECRET_KEY_BASE=long-and-random-alphanumeric-string
- GITLAB_SECRETS_OTP_KEY_BASE=long-and-random-alphanumeric-string

# 这里给一个长度大于8的密码
- GITLAB_ROOT_PASSWORD=12345678
- GITLAB_ROOT_EMAIL=itheima@itcast.cn

- GITLAB_NOTIFY_ON_BROKEN_BUILDS=true
- GITLAB_NOTIFY_PUSHER=false

- GITLAB_EMAIL=notifications@example.com
- GITLAB_EMAIL_REPLY_TO=noreply@example.com
- GITLAB_INCOMING_EMAIL_ADDRESS=reply@example.com

- GITLAB_BACKUP_SCHEDULE=daily
- GITLAB_BACKUP_TIME=01:00

- SMTP_ENABLED=false
- SMTP_DOMAIN=www.example.com
- SMTP_HOST=smtp.gmail.com
- SMTP_PORT=587
- SMTP_USER=mailer@example.com
- SMTP_PASS=password
- SMTP_STARTTLS=true
- SMTP_AUTHENTICATION=login

- IMAP_ENABLED=false
- IMAP_HOST=imap.gmail.com
- IMAP_PORT=993
- IMAP_USER=mailer@example.com
- IMAP_PASS=password
- IMAP_SSL=true
- IMAP_STARTTLS=false

- OAUTH_ENABLED=false
- OAUTH_AUTO_SIGN_IN_WITH_PROVIDER=
- OAUTH_ALLOW_SSO=
- OAUTH_BLOCK_AUTO_CREATED_USERS=true
- OAUTH_AUTO_LINK_LDAP_USER=false
- OAUTH_AUTO_LINK_SAML_USER=false
- OAUTH_EXTERNAL_PROVIDERS=

- OAUTH_CAS3_LABEL=cas3
- OAUTH_CAS3_SERVER=
- OAUTH_CAS3_DISABLE_SSL_VERIFICATION=false
- OAUTH_CAS3_LOGIN_URL=/cas/login
- OAUTH_CAS3_VALIDATE_URL=/cas/p3/serviceValidate
- OAUTH_CAS3_LOGOUT_URL=/cas/logout

- OAUTH_GOOGLE_API_KEY=
- OAUTH_GOOGLE_APP_SECRET=
- OAUTH_GOOGLE_RESTRICT_DOMAIN=

- OAUTH_FACEBOOK_API_KEY=
- OAUTH_FACEBOOK_APP_SECRET=

- OAUTH_TWITTER_API_KEY=
- OAUTH_TWITTER_APP_SECRET=

- OAUTH_GITHUB_API_KEY=
- OAUTH_GITHUB_APP_SECRET=
- OAUTH_GITHUB_URL=
- OAUTH_GITHUB_VERIFY_SSL=

- OAUTH_GITLAB_API_KEY=
- OAUTH_GITLAB_APP_SECRET=

- OAUTH_BITBUCKET_API_KEY=
- OAUTH_BITBUCKET_APP_SECRET=

- OAUTH_SAML_ASSERTION_CONSUMER_SERVICE_URL=
- OAUTH_SAML_IDP_CERT_FINGERPRINT=
- OAUTH_SAML_IDP_SSO_TARGET_URL=
- OAUTH_SAML_ISSUER=
- OAUTH_SAML_LABEL="Our SAML Provider"
- OAUTH_SAML_NAME_IDENTIFIER_FORMAT=urn:oasis:names:tc:SAML:2.0:nameid-format:transient
- OAUTH_SAML_GROUPS_ATTRIBUTE=
- OAUTH_SAML_EXTERNAL_GROUPS=
- OAUTH_SAML_ATTRIBUTE_STATEMENTS_EMAIL=
- OAUTH_SAML_ATTRIBUTE_STATEMENTS_NAME=
- OAUTH_SAML_ATTRIBUTE_STATEMENTS_USERNAME=
- OAUTH_SAML_ATTRIBUTE_STATEMENTS_FIRST_NAME=
- OAUTH_SAML_ATTRIBUTE_STATEMENTS_LAST_NAME=

- OAUTH_CROWD_SERVER_URL=
- OAUTH_CROWD_APP_NAME=
- OAUTH_CROWD_APP_PASSWORD=

- OAUTH_AUTH0_CLIENT_ID=
- OAUTH_AUTH0_CLIENT_SECRET=
- OAUTH_AUTH0_DOMAIN=

- OAUTH_AZURE_API_KEY=
- OAUTH_AZURE_API_SECRET=
- OAUTH_AZURE_TENANT_ID=

总结

通过这篇文章大家了解了什么是CI/CD以及它的流程。前端项目怎么结合CI/CD流程,实现快速迭代。Docker的使用,Jenkins+gitlab+nodejs自动化项目。了解了持续集成工具Jenkins、Travis CI、Circle CI。一起学习呀~

Promise实现

简述

Promise是ES6中的新的异步语法,解决了回调嵌套的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
new Promise((resolve)=>{
setTimeout(()=>{
resolve(1)
},1000)
}).then(val =>{
console.log(val);
return new Promise((resolve)=>{
setTimeout(()=>{
resolve(2)
},1000)
})
}).then(val => {
console.log(val);
})

实现状态切换

  • promise实例有三个状态,pending,fulfilled,rejected
  • promise实例在构造是可以传入执行函数,执行函数有两个形参resolve,reject可以改变promise的状态,promise的状态一旦改变后不可再进行改变。
  • 执行函数会在创建promise实例时,同步执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const PENDING = 'PENDING'; // 初始状态
const FULFILLED = 'FULFILLED'; // 成功状态
const REJECTED = 'REJECTED'; // 失败状态
class Promise2 {
constructor(executor){
this.status = PENDING
this.value = null
this.reason = null
const resolve = (value) => {
if (this.status === PENDING) {
this.value = value;
this.status = FULFILLED;
}
}
const reject = (reason) => {
if (this.status === PENDING) {
this.reason = reason;
this.status = REJECTED;
}
}
try {
executor(resolve,reject)
}catch (e) {
reject(e)
}

}
}
let p = new Promise2((resolve,reject)=>{resolve(1)})

实现then异步执行

promise实例可以调用then方法并且传入回调:

如果调用then时,Promise实例是fulfilled状态,则马上异步执行传入的回调。

如果调用then时,Promise实例是pending状态,传入的回调会等到resolve后再异步执行

例子:

1
2
3
4
5
6
7
8
9
10
11
let p = new Promise((resolve, reject)=>{
console.log(1);
resolve(2)
console.log(3);
})
p.then((val)=>{
console.log(val);
})
//1
//3
//2
1
2
3
4
5
6
7
8
let p = new Promise((resolve, reject)=>{
setTimeout(()=>{
resolve(1)
},2000)
})
p.then((val)=>{
console.log(val);
})

思路:需要用回调先保存到队列中,在resolve后异步执行队列里的回调,在then时判断实例的状态再决定是将回调推入队列,还是直接异步执行回调:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
const PENDING = 'PENDING'; // 初始状态
const FULFILLED = 'FULFILLED'; // 成功状态
const REJECTED = 'REJECTED'; // 失败状态
class Promise2 {
constructor(executor){
this.status = PENDING
this.value = null
this.reason = null
this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];
const resolve = (value) => {
if (this.status === PENDING) {
this.value = value;
this.status = FULFILLED;
setTimeout(()=>{
this.onFulfilledCallbacks.forEach(fn => fn(this.value));
})
}
}
const reject = (reason) => {
if (this.status === PENDING) {
this.reason = reason;
this.status = REJECTED;
setTimeout(()=>{
this.onFulfilledCallbacks.forEach(fn => fn(this.reason));
})
}
}
try {
executor(resolve,reject)
}catch (e) {
reject(e)
}

}
then(onFulfilled, onRejected) {
if (this.status === FULFILLED) {
setTimeout(()=>{
onFulfilled(this.value);
})
}
if (this.status === REJECTED) {
setTimeout(()=>{
onRejected(this.reason);
})

}
if (this.status === PENDING) {
this.onFulfilledCallbacks.push(onFulfilled); // 存储回调函数
this.onRejectedCallbacks.push(onRejected); // 存储回调函数
}
}
}

resolve Promise实例的情况

resolve的值有可能也是个promise实例,这时候就要用前述实例自己resolve的值

1
2
3
4
5
6
7
8
9
10
let p = new Promise((resolve,reject) =>{  //promise1
resolve(new Promise((resolve2,reject2)=>{ //promise2
setTimeout(()=>{
resolve2(1)
},1000)
}))
})
p.then((val)=>{
console.log(val);
})

因此需要在promise1的resolve函数中进行判断,是promise实例则在这个promise实例(promise2)后接一个then,并且将promise1的resolve作为回调传入promise2的then

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
const PENDING = 'PENDING'; // 初始状态
const FULFILLED = 'FULFILLED'; // 成功状态
const REJECTED = 'REJECTED'; // 失败状态
class Promise2 {
constructor(executor){
this.status = PENDING
this.value = null
this.reason = null
this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];
const resolve = (value) => {
if (value instanceof this.constructor) {
value.then(resolve, reject); //resolve reject是箭头函数,this已经绑定到外层Promise
return
}
if (this.status === PENDING) {
this.value = value;
this.status = FULFILLED;
setTimeout(()=>{
this.onFulfilledCallbacks.forEach(fn => fn(this.value));
})
}
}
const reject = (reason) => {
if (this.status === PENDING) {
this.reason = reason;
this.status = REJECTED;
setTimeout(()=>{
this.onFulfilledCallbacks.forEach(fn => fn(this.reason));
})
}
}
try {
executor(resolve,reject)
}catch (e) {
reject(e)
}

}
then(onFulfilled, onRejected) {
if (this.status === FULFILLED) {
setTimeout(()=>{
onFulfilled(this.value);
})
}
if (this.status === REJECTED) {
setTimeout(()=>{
onRejected(this.reason);
})

}
if (this.status === PENDING) {
this.onFulfilledCallbacks.push(onFulfilled); // 存储回调函数
this.onRejectedCallbacks.push(onRejected); // 存储回调函数
}
}
}
let p = new Promise2((resolve,reject) =>{
resolve(new Promise2((resolve2,reject2)=>{
setTimeout(()=>{
resolve2(1)
},1000)
}))
})
p.then((val)=>{
console.log(val);
})

实现链式调用

then可以链式调用,而且前一个then的回调的返回值,如果不是promise实例,则下一个then回调的传参值就是上一个then回调的返回值,如果是promise实例,则下一个then回调的传参值,是上一个then回调返回的promise实例的解决值(value)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let p = new Promise((resolve,reject) =>{
setTimeout(()=>{
resolve(1)
},1000)
})
p.then(val => {
console.log(val);
return new Promise((resolve) => {
setTimeout(()=>{
resolve(2)
},1000)
})
}).then(val => {
console.log(val);
return 3
}).then(val => {
console.log(val);
})

既然能够链式调用,那么then方法本身的返回值必定是一个Promise实例。那么返回的promise实例是不是自身呢?答案显而易见:不是。如果一个promise的then方法的返回值是promise自身,在new一个Promise时,调用了resolve方法,因为promise的状态一旦更改便不能再次更改,那么下面的所有then便只能执行成功的回调,无法进行错误处理,这显然并不符合promise的规范和设计promise的初衷。

因此 then方法会返回一个新的promise实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
const PENDING = 'PENDING'; // 初始状态
const FULFILLED = 'FULFILLED'; // 成功状态
const REJECTED = 'REJECTED'; // 失败状态
class Promise2 {
constructor(executor){
this.status = PENDING
this.value = null
this.reason = null
this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];
const resolve = (value) => {
if (value instanceof this.constructor) {
value.then(resolve, reject); //resolve reject是箭头函数,this已经绑定到外层Promise

return
}
if (this.status === PENDING) {
this.value = value;
this.status = FULFILLED;
setTimeout(()=>{
this.onFulfilledCallbacks.forEach(fn => fn(this.value));
})
}
}
const reject = (reason) => {
if (this.status === PENDING) {
this.reason = reason;
this.status = REJECTED;
setTimeout(()=>{
this.onFulfilledCallbacks.forEach(fn => fn(this.reason));
})
}
}
try {
executor(resolve,reject)
}catch (e) {
reject(e)
}

}
then(onFulfilled, onRejected) {
const promise2 = new this.constructor((resolve, reject) => { // 待返回的新的promise实例
if (this.status === FULFILLED) {
setTimeout(()=>{
try {
let callbackValue = onFulfilled(this.value);
resolve(callbackValue);
}catch(error) {
reject(error) // 如果出错此次的then方法的回调函数出错,在将错误传递给promise2
}
})
}
if (this.status === REJECTED) {
setTimeout(()=>{
try {
let callbackValue= onRejected(this.reason);
resolve(callbackValue);
} catch (error) {
reject(error);
}
})

}
if (this.status === PENDING) {
this.onFulfilledCallbacks.push(() => {
try {
let callbackValue = onFulfilled(this.value);
resolve(callbackValue);
}catch (error) {
reject(error)
}
});
this.onRejectedCallbacks.push(() => {
try {
let callbackValue = onRejected(this.reason);
resolve(callbackValue);
} catch (error) {
reject(error);
}
});
}
})
return promise2;
}
}

实现其他方法

  • catch
  • resolve
  • reject
  • all
  • race

方法演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/*catch方法*/
let p = new Promise((resolve, reject) => {
reject(1)
})
p.catch(reason => {
console.log(reason);
})

/*Promise.resolve*/
let p = Promise.resolve(1)

/*Promise.resolve*/
let p = Promise.reject(1)

/*Promise.all*/
let p = Promise.all([
new Promise(resolve => {
setTimeout(() => {
resolve(1)
}, 1000)
}),
new Promise(resolve => {
setTimeout(() => {
resolve(2)
}, 2000)
}),
new Promise(resolve => {
setTimeout(() => {
resolve(3)
}, 3000)
}),
])
p.then(val => {
console.log(val);
})
/*Promise.race*/
let p = Promise.race([
new Promise(resolve => {
setTimeout(() => {
resolve(1)
}, 1000)
}),
new Promise(resolve => {
setTimeout(() => {
resolve(2)
}, 2000)
}),
new Promise(resolve => {
setTimeout(() => {
resolve(3)
}, 3000)
}),
])
p.then(val => {
console.log(val);
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
const PENDING = 'PENDING'; // 初始状态
const FULFILLED = 'FULFILLED'; // 成功状态
const REJECTED = 'REJECTED'; // 失败状态
class Promise2 {

static resolve(value) {
if (value instanceof this) {
return value;
}
return new this((resolve, reject) => {
resolve(value);
});
};

static reject(reason) {
return new this((resolve, reject) => reject(reason))
};
static all(promises){
return new this((resolve, reject) => {
let resolvedCounter = 0;
let promiseNum = promises.length;
let resolvedValues = new Array(promiseNum);
for (let i = 0; i < promiseNum; i += 1) {
Promise2.resolve(promises[i]).then(
value => {
resolvedCounter++;
resolvedValues[i] = value;
if (resolvedCounter === promiseNum) {
return resolve(resolvedValues);
}
},
reason => {
return reject(reason);
},
);

}
});
};
static race(promises){
return new this((resolve, reject) => {
if (promises.length === 0) {
return;
} else {
for (let i = 0, l = promises.length; i < l; i += 1) {
Promise2.resolve(promises[i]).then(
data => {
resolve(data);
return;
},
err => {
reject(err);
return;
},
);
}
}
});
}
constructor(executor) {
this.status = PENDING
this.value = null
this.reason = null
this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];
const resolve = (value) => {
if (value instanceof this.constructor) {
value.then(resolve, reject); //resolve reject是箭头函数,this已经绑定到外层Promise

return
}
if (this.status === PENDING) {
this.value = value;
this.status = FULFILLED;
setTimeout(() => {
this.onFulfilledCallbacks.forEach(fn => fn(this.value));
})
}
}
const reject = (reason) => {
if (this.status === PENDING) {
this.reason = reason;
this.status = REJECTED;
setTimeout(() => {
this.onFulfilledCallbacks.forEach(fn => fn(this.reason));
})
}
}
try {
executor(resolve, reject)
} catch (e) {
reject(e)
}

}

then(onFulfilled, onRejected) {
const promise2 = new this.constructor((resolve, reject) => { // 待返回的新的promise实例
if (this.status === FULFILLED) {

setTimeout(() => {
try {
let callbackValue = onFulfilled(this.value);
resolve(callbackValue);
} catch (error) {
reject(error) // 如果出错此次的then方法的回调函数出错,在将错误传递给promise2
}
})
}
if (this.status === REJECTED) {
setTimeout(() => {
try {
let x = onRejected(this.reason);
resolve(x);
} catch (error) {
reject(error);
}
})

}
if (this.status === PENDING) {
this.onFulfilledCallbacks.push(() => {
try {
let callbackValue = onFulfilled(this.value);
resolve(callbackValue);
} catch (error) {
reject(error)
}
});
this.onRejectedCallbacks.push(() => {
try {
let callbackValue = onRejected(this.reason);
resolve(callbackValue);
} catch (error) {
reject(error);
}
});
}
})
return promise2;
}

catch(onRejected) {
return this.then(null, onRejected);
}
}

macrotask和mirotask

所谓macroTask(宏任务)是指将任务排到下一个事件循环,microTask(微任务)是指将任务排到当前事件循环的队尾,执行时机会被宏任务更早。Promise的标准里没有规定Promise里的异步该使用哪种,但在node和浏览器的实现里都是使用的miroTask(微任务)

![](../../../../Downloads/进阶/阶段1:基础进阶/1-7 常规面试题/resource/笔记/.\img\2.png)

1
2
3
4
5
6
7
setTimeout(() => {
console.log(1);
}, 0)
let p = Promise.resolve(2)
p.then((val) => {
console.log(val);
})

宏任务api包括:setTimeout,setInterval,setImmediate(Node),requestAnimationFrame(浏览器),各种IO操作,网络请求

微任务api包括:process.nextTick(Node),MutationObserver(浏览器)

MutaionObserver演示:

1
2
3
4
5
6
7
8
let observer = new MutationObserver(()=>{
console.log(1);
})
let node = document.createElement('div')
observer.observe(node, { // 监听节点
childList: true // 一旦改变则触发回调函数 nextTickHandler
})
node.innerHTML = 1

利用MutaionObserver封装一个微任务执行函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let nextTick = (function () {
let callbacks = []
function nextTickHandler() {
let copies = callbacks.slice(0)
callbacks = []
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}

let counter = 1
let observer = new MutationObserver(nextTickHandler) // 声明 MO 和回调函数
let node = document.createElement('div')
observer.observe(node, { // 监听节点
childList: true // 一旦改变则触发回调函数 nextTickHandler
})
return function (cb) {
callbacks.push(cb)
counter = (counter + 1) % 2 //让节点内容文本在 1 和 0 间切换
node.innerHTML = counter
}
})()

ES6面试题

ES6是什么,为什么要学习它,不学习ES6会怎么样?

答:

ES6是新一代的JS语言标准,对分JS语言核心内容做了升级优化,规范了JS使用标准,新增了JS原生方法,使得JS使用更加规范,更加优雅,更适合大型应用的开发。

学习ES6是成为专业前端正规军的必经之路。不学习ES6也可以写代码打鬼子,但是最多只能当个游击队长。

ES5、ES6、ES2015的区别

答:

ES2015特指在2015年发布的新一代JS语言标准,

ES6泛指下一代JS语言标准,包含ES2015、ES2016、ES2017、ES2018等。现阶段在绝大部分场景下,ES2015默认等同ES6。

ES5泛指上一代语言标准。ES2015可以理解为ES5和ES6的时间分界线。babel是什么,有什么作用?

答:

babel是一个 ES6 转码器,可以将 ES6 代码转为 ES5 代码,以便兼容那些还没支持ES6的平台。

let有什么用,有了var为什么还要用let?

答:

在ES6之前,声明变量只能用var,var方式声明变量其实是很不合理的,准确的说,是因为ES5里面没有块级作用域是很不合理的,甚至可以说是一个语言层面的bug。

没有块级作用域回来带很多难以理解的问题,比如for循环var变量泄露,变量覆盖等问题。

let 声明的变量拥有自己的块级作用域,且修复了var声明变量带来的变量提升问题。

问:举一些ES6对String字符串类型做的常用升级优化?

答:

优化部分:

ES6新增了字符串模板,在拼接大段字符串时,用反斜杠(`)取代以往的字符串相加的形式,能保留所有空格和换行,使得字符串拼接看起来更加直观,更加优雅。

升级部分:

ES6在String原型上新增了includes()方法,用于取代传统的只能用indexOf查找包含字符的方法(indexOf返回-1表示没查到不如includes方法返回false更明确,语义更清晰)

此外还新增了startsWith(), endsWith(), padStart(),padEnd(),repeat()等方法,可方便的用于查找,补全字符串。

举一些ES6对Array数组类型做的常用升级优化?

答:

优化部分:

数组解构赋值:ES6可以直接以let [a,b,c] = [1,2,3]形式进行变量赋值,在声明较多变量时,不用再写很多let(var),且映射关系清晰,且支持赋默认值

扩展运算符:ES6新增的扩展运算符(…)(重要),可以轻松的实现数组和松散序列的相互转化,可以取代arguments对象和apply方法,轻松获取未知参数个数情况下的参数集合。

(尤其是在ES5中,arguments并不是一个真正的数组,而是一个类数组的对象,但是扩展运算符的逆运算却可以返回一个真正的数组)。

扩展运算符还可以轻松方便的实现数组的复制和解构赋值(let a = [2,3,4]; let b = […a])。

升级部分:

ES6在Array原型上新增了find()方法,用于取代传统的只能用indexOf查找包含数组项目的方法,且修复了indexOf查找不到NaN的bug([NaN].indexOf(NaN) === -1),

此外还新增了copyWithin(), includes(), fill(),flat()等方法,可方便的用于字符串的查找,补全,转换等。

举一些ES6对Number数字类型做的常用升级优化?

答:

优化部分:

ES6在Number原型上新增了isFinite(), isNaN\()方法,用来取代传统的全局isFinite(), isNaN()方法检测数值是否有限、是否是NaN。

ES5的isFinite(), isNaN()方法都会先将非数值类型的参数转化为Number类型再做判断 这其实是不合理的

最造成isNaN(‘NaN’) === true的奇怪行为–’NaN’是一个字符串,但是isNaN却说这就是NaN。

Number.isFinite()Number.isNaN()则不会有此类问题(Number.isNaN('NaN') === false)。(isFinite()同上)

升级部分:

ES6在Math对象上新增了Math.cbrt(),trunc(),hypot()等等较多的科学计数法运算方法,可以更加全面的进行立方根、求和立方根等等科学计算。

举一些ES6对Object类型做的常用升级优化?(重要)

答:

优化部分:

1.对象属性变量式声明:ES6可以直接以变量形式声明对象属性或者方法,比传统的键值对形式声明更加简洁,更加方便,语义更加清晰。

1
2
let [apple, orange] = ['red appe', 'yellow orange'];
let myFruits = {apple, orange}; // let myFruits = {apple: 'red appe', orange: 'yellow orange'};

尤其在对象解构赋值(见优化部分2.)或者模块输出变量时,这种写法的好处体现的最为明显:

1
2
let {keys, values, entries} = Object;
let MyOwnMethods = {keys, values, entries}; // let MyOwnMethods = {keys: keys, values: values, entries: entries}

可以看到属性变量式声明属性看起来更加简洁明了。方法也可以采用简洁写法:

1
2
3
4
5
6
let es5Fun = {
method: function(){}
};
let es6Fun = {
method(){}
}

2.对象的解构赋值:ES6对象也可以像数组解构赋值那样,进行变量的解构赋值:

1
let {apple, orange} = {apple: 'red appe', orange: 'yellow orange'};

3.对象的扩展运算符(…):ES6对象的扩展运算符和数组扩展运算符用法本质上差别不大,毕竟数组也就是特殊的对象。

对象的扩展运算符一个最常用也最好用的用处就在于可以轻松的取出一个目标对象内部全部或者部分的可遍历属性,从而进行对象的合并和分解。

1
2
3
4
5
let {apple, orange, ...otherFruits} = {apple: 'red apple', orange: 'yellow orange', grape: 'purple grape', peach: 'sweet peach'}; 
// otherFruits {grape: 'purple grape', peach: 'sweet peach'}
// 注意: 对象的扩展运算符用在解构赋值时,扩展运算符只能用在最有一个参数(otherFruits后面不能再跟其他参数)
let moreFruits = {watermelon: 'nice watermelon'};
let allFruits = {apple, orange, ...otherFruits, ...moreFruits};

4.super 关键字:ES6在Class类里新增了类似this的关键字super。同this总是指向当前函数所在的对象不同,super关键字总是指向当前函数所在对象的原型对象。

升级部分:

1.ES6在Object原型上新增了is()方法,做两个目标对象的相等比较,用来完善’===‘方法。

‘===’方法中NaN === NaN //false其实是不合理的,Object.is修复了这个小bug。(Object.is(NaN, NaN) // true)

2.ES6在Object原型上新增了assign()方法,用于对象新增属性或者多个对象合并。

1
2
3
4
5
const target = { a: 1 };
const source1 = { b: 2 };
const source2 = { c: 3 };
Object.assign(target, source1, source2);
target // {a:1, b:2, c:3}

注意:

assign合并的对象target只能合并source1source2中的自身属性。

并不会合并source1、source2中的继承属性,也不会合并不可枚举的属性,且无法正确复制getset属性(会直接执行get/set函数,取return的值)。

3.ES6在Object原型上新增了getOwnPropertyDescriptors()方法,此方法增强了ES5中getOwnPropertyDescriptor()方法,可以获取指定对象所有自身属性的描述对象。

结合defineProperties()方法,可以完美复制对象,包括复制get和set属性。

4.ES6在Object原型上新增了getPrototypeOf()setPrototypeOf()方法,用来获取或设置当前对象的prototype对象。

这个方法存在的意义在于,ES5中获取设置prototype对像是通过proto属性来实现的,

然而proto属性并不是ES规范中的明文规定的属性,只是浏览器各大产商“私自”加上去的属性,只不过因为适用范围广而被默认使用了,再非浏览器环境中并不一定就可以使用.

所以为了稳妥起见,获取或设置当前对象的prototype对象时,都应该采用ES6新增的标准用法。

5.ES6在Object原型上还新增了Object.keys(),Object.values(),Object.entries()方法,用来获取对象的所有键、所有值和所有键值对数组。

举一些ES6对Function函数类型做的常用升级优化?(重要)

答:

1.箭头函数(核心):箭头函数是ES6核心的升级项之一,箭头函数里没有自己的this,这改变了以往JS函数中最让人难以理解的this运行机制。

主要优化点:

箭头函数内的this指向的是函数定义时所在的对象,而不是函数执行时所在的对象

ES5函数里的this总是指向函数执行时所在的对象,这使得在很多情况下this的指向变得很难理解,尤其是非严格模式情况下,this有时候会指向全局对象,这甚至也可以归结为语言层面的bug之一。

ES6的箭头函数优化了这一点,它的内部没有自己的this,这也就导致了this总是指向上一层的this,如果上一层还是箭头函数,则继续向上指,直到指向到有自己this的函数为止,并作为自己的this;

箭头函数不能用作构造函数,因为它没有自己的this,无法实例化;也是因为箭头函数没有自己的this,所以箭头函数 内也不存在arguments对象。(可以用扩展运算符代替)

2.函数默认赋值:ES6之前,函数的形参是无法给默认值得,只能在函数内部通过变通方法实现。ES6以更简洁更明确的方式进行函数默认赋值。

1
2
3
4
function es6Fuc (x, y = 'default') {
console.log(x, y);
}
es6Fuc(4) // 4, default

升级部分:

ES6新增了双冒号运算符,用来取代以往的bind,call,和apply。(浏览器暂不支持,Babel已经支持转码)

1
2
3
4
5
6
7
foo::bar;
// 等同于
bar.bind(foo);

foo::bar(...arguments);
// 等同于
bar.apply(foo, arguments);

Symbol是什么,有什么作用?

答:

Symbol是ES6引入的第七种原始数据类型(说法不准确,应该是第七种数据类型,Object不是原始数据类型之一,已更正),
所有Symbol()生成的值都是独一无二的,可以从根本上解决对象属性太多导致属性名冲突覆盖的问题。
对象中Symbol()属性不能被for…in遍历,但是也不是私有属性。

Set是什么,有什么作用?

答:

Set是ES6引入的一种类似Array的新的数据结构,Set实例的成员类似于数组item成员

区别是Set实例的成员都是唯一,不重复的。这个特性可以轻松地实现数组去重。

Map是什么,有什么作用?

答:

Map是ES6引入的一种类似Object的新的数据结构。
Map可以理解为是Object的超集,打破了以传统键值对形式定义对象,对象的key不再局限于字符串,也可以是Object。可以更加全面的描述对象的属性。

Proxy是什么,有什么作用?

答:

Proxy是ES6新增的一个构造函数,可以理解为JS语言的一个代理,用来改变JS默认的一些语言行为,包括拦截默认的get/set等底层方法,使得JS的使用自由度更高,可以最大限度的满足开发者的需求。

比如通过拦截对象的get/set方法,可以轻松地定制自己想要的key或者value
下面的例子可以看到,随便定义一个myOwnObj的key,都可以变成自己想要的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function createMyOwnObj() {
//想把所有的key都变成函数,或者Promise,或者anything
return new Proxy({}, {
get(target, propKey, receiver) {
return new Promise((resolve, reject) => {
setTimeout(() => {
let randomBoolean = Math.random() > 0.5;
let Message;
if (randomBoolean) {
Message = `你的${propKey}运气不错,成功了`;
resolve(Message);
} else {
Message = `你的${propKey}运气不行,失败了`;
reject(Message);
}
}, 1000);
});
}
});
}

let myOwnObj = createMyOwnObj();

myOwnObj.hahaha.then(result => {
console.log(result) //你的hahaha运气不错,成功了
}).catch(error => {
console.log(error) //你的hahaha运气不行,失败了
})

myOwnObj.wuwuwu.then(result => {
console.log(result) //你的wuwuwu运气不错,成功了
}).catch(error => {
console.log(error) //你的wuwuwu运气不行,失败了
})

Reflect是什么,有什么作用?

答:

Reflect是ES6引入的一个新的对象,他的主要作用有两点:

  • 一是将原生的一些零散分布在Object、Function或者全局函数里的方法(如apply、delete、get、set等等),统一整合到Reflect上,这样可以更加方便更加统一的管理一些原生API;
  • 二就是因为Proxy可以改写默认的原生API,如果一旦原生API别改写可能就找不到了,
    所以Reflect也可以起到备份原生API的作用,使得即使原生API被改写了之后,也可以在被改写之后的API用上默认的API。

Promise是什么,有什么作用?

答:

Promise是ES6引入的一个新的对象,他的主要作用是用来解决JS异步机制里,回调机制产生的“回调地狱”。

它并不是什么突破性的API,只是封装了异步回调形式,使得异步回调可以写的更加优雅,可读性更高,而且可以链式调用。

Iterator是什么,有什么作用?(重要)

答:

Iterator是ES6中一个很重要概念,它并不是对象,也不是任何一种数据类型。

因为ES6新增了Set、Map类型,他们和Array、Object类型很像,Array、Object都是可以遍历的,但是Set、Map都不能用for循环遍历,解决这个问题有两种方案:

一种是为Set、Map单独新增一个用来遍历的API。

另一种是为Set、Map、Array、Object新增一个统一的遍历API。

显然,第二种更好,ES6也就顺其自然的需要一种设计标准,来统一所有可遍历类型的遍历方式。

Iterator正是这样一种标准。或者说是一种规范理念。
就好像JavaScript是ECMAScript标准的一种具体实现一样,Iterator标准的具体实现是Iterator遍历器。

Iterator标准规定,所有部署了key值为[Symbol.iterator],且[Symbol.iterator]的value是标准的Iterator接口函数(标准的Iterator接口函数:

该函数必须返回一个对象,且对象中包含next方法,且执行next()能返回包含value/done属性的Iterator对象)的对象,都称之为可遍历对象,next()后返回的Iterator对象也就是Iterator遍历器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// obj就是可遍历的,因为它遵循了Iterator标准,且包含[Symbol.iterator]方法,方法函数也符合标准的Iterator接口规范。
// obj.[Symbol.iterator]() 就是Iterator遍历器
let obj = {
data: [ 'hello', 'world' ],
[Symbol.iterator]() {
const self = this;
let index = 0;
return {
next() {
if (index < self.data.length) {
return {
value: self.data[index++],
done: false
};
} else {
return { value: undefined, done: true };
}
}
};
}
};

ES6给Set、Map、Array、String都加上了[Symbol.iterator]方法,

且[Symbol.iterator]方法函数也符合标准的Iterator接口规范,
所以Set、Map、Array、String默认都是可以遍历的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Array
let array = ['red', 'green', 'blue'];
array[Symbol.iterator]() //Iterator遍历器
array[Symbol.iterator]().next() //{value: "red", done: false}

// String
let string = '1122334455';
string[Symbol.iterator]() //Iterator遍历器
string[Symbol.iterator]().next() //{value: "1", done: false}

// set
let set = new Set(['red', 'green', 'blue']);
set[Symbol.iterator]() //Iterator遍历器
set[Symbol.iterator]().next() //{value: "red", done: false}

// Map
let map = new Map();
let obj= {map: 'map'};
map.set(obj, 'mapValue');
map[Symbol.iterator]().next() {value: Array(2), done: false}

for…in 和for…of有什么区别?

答:

如果看到上个问题,那么就很好回答。
问题十六提到了ES6统一了遍历标准,制定了可遍历对象,那么用什么方法去遍历呢?

答案就是用for…of。ES6规定,有所部署了载了Iterator接口的对象(可遍历对象)都可以通过for…of去遍历,而for..in仅仅可以遍历对象。

这也就意味着,数组也可以用for…of遍历,这极大地方便了数组的取值,且避免了很多程序用for..in去遍历数组的恶习。

上面提到的扩展运算符本质上也就是for..of循环的一种实现。

Generator函数是什么,有什么作用?

答:

如果说JavaScript是ECMAScript标准的一种具体实现、Iterator遍历器是Iterator的具体实现,那么Generator函数可以说是Iterator接口的具体实现方式。

执行Generator函数会返回一个遍历器对象,每一次Generator函数里面的yield都相当一次遍历器对象的next()方法,并且可以通过next(value)方法传入自定义的value,来改变Generator函数的行为。

Generator函数可以通过配合Thunk 函数更轻松更优雅的实现异步编程和控制流管理。

async函数是什么,有什么作用?

答:

async函数可以理解为内置自动执行器的Generator函数语法糖,它配合ES6的Promise近乎完美的实现了异步编程解决方案。

Class、extends是什么,有什么作用?

答:

ES6 的class可以看作只是一个ES5生成实例对象的构造函数的语法糖。

它参考了java语言,定义了一个类的概念,让对象原型写法更加清晰,对象实例化更像是一种面向对象编程。Class类可以通过extends实现继承。

它和ES5构造函数的不同点:
类的内部定义的所有方法,都是不可枚举的;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/// ES5
function ES5Fun (x, y) {
this.x = x;
this.y = y;
}
ES5Fun.prototype.toString = function () {
return '(' + this.x + ', ' + this.y + ')';
}
var p = new ES5Fun(1, 3);
p.toString();
Object.keys(ES5Fun.prototype); //['toString']

//ES6
class ES6Fun {
constructor (x, y) {
this.x = x;
this.y = y;
}
toString () {
return '(' + this.x + ', ' + this.y + ')';
}
}

Object.keys(ES6Fun.prototype); //[]

ES6的class类必须用new命令操作,而ES5的构造函数不用new也可以执行;

ES6的class类不存在变量提升,必须先定义class之后才能实例化,不像ES5中可以将构造函数写在实例化之后;

ES5 的继承,实质是先创造子类的实例对象this,然后再将父类的方法添加到this上面。

ES6 的继承机制完全不同,实质是先将父类实例对象的属性和方法,加到this上面(所以必须先调用super方法),然后再用子类的构造函数修改this。

module、export、import是什么,有什么作用?

答:

module、export、import是ES6用来统一前端模块化方案的设计思路和实现方案。

export、import的出现统一了前端模块化的实现方案,整合规范了浏览器/服务端的模块化方法,

之后用来取代传统的AMD/CMD、requireJS、seaJS、commondJS等等一系列前端模块不同的实现方案,使前端模块化更加统一规范,JS也能更加能实现大型的应用程序开发。

import引入的模块是静态加载(编译阶段加载)而不是动态加载(运行时加载)。

import引入export导出的接口值是动态绑定关系,即通过该接口,可以取到模块内部实时的值。

日常前端代码开发中,有哪些值得用ES6去改进的编程优化或者规范?

答:

常用箭头函数来取代的做法;

  1. 常用let取代var命令;

    常用数组/对象的结构赋值来命名变量,结构更清晰,语义更明确,可读性更好;

    在长字符串多变量组合场合,用模板字符串来取代字符串累加,能取得更好地效果和阅读体验;

    用Class类取代传统的构造函数,来生成实例化对象;

    在大型应用开发中,要保持module模块化开发思维,分清模块之间的关系,常用import、export方法。

自动化测试

为什么需要自动化测试?

项目经过不断的开发,最终肯定会趋于稳定,在适当的时机下引入自动化测试能及早发现问题,保证产品的质量

测试作为完整的开发流程中最后的一环,是保证产品质量重要的一环。而前端测试一般在产品开发流程中属于偏后的环节,在整个开发架构中属于较高层次,前端测试更加偏向于 GUI 的特性,因此前端的测试难度很大。

测试的目的:

  • 有利于写出高质量的代码,尽早发现问题
  • 有利于代码的扩展
  • 有利于代码的维护

前端自动化测试

测试是一个庞大的主题,包括各种分类的测试,诸如黑盒测试/白盒测试、单元测试/集成测试/端到端测试等。通常程序员在测试自己的代码的时候用得最多的便是单元测试,但是因为测试也是需要代价,很多人是不喜欢写测试的,甚至是一点都不写。

那么是什么原因让大家不愿意写呢?

  1. 不熟悉
  2. 浪费时间
  3. 知识不成体系
  4. 团队氛围
  5. 缺少实践

我们要从基础的东西学起,打消对测试的恐惧。

测试的分类

在多浏览器的自动化测试,我们多半是进行端到端的测试工作,一小部分是大粒度的单元测试。端到端测试测试模拟用户的行为。在 Web 应用程序中,他们会启动服务器,打开浏览器,模拟用户的行为进行点击、输入、提交等动作,断言浏览器中发生了特定的事情或者是得到了期待的结果,从而让我们相信功能可以正常的运行。

而单元测试根据代码单元的公共 API 运行它们。这些测试需要创建一个类的实例,使用特定的输入调用它的方法,断言被调用的方法达到了预期的效果。在下文中我们会看到这两种测试的实践,当然有时候区分度并不大,可能无法明显地区分哪些是端对端测试哪些是单元测试,有时候他们是混合起来的,不过只要记住我们的目标是保证功能可以正常运行救足够了。

按照软件工程自底而上的概念,前端测试一般分为单元测试(Unit Testing )、集成测试(Integration Testing)和端到端测试(E2E Testing)。从下面的图可以看出,从底向上测试的复杂度将不断提高,另一方面测试的收益反而不断降低的。

img

关于软件测试分类,可见软件测试的分类

测试工具对比

在进行项目实践前,很重要的一项工作是选择合适的技术栈。好比在前端开发时应该选择 React,Vue 还是 Angular 作为框架一样,前端的测试工作也需要选择一套技术栈。很多时候大家在制定技术栈时容易走偏,在选择技术框架时不是选择最合适的框架,而是选择最热门的框架。当然一定程度上热门的框架能反应其受欢迎程度,可能是因为其出众的优点,如较高的开发效率、高效的渲染特性或者是活跃的社区。在前端开发中,很容易有这样的感受,就是只要半个月没有关注业界的最新动态,就感觉恍若隔世,新的解决方案层出不穷,让人喘不过气。

经过几年的前端洗礼之后,就已经过了慌乱的年纪,再也不会盲目地追寻新技术,而转向关注技术背后解决的痛点,原理等。

img

如何选择测试框架

测试框架基本上都做了一件事儿:

  • 描述你要测试的东西
  • 对其进行测试
  • 判断是否符合预期

选择框架会考虑下面的点:

  • 测试框架是否有简明的语法与文档。

    Mocha、Jasmine、Jest、AVA、Karma、Nightmare

  • 断言(Assertions):用于判断结果是否符合预期。有些框架需要单独的断言库。

    Should.js、chai、expect.js 等等,断言库提供了很多语义化的方法来对值做各种各样的判断。当然也可以不用断言库,Node.js 中也可以直接使用原生 assert 库。

  • 适合 TDD / BDD:是否适合 测试驱动型 / 行为驱动型 的测试风格。

    BDD(Bebavior Driven Developement,行为驱动测试)和 TDD(Testing Driven Developement,测试驱动开发)

    BDD 和 TDD 均有各自的适用场景,BDD 一般更偏向于系统功能和业务逻辑的自动化测试设计,而 TDD 在快速开发并测试功能模块的过程中则更加高效,以快速完成开发为目的。下面我们看下 BDD 和 TDD 具体的特点:

    BDD 的特点:

    • 从业务逻辑的角度定义具体的输入与预期输出,以及可衡量的目标;
    • 尽可能覆盖所有的测试用例情况;
    • 描述一系列可执行的行为,根据业务的分析来定义预期输出。例如,expect, should, assert;
    • 设定关键的测试通过节点输出提示,便于测试人员理解;
    • 最大程度的交付出符合用户期望的产品,避免输出不一致带来的问题。

    TDD 的特点:

    • 需求分析,快速编写对应的输入输出测试脚本;
    • 仅在自动测试失败时才编写新代码
    • 重构去除不必要的依赖关系,然后重复测试,最终让程序符合所有要求。
  • 异步测试:有些框架对异步测试支持良好。

  • 使用的语言:大部分 js 测试框架使用 js。

  • 用于特定目的:每个框架可能会擅长处理不同的问题。

    是要测试单个功能、单个组件、还是集成化测试?

    是要测试 GUI 逻辑、交互?

    是要测试非功能性指标?兼容性?

  • 社区是否活跃。

测试工具的类型

测试工具可分为以下功能。有些只为我们提供了一种功能,有些功能为我们提供了一种组合。

为了实现最灵活的集合功能,通常使用多种工具的组合。

单元测试类工具

npm trends: 点击链接

image-20190706101634263

Karma

Karma 是一个 Runner(即运行环境),具体详细的介绍见 后面的章节 Karma

A test runner is the library or tool that picks up an assembly (or a source code directory) that contains unit tests, and a bunch of settings, and then executes them and writes the test results to the console or log files.
there are many runners for different languages. See Nunit and MSTest for C#, or Junit for Java.

karma 设计目标主要有下面四点:

高效
扩展性
运行在真实设备
无缝的使用流程

karma 是一个典型的 C/S 程序,包含 client 和 server ,通讯方式基于 Http ,通常情况下,客户端和服务端基本都运行在开发者本地机器上。

一个服务端实例对应一个项目,假如想同时运行多个项目,得同时开启多个服务端实例。

Karma 的优点是能通过插件和配置的方式集成大部分的主流的测试框架和前端库,能方便的一次在多浏览器环境执行测试用例,并集成了测试覆盖率生成功能,生成页面形式覆盖率报告并能导出不同形式的覆盖率报告数据。

它的缺点是,对测试页面环境的搭建和资源文件的加载不是常见的形式,最开始搭建环境时会有很多跟预期不一致的情况,配置不直观。

Jasmine

Jasmine 带有 assertions(断言),spies (用来模拟函数的执行环境)和 mocks (mock 工具),Jasmine 初始化设置简单,同时,如果你需要一些单元功能的时候你仍然可以加一些库进来。

Mocha

Mocha 是一个灵活的库,提供给开发者的只有一个基础测试结构。然后,其它功能性的功能如 assertions, spies 和 mocks,这些功能需要引用添加其它库/插件来完成。

Jest

被 Facebook 和各种 React 应用推荐和使用,Jest 得到了很好的支持。Jest 也被发现是一个非常快速的测试库在平行测试报告中。

对于小型项目来说你可能在开始的时候不用过多担心,而性能的提高,对于希望全天持续部署的大型应用 app 来说是非常之好的。

而开发人员主要是用 Jest 去测试 React 应用,Jest 可以很容易地集成到其它应用程序中充许你使用更独特的特性在其它地方

快照测试是一个非常好用的工具,去确保你的应用 UI 不会有超出预期的错误,在产品发布替换的期间发生。虽然大部分功能,专门设计都是使用在 React 上。

Jest 有着很广阔的 API 。

AVA

AVA 它的优势是 JavaScript 的异步特性和并发运行测试.

利用了 JavaScript 的异步特性优势,优化了在部署的时间等待

保留了简单的 API 为你提供你所需要的功能。

如果搭配 mocking 来使用它会显得更加友好,但是必须安装一个单独的库。

E2E 测试类工具

npm trends: 点击链接

image-20190706100636750

最佳实践

测试有很多好处,但不代表一上来就要写出 100%场景覆盖的测试用例。

最佳的实践:基于投入产出比来做测试

由于维护测试用例也是一大笔开销(毕竟没有多少测试会专门帮前端写业务测试用例,而前端使用的流程自动化工具更是没有测试参与了)。

对于像基础组件、基础模型之类的不常变更且复用较多的部分,可以考虑去写测试用例来保证质量。个

先写少量的测试用例覆盖到 80%+的场景,保证覆盖主要使用流程。

一些极端场景出现的 bug 可以在迭代中形成测试用例沉淀,场景覆盖也将逐渐趋近 100%。

但对于迭代较快的业务逻辑以及生存时间不长的活动页面之类的就别花时间写测试用例了,维护测试用例的时间大了去了,成本太高。

大型项目,可以使用 Jest 快速形成配置并且开始单元测试。

需要测试快照,则可以选择 Jest 或者 Ava。

对于配置性要求高,对测试框架性能有要求的可以选择 mocha。

对模拟还原浏览器业务操作有很大的需求的,可以选择 nightmare

配合 CI 工具完成自动化测试、测试覆盖率、测试结果推送。

喜欢简单,选择 Mocha

Mocha(发音”摩卡”)诞生于 2011 年,是现在最流行的 JavaScript 测试框架之一,在浏览器和 Node 环境都可以使用。所谓”测试框架”,就是运行测试的工具。通过它,可以为 JavaScript 应用添加测试,从而保证代码的质量。

安装

全局安装 Mocha

1
npm install -g mocha

项目中也安装 Mocha

1
npm install --save-dev mocha

在 package.json 中加入下面脚本:

1
2
3
"scripts": {
"test": "mocha"
}

Chai 是一个针对 Node.js 和浏览器的行为驱动测试和测试驱动测试的断言库,可与任何 JavaScript 测试框架集成。它是 Mocha 的好帮手~~

1
npm install --save-dev chai

在 package.json 中加入下面脚本:

1
2
3
"scripts": {
"test": "mocha"
}

关于断言

expect断言的优点是很接近自然语言,下面是一些例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 相等或不相等
expect(4 + 5).to.be.equal(9);
expect(4 + 5).to.be.not.equal(10);
expect(foo).to.be.deep.equal({ bar: "baz" });

// 布尔值为true
expect("everthing").to.be.ok;
expect(false).to.not.be.ok;

// typeof
expect("test").to.be.a("string");
expect({ foo: "bar" }).to.be.an("object");
expect(foo).to.be.an.instanceof(Foo);

// include
expect([1, 2, 3]).to.include(2);
expect("foobar").to.contain("foo");
expect({ foo: "bar", hello: "universe" }).to.include.keys("foo");

// empty
expect([]).to.be.empty;
expect("").to.be.empty;
expect({}).to.be.empty;

// match
expect("foobar").to.match(/^foo/);

两种使用方式:

1
2
3
4
5
// commonjs
const expect = require("chai").expect;

// es6
import { expect } from "chai";

测试案例

其中 index.js 为我们的被测试代码:

1
2
3
4
5
6
7
8
9
/**
* 加法函数
* @param {第一个数} a
* @param {第二个数} b
*/
function addNum(a, b) {
return a + b;
}
module.exports = addNum;

新建测试脚本test/demo.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const expect = require('chai').expect;
const addNum = require('../src/index')

describe('测试index.js', function() {
describe('测试addNum函数', function() {
it('两数相加结果为两个数字的和', function() {
expect(addNum(1,2)).to.be.equal(3);
// 以上语法为chai的expect语法,它还有should语法和asset语法。
});
});
});


// 等价的意思
var addNum=require('../src/index')

describe('测试index.js', function() {
describe('测试addNum函数', function() {
it('两数相加结果为两个数字的和', function() {
if(addNum(1,2)!==3){
throw new Error("两数相加结果不为两个数字的和");
}
});
});
});

Mocha 测试命令

如果想测试单一的测试 js,可以用:

1
mocha test/index.test.js

或者多个 js

1
mocha test/index.test.js test/add.test.js

当然也可以用通配符测试某个文件夹下所有的 js 和 jsx:

1
2
3
4
5
6
7
# node 通配符
mocha 'test/some/*.@(js|jsx)'

# shell 通配符
mocha test/unit/*.js

mocha spec/{my,awesome}.js

ES6 语法支持

在上面我们用的并非是 ES6 的语法,那么让我们把其中的代码都改为 ES6 的语法。
其中 index.js 为:

1
2
3
4
5
6
7
8
9
10
/**
* 加法函数
* @param {第一个数} a
* @param {第二个数} b
*/
function addNum(a, b) {
return a + b;
}

export { addNum };

而 index.test.js 为:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { expect } from "chai";
import { addNum } from "../src/index";

describe("测试index.js", function () {
describe("测试addNum函数", function () {
it("两个参数相加结果为两个数字的和", function () {
expect(addNum(1, 2)).to.be.equal(3);
});
it("两个参数相加结果不为和以外的数", function () {
expect(addNum(1, 2)).to.be.not.equal(4);
});
});
});

此时直接运行 mocha 肯定是不行的,我们现需要安装一下 babel:

1
npm install --save-dev @babel/cli @babel/core @babel/node @babel/register @babel/preset-env chai mocha nodemon

然后,在项目目录下面,新建一个.babelrc 文件:

1
2
3
{
"presets": ["@babel/preset-env"]
}

接着讲 package.json 中的脚本改为:

1
2
3
"scripts": {
"test": "mocha --require @babel/register"
},

命令变得更加简单了

更多用法

超时

1
2
--timeout, -t, --timeouts  Specify test timeout threshold (in milliseconds)
[number] [default: 2000]

官方默认的超时是 2000 毫秒,即 2s。

有三种方式来修改超时:

--no-timeout参数或者debug模式中,全局禁用了超时;

--timeout后面接时间(毫秒),全局修改了本次执行测试用例的超时时间;

在测试用例里面,使用this.timeout方法:

1
2
3
4
it("should take less than 500ms", function (done) {
this.timeout(500);
setTimeout(done, 300);
});

钩子方法里面使用:

1
2
3
4
5
6
describe("a suite of tests", function () {
beforeEach(function (done) {
this.timeout(3000); // A very long environment setup.
setTimeout(done, 2500);
});
});

同样,可以使用``this.timeout(0)去禁用超时。

钩子方法(生命周期函数)

Mocha 在 describe 块之中,提供测试用例的四个钩子:before()、after()、beforeEach()和 afterEach()。它们会在指定时间执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
describe("测试index.js", () => {
before(() => console.info("在本区块的所有测试用例之前执行"));

after(() => console.info("在本区块的所有测试用例之后执行"));

beforeEach(() => console.info("在本区块的每个测试用例之前执行"));

afterEach(() => console.info("在本区块的每个测试用例之后执行"));

describe("测试addNum函数", () => {
it("两数相加结果为两个数字的和", () => {
assert.equal(addNum(1, 2), 3);
});
});
});

异步测试

Mocha 本身是支持异步测试的。只需要为describe回调函数添加一个done参数, 成功时调用done(),失败时调用done(err)。例如:

1
2
3
4
5
6
7
8
9
10
var expect = require("chai").expect;
describe("db", function () {
it("#get", function (done) {
db.get("foo", function (err, foo) {
if (err) done(err);
expect(foo).to.equal("bar");
done();
});
});
});
  • 如果未调用done函数,Mocha 会一直等待直到超时。
  • 如果未添加done参数,Mocha 会直接返回成功,不会捕获到异步的断言失败。例如:
1
2
3
4
5
it("#get", function () {
setTimeout(function () {
expect(1).to.equal(2);
}, 100);
});

运行上述测试Mocha总会提示 Passing。

Mocha 怎么知道是否要等待异步断言呢?因为 JavaScript 中的Function有一个length属性, 通过它可以获得该函数的形参个数。Mocha 通过传入回调的length来判断是否需要等待。

或者,done()您可以返回Promise,而不是使用回调。如果您正在测试的 API 返回 promises 而不是回调,可以这样进行使用:

1
2
3
4
5
6
7
8
9
10
11
beforeEach(function () {
return db.clear().then(function () {
return db.save([tobi, loki, jane]);
});
});

describe("#find()", function () {
it("respond with matching records", function () {
return db.find({ type: "User" }).should.eventually.have.length(3);
});
});

同样,可以使用async / await,您还可以编写如下的异步测试:

1
2
3
4
5
6
7
8
9
10
11
beforeEach(async function () {
await db.clear();
await db.save([tobi, loki, jane]);
});

describe("#find()", function () {
it("responds with matching records", async function () {
const users = await db.find({ type: "User" });
users.should.have.length(3);
});
});

需要 Babel 支持~

示例项目

创建项目&安装依赖

1
2
3
4
5
// 初始化一个nodejs项目
npm init -y

// 安装依赖
npm install --save-dev @babel/cli @babel/core @babel/node @babel/register @babel/preset-env chai mocha nodemon

形成 package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"name": "projects",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/cli": "^7.5.0",
"@babel/core": "^7.5.0",
"@babel/node": "^7.5.0",
"@babel/preset-env": "^7.5.0",
"@babel/register": "^7.4.4",
"chai": "^4.2.0",
"mocha": "^6.1.4",
"nodemon": "^1.19.1"
}
}

测试过程

新建一个待测试的方法./src/index.js

1
2
3
4
5
6
const sayHello = () => "Hello world!!!";

console.log(sayHello());

// ES6语法
export default sayHello;

测试脚本./test/index.spec.js

1
2
3
4
5
6
7
8
9
10
11
import { expect } from "chai";
import sayHello from "../src/index";

describe("index test", () => {
describe("sayHello function", () => {
it("should say Hello guys!", () => {
const str = sayHello();
expect(str).to.equal("Hello guys!");
});
});
});

package.json中的脚本:

1
2
3
4
5
6
7
8
9
"scripts": {
// "start": "nodemon ./src/index.js", // 针对ES5语法
"start:babel": "nodemon --exec babel-node ./src/index.js",
"test:watch": "mocha --require @babel/register --watch",
"test": "mocha --require @babel/register",
"build": "babel src --out-dir ./dist --source-maps",
"serve": "node ./dist/index.js",
"debug": "node --inspect-brk ./dist/index.js"
},

开始测试:npm run test

报错了,因为期望的值与实际值不一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Hello world!!!


index test
sayHello function
1) should say Hello guys!


0 passing (9ms)
1 failing

1) index test
sayHello function
should say Hello guys!:

AssertionError: expected 'Hello world!!!' to equal 'Hello guys!'
+ expected - actual

-Hello world!!!
+Hello guys!

at Context.equal (test/index.spec.js:9:22)

修改测试脚本,或者修改 index.js 文件:

修改./test/index.spec.js

1
2
3
4
5
6
7
8
9
10
11
import { expect } from "chai";
import sayHello from "../src/index";

describe("index test", () => {
describe("sayHello function", () => {
it("should say Hello world!!!", () => {
const str = sayHello();
expect(str).to.equal("Hello world!!!");
});
});
});

再次测试:

1
2
3
4
5
6
7
8
9
10
11
> mocha --require @babel/register

Hello world!!!


index test
sayHello function
✓ should say Hello world!!!


1 passing (6ms)

使用mochawesome展示你的测试结果:

1
npm install --save-dev mochawesome

然后在package.jsonscripts中添加如下内容,

1
2
3
{
"report": "mocha --require @babel/register --reporter mochawesome"
}

使用方式:

1
npm run report

形成出来的报告在浏览器中打开:

image-20190707212821248

在 Vscode 中可以安装 Live Server 这个插件快速打开:

image-20190707212923919

开箱即用 Jest

Jest 是由 Facebook 发布的开源的、基于Jasmine的 JavaScript 单元测试框架。Jest 源于 Facebook 的构想,用于快速、可靠地测试 Web 聊天应用。它吸引了公司内部的兴趣,Facebook 的一名软件工程师 Jeff Morrison 半年前又重拾这个项目,改善它的性能,并将其开源。Jest 的目标是减少开始测试一个项目所要花费的时间和认知负荷,因此它提供了大部分你需要的现成工具:快速的命令行接口、Mock 工具集以及它的自动模块 Mock 系统。此外,如果你在寻找隔离工具例如 Mock 库,大部分其它工具将让你在测试中(甚至经常在你的主代码中)写一些不尽如人意的样板代码,以使其生效。Jest 与 Jasmine 框架的区别是在后者之上增加了一些层。最值得注意的是,运行测试时,Jest 会自动模拟依赖。Jest 自动为每个依赖的模块生成 Mock,并默认提供这些 Mock,这样就可以很容易地隔离模块的依赖。

Jest 支持 Babel,我们将很轻松的使用 ES6 的高级语法

Jest 支持 webpack,非常方便的使用它来管理我们的项目

Jest 支持 TypeScript,书写测试用例更加严谨

  1. 简化 API

    Jest 既简单又强大,内置支持以下功能:

    • 灵活的配置:比如,可以用文件名通配符来检测测试文件。
    • 测试的事前步骤(Setup)和事后步骤(Teardown),同时也包括测试范围。
    • 匹配表达式(Matchers):能使用期望expect句法来验证不同的内容。
    • 测试异步代码:支持承诺(promise)数据类型和异步等待async / await功能。
    • 模拟函数:可以修改或监查某个函数的行为。
    • 手动模拟:测试代码时可以忽略模块的依存关系。
    • 虚拟计时:帮助控制时间推移。
  2. 性能与隔离

    Jest 文档里写道:

    Jest 能运用所有的工作部分,并列运行测试,使性能最大化。终端上的信息经过缓冲,最后与测试结果一起打印出来。沙盒中生成的测试文件,以及自动全局状态在每个测试里都会得到重置,这样就不会出现两个测试冲突的情况。

    Mocha 用一个进程运行所有的测试,和它比较起来,Jest 则完全不同。要在测试之间模拟出隔离效果,我们必须要引入几个测试辅助函数来妥善管理清除工作。这种做法虽然不怎么理想,但 99%的情况都可以用,因为测试是按顺序进行的。

  3. 沉浸式监控模式

    快速互动式监控模式可以监控到哪些测试文件有过改动,只运行与改动过的文件相关的测试,并且由于优化作用,能迅速放出监控信号。设置起来非常简单,而且还有一些别的选项,可以用文件名或测试名来过滤测试。我们用 Mocha 时也有监控模式,不过没有那么强大,要运行某个特定的测试文件夹或文件,就不得不自己创造解决方法,而这些功能 Jest 本身就已经提供了,不用花力气。

  4. 代码覆盖率&测试报告

    Jest 内置有代码覆盖率报告功能,设置起来易如反掌。可以在整个项目范围里收集代码覆盖率信息,包括未经受测试的文件。

    要使完善 Circle CI 整合,只需要一个自定义报告功能。有了 Jest,用jest-junit-reporter就可以做到,其用法和 Mocha 几乎相同。

  5. 快照功能

    快照测试的目的不是要替换现有的单元测试,而是要使之更有价值,让测试更轻松。在某些情况下,某些功能比如 React 组件功能,有了快照测试意味着无需再做单元测试,但同样这两者不是非此即彼。

安装

新建文件夹然后通过 npm 命令安装:

1
npm install --save-dev jest

或者通过 yarn 来安装:

1
yarn add --dev jest

然后就可以开始测试了

也可用npm install -g jest进行全局安装;并在 package.json 中指定 test 脚本:

1
2
3
4
5
{
"scripts": {
"test": "jest"
}
}

Jest 的测试脚本名形如.test.js,不论 Jest 是全局运行还是通过npm test运行,它都会执行当前目录下所有的*.test.js*.spec.js 文件、完成测试。

ES6 语法支持:

  1. 安装依赖
1
yarn add --dev babel-jest @babel/core @babel/preset-env
  1. 配置.babelrc
1
2
3
4
5
6
7
8
9
10
11
12
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": "current"
}
}
]
]
}

接下来就可以使用 ES6 的语法了~

更多高阶的 ES6/7/8…语法,可以参见:babel 官网

关于 Typescript 的支持,可以参见 :Using Typescript

举个例子

关于 test suite 与 test case:

img

describe 属于 test suite 的描述,而每个 test 或者 it 则描述了每个 test case。

例如:math.js

1
2
3
export const add = (a, b) => a + b;

export const multiple = (a, b) => a * b;

测试脚本:math.test.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const math = require("./src/math");

describe("math", () => {
let a;
let b;

beforeEach(function () {
a = 2;
b = 3;
});

test("#should return result as a+b", () => {
// test code
const result = math.add(a, b);
expect(result).toEqual(5);
});

it("#should return result as a*b", () => {
//test code
const result = math.multiple(a, b);
expect(result).toEqual(6);
});
});

test suite 可以进行嵌套:

1
2
3
4
5
6
7
describe("foo", () => {
describe("bar", () => {
it("foo bar", () => {
//test code
});
});
});

test case 也可以脱离 test suite 独立运行:

1
2
3
4
5
6
7
8
9
10
// hello.js
module.exports = () => "Hello world";

// hello.test.js
let hello = require("hello.js");

test('should get "Hello world"', () => {
expect(hello()).toBe("Hello world"); // 测试成功
// expect(hello()).toBe('Hello') // 测试失败
});

Mock 与 Spy

mock测试就是在测试过程中,对于某些不容易构造或者不容易获取的对象,用一个虚拟的对象来创建以便测试的测试方法。

Mock 是单元测试中经常使用的一种技术。单元测试,顾名思义测试的重点是某个具体单元。但是在实际代码中,代码与代码之间,模块与模块之间总是会存在着相互引用。这个时候,剥离出这种单元的依赖,让测试更加独立,使用到的技术就是 Mock。

为什么要使用 Mock 函数?

在项目中,一个模块的方法内常常会去调用另外一个模块的方法。在单元测试中,我们可能并不需要关心内部调用的方法的执行过程和结果,只想知道它是否被正确调用即可,甚至会指定该函数的返回值。此时,使用 Mock 函数是十分有必要。

Mock 函数提供的以下三种特性,在我们写测试代码时十分有用:

  • 捕获函数调用情况
  • 设置函数返回值
  • 改变函数的内部实现

img

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
// math.js
export const getFooResult = () => {
// foo logic here
};
export const getBarResult = () => {
// bar logic here
};

// caculate.js
import { getFooResult, getBarResult } from "./math";

export const getFooBarResult = () => getFooResult() + getBarResult();

此时,getFooResult() 和 getBarResult() 就是 getFooBarResult 这个函数的依赖。如果我们关注的点是 getFooBarResult 这个函数,我们就应该把 getFooResult 和 getBarResult Mock 掉,剥离这种依赖。下面是一个使用 Jest 进行 Mock 的例子。

jest.fn()是  创建 Mock 函数最简单的方式,如果没有定义函数内部的实现,jest.fn()会返回undefined作为返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
test("测试jest.fn()调用", () => {
let mockFn = jest.fn();
let result = mockFn(1, 2, 3);

// 断言mockFn的执行后返回undefined
expect(result).toBeUndefined();
// 断言mockFn被调用
expect(mockFn).toBeCalled();
// 断言mockFn被调用了一次
expect(mockFn).toBeCalledTimes(1);
// 断言mockFn传入的参数为1, 2, 3
expect(mockFn).toHaveBeenCalledWith(1, 2, 3);
});

情景一:设置函数 的返回值

1
2
3
4
5
6
7
8
9
10
11
12
13
// calculate.test.js
import { getFooBarResult } from "./calculate";
import * as fooBar from "./math";

test("getResult should return result getFooResult() + getBarResult()", () => {
// mock add方法和multiple方法
fooBar.getFooBarResult = jest.fn(() => 10);
fooBar.getBarResult = jest.fn(() => 5);

const result = getFooBarResult();

expect(result).toEqual(15);
});

Mock 其实就是一种 Spies,在 Jest 中使用 spies 来“spy”(窥探)一个函数的行为。

Jest 文档对于 spies 的解释:

Mock 函数也称为“spies”,因为它们让你窥探一些由其他代码间接调用的函数的行为,而不仅仅是测试输出。你可以通过使用 jest.fn() 创建一个 mock 函数。

简单来说,一个 spy 是另一个内置的能够记录对其调用细节的函数:调用它的次数,使用什么参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// calculate.test.js
import { getFooBarResult } from "./calculate";
import * as fooBar from "./math";

test("getResult should return result getFooResult() + getBarResult()", () => {
// mock add方法和multiple方法
fooBar.getFooResult = jest.fn(() => 10);
fooBar.getBarResult = jest.fn(() => 5);

const result = getFooBarResult();

// 监控getFooResult和getBarResult的调用情况.
expect(fooBar.getFooResult).toHaveBeenCalled();
expect(fooBar.getBarResult).toHaveBeenCalled();
});

情景二:捕获函数调用情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// bot method
const bot = {
sayHello: (name) => {
console.log(`Hello ${name}!`);
},
};

// test.js
describe("bot", () => {
it("should say hello", () => {
const spy = jest.spyOn(bot, "sayHello");

bot.sayHello("Michael");

expect(spy).toHaveBeenCalledWith("Michael");

spy.mockRestore();
});
});

我们通过 jest.spyOn 创建了一个监听 bot 对象的 sayHello 方法的 spy。它就像间谍一样监听了所有对 bot#sayHello 方法的调用。由于创建 spy 时,Jest 实际上修改了 bot 对象的 sayHello 属性,所以在断言完成后,我们还要通过 mockRestore 来恢复 bot 对象原本的 sayHello方法。

Jest 的spyOn 介绍

情景三:修改函数的内容实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const bot = {
sayHello: (name) => {
console.log(`Hello ${name}!`);
},
};

describe("bot", () => {
it("should say hello", () => {
const spy = jest.spyOn(bot, "sayHello").mockImplementation((name) => {
console.log(`Hello mix ${name}`);
});

bot.sayHello("Michael");

expect(spy).toHaveBeenCalledWith("Michael");

spy.mockRestore();
});
});

使用 spyOn 方法,还可以去修改 Math.random 这样的函数

1
jest.spyOn(Math, "random").mockImplementation(() => 0.9);

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// getNum.js
const arr = [1, 2, 3, 4, 5, 6];

const getNum = (index) => {
if (index) {
return arr[index % 6];
} else {
return arr[Math.floor(Math.random() * 6)];
}
};

// num.test.js
import { getNum } from "../src/getNum";

describe("getNum", () => {
it("should select numbber based on index if provided", () => {
expect(getNum(1)).toBe(2);
});

it("should select a random number based on Math.random if skuId not available", () => {
const spy = jest.spyOn(Math, "random").mockImplementation(() => 0.9);

expect(getNum()).toBe(6);
expect(spy).toHaveBeenCalled();

spy.mockRestore();
});
});

CLI 命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
➜ npx jest --help
Usage: jest [--config=<pathToConfigFile>] [TestPathPattern]

选项:
--help, -h 显示帮助信息 [布尔]
--version, -v Print the version and exit [布尔]
--config, -c The path to a jest config file specifying how to
find and execute tests. If no rootDir is set in
the config, the directory containing the config
file is assumed to be the rootDir for the
project.This can also be a JSON encoded value
which Jest will use as configuration. [字符串]
--coverage Indicates that test coverage information should
be collected and reported in the output. [布尔]
--timers Setting this value to fake allows the use of
fake timers for functions such as setTimeout.
[字符串]
--verbose Display individual test results with the test
suite hierarchy. [布尔]
--watch Watch files for changes and rerun tests related
to changed files. If you want to re-run all
tests when a file has changed, use the
`--watchAll` option. [布尔]
--watchAll Watch files for changes and rerun all tests. If
you want to re-run only the tests related to the
changed files, use the `--watch` option. [布尔]
...

常见使用:

--verbose显示详细的测试信息,包括测试 suite 和 case:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
➜ npx jest --verbose
PASS test/mock.test.js
bot
✓ should say hello (7ms)

console.log test/mock.test.js:10
Hello mix Michael

PASS test/domain.test.js
getImageDomain
✓ should select domain based on skuId if provided (1ms)
✓ should select a random domain based on Math.random if skuId not available (1ms)

console.log test/sayhello.test.js:3
Hello Michael!

PASS test/sayhello.test.js
bot
✓ should say hello (6ms)

PASS test/num.test.js
getNum
✓ should select numbber based on index if provided (1ms)
✓ should select a random number based on Math.random if skuId not available

PASS test/math.test.js
math
#should return result as a+b (1ms)
#should return result as a*b (4ms)

Test Suites: 5 passed, 5 total
Tests: 8 passed, 8 total
Snapshots: 0 total
Time: 1.075s
Ran all test suites.

--watch--watchAll用来监听测试文件的变化

1
2
3
4
5
6
7
8
9
Ran all test suites.

Watch Usage
› Press f to run only failed tests.
› Press o to only run tests related to changed files.
› Press p to filter by a filename regex pattern.
› Press t to filter by a test name regex pattern.
› Press q to quit watch mode.
› Press Enter to trigger a test run.

--coverage用来形成测试覆盖率报告

1
2
3
4
5
6
7
8
9
10
11
12
-----------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
-----------|----------|----------|----------|----------|-------------------|
All files | 100 | 100 | 100 | 100 | |
getNum.js | 100 | 100 | 100 | 100 | |
math.js | 100 | 100 | 100 | 100 | |
-----------|----------|----------|----------|----------|-------------------|

Test Suites: 5 passed, 5 total
Tests: 8 passed, 8 total
Snapshots: 0 total
Time: 1.497s

Jest 在 React、Vue 项目中的应用

create-react-app中的应用:

安装对应的依赖:

1
npm install react-test-renderer enzyme enzyme-adapter-react-16

Enzyme是一个非常棒的 React 组件测试测试库。

Enzyme is a JavaScript Testing utility for React that makes it easier to test your React Components’ output. You can also manipulate, traverse, and in some ways simulate runtime given the output.

Enzyme’s API is meant to be intuitive and flexible by mimicking jQuery’s API for DOM manipulation and traversal

需要注意的两点是:

  • 需要配置 Adapter,不同的 React 的 Adapter 不同

| Enzyme Adapter Package | React semver compatibility |
| ————————— | ————————– | — | —— |
| enzyme-adapter-react-16 | ^16.4.0-0 |
| enzyme-adapter-react-16.3 | ~16.3.0-0 |
| enzyme-adapter-react-16.2 | ~16.2 |
| enzyme-adapter-react-16.1 | ~16.0.0-0 | | ~16.1 |
| enzyme-adapter-react-15 | ^15.5.0 |
| enzyme-adapter-react-15.4 | 15.0.0-0 - 15.4.x |
| enzyme-adapter-react-14 | ^0.14.0 |
| enzyme-adapter-react-13 | ^0.13.0 |

  • 需要初始化配置setUpTests.js

    官方文档在 Package.json 中设置 jest 配置,已经过时,Jest 框架最新会默认加载文件src/setUpTests.js

  • import { configure } from 'enzyme';
    import Adapter from 'enzyme-adapter-react-16'
    
    configure({ adapter: new Adapter() })
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12

    在 vue 工程化项目中:

    ```json
    "devDependencies": {
    "@vue/cli-plugin-unit-jest": "^3.9.0",
    "@vue/test-utils": "1.0.0-beta.29",
    "babel-core": "7.0.0-bridge.0",
    "babel-eslint": "^10.0.1",
    "babel-jest": "^23.6.0",
    "babel-preset-env": "^1.7.0",
    },

添加如上依赖,

配置 scripts:

1
2
3
"scripts": {
"test": "vue-cli-service test:unit"
},

配置jest.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
module.exports = {
// 处理vue结尾的文件
moduleFileExtensions: ["js", "jsx", "json", "vue"],
// es6转义
transform: {
"^.+\\.vue$": "vue-jest",
".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$":
"jest-transform-stub",
"^.+\\.jsx?$": "babel-jest",
},
transformIgnorePatterns: ["/node_modules/"],
// cli配置了webpack别名
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
snapshotSerializers: ["jest-serializer-vue"],
testMatch: [
"**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)",
],
testURL: "http://localhost/",
watchPlugins: [
"jest-watch-typeahead/filename",
"jest-watch-typeahead/testname",
],
};

写一个测试用例:

1
2


简约之美 AVA

简单的说 ava 是 mocha 的替代品:

  • es6 语法支持更好,对 aysnc/await 有支持
  • 执行效率更高,使用 io 并发,就必须保证测试的原子性
  • 语义上更简单,集众家之长

虽然 JavaScript 是单线程,但在 Node.js 里由于其异步的特性使得 IO 可以并行。AVA 利用这个优点让你的测试可以并发执行,这对于 IO 繁重的测试特别有用。另外,测试文件可以在不同的进程里并行运行,让每一个测试文件可以获得更好的性能和独立的环境。

AVA 特点

  • 轻量和高效

  • 简单的测试语法

  • 并发运行测试

  • 强制编写原子测试

    一旦开始,就一直运行到结束,中间不会切换到另一个测试

  • 没有隐藏的全局变量

  • 为每个测试文件隔离环境

  • 用 ES2015 编写测试

  • 支持 Promise

  • 支持 Generator

  • 支持 Async

  • 支持 Observable

  • 强化断言信息

  • 可选的 TAP 输出显示

    img

  • 简明的堆栈跟踪

安装&开始

情景一:

1
2
// 创建一个ava项目
npm init ava

形成 package.json

1
2
3
4
5
6
7
8
9
{
"name": "awesome-package",
"scripts": {
"test": "ava"
},
"devDependencies": {
"ava": "^1.0.0"
}
}

情景二:(推荐)

1
2
3
4
5
6
7
npm init -y

// npm & cnpm
npm install -D ava

// yarn
yarn add ava -D

测试 ava 正常安装:

1
2
➜ npx ava --version
2.2.0

第一个 ava 测试例子

流程:

  1. 引用 ava 的测试 API
  2. 执行测试
  3. 使用断言

创建test.js文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import test from "ava";

const testfn = (a, b) => a + b;

test("hello ava", (t) => {
t.pass();
});

test("my first test", async (t) => {
const str = "hello ava!!!!";
t.is(str, "hello ava!!!!");
});

test("add method", async (t) => {
const result = testfn(3, 4);
t.is(result, 7);
});

ava 自动搜索如下文件结尾的文件:

  • **/test.js
  • **/test-*.js
  • **/*.spec.js
  • **/*.test.js
  • **/test/**/*.js
  • **/tests/**/*.js
  • **/__tests__/**/*.js

ava 中的断言:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.pass([message])
.fail([message])
.assert(value, [message])
.truthy(value, [message])
.falsy(value, [message])
.true(value, [message])
.false(value, [message])
.is(value, expected, [message])
.not(value, expected, [message])
.deepEqual(value, expected, [message])
.notDeepEqual(value, expected, [message])
.deepEqual()。
.throws(fn, [expected, [message]])
.throwsAsync(thrower, [expected, [message]])
.notThrows(fn, [message])
.notThrowsAsync(nonThrower, [message])
.regex(contents, regex, [message])
.notRegex(contents, regex, [message])
.snapshot(expected, [message])
.snapshot(expected, [options], [message])

CLI 命令

使用--help命令去查看 ava 支持的 cli 参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
➜ npx ava --help

Testing can be a drag. AVA helps you get it done.

Usage
ava [<file> ...]

Options
--watch, -w Re-run tests when tests and source files change
--match, -m Only run tests with matching title (Can be repeated)
--update-snapshots, -u Update snapshots
--fail-fast Stop after first test failure
--timeout, -T Set global timeout (milliseconds or human-readable, e.g. 10s, 2m)
--serial, -s Run tests serially
--concurrency, -c Max number of test files running at the same time (Default: CPU cores)
--verbose, -v Enable verbose output
--tap, -t Generate TAP output
--color Force color output
--no-color Disable color output
--reset-cache Reset AVA's compilation cache and exit
--config JavaScript file for AVA to read its config from, instead of using package.json
or ava.config.js files

Examples
ava
ava test.js test2.js
ava test-*.js
ava test

The above relies on your shell expanding the glob patterns.
Without arguments, AVA uses the following patterns:
**/test.js **/test-*.js **/*.spec.js **/*.test.js **/test/**/*.js **/tests/**/*.js **/__tests__/**/*.js

文件匹配

使用match指令,匹配对应需要测试的文件:

匹配标题以foo:结尾

1
npx ava --match ='* foo'

匹配标题以foo

1
npx ava --match ='foo *'

匹配标题包含foo

1
npx ava --match ='* foo *'

匹配是完全相同 foo

1
npx ava --match ='foo'

匹配标题不包含foo

1
npx ava --match ='!* foo *'

匹配以下foo结尾的标题bar

1
npx ava --match ='foo * bar'

匹配foobar:开头或结尾的标题:

1
npx ava --match ='foo *' -  match ='* bar'

关于 reporter

默认情况下,AVA 使用最小的报告:

img

使用该--verbose标志启用详细的报告者。除非启用 TAP 报告,否则始终在 CI 环境中使用此选项。

img

TAP 报告(推荐)

AVA 支持 TAP 格式,因此与任何 TAP 报告器兼容。使用该--tap标志启用 TAP 输出。

1
$ npx ava --tap | npx tap-nyan

img

这里有一些格式:

快照功能

ava 自动进行项目测试快照,如果文件放置在test或者tests目录,则快照会放置在snapshots目录。如果测试放置在__test__目录,则快照放置在__snapshots__目录

1
ava --update-snapshots

可以指定一个固定位置,以便在 AVA 的package.json配置中存储快照文件:

package.json:

1
2
3
4
5
{
"ava":{
"snapshotDir":"自定义目录"
}
}

设置超时

AVA 中的超时行为与其他测试框架中的行为不同。AVA 在每次测试后重置计时器,如果在指定的超时内没有收到新的测试结果,则强制测试退出。这可用于处理停滞的测试。

没有默认超时。

您可以配置使用超时--timeout 命令行选项,或配置文件中设置。它们可以以人类可读的方式设置:

1
2
3
4
5
6
7
8
# 10秒
npx ava --timeout = 10s

# 2分钟
npx ava --timeout = 2m

# 100毫秒
npx ava --timeout = 100

还可以为每个测试单独设置超时。每次进行断言时都会重置这些超时。

1
2
3
4
test("foo", (t) => {
t.timeout(100); // 100 milliseconds
// Write your assertions here
});

其他 ava 设置相关

ESLint

如果使用了 ESLint,请添加eslint-plugin-ava

1
2
3
{
"plugins": ["ava"]
}

异步相关

如果异步操作使用 promises,则应返回 promise:

1
2
3
4
5
test("fetches foo", (t) => {
return fetch().then((data) => {
t.is(data, "foo");
});
});

更好的是,使用async/ await

1
2
3
4
test("fetches foo", async (t) => {
const data = await fetch();
t.is(data, "foo");
});

测试环境&帮手 Karma

Karma 是一个基于 Node.js 的 JavaScript 测试执行过程管理工具(Test Runner)。该工具可用于测试所有主流 Web 浏览器,也可以集成到 CI(Continuous integration)工具,还可以和其他代码编辑器一起使用。

Karma 会监控配置文件中所指定的每一个文件,每当文件发生改变,它都会向测试服务器发送信号,来通知所有的浏览器再次运行测试代码。此时,浏览器会重新加载源文件,并执行测试代码。其结果会传递回服务器,并以某种形式显示给开发者。

访问浏览器执行结果,可通过以下的方式

  • 手工方式 - 通过浏览器
  • 自动方式 - 让 karma 来启动对应的浏览器

工作原理简介

karma 是一个典型的 C/S 程序,包含 client 和 server ,通讯方式基于 Http ,通常情况下,客户端和服务端基本都运行在开发者本地机器上。

一个服务端实例对应一个项目,假如想同时运行多个项目,得同时开启多个服务端实例。

Server

Server 是框架的主要组成部分之一,它内部保存了所有的程序运行状态,比如 client 连接,当前运行的单测文件,根据这些数据状态,它提供了下面几个功能, 下图是 server 的结构

karma_server

  • 监听文件
  • 与 client 进行通讯
  • 向开发者输出测试结果
  • 提供 client 端所需的资源文件

Client

client 是单测最终运行的地方,类似一个 web app , 跟 server 端通讯利用 socket.io, 执行单测在一个独立的 iframe 中。下面是它的结构图

karma_impl_client

client 和 server 端通讯采用 socket.io

  • client 端会发送这些消息

karma_impl_client_message_c

  • server 端会发送这些消息

karma_impl_client_message_s

安装 Karma

对于 Nodejs 版本的要求:

Karma currently works on Node.js 6.x, 8.x, and 10.x. See FAQ for more info.

  1. 全局安装

    1
    $npm install -g karma

    安装 Karma 命令会到全局的 node_modules 目录下,我们可以在任何位置直接运行 karma 命令。

    1
    npm install -g karma-cli

    此命令用来安装 karma-cli,它会在当前目录下寻找 karma 的可执行文件。这样我们就可以在一个系统内运行多个版本的 Karma。

  2. 本地安装

    1
    $ npm install karma --save-dev

    安装 Karma 命令到当前 node_modules 目录下,此时,如果需要执行 karma 命令,就需要这样

    1
    2
    3
    ./node_modules/.bin/karma

    npx karma --version

配置 Karma

karma 配置文件可以用 JavaScript,CoffeeScript 或 TypeScript 编写,并作为常规 Node.js 模块加载。

除非作为参数提供,否则 Karma CLI 将在以下位置以该顺序(从上至下)查找配置文件

  • ./karma.conf.js
  • ./karma.conf.coffee
  • ./karma.conf.ts
  • ./.config/karma.conf.js
  • ./.config/karma.conf.coffee
  • ./.config/karma.conf.ts

在配置文件中,配置代码通过设置module.exports指向一个接受一个参数的函数:配置对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// karma.conf.js
module.exports = function(config) {
config.set({
basePath: '../..',
frameworks: ['jasmine'],
//...
});
};
# karma.conf.coffee
module.exports = (config) ->
config.set
basePath: '../..'
frameworks: ['jasmine']
# ...
// karma.conf.ts
module.exports = (config) => {
config.set({
basePath: '../..',
frameworks: ['jasmine'],
//...
});
}

关于 typescript 的支持,需要使用到ts-node,配置 ts-node 以使用commonjs模块格

配置文件中的基本的属性介绍:Overview

使用 CLI 工具,快速创建配置

开始配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
~/Downloads/Demo is 📦 v1.0.0 via ⬢ v10.16.0
➜ npx karma init

# 如果在应用中用到了其它的测试框架,那就需要我们安装它们所对应的插件,并在配置文件中标注它们(详见 karma.conf.js 中的 plugins 项)
Which testing framework do you want to use ?
Press tab to list possible options. Enter to move to the next question.
> jasmine
# mocha
# qunit
# nodeunit
# nunit

# Require.js 是异步加载规范(AMD)的实现。常被作为基础代码库,应用在了很多的项目与框架之中,例如 Dojo, AngularJs 等
Do you want to use Require.js ?
This will add Require.js plugin.
Press tab to list possible options. Enter to move to the next question.
> no
# yes

# 选择需要运行测试用例的浏览器。需要注意的就是,必须保证所对应的浏览器插件已经安装成功。
Do you want to capture any browsers automatically ?
Press tab to list possible options. Enter empty string to move to the next question.
> Chrome
# ChromeHeadless
# ChromeCanary
# Firefox
# Safari
# PhantomJS
# Opera
# IE

# 选择测试用例所在的目录位置。Karma 支持通配符的方式配置文件或目录,例如 *.js, test/**/*.js 等。如果目录或文件使用相对位置,要清楚地是,此时的路径是相对于当前运行 karma 命令时所在的目录。
What is the location of your source and test files ?
You can use glob patterns, eg. "js/*.js" or "test/**/*Spec.js".
Enter empty string to move to the next question.
> src/*js

# 目录中不包括的那些文件。
Should any of the files included by the previous patterns be excluded ?
You can use glob patterns, eg. "**/*.swp".
Enter empty string to move to the next question.

# 是否需要 Karma 自动监听文件?并且文件一旦被修改,就重新运行测试用例?
Do you want Karma to watch all the files and run the tests on change ?
Press tab to list possible options.
> yes

生成了一个karma.conf.js文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// Karma configuration
// Generated on Wed Jul 10 2019 22:46:32 GMT+0800 (GMT+08:00)

module.exports = function (config) {
config.set({
// base path that will be used to resolve all patterns (eg. files, exclude)
basePath: "",

// frameworks to use
// available frameworks: https://npmjs.org/browse/keyword/karma-adapter
frameworks: ["mocha"],

// list of files / patterns to load in the browser
files: ["src/*js"],

// list of files / patterns to exclude
exclude: [],

// preprocess matching files before serving them to the browser
// available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
preprocessors: {},

// test results reporter to use
// possible values: 'dots', 'progress'
// available reporters: https://npmjs.org/browse/keyword/karma-reporter
reporters: ["progress"],

// web server port
port: 9876,

// enable / disable colors in the output (reporters and logs)
colors: true,

// level of logging
// possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
logLevel: config.LOG_INFO,

// enable / disable watching file and executing tests whenever any file changes
autoWatch: true,

// start these browsers
// available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
browsers: ["Chrome"],

// Continuous Integration mode
// if true, Karma captures browsers, runs the tests and exits
singleRun: false,

// Concurrency level
// how many browser should be started simultaneous
concurrency: Infinity,
});
};

karma 示例

目标 :

  • babel 支持,ES6 语法支持
  • mocha 与 chai 支持
  • karma 与 chrome、webpack 对接

说明:

Karma 对 babel 支持的,一个可选项:karma-babel-preprocessor,但是:

babel and karma-babel-preprocessor only convert ES6 modules to CommonJS/AMD/SystemJS/UMD. If you choose CommonJS, you still need to resolve and concatenate CommonJS modules on your own. We recommend karma-browserify + babelify or webpack + babel-loader in such cases.

所以,我们选择了 webpack

  1. 安装依赖

    1
    npm install @babel/core @babel/preset-env chai mocha webpack webpack-cli babel-loader -D
  2. 安装 karma 的适配器

    1
    npm install karma-webpack karma-chrome-launcher karma-mocha karma-chai -D
  3. 配置karma.config.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    // Karma configuration
    // Generated on Thu Jul 11 2019 23:23:44 GMT+0800 (GMT+08:00)

    module.exports = function (config) {
    config.set({
    // base path that will be used to resolve all patterns (eg. files, exclude)
    basePath: "",

    // frameworks to use
    // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
    frameworks: ["mocha"],

    // list of files / patterns to load in the browser
    files: ["src/**/*.js", "test/**/*.js"],

    // ....

    // preprocess matching files before serving them to the browser
    // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
    preprocessors: {
    "src/**/*.js": ["webpack"],
    "test/**/*.js": ["webpack"],
    },

    webpack: {
    mode: "none",
    node: {
    fs: "empty",
    },
    module: {
    rules: [
    {
    test: /\.js?$/,
    loader: "babel-loader",
    options: { presets: ["@babel/env"] },
    },
    ],
    },
    },

    // ....

    plugins: [
    "karma-mocha",
    "karma-chai",
    "karma-chrome-launcher",
    "karma-webpack",
    ],
    });
    };
  4. 书写测试用例test.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    import { describe } from "mocha";
    import { expect } from "chai";

    describe("first test", () => {
    it("hello mocha and karma", () => {
    console.log("hello mocha");
    expect(true).to.be.equal(true);
    });
    });
  5. 开始测试:

    1
    npx karma start

    添加到package.json

    1
    2
    3
    "scripts": {
    "karma": "karma start"
    },

    然后使用,npm run karma

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    ➜ npx karma start
    ℹ 「wdm」: Hash: 9d2c943b68425fd58dd0
    Version: webpack 4.35.3
    Time: 53ms
    Built at: 2019-07-12 5:07:49 PM
    ℹ 「wdm」: Compiled successfully.
    ℹ 「wdm」: Compiling...
    ⚠ 「wdm」: Hash: cca8316f4bd5855fc4de
    Version: webpack 4.35.3
    Time: 2414ms
    Built at: 2019-07-12 5:07:52 PM
    Asset Size Chunks Chunk Names
    src/index.js 3.62 KiB 0 [emitted] src/index
    test/test.js 1010 KiB 1 [emitted] test/test
    Entrypoint src/index = src/index.js
    Entrypoint test/test = test/test.js
    [0] ./src/index.js 27 bytes {0} [built]
    [1] ./test/test.js 223 bytes {1} [built]
    [2] ./node_modules/mocha/browser-entry.js 4.19 KiB {1} [built]
    [3] ./node_modules/process/browser.js 4.96 KiB {1} [built]
    [4] (webpack)/buildin/global.js 878 bytes {1} [built]
    [5] ./node_modules/browser-stdout/index.js 662 bytes {1} [built]
    [6] ./node_modules/stream-browserify/index.js 3.53 KiB {1} [built]
    [36] ./node_modules/util/util.js 19 KiB {1} [built]
    [38] ./node_modules/mocha/lib/mocha.js 21.8 KiB {1} [built]
    [39] (webpack)/buildin/module.js 552 bytes {1} [built]
    [40] ./node_modules/escape-string-regexp/index.js 230 bytes {1} [built]
    [41] path (ignored) 15 bytes {1} [built]
    [42] ./node_modules/mocha/lib/reporters/index.js 945 bytes {1} [built]
    [105] ./node_modules/chai/index.js 39 bytes {1} [built]
    [106] ./node_modules/chai/lib/chai.js 1.22 KiB {1} [built]
    + 128 hidden modules

    WARNING in ./node_modules/mocha/lib/mocha.js 217:20-37
    Critical dependency: the request of a dependency is an expression
    @ ./node_modules/mocha/browser-entry.js
    @ ./test/test.js

    WARNING in ./node_modules/mocha/lib/mocha.js 222:24-70
    Critical dependency: the request of a dependency is an expression
    @ ./node_modules/mocha/browser-entry.js
    @ ./test/test.js

    WARNING in ./node_modules/mocha/lib/mocha.js 266:24-35
    Critical dependency: the request of a dependency is an expression
    @ ./node_modules/mocha/browser-entry.js
    @ ./test/test.js

    WARNING in ./node_modules/mocha/lib/mocha.js 313:35-48
    Critical dependency: the request of a dependency is an expression
    @ ./node_modules/mocha/browser-entry.js
    @ ./test/test.js

    WARNING in ./node_modules/mocha/lib/mocha.js 329:23-44
    Critical dependency: the request of a dependency is an expression
    @ ./node_modules/mocha/browser-entry.js
    @ ./test/test.js
    ℹ 「wdm」: Compiled with warnings.
    12 07 2019 17:07:52.761:WARN [karma]: No captured browser, open http://localhost:9876/
    12 07 2019 17:07:52.770:INFO [karma-server]: Karma v4.1.0 server started at http://0.0.0.0:9876/
    12 07 2019 17:07:52.770:INFO [launcher]: Launching browsers Chrome with concurrency unlimited
    12 07 2019 17:07:52.773:INFO [launcher]: Starting browser Chrome
    12 07 2019 17:07:54.135:INFO [Chrome 75.0.3770 (Mac OS X 10.14.5)]: Connected on socket R9R31QB1GtR_kayNAAAA with id 55412577
    Chrome 75.0.3770 (Mac OS X 10.14.5) LOG: 'hello karma'

    LOG: 'hello mocha'
    Chrome 75.0.3770 (Mac OS X 10.14.5): Executed 1 of 1 SUCCESS (0 secs / 0.001 secs
    ~/Downloads/karma-demo is 📦 v1.0.0 via ⬢ v10.16.0
    ➜ npm run karma

    > karma-demo@1.0.0 karma /Users/huasheng/Downloads/karma-demo
    > karma start

    ℹ 「wdm」: Hash: 9d2c943b68425fd58dd0
    Version: webpack 4.35.3
    Time: 48ms
    Built at: 2019-07-12 5:24:02 PM
    ℹ 「wdm」: Compiled successfully.
    ℹ 「wdm」: Compiling...
    ⚠ 「wdm」: Hash: cca8316f4bd5855fc4de
    Version: webpack 4.35.3
    Time: 2307ms
    Built at: 2019-07-12 5:24:04 PM
    Asset Size Chunks Chunk Names
    src/index.js 3.62 KiB 0 [emitted] src/index
    test/test.js 1010 KiB 1 [emitted] test/test
    Entrypoint src/index = src/index.js
    Entrypoint test/test = test/test.js
    [0] ./src/index.js 27 bytes {0} [built]
    [1] ./test/test.js 223 bytes {1} [built]
    [2] ./node_modules/mocha/browser-entry.js 4.19 KiB {1} [built]
    [3] ./node_modules/process/browser.js 4.96 KiB {1} [built]
    [4] (webpack)/buildin/global.js 878 bytes {1} [built]
    [5] ./node_modules/browser-stdout/index.js 662 bytes {1} [built]
    [6] ./node_modules/stream-browserify/index.js 3.53 KiB {1} [built]
    [36] ./node_modules/util/util.js 19 KiB {1} [built]
    [38] ./node_modules/mocha/lib/mocha.js 21.8 KiB {1} [built]
    [39] (webpack)/buildin/module.js 552 bytes {1} [built]
    [40] ./node_modules/escape-string-regexp/index.js 230 bytes {1} [built]
    [41] path (ignored) 15 bytes {1} [built]
    [42] ./node_modules/mocha/lib/reporters/index.js 945 bytes {1} [built]
    [105] ./node_modules/chai/index.js 39 bytes {1} [built]
    [106] ./node_modules/chai/lib/chai.js 1.22 KiB {1} [built]
    + 128 hidden modules

    WARNING in ./node_modules/mocha/lib/mocha.js 217:20-37
    Critical dependency: the request of a dependency is an expression
    @ ./node_modules/mocha/browser-entry.js
    @ ./test/test.js

    WARNING in ./node_modules/mocha/lib/mocha.js 222:24-70
    Critical dependency: the request of a dependency is an expression
    @ ./node_modules/mocha/browser-entry.js
    @ ./test/test.js

    WARNING in ./node_modules/mocha/lib/mocha.js 266:24-35
    Critical dependency: the request of a dependency is an expression
    @ ./node_modules/mocha/browser-entry.js
    @ ./test/test.js

    WARNING in ./node_modules/mocha/lib/mocha.js 313:35-48
    Critical dependency: the request of a dependency is an expression
    @ ./node_modules/mocha/browser-entry.js
    @ ./test/test.js

    WARNING in ./node_modules/mocha/lib/mocha.js 329:23-44
    Critical dependency: the request of a dependency is an expression
    @ ./node_modules/mocha/browser-entry.js
    @ ./test/test.js
    ℹ 「wdm」: Compiled with warnings.
    12 07 2019 17:24:04.905:WARN [karma]: No captured browser, open http://localhost:9876/
    12 07 2019 17:24:04.914:INFO [karma-server]: Karma v4.1.0 server started at http://0.0.0.0:9876/
    12 07 2019 17:24:04.914:INFO [launcher]: Launching browsers Chrome with concurrency unlimited
    12 07 2019 17:24:04.922:INFO [launcher]: Starting browser Chrome
    12 07 2019 17:24:06.289:INFO [Chrome 75.0.3770 (Mac OS X 10.14.5)]: Connected on socket EbCCSbQRPbxHbq3OAAAA with id 92342032
    Chrome 75.0.3770 (Mac OS X 10.14.5) LOG: 'hello karma'

    LOG: 'hello mocha'
    Chrome 75.0.3770 (Mac OS X 10.14.5): Executed 1 of 1 SUCCESS (0 secs / 0.001 secs
    Chrome 75.0.3770 (Mac OS X 10.14.5): Executed 1 of 1 SUCCESS (0.006 secs / 0.001
    secs)
    TOTAL: 1 SUCCESS

UI 测试利器 Nightmare

Nightmare 是Segment的高级浏览器自动化库。

目标是公开一些模仿用户操作(例如gototypeclick)的简单方法,使用对每个脚本块感觉同步的 API,而不是深层嵌套的回调。它最初设计用于在没有 API 的站点之间自动执行任务,但最常用于 UI 测试和爬网。

它使用Electron,它与PhantomJS类似,但大约快两倍,更现代。

安装与起步

  1. 安装nightmare

  2. // 初始化项目
    npm init -y
    
    npm install --save-dev nightmare
    
    
    1
    2

    npm install --save-dev mocha
  3. 淘宝源加速

1
2
3
4
5

// 使用淘宝源加速 electron 的安装
export ELECTRON_MIRROR="https://npm.taobao.org/mirrors/electron/"

`
  1. 起步测试:

​```js
const Nightmare = require(‘nightmare’)
const assert = require(‘assert’)

describe(‘Load a Page’, function () {
// Recommended: 5s locally, 10s to remote server, 30s from airplane ¯_(ツ)_/¯
this.timeout(‘30s’)

let nightmare = null
beforeEach(() => {
nightmare = new Nightmare()
})

describe(‘/ (Home Page)’, () => {
it(‘should load without error’, done => {
// your actual testing urls will likely be http://localhost:port/path
nightmare.goto(‘https://www.baidu.com')
.end()
.then(function (result) {
done()
})
.catch(done)
})
})
})

1
2
3
4
5
6
7
8
9

### nightmare 配合 mocha 测试

nightmare 可以进行网页的抓取,配合 mocha 进行页面的测试:

安装`mocha`

```bash
npm install --save-dev mocha

还可以安装一些断言库,如:chai

新建测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const Nightmare = require("nightmare");
const assert = require("assert");

describe("Search nightmare", () => {
this.timeout("30s");

let nightmare = null;

beforeEach(() => {
nightmare = new Nightmare();
});

it("should load with result nightmare", (done) => {
const selector = "em";
nightmare
.goto("https://www.baidu.com")
.type("#kw", "nightmare")
.click("#su")
.wait("em")
.evaluate((selector) => {
// now we're executing inside the browser scope.
return document.querySelector(selector).innerText;
}, selector) // <-- that's how you pass parameters from Node scope to browser scope
.end()
.then(function (result) {
console.log(result);
assert.equal(result, "nightmare");
done();
})
.catch(done);
});
});

将 mocha 作为测试脚本添加到您的 package.json

1
2
3
"scripts": {
"test": "mocha"
}

API 介绍

nightmare 的配置项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
waitTimeout (default: 30s)
gotoTimeout (default: 30s)
loadTimeout (default: infinite)
executionTimeout (default: 30s)
paths
switches
electronPath
dock
openDevTools
typeInterval (default: 100ms)
pollInterval (default: 250ms)
maxAuthRetries (default: 3)
certificateSubjectName
.engineVersions()
.useragent(useragent)
.authentication(user, password)
.authentication(user, password)
.halt(error, done)

配置链接:https://github.com/segmentio/nightmare#nightmareoptions

页面交互相关

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
.back()
.forward()
.refresh()
.click(selector)
.mousedown(selector)
.mouseup(selector)
.mouseover(selector)
.mouseout(selector)
.type(selector[, text])
.insert(selector[, text])
.check(selector)
.uncheck(selector)
.select(selector, option)
.scrollTo(top, left)
.viewport(width, height)
.inject(type, file)
.evaluate(fn[, arg1, arg2,...])
.wait(ms)
.wait(selector)
.wait(fn[, arg1, arg2,...])
.header(header, value)

配置参考链接

页面提取

1
2
3
4
5
6
7
8
9
10
11
.exists(selector)
.visible(selector)
.on(event, callback)
.once(event, callback)
.removeListener(event, callback)
.screenshot([path][, clip])
.html(path, saveType)
.pdf(path, options)
.title()
.url()
.path()

配置参考链接

其他相关

补充资料

软件测试的分类

第一部分:软件测试的分类

  • 按测试执行阶段划分

    单元测试、集成测试、系统测试、验收测试(正式验收测试、Alpha 测试、Beta 测试)

  • 按测试技术划分

    白盒测试、黑盒测试、灰盒测试

  • 被测试对象是否运行划分

    动态测试、静态测试(文档检查、代码走查、界面检查)

  • 按不同的测试手段划分

    手工测试、自动化测试

  • 按测试包含的内容划分

    功能测试、界面测试、安全测试、兼容性测试、易用性测试、性能测试、压力测试、负载测试、恢复测试

  • 其他测试

    冒烟测试、回归测试、探索性测试/自由测试(测试思维)

第二部分:接下来对软件测试分类进行一个说明

第三部分:测试工具

SVN,Git——>版本控制管理工具

禅道——>Bug 管理工具

Fiddler——>抓包,定位问题你

postman,jmeter,soapui——>接口测试

Loadrunner,Jmeter——>性能,压力测试

2019 年 Javascript 测试概览

https://medium.com/welldone-software/an-overview-of-javascript-testing-in-2019-264e19514d0a

这是一篇非常好的国外的博文,同时也是 2018 年 Javascript 测试概览的作者。这里有 2018 年的译文:

展望 2018 年 JavaScript Testing

在文中很好介绍到了测试类型,并举了大量的例子,非常全面。

什么是 TDD?

测试驱动开发(TDD)总结——原理篇

TDD (Test Driven Development) 在不同的圈子、不同的角色的认知中可能会有不同的理解,有人可能会理解成 ATDD(Acceptance Test Driven Development),也有人可能会理解成 UTDD(Unit Test Driven Development),为了避免产生歧义,文章涉及到 TDD 专指 UTDD(Unit Test Driven Development),即 「单元测试驱动开发」

TDD 的目标

Kent Beck 在他的著作《Test-Driven Development》一书中提到:“代码简洁可用这句言简意赅的话,正是 TDD 所追求的目标”。

对于如何保证“代码简洁可用”可以使用分而治之的方法,先达到“可用”目标,再追求“简洁”目标。

可用: 保证代码通过自动化测试。

代码简洁: 在不同阶段人们对简洁的理解程度也不一样,不过遵循的原则差不多,例如 OOD 的 SOLID 原则,Kent Beck 的 Simple Design 原则等。

虽然有很多因素妨碍我们得到整洁的代码,甚至可用的代码,无需征求太多意见,只需要采用 TDD 的开发方式来驱动出简洁可用的代码。

Karma 的前世今生

2016 年的文章,由淘宝前端团队书写:http://taobaofed.org/blog/2016/01/08/karma-origin/

通篇介绍了 karma 的工作原理及实现原理,非常有价值的文章。

ava 框架的配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
"ava": {
"files": [
"test/**/*",
"!test/exclude-files-in-this-directory",
"!**/exclude-files-with-this-name.*"
],
"helpers": ["**/helpers/**/*"],
"sources": ["src/**/*"],
"match": ["*oo", "!foo"],
"cache": true,
"concurrency": 5,
"failFast": true,
"failWithoutAssertions": false,
"environmentVariables": {
"MY_ENVIRONMENT_VARIABLE": "some value"
},
"tap": true,
"verbose": true,
"compileEnhancements": false,
"require": ["@babel/register"],
"babel": {
"extensions": ["js", "jsx"],
"testOptions": {
"babelrc": false
}
}
}
}
  • files:用于选择测试文件的 glob 模式数组。带有下划线前缀的文件将被忽略。默认情况下,仅选择具有js扩展名的文件,即使该模式与其他文件匹配。指定extensionsbabel.extensions允许其他文件扩展名
  • helpers:用于选择帮助文件的 glob 模式数组。这里匹配的文件永远不会被视为测试。默认情况下,仅选择具有js扩展名的文件,即使该模式与其他文件匹配。指定extensionsbabel.extensions允许其他文件扩展名
  • sources:一组 glob 模式,用于匹配文件,这些文件在更改时会导致重新运行测试(在监视模式下)。有关详细信息,请参阅
  • match:通常在package.json配置中没用,但等同于在 CLI 上指定--match
  • cache:缓存编译的测试和帮助文件node_modules/.cache/ava。如果false,文件缓存在临时目录中
  • failFast:一旦测试失败,停止运行进一步的测试
  • failWithoutAssertions:如果设置成false,那么如果没有运行断言,则测试失败
  • environmentVariables:指定要供测试使用的环境变量。此处定义的环境变量会覆盖其中的环境变量process.env
  • tap:设置成 true,启用 TAP 报告
  • verbose:设置成 true,启用详细输出
  • snapshotDir:指定用于存储快照文件的固定位置。如果快照最终位于错误的位置,请使用此选项
  • compileEnhancements:设置成 false,禁用了 power-assert,否则有助于提供更具描述性的错误消息, 并检测t.throws()断言的不当使用
  • extensions:未使用 AVA 的 Babel 预设进行预编译的测试文件的扩展名。请注意,文件仍然会被编译为启用power-assert和其他功能,因此您可能还需要设置compileEnhancementsfalse文件是否为有效的 JavaScript。设置此"js"值会覆盖默认值,因此请确保在列表中包含该扩展名,只要它不包含在内babel.extensions
  • require:在运行测试之前需要额外的模块。工作进程中需要模块
  • babel:测试文件特定的 Babel 选项。有关详细信息,请参阅Babel 配置
  • babel.extensions:将使用 AVA 的 Babel 预设进行预编译的测试文件的扩展。设置此选项会覆盖默认"js"值,因此请确保在列表中包含该扩展名。
  • timeout:AVA 中的超时行为与其他测试框架中的行为不同。AVA 在每次测试后重置计时器,如果在指定的超时内没有收到新的测试结果,则强制测试退出。这可用于处理停滞的测试。请参阅我们的超时文档以获取更多选

请注意,在 CLI 上提供文件会覆盖该files选项。

参考资料

async-validator基本使用

业务需求

最近公司业务需求,需要实现一个表单设计器。在编写和保存时都需要进行schema校验,组件库使用 antdv,所以首选async-validator作为表单校验库并配合 antdv 使用。在学习过程中,并整理一下所学知识方便查阅。

##async-validator

一个用于表单异步校验的库,参考了 https://github.com/freeformsystems/async-validate

Usage 使用方法

基本的使用方法:定义一个 descriptor,将它传入 schema,得到一个 validator。将需要校验的对象和回调传入 validator.validate 方法中。

注:descriptor 是对校验规则的描述,validator 是根据校验规则得到的校验器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
var schema = require('async-validator');
var descriptor = {
name: {
type: "string",
required: true,
validator: (rule, value) => value === 'Peanut',
},
};
var validator = new schema(descriptor);
validator.validate({name: "Peanut"}, (errors, fields) => {
if(errors) {
// validation failed, errors is an array of all errors
// fields is an object keyed by field name with an array of
// errors per field

// 校验未通过的情况,errors 是所有错误的数组
// fields 是一个 object,以字段作为 key 值,该字段对应的错误数组作为 value
// (其实 fields 就是把 errors 按照原对象的 key 值分组)

return handleErrors(errors, fields);
}

// validation passed
// 这里说明校验已通过
});

// PROMISE USAGE
// Promise 式用法

validator.validate({
name: "Peanut",
asyncValidator: (rule, value) => axios.post('/nameValidator', { name: value }),
}, (errors, fields) => {
if(errors) {
// validation failed, errors is an array of all errors
// fields is an object keyed by field name with an array of
// errors per field

// 校验未通过的情况,errors 和 fields 同上
return handleErrors(errors, fields);
}
// validation passed
// 校验通过
})
.then(() => {
// validation passed
// 校验通过
})
.catch(({ errors, fields }) => {
return handleErrors(errors, fields);
})

Validate 方法参数

1
function(source, [options], callback): Promise
  • source: 需要校验的对象(必填).
  • options: 校验选项(可选).
  • callback: 校验完成时的回调(必填).

方法返回一个 Promise 对象:

  • then(),说明校验通过
  • catch({ errors, fields }),校验未通过,errors, fields 含义见前面示例

Options 选项

  • first: Boolean, 遇见第一个未通过校验的值时便调用 callback 回调,不再继续校验剩余规则。
    适用情况:校验涉及到多个异步调用,比如数据库查询,而你只需要获取首个校验错误时
  • firstFields: Boolean|String[], 对于指定字段,遇见第一条未通过的校验规则时便调用 callback 回调,而不再校验该字段的其他规则 ,传入 true 代表所有字段。

Rules

Rules 也可以是用于校验的函数

1
function(rule, value, callback, source, options)
  • rule: 当前校验字段在 descriptor 中所对应的校验规则,其中的 field 属性是当前正在校验字段的名称
  • value: 当前校验字段的值
  • callback: 在校验完成时的回调,传入 Error [或者是一个数组] 代表校验失败,如果校验是同步的话,直接返回 falseErrorError 数组也可以(注:异步校验通过时直接不带参数调用 callback(),代表没有错误)
  • source: 传入 validate 方法的 object,也就是需要校验的对象
  • options: 传入的额外选项
  • options.messages: 对象包含的校验错误提示信息,会被合并到默认的提示信息中

传入 validateasyncValidate 的 options 被带到了校验函数中,以便你可以在校验函数中拿到数据(比如 model 引用)。然而,option中部分属性名是被保留的,你如果使用了的话会被覆盖掉,其中包括 messages, exceptionerror


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var schema = require('async-validator');
var descriptor = {
name(rule, value, callback, source, options) {
var errors = [];
if(!/^[a-z0-9]+$/.test(value)) {
errors.push(
new Error(
util.format("%s must be lowercase alphanumeric characters",
rule.field)));
}
return errors;
}
}
var validator = new schema(descriptor);
validator.validate({name: "Firstname"}, (errors, fields) => {
if(errors) {
return handleErrors(errors, fields);
}
// validation passed
});

在需要对一个字段设置多条校验规则时,可以把规则设为一个数组,比如


1
2
3
4
5
6
7
8
9
10
11
var descriptor = {
email: [
{type: "string", required: true, pattern: schema.pattern.email},
{validator(rule, value, callback, source, options) {
var errors = [];
// test if email address already exists in a database
// and add a validation error to the errors array if it does
return errors;
}}
]
}

Type 内置类型

下列是 type 可用的值:

  • string: 必须是 string. This is the default type.
  • number: 必须是 number.
  • boolean: 必须是 boolean.
  • method: 必须是 function.
  • regexp: 必须是正则或者是在调用 new RegExp 时不报错的字符串.
  • integer: 整数.
  • float: 浮点数.
  • array: 必须是数组,通过 Array.isArray 判断.
  • object: 是对象且不为数组.
  • enum: 值必须出现在 enmu 枚举值中.
  • date: 合法的日期,使用 Date 判断
  • url: url.
  • hex: 16进制.
  • email: 邮箱地址.

Required

required 属性代表这个字段必须出现在对象中


Pattern

pattern 属性代表需要符合的正则


Range

使用 minmax 属性定义范围,对于字符串和数组会与 value.length 比较,对于数字会直接与值比较


Length

使用 len 属性直接指定长度,会与字符串和数组的 value.length 比较相等,对于数字会直接与值比较是否相等
如果 lenminmax 同时使用, len 优先。


Enumerable

可枚举值

对于可以枚举出所有情况的类型,可以使用枚举校验,如下:

1
2
3
var descriptor = {
role: {type: "enum", enum: ['admin', 'user', 'guest']}
}

Whitespace

把仅包含空格的字段视为错误是很典型的做法,为了额外测试字段是否只有空格,添加 whitespace 属性并设为true。这个属性要求字段必须为 string 类型。

如果你想要修正用户的输入而不是测试有无空格,查看 transform 中去除空格的例子。


Deep Rules 深层规则

如果需要校验一个深层的对象,你需要使用 fields 属性来设置嵌套的规则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var descriptor = {
address: {
type: "object", required: true,
fields: {
street: {type: "string", required: true},
city: {type: "string", required: true},
zip: {type: "string", required: true, len: 8, message: "invalid zip"}
}
},
name: {type: "string", required: true}
}
var validator = new schema(descriptor);
validator.validate({ address: {} }, (errors, fields) => {
// errors for address.street, address.city, address.zip
});

需要注意的是,如果没有在父规则上指定 required 属性,在校验对象中不存在这个属性是完全合法的,嵌套的深层规则也不会运行。

深层规则提供了直接一个定义嵌套规则的方式,让你可以简化传递给 schema.validate()options

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var descriptor = {
address: {
type: "object", required: true, options: {single: true, first: true},
fields: {
street: {type: "string", required: true},
city: {type: "string", required: true},
zip: {type: "string", required: true, len: 8, message: "invalid zip"}
}
},
name: {type: "string", required: true}
}
var validator = new schema(descriptor);

validator.validate({ address: {} })
.catch(({ errors, fields }) => {
// now only errors for street and name
});

如果你像下面这样写,父规则也会被校验

1
2
3
4
5
6
7
8
9
10
var descriptor = {
roles: {
type: "array", required: true, len: 3,
fields: {
0: {type: "string", required: true},
1: {type: "string", required: true},
2: {type: "string", required: true}
}
}
}

比如用于 {roles: ["admin", "user"]} 会产生两个错误,一个是数组长度不匹配,一个是缺少了索引为 2 的元素

defaultField 默认字段

defaultField 属性可以在 arrayobject 类型中用于校验所有的值,它可以是一个包含有校验规则的对象或数组。 例子如下:

1
2
3
4
5
6
var descriptor = {
urls: {
type: "array", required: true,
defaultField: {type: "url"}
}
}

注意,defaultFieldfields 的扩展,见 deep rules.

Transform 变换

有时候需要在校验前修改值,强制修改为特定格式。 为此在校验规则中添加了 transform, 这个属性会在校验前执行,以适当的方式改变原始对象的值。(也就是返回值会作用在原始对象的值上)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var schema = require('async-validator');
var sanitize = require('validator').sanitize;
var descriptor = {
name: {
type: "string",
required: true, pattern: /^[a-z]+$/,
transform(value) {
return sanitize(value).trim();
}
}
}
var validator = new schema(descriptor);
var source = {name: " user "};
validator.validate(source)
.then(() => assert.equal(source.name, "user"));

如果没有 transform 函数校验会失败因为前后空格导致正则与输入不符,但在添加了 transform 函数后便可通过因为字段已经被清洗了(或者翻译为使输入值符合预期格式)

Messages 提示信息

在某些需求下,你可能需要格式化支持或者想要不同校验错误信息。

最简单的方式就是直接为 message 属性赋值:

1
{name:{type: "string", required: true, message: "Name is required"}}

消息可以是任意类型的,比如 JSX

1
{name:{type: "string", required: true, message: <b>Name is required</b>}}

也可以是函数,比如使用 vue-i18n 时:

1
{name:{type: "string", required: true, message: () => this.$t( 'name is required' )}}

有时候你只是需要对相同的校验规则定义不同语言的提示信息,在这种情况下为各种语言重复定义信息就显得很多余。

你也可以采取这个方案:定义你自己的提示信息并赋值给 schema

1
2
3
4
5
6
7
8
9
var schema = require('async-validator');
var cn = {
required: '%s 必填',
};
var descriptor = {name:{type: "string", required: true}};
var validator = new schema(descriptor);
// deep merge with defaultMessages
validator.messages(cn);
...

如果你要定义自己的校验函数,最好将提示信息赋值给消息对象,并在校验函数中通过 options.messages 访问消息。(说实话我没看懂是什么意思,应该是指不要把消息硬编码写在校验函数里面而是通过option传递,以便修改)

asyncValidator 异步校验函数

你可以对指定的字段自定义包含异步操作的校验函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const fields = {
asyncField:{
asyncValidator(rule,value,callback){
ajax({
url:'xx',
value:value
}).then(function(data){
callback();
},function(error){
callback(new Error(error))
});
}
},

promiseField:{
asyncValidator(rule, value){
return ajax({
url:'xx',
value:value
});
}
}
};

validator 校验函数

你也可像下面这样自定义校验函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const fields = {
field:{
validator(rule,value,callback){
return value === 'test';
},
message: 'Value is not equal to "test".',
},

field2:{
validator(rule,value,callback){
return new Error(`'${value} is not equal to "test".'`);
},
},

arrField:{
validator(rule, value){
return [
new Error('Message 1'),
new Error('Message 2'),
];
}
},
};

FAQ

How to avoid warning 如何关闭警告

1
2
var Schema = require('async-validator');
Schema.warning = function(){};

CommonJS、ESModule、AMD和CMD模块化区别

CommonJS

  • 对于基本数据类型,属于复制。即会被模块缓存。同时,在另一个模块可以对该模块输出的变量重新赋值。
  • 对于复杂数据类型,属于浅拷贝。由于两个模块引用的对象指向同一个内存空间,因此对该模块的值做修改时会影响另一个模块。
  • 当使用 require 命令加载某个模块时,就会运行整个模块的代码。
  • 当使用 require 命令加载同一个模块时,不会再执行该模块,而是取到缓存之中的值。也就是说,CommonJS 模块无论加载多少次,都只会在第一次加载时运行一次,以后再加载,就返回第一次运行的结果,除非手动清除系统缓存。
  • 循环加载时,属于加载时执行。即脚本代码在 require 的时候,就会全部执行。一旦出现某个模块被”循环加载”,就只输出已经执行的部分,还未执行的部分不会输出。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// b.js
exports.done = false
let a = require('./a.js')
console.log('b.js-1', a.done)
exports.done = true
console.log('b.js-2', '执行完毕')

// a.js
exports.done = false
let b = require('./b.js')
console.log('a.js-1', b.done)
exports.done = true
console.log('a.js-2', '执行完毕')

// c.js
let a = require('./a.js')
let b = require('./b.js')

console.log('c.js-1', '执行完毕', a.done, b.done)

node c.js
b.js-1 false
b.js-2 执行完毕
a.js-1 true
a.js-2 执行完毕
c.js-1 执行完毕 true true

ES Module

  • ES Module 中的值属于动态只读引用, 不可修改(修改即报错)。复杂数据类型可修改其属性
  • 循环加载时,ES6 模块是动态引用。只要两个模块之间存在某个引用,代码就能够执行。
  • ES6 模块化采用静态编译,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// b.js
import {foo} from './a.js';
export function bar() {
console.log('bar');
if (Math.random() > 0.5) {
foo();
}
}

// a.js
import {bar} from './b.js';
export function foo() {
console.log('foo');
bar();
console.log('执行完毕');
}
foo();

babel-node a.js
foo
bar
执行完毕

// 执行结果也有可能是
foo
bar
foo
bar
执行完毕
执行完毕

AMD

基于 commonJS 规范的 nodeJS 出来以后,服务端的模块概念已经形成,很自然地,大家就想要客户端模块。而且最好两者能够兼容,一个模块不用修改,在服务器和浏览器都可以运行。但是,由于一个重大的局限,使得 CommonJS 规范不适用于浏览器环境

浏览器端的模块,不能采用”同步加载”(synchronous),只能采用”异步加载”(asynchronous)。这就是 AMD 规范诞生的背景。

它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。

  • 异步加载模块
  • 依赖前置
1
define([依赖...],function(变量...){})

CMD

  • 没有依赖前置,即用即返
1
2
3
defind(function (require, exports, module) {
var a = require("./a");
});

总结

  • CommonJS 为服务端规范,ESModule、AMD(requireJS)和 CMD(seaJS)均为浏览器端规范
  • CommonJS 同一模块只会被加载一次,第二次便只会取首次的缓存结果;ESModule 只要引入就会执行导入模块
  • CommonJS、CMD 为同步导入,ESModule、AMD 为异步导入
  • CommonJS 导入支持动态导入 require(${path}/xx.js),ES6 模块化导入不支持

了解HTTPS、HTTP(HTTP2.0)区别

HTTP与HTTPS

HTTPS相对于HTTP,可以说是安全以及认证的加强版。更通俗的说,HTTPS = HTTP + 加密 + 认证 +完整性检查

HTTP和HTTPS的基本概念

  HTTP:是互联网上应用最为广泛的一种网络协议,是一个客户端和服务器端请求和应答的标准(TCP),用于从WWW服务器传输超文本到本地浏览器的传输协议,它可以使浏览器更加高效,使网络传输减少。

  HTTPS:是以安全为目标的HTTP通道,简单讲是HTTP的安全版,即HTTP下加入SSL层,HTTPS的安全基础是SSL,因此加密的详细内容就需要SSL。

  HTTPS协议的主要作用可以分为两种:一种是建立一个信息安全通道,来保证数据传输的安全;另一种就是确认网站的真实性。

HTTP与HTTPS有什么区别

  HTTP协议传输的数据都是未加密的,也就是明文的,因此使用HTTP协议传输隐私信息非常不安全,为了保证这些隐私数据能加密传输,于是网景公司设计了SSL(Secure Sockets Layer)协议用于对HTTP协议传输的数据进行加密,从而就诞生了HTTPS。简单来说,HTTPS协议是由SSL/TLS+HTTP协议构建的可进行加密传输、身份认证的网络协议,要比http协议安全。

  HTTPS和HTTP的区别主要如下:

  1、https协议需要到ca申请证书,一般免费证书较少,因而需要一定费用。

  2、http是超文本传输协议,信息是明文传输,https则是具有安全性的ssl加密传输协议。

  3、http和https使用的是完全不同的连接方式,用的端口也不一样,前者是80,后者是443。

  4、http的连接很简单,是无状态的;HTTPS协议是由SSL/TLS+HTTP协议构建的可进行加密传输、身份认证的网络协议,比http协议安全。

HTTPS的工作原理

在HTTPS中,采用的加密机制是 混合加密机制。在交换密钥阶段采用的是 公开密钥加密(非对称加密),而交换完密钥之后采取的是 共享密钥加密(对称加密)

为什么要采取混合加密的方式:对于非对称加密来说,虽然安全但是需要的时间和消耗会比较大;而对于对称加密来说,虽然时间快但是安全性不够,一旦密钥被窃取那就会引发安全问题。

  • 客户使用https的URL访问Web服务器,要求与Web服务器建立SSL连接。

  • Web服务器收到客户端请求后,会将网站的证书信息(证书中包含公钥)传送一份给客户端。

  • 客户端的浏览器与Web服务器开始协商SSL连接的安全等级,也就是信息加密的等级。

  • 客户端的浏览器根据双方同意的安全等级,建立会话密钥,然后利用网站的公钥将会话密钥加密(对称密钥),并传送给网站。

  • Web服务器利用自己的私钥解密出会话密钥(非对称加密)。

  • Web服务器利用会话密钥加密与客户端之间的通信(对称加密)。

    个人对对称与非对称加密的理解为:两端都是使用私钥加密解密即为对称;使用公钥加密私钥解,私钥加密公钥解即为非对称。

HTTPS的优点

  尽管HTTPS并非绝对安全,掌握根证书的机构、掌握加密算法的组织同样可以进行中间人形式的攻击,但HTTPS仍是现行架构下最安全的解决方案,主要有以下几个好处:

  (1)使用HTTPS协议可认证用户和服务器,确保数据发送到正确的客户机和服务器;

  (2)HTTPS协议是由SSL+HTTP协议构建的可进行加密传输、身份认证的网络协议,要比http协议安全,可防止数据在传输过程中不被窃取、改变,确保数据的完整性。

  (3)HTTPS是现行架构下最安全的解决方案,虽然不是绝对安全,但它大幅增加了中间人攻击的成本。

  (4)谷歌曾在2014年8月份调整搜索引擎算法,并称“比起同等HTTP网站,采用HTTPS加密的网站在搜索结果中的排名将会更高”。

HTTPS的缺点

  虽然说HTTPS有很大的优势,但其相对来说,还是存在不足之处的:

  (1)HTTPS协议握手阶段比较费时,会使页面的加载时间延长近50%,增加10%到20%的耗电;

  (2)HTTPS连接缓存不如HTTP高效,会增加数据开销和功耗,甚至已有的安全措施也会因此而受到影响;

  (3)SSL证书需要钱,功能越强大的证书费用越高,个人网站、小网站没有必要一般不会用。

  (4)SSL证书通常需要绑定IP,不能在同一IP上绑定多个域名,IPv4资源不可能支撑这个消耗。

  (5)HTTPS协议的加密范围也比较有限,在黑客攻击、拒绝服务攻击、服务器劫持等方面几乎起不到什么作用(防劫持DNS劫持需要使用HTTPDNS)。最关键的,SSL证书的信用链体系并不安全,特别是在某些国家可以控制CA根证书的情况下,中间人攻击一样可行。

HTTP1.0与HTTP2.0

消除或减少不必要的网络延迟将需要传输的数据压缩至最少。HTTP2.0就是为了做这些优化而出现的。

新的二进制格式(Binary Format)

HTTP1.x的解析是基于文本。基于文本协议的格式解析存在天然缺陷,文本的表现形式有多样性,要做到健壮性考虑的场景必然很多,二进制则不同,只认0和1的组合。基于这种考虑HTTP2.0的协议解析决定采用二进制格式,实现方便且健壮。

多路复用(MultiPlexing)

即连接共享,即每一个request都是是用作连接共享机制的。一个request对应一个id,这样一个连接上可以有多个request,每个连接的request可以随机的混杂在一起,接收方可以根据request的 id将request再归属到各自不同的服务端请求里面。

  • HTTP/1.* 一次请求-响应,建立一个连接,用完关闭;每一个请求都要建立一个连接;
  • HTTP/1.1 Pipeling解决方式为,若干个请求排队串行化单线程处理,后面的请求等待前面请求的返回才能获得执行机会,一旦有某请求超时等,后续请求只能被阻塞,毫无办法,也就是人们常说的线头阻塞;
  • HTTP/2多个请求可同时在一个连接上并行执行。某个请求任务耗时严重,不会影响到其它连接的正常执行;
  • HTTP 性能优化的关键并不在于高带宽,而是低延迟。TCP 连接会随着时间进行自我「调谐」,起初会限制连接的最大速度,如果数据成功传输,会随着时间的推移提高传输的速度。这种调谐则被称为 TCP 慢启动。由于这种原因,让原本就具有突发性和短时性的 HTTP 连接变的十分低效。
    HTTP/2 通过让所有数据流共用同一个连接,可以更有效地使用 TCP 连接,让高带宽也能真正的服务于 HTTP 的性能提升。

头部压缩

HTTP1.x的header带有大量信息,而且每次都要重复发送,HTTP2.0使用encoder来减少需要传输的header大小,通讯双方各自cache一份header fields表,既避免了重复header的传输,又减小了需要传输的大小。

  • 假定一个页面有100个资源需要加载(这个数量对于今天的Web而言还是挺保守的), 而每一次请求都有1kb的消息头(这同样也并不少见,因为Cookie和引用等东西的存在), 则至少需要多消耗100kb来获取这些消息头。HTTP2.0可以维护一个字典,差量更新HTTP头部,大大降低因头部传输产生的流量。

服务端推送(server push)

同SPDY一样,HTTP2.0也具有server push功能。

  • 服务端推送能把客户端所需要的资源伴随着index.html一起发送到客户端,省去了客户端重复请求的步骤。正因为没有发起请求,建立连接等操作,所以静态资源通过服务端推送的方式可以极大地提升速度。

总结

http相比https而言更加的安全,基本可以防止90%以上的恶意攻击,但是没办法防止DNS劫持,不过可以使用HTTPDNS可以解决劫持问题。https建立连接的时长更长、网络资源和两端资源消耗更大,而且好的证书成本更高,所以是否使用https应当权量一下利弊。例如:https有利于SEO,但是时长又会降低SEO。

http2.0相比与http1.x而言主要就是消除或减少不必要的网络延迟将需要传输的数据压缩至最少,主要通过多路复用、头部压缩、服务端推送来实现。

Redux基本原理实现

Redux基本组成

Redux是将整个应用状态存储到一个地方上称为store,里面保存着一个状态树store tree,组件可以派发(dispatch)行为(action)给store,而不是直接通知其他组件,组件内部通过订阅store中的状态state来刷新自己的视图。

State(初始化数据)

state就是store里面存储的数据,store里面可以拥有多个state,Redux规定一个state对应一个View,只要state相同,view就是一样的,反过来也是一样的,可以通过store.getState( )获取。

Action(行为)

定制行为,或传递参数给reducer中使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 无需传递参数
const action = {
type: 'ADD_TODO',
params: '1'
}

// 需传递参数
const action = (...params) => {
type: 'ADD_TODO',
...params,
}

// 异步action (需要引入thunk中间件,createStore(reducer, applyMiddleware(thunk)))
const sub = (...params)=>{
type: 'SUB_TODO',
...params,
}

const action = (...params)=>{
return (dispatch)=>{
dispatch(sub(...params))
}
}

Reducer(执行行为,纯函数)

Store收到Action以后,必须给出一个新的state,这样view才会发生变化。这种state的计算过程就叫做Reducer。Reducer是一个纯函数,他接收Action和当前state作为参数,返回一个新的state

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import state from '../state'
import actionType from '../action/actionType.js'

const reducer = (oldState = state, action) => {
switch (action.type) {
case actionType.ADD_NUM:
const newState1 = Object.assign({}, oldState)
newState1[action.params]++
return newState1
case actionType.SUB_NUM:
const newState2 = Object.assign({}, oldState)
newState2[action.params]--
return newState2
default:
return oldState
}
}
export default reducer

Store

以上state、action和reducer均为自己实现,只不过是根据Redux风格约定,只要能实现上述功能,名称其实并没有很强约束。Redux只不过是帮我们将action与reducer函数相关联而已,所以redux原理主要实现createStore函数即可。

1
2
3
4
import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
import reducer from './reducer'
export default createStore(reducer, applyMiddleware(thunk))

CreateStore原理实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
let createStore = (reducer) => {
let state;
//获取状态对象
//存放所有的监听函数
let listeners = [];
let getState = () => state;
//提供一个方法供外部调用派发action
let dispath = (action) => {
//调用管理员reducer得到新的state
state = reducer(state, action);
//执行所有的监听函数
listeners.forEach((l) => l())
}
//订阅状态变化事件,当状态改变发生之后执行监听函数
let subscribe = (listener) => {
listeners.push(listener);
}
dispath();
return {
getState,
dispath,
subscribe
}
}
let combineReducers=(renducers)=>{
//传入一个renducers管理组,返回的是一个renducer
return function(state={},action={}){
let newState={};
for(var attr in renducers){
newState[attr]=renducers[attr](state[attr],action)

}
return newState;
}
}
export {createStore,combineReducers};

前端性能优化

我们提要的优化知识点主要来自 Chrome 和 V8 引擎,使用者众多

如何优化

  1. 第一位不是具体手段,而是先收集数据

  2. 收集数据之前需要了解页面从加载到呈现的步骤(越详细越好)

  3. 有了数据可以很清晰的了解我们的短板

  4. 如果不确定哪里是短板可以参考别家的数据

  5. 结合我们的产品形态制定目标和基准

  6. 根据目标“寻找”具体手段进行“优化”

这是可能诸位经常碰到的一个棘手的问题,当然 它也是一道面试高频题,很多人处理或者回答这个问题的时候,第一反应就是优化手段,诸如:压缩图片、合并资源、减少请求、SSR 等等。如果你有实战过前端优化的化,可能就不是这个答案了。这并不是说上述的优化手段有问题,具体手段当然很重要,但没有任何相关性能数据收集的话,一切都是浮云,我们很难知道性能短板在哪里。平时开发工作比较繁重,补丁摞补丁加班比雨后春笋还多,虽然这不是一个性能低下烂代码的理由,但这是客观存在的问题…

因此我们在开发业务的时候除了架构设计要合理、注重代码逻辑清晰简洁高效之外,很难了解页面整体的性能情况,所以比较正常的思路是:找到合理的性能统计方法并且收集具体的数据,对症下药。当然了,在收集数据之前,我们需要先了解一些浏览器的渲染原理和一些概念,否则很容易吃错药,出现头痛医脚的情况。

浏览器展示原理

旁白

这又是一个基础知识,当然也是个面试高频题。我很喜欢这个问题,知识点几乎是可以无限扩充的,基本知识点都掌握了,基本上也就了解优化的奥义了。首先先从宏观的角度看一下,用户输入 url 之后,页面是如何呈现的,先看看这张图

数据加载步骤

  1. 如果当前页面有正在显示的文档话,则卸载当前页面

  2. 同时检测有没有缓存,这里的缓存指的是离线缓存(manifest)或者 PWA 设置的缓存并不是咱们平时提到的浏览器强制或者协商缓存,有则读取缓存,否则继续 DNS 解析(这里多说一句:关于这部分缓存可以看一下的WHATWG 的离线缓存规范,WHATWG == Web Hypertext Application Technology Working Group 即网页超文本应用技术工作小组,W3C 宣布与 WHATWG 达成协议,HTML 和 DOM 标准都以 WHATWG 为准,也就是说以后只有一套 HTML 标准了)

  3. DNS 解析

  4. 建立 TCP 链接,由于网络层的 IP 协议是无状态的,协议只负责把数据包发送到指定 IP,不会考虑前面是否已经发送过数据包,也不考虑后面还会不会发送数据包。和渣男一样哈,三不原则,不主动、不拒绝、不负责,当然应用层的 UDP 也继承这个行为。不过 TCP 就不同了,是个负责的协议,为了保证传输就要建立连接。如果你要是使用 HTTPS 的话,当然这一步里还藏着个安全连接的握手过程

  5. 浏览器发送请求

  6. 服务器接到请求处理,返回给浏览器数据,这一步从浏览器角度来看也叫做数据下载

  7. 最后就是浏览器获取数据后处理数据渲染页面了

具体渲染步骤

由于已经有前人栽树了,我就乘个凉,借花献佛了浏览器渲染原理,这个是已经离开我们多时的一位前端同学的一篇浏览器渲染文章,以前这个同学也分享过。所以粗略的说一下,浏览器渲染又分为:

  1. 把 HTML 结构字符串解析转换为 DOM 树,构建 DOM 树完成后,触发 DomContendLoaded 事件。

  2. 下载并解析 CSS 产生 CSS Rule Tree

  3. 下载 JS 并通过 DOM API 和 CSSOM API 来操作 DOM Tree 和 CSS Rule Tree

  4. 解析完后,浏览器引擎会通过 DOM Tree 和 CSS Rule Tree 来构造 Rendering Tree

    • 这里要注意一下,还是老生常谈的问题:
      • 在标签没有设置 async/defer 属性时,JS 会阻塞 DOM 的生成
      • JS 文件不只是阻塞 DOM 的构建,它会导致 CSSOM 也阻塞 DOM 的构建
      • 当然他们最终也是会阻塞整个页面的渲染,如果是在页面打开的第一次就会出现所谓”白屏“的问题
  1. 布局 Rendering Tree(Layout/reflow),负责各元素尺寸、位置的计算

  2. 绘制 Rendering Tree(Paint),绘制页面像素信息

  3. 浏览器会将各层的信息发送给 GPU(GPU 进程:最多一个,用于 3D 绘制等),GPU 会将各层合成(composite),显示在屏幕上

由于 JavaScript 代码运行在浏览器的主线程上,与此同时,浏览器的主线程还负责样式计算、布局、绘制的工作,如果 JavaScript 代码运行时间过长,就会阻塞其他渲染工作,Composite 是个例外,如果你开启 GPU 加速的话,这部分会在 GPU 进程去运行。阻塞和渲染的原理就说的这里,感兴趣的同学可以好好翻翻这篇文章,学习一下。

JS 引擎工作原理

词法分析

语法分析

生成字节码

即时编译(JIT

  • 词法分析:字节流解码器会先从代码字节流中创建 令牌 (token),每当一个 令牌 创建后,就会被传递给 解析器(parser)

  • 语法分析:解析器便会根据传过来的令牌创建出 抽象语法树 (Abstract Syntax Tree)

  • 字节码:AST 被生成之后,接下来就要交给 解释器(interpreter) 了。解释器会遍历整个 AST,并生成 字节码。当字节码生成后,AST 便会被删除以节省内存空间。最终我们得到了更贴近 机器码 的 字节码。这里的 字节码 是介于 AST 和 机器码 之间的一种代码,它还是需要通过 解释器 将其转换为 机器码 后才能执行

  • 即时编译:尽管 字节码 很快,但是它还可以更快!解释器在逐条解释执行字节码时,会分析是否有某段代码被多次执行,这样的代码被称为 热点代码。热点代码 和生成的 类型反馈 (type feedback) 会被发送到一个称为 优化编译器 的东西中,然后由它转换为可以直接被电脑执行的 机器码,这样在下次执行这段代码的时候就不需要再编译了,从而大大提升了代码的执行效率。

  • 感兴趣的话可以看看这篇文章

事件循环

由于 JS 是个单线程语言,单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。为了解决这个问题 Eventloop 事件循环机制就诞生,这里面分为同步任务和异步任务。同步任务是调用立即得到结果的任务,同步任务在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务是调用无法立即得到结果,需要额外的操作才能预期结果的任务,异步任务不进入主线程、而进入”任务队列”(task queue)的任务,只有”任务队列”通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

需要注意的是,虽然异步任务分为宏任务和微任务,但上面提到异步任务是宏任务,微任务没有这个功效。宏任务包括了:

  1. 浏览器加载的 JS 脚本

  2. 事件处理函数

  3. 定时任务(setTimout,setInterval ,setImmediate)

  4. IO 处理(网络请求、磁盘读写)

  5. UI 渲染;

而微任务是 JavaScript 引擎在执行每一个宏任务之后,会立刻执行微任务队列中所有任务之后,再执行宏队列中的其它任务或者进行渲染,因次对于渲染来说,微任务并没有什么帮助,微任务重了,依然解决不了主线程阻塞的问题。那关于事件循环的具体知识点可以看看 B 站这个熟肉

收集数据

数据加载耗时:单点+用户收集

上面就是比较宏观的浏览器知识和概念,全都了解过原理之后我们再看一下具体的指标和概念就非常好理解了。回到之前看过的图

这个图描述的是W3C Web 性能工作组带来的 PerformanceTiming,一个 W3C 的规范,我们可以通过浏览器的API,去获取这些数据。根据上图描述,我们可以计算出资源加载在网络上的一些耗时

1
2
3
4
**let** dns = domainLookupEnd - domainLookupStart,//dns耗时
tcp = connectEnd - connectSta
ttfb = r0.responseStart - startTime,//获取首字节耗时
ssl = secureConnectionStart?connectEnd - secureConnectionStart : 0;//https握手耗时

*参考资料

指标 含义
navigationStart 当卸载提示在同一浏览上下文中的上一个文档终止时. 如果没有以前的文档,则此值将与 PerformanceTiming.fetchStart 相同
unloadEventStart 引发了 unload >事件后,指示窗口中上一个文档开始卸载的时间. 如果没有先前的文档,或者先前的文档或所需的重定向之一不是同一来源,则返回的值为 0
unloadEventEnd unload 事件处理程序完成时. 如果没有先前的文档,或者先前的文档或所需的重定向之一不是同一来源,则返回的值为 0
redirectStart 当第一个 HTTP 重定向开始时. 如果没有重定向,或者其中一个重定向源不同,则返回值为 0
redirectEnd 当最后一个 HTTP 重定向完成时,即已收到 HTTP 响应的最后一个字节. 如果没有重定向,或者其中一个重定向源不同,则返回值为 0
fetchStart 当浏览器准备好使用 HTTP 请求获取文档时. 此刻在检查任何应用程序缓存之前
domainLookupStart 域查找开始时. 如果使用持久连接,或者信息存储在缓存或本地资源中,则该值将与 PerformanceTiming.fetchStart 相同
domainLookupEnd 域查找完成后. 如果使用持久连接,或者信息存储在缓存或本地资源中,则该值将与 PerformanceTiming.fetchStart 相同.
connectStart 当打开连接的请求发送到网络时. 如果传输层报告错误,并且连接建立再次开始,则给出最后的连接建立开始时间. 如果使用持久连接,则该值将与 PerformanceTiming.fetchStart 相同
connectEnd 打开连接网络时. 如果传输层报告错误,并且连接建立再次开始,则给出最后的连接建立结束时间. 如果使用持久连接,则该值将与 PerformanceTiming.fetchStart 相同. 当所有安全连接握手或 SOCKS 身份验证终止时,连接被视为已打开
secureConnectionStart 安全连接握手开始时. 如果不请求此类连接,则返回 0
responseStart 当浏览器从服务器从缓存或本地资源接收到响应的第一个字节时
responseEnd 当浏览器收到响应的最后一个字节时,或者如果第一次发生则关闭连接时,包括来自服务器,缓存或本地资源
domLoading 解析器开始工作时,即其 Document.readyState 更改为’loading’并且引发了相应的 readystatechange 事件
domInteractive 解析器完成对主文档的工作时,即其 Document.readyState 更改为’interactive’并且引发了相应的 readystatechange 事件
domContentLoadedEventStart DOM 解析完成后,在解析器发送 DOMContentLoaded 事件之前,网页内资源开始加载的时间
domContentLoadedEventEnd DOM 解析完成后,在所有需要尽快执行的脚本(无论是否按顺序执行)之后
domComplete 解析器完成对主文档的工作时,即其 Document.readyState 更改为’complete’并且引发了相应的 readystatechange 事件
loadEventStart 为当前文档发送 load 事件的时间. 如果尚未发送此事件,则返回 0
loadEventEnd 当 load 事件处理程序终止时,即加载事件完成时. 如果此事件尚未发送或尚未完成,则返回 0

前端性能耗时:单点收集

参考资料

  • 总概念

    • 蓝色(Loading):网络通信和 HTML 解析
    • 黄色(Scripting):JavaScript 执行
    • 紫色(Rendering):样式计算和布局,即重排
    • 绿色(Painting):重绘
    • 灰色(other):其它事件花费的时间
    • 白色(Idle):空闲时间

-

无法复制加载中的内容

  • 详细

    • Interactions:交互
    • Main:主线程
    • Worker:web work 线程
    • Raster:光栅图像(也称为“位图”)
    • GPU:GPU 渲染
    • Chrome_ChildIOThread:用来接受来自其它进程的 IPC 消息和派发自身消息到其它进程
    • Compositor:合成

当然你英文不好的话,可以下载使用微软最新版本的edge,目前最新版本的 Win10 俗称 sp2 的 20H1 版本月底将要 rtm 了,也是内置这个版本的 edge,这个版本的 edge 最大的优点可以理解为===chrome,并且还是个全汉化版本,当然他汉化了你也不一定能看到懂,他的画风是这样的。

chrome 官方文档:https://developers.google.com/web/tools/chrome-devtools/evaluate-performance/reference

旁白

目前前端和 SRE 一起开发的 EA 全链路监控系统前端 sdk 部分就是根据这个 api 去开发的,感兴趣的同学可以看一下这篇文章:https://hotoo.github.io/blog/post/resource-timing-practical-tips

大家注意到上面那张图还有条红线,红线前半部分是网络耗时,后面的是浏览器解析 HTML 文档的耗时,不过后面 Load 和 DOMContentLoaded 等事件并不能真正的体现页面加载的性能,因为它们并不是与用户在屏幕上看到的相对应,我们需要更用户的一些的数据,因此可以使用 Chrome 自带的 Performance 工具来查看

*名词解释:FP、FCP、DCL、L、FMP、LCP、TTI、FID 😑

  • FP(First Paint): 页面在导航后首次呈现出不同于导航前内容的时间点,FP 事件在图层进行绘制的时候触发,而不是文本、图片或 Canvas 出现的时候

  • FCP(First Contentful Paint):这是当用户看见一些“内容”元素被绘制在页面上的时间点。和白屏是不一样的,它可以是文本的首次出现,或者 SVG 的首次出现,或者 Canvas 的首次出现等等

  • 注意:只有首次绘制文本、图片(包含背景图)、非白色的 canvas 或 SVG 时才被算作 FCP。FP 与 FCP 这两个指标之间的主要区别是:FP 是当浏览器开始绘制内容到屏幕上的时候,只要在视觉上开始发生变化,无论是什么内容触发的视觉变化,在这一刻,这个时间点,叫做 FP。相比之下,FCP 指的是浏览器首次绘制来自 DOM 的内容。例如:文本,图片,SVG,canvas 元素等,这个时间点叫 FCP。FP 和 FCP 可能是相同的时间,也可能是先 FP 后 FCP。

  • DCL(DomContentloaded): 当 HTML 文档被完全加载和解析完成之后,DOMContentLoaded 事件被触发,无需等待样式表、图像和子框架的完成加载.

  • L(onLoad), 当依赖的资源, 全部加载完毕之后才会触发.

  • FMP(First Meaningful Paint):基于 Chromium 的实现,这个绘制是使用 LayoutAnalyzer 进行计算的,它会收集所有的布局变化,当布局发生最大变化时得出时间,而这个时间就是 FMP,本质上是通过一个算法来猜测某个时间点可能是 FMP,所以有时候不准。

  • LCP:了解和测量网站真实的性能其实非常困难,像 load 和 DOMContentLoaded 不会告诉我们用户什么时候可以在屏幕上看到内容。而 FP 和 FCP 又只能捕获整个渲染过程的最开始,FMP 更好一点,但是它的算法比较复杂,而且前面说了,有时候不准。根据 W3C Web 性能工作组的讨论和 Google 的研究,发现测量页面主要内容的可见时间有一种更精准且简单的方法是查看什么时候渲染最大元素,LCP 等于这个元素开始渲染的时间

  • TTI (Time to Interactive) 可交互时间: 指标用于标记应用已进行视觉渲染并能可靠响应用户输入的时间点

摘自:https://web.dev/lcp/

  • FID (First Input Delay) 首次输入延迟: 指标衡量的是从用户首次与您的网站进行交互(即当他们单击链接,点击按钮等)到浏览器实际能够访问之间的时间

寻找短板

制定目标

从业务和用户体验的角度确认关键路径非关键路径让路

首页

播放页

审批页

我入行以来,参与过很多次优化,其中大兴土木的有 3 次:

  • 视频网站首页和播放页的优化

  • 视频 App 启动优化(android)

  • 咱们现在的审批优化

不同的性质的项目,从业务和用户体验的角度来看要求肯定是不一样的,所以目标也是完全不同的,不过也有相似点,给关键路径让路。

比如上面提到的视频网站首页,关键路径是让用户尽快看到第一屏的焦点图和推荐内容,其他信息可以让路;播放页用户肯定关注的视频,所以其他内容可以让路;视频 App 套路也一样,不过 app 现在首屏广告位是一大收入,那广告就要第一时间显示,我之前东家就有一项专利,在电视启动的时候,先来一发广告,本身这只是为了在 android 启动的时候替换启动系统带来的无聊时间,类似 windows 的启动画面,结果这个方案已经被滥用到,广告时间严重超过启动时间了,万恶的商人;我们的审批页重点是让用户最先看到关键审批信息并可以操作审批按钮。

关于关键路径再多说一句,一个页面的元素很多,逻辑也不少,要找准重要流程,对症下药。制定关键路径的时候一定要对用户行为进行分析,因为关键路径的优化势必影响非关键路径的展现或操作,换句话来说也就是对于某些逻辑就是要有意识的略化,正所谓鱼翅熊掌不能兼得。比如小米手机现在的急速启动方案,看起来启动确实快了,但你会发现手机启动之后诸如 nfc 等后台程序并没有一起加载,而是延后了很久。我就是上了这个当,因为每天乘地铁上班,用手机刷公交卡,有一次手机重启,我溜溜在闸机旁边等了 1 分多种,最后才发现其实还可以手动加载。但是这种关键路径已经满足了大多数人的需求,我这个遭遇相对小众,所以这么做无可厚非。回来再说我们的优化,不同的目标也就意味着我们要梳理重要流程的逻辑,指定好大多数人的关键路径上,把好钢用在韧上。比如上面提到的视频网站播放页的优化,页面刚加载的时候无论是网络还是 cpu 都很忙,我们要忙中偷闲,把资源留给重要的流程,所以页面上如评论区、内容介绍,推荐区等等其他的都需要给视频第一帧让步。同样我们的审批页也是如此,比如合同审批页面,从用户角度来说看到审批内容,操作审批按钮是重要的事情,其他的诸如审批流程、讨论区等等相对不是重要流程,因此可以给关键路径让让路,躲一躲。

具体手段

网络传输:减少体积,善用缓存

*连接池限制:同时 6 个,多了等待

参考资料

浏览器 HTTP 1.1HTTP 1.0IE 6, 724IE 8, 966Firefox 1366Chrome 206?Safari 5.1.76?Opera 11.648?

https://source.chromium.org/chromium/chromium/src/+/master:net/socket/client_socket_pool_manager.cc

1
2
3
4
5
6
7
8
9
10
11
12
// Default to allow up to 6 connections per host. Experiment and tuning may
// try other values (greater than 0). Too large may cause many problems, such
// as home routers blocking the connections!?!? See http://crbug.com/12066.
//
// WebSocket connections are long-lived, and should be treated differently
// than normal other connections. Use a limit of 255, so the limit for wss will
// be the same as the limit for ws. Also note that Firefox uses a limit of 200.
// See http://crbug.com/486800
int g_max_sockets_per_group[] = {
6, // NORMAL_SOCKET_POOL
255 // WEBSOCKET_SOCKET_POOL
};

为了解决这个问题我们需要把一些太过细碎的资源请求合并,解决连接池阻塞的问题,所以

  1. 合并接口

  2. 合并静态资源

合并之后还有的好处,可以利用 TCP(HTTP)慢启动特性

旁白

  1. 由于 TCP 协议的限制,PC 端只有 65536 个端口可用以向外部发出连接,而操作系统对半开连接数也有限制以保护操作系统的 TCP\IP 协议栈资源不被迅速耗尽,因此浏览器不好发出太多的 TCP 连接,而是采取用完了之后再重复利用 TCP 连接或者干脆重新建立 TCP 连接的方法。

  2. 如果采用阻塞的套接字模型来建立连接,同时发出多个连接会导致浏览器不得不多开几个线程,而线程有时候算不得是轻量级资源,毕竟做一次上下文切换开销不小。

  3. 这是浏览器作为一个有良知的客户端在保护服务器。就像以太网的冲突检测机制,客户端在使用公共资源的时候必须要自行决定一个等待期。当超过 2 个客户端要使用公共资源时,强势的那个邪恶的客户端可能会导致弱势的客户端完全无法访问公共资源。从前迅雷被喷就是因为它不是一个有良知的客户端,它作为 HTTP 协议客户端没有考虑到服务器的压力,作为 BT 客户端没有考虑到自己回馈上传量的义务。

*TCP 特性:越下越快

慢启动:最初的 TCP 在连接建立成功后会向网络中发送大量的数据包,这样很容易导致网络中路由器缓存空间耗尽,从而发生拥塞。因此新建立的连接不能够一开始就大量发送数据包,而只能根据网络情况逐步增加每次发送的数据量,以避免上述现象的发生。具体来说,当新建连接时,cwnd 初始化为 1 个最大报文段(MSS)大小,发送端开始按照拥塞窗口大小发送数据,每当有一个报文段被确认,cwnd 就增加 1 个 MSS 大小。这样 cwnd 的值就随着网络往返时间(Round Trip Time,RTT)呈指数级增长,事实上,慢启动的速度一点也不慢,只是它的起点比较低一点而已。我们可以简单计算下:

开始 cwnd = 1
经过 1 个 RTT cwnd = 2*1 = 2
经过 2 个 RTT cwnd = 2*2= 4
经过 3 个 RTT cwnd = 4*2 = 8

旁白

这么看来是不是都放在一起更好呢,并不是~首先你并不能确认每个服务器下载速度都能>=你的全速带

宽,再考虑到丢包重试问题,以及浏览器的多线程下载,而且数据下载完才能解析,而浏览器运行是多线程,这部分一直在下载,其他线程在空闲也是不合理的,所以要权衡,因此回到之前的说法,给重要的路径来让路。我们可以把跟关键的资源放在一起,非必要的资源切分。一句话概括,以业务逻辑为主该合并的合并,该切分的切分。不过现在我们用到的 vue 有一个比较麻烦的地方,渲染逻辑和交互逻辑都放在一起了,只能合并打包。理想的情况是是模版和交互逻辑分离,预先模板加载。比如以前比较古老的 JQuery 开发方式就可以很好的分离。不过我们依然可以从展示的角度,把目前的关键路径组件隔离出来优先加载执行。这里再说一下,关于如何合并可以做到极致也不用太过纠结,目前我们现在数量最多的浏览器是 chrome,他是支持 http2 协议的,而 H2 协议有链路复用的功能

*HTTP2:多路复用

https://www.nginx.com/blog/7-tips-for-faster-http2-performance/

所以这部分可以不用特别极致,反而稍微多一些比大一些更好,按照关键路径理顺就成了。

减少体积:刚性优化

减少体积俗称减肥,感谢这个时代,我们有了 webpack,他有一些非常好用的减肥药,比如:webpack-bundle-analyzer。

旁白

这个插件,可以分析我们每个入口 js 文件的依赖。据此,我们可以了解到每块资源的大小和依赖关系,看看我们引用的组件是否全都用到了,没有的话就去除,为了个很小的功能引入的话可以考虑局部模块引入,如果非得全部引入的话,就得考虑我们或者第三方组件设计问题了,这时就需要手动拆卸出逻辑引入了。我之前整理项目曾经看到过为了一个时间格式化功能,引入了一个整个包含多国语言包的 moment.js,这基本上就是为了用捡到个鼠标垫买了台电脑的思路。

缓存:减少下载

  • 协商缓存

  • 强制缓存

    • 自动
    • 手动

旁白

浏览器缓存分为强制缓存和协商缓存两种,强制缓存不需要直接从本地磁盘或内存读取,而协商缓存 ETag/if-None-Match 和 Last-Modified/if-Modify-Since 虽然不需要下载,但还是要建立 TCP 连接去获取资源更新信息,因此我们尽量使用强制缓存。而强制缓存还可以分为浏览器自动强制缓存,自动指的是浏览器自身的策略,我们可以在服务器上设置资源返回的 ResponseHeader,Cache-Control: max-age=时间(秒)即可,只要设置得当基本上可以满足大多数需求,不过要是想极致一些就考虑手动方案了,具体实现方式有:

  • localStorage:把一些要路径的代码以字符串的方式存到本地,使用时获取

  • Application Cache:做一个缓存清单,浏览器根据清单的指示进行读取

  • Progressive Web App(PWA):PWA 其实不止缓存这么简单,还可以做诸如信息推送等功能,而且缓存内容代码可控

目前从我们的浏览器统计来看,PWA 可以大规模应用了,字节内部的网站很多都用到了这个功能,当然我们做审批优化的时候也做了,不过目前只是缓存了静态资源,终极方案可以把 HTML 文档全都缓存,基本上就可以理解为是个 APP 了,完全没有任何态资源加载,只有运行和接口加载的耗时了。当然这也是个双刃刀,像小程序和 APP 一样,需要做一些发版更新策略,可能有些用户体验上的问题,这也是我们迟迟未上的原因

逻辑顺序调整:Loading、异步加载、预加载

  • 从体验上讲,看到白屏确实不好,不过这时候可以让用户“愉快”的等待:loading 图片或骨架屏,不管哪种方式,都需要加载资源。这部分资源可能包含 js css 和 图片,就建议写在 html 文档里了。试想如果你把这部分资源打到 js 里,岂不是用户还得走之前的下载解析过程,要白屏了?万一比业务代码还慢呢?

  • 代码加载(业务逻辑)顺序依然可以用这个工具检查一波,把非关键路径上的业务异步化。根据业务逻辑,异步分为主动异步和被动异步。比如我们的审批页面,就可以把讨论区和流程显示部分组件和数据加载异步化。主动异步的意思是在没有用户操作的情况下进行异步加载,比如用户停留在这个页面上,不进行任何操作,浏览器发现目前计算资源空闲了,就可以加载了。还可以被动异步,用户滚动页面时候,检测到用户可见区域就去加载一下数据。https://www.toutiao.com/

  • 最后就是预加载了,简单来说,就是把本次要做的事情放在更早去做,比如Fast API 方案简述 ,或者目前做的审批预加载

*其他问题常规思路

  • Chrome 自带的工具

  • 减少主线程单次任务复杂度

  • 少回流、少重绘、能合成就合成

旁白

除了上面说的优化之外,还有些纯前端性能的优化,比如解决卡顿或者操作不流畅问题。这些问题还得需要用到最开始提到 Chrome 的火焰图工具,看看时序图有没有什么问题,恭喜你现在事件循环和浏览器渲染的知识全都用到了。首先看看有没有 long task,这上面带红色三角的就是 chrome 认为过长的任务,会在主线程阻塞渲染,先看看为什么长,看一下 js 的堆栈信息,如果有代码运算大的情况先考虑是否有不合理的地方可以优化,再者就是切分任务了,把一个过长的任务切分成多个,解决主线程耗时过长的问题。比如 React16 的 fiber 虽然实现策略是挺复杂的,但根本原理还是就是把任务切分了。除了 task 之外还可以从布局、渲染部分进行优化,原则很简单:少回流、少重绘、能合成就合成,万一避免不了回流,可以考虑经常回流的层独立,用局部回流的方式降低整体回流的消耗。

*不要迷信:比如 SSR

  • 最后要说的就是,千万不要迷信优化方法,一定要收集完整的数据,找准目标,再做打算,切勿盲目入手。比如现在比较流行的 SSR,也就是服务端渲染,这和以前 JSP 原理一样,不同的时候可以大部分复用相同的前端代码用 Nodejs 在服务端页面 html 渲染好,直接输出到前端。我们可以分析一下这种优化要解决什么问题,在什么情况下才能起到优化的效果。

  • 浏览器端程序无法”流式“加载(非绝对),浏览器中 HTML 就是流式加载的代表:边下载边解析边展示

  • 数据和模板特别复杂,运算量非常大,受众的客户端机能浏览器性能很差,用户端渲染非常慢

  • 第一个场景,如果我们是个长内容网站,内容数据量远远大于我们的静态资源,那么这种方案就很合适,服务端输出的 HTML 内容可以流式加载,不用拿到全量数据就能把第一屏数据及早的展示给用户。但对于我们现在的审批页面,要渲染的内容才几 k,静态资源远远大于内容,这种方式显然就没有什么意义,况且该加载的前端资源一点儿也没少, 渲染部分每个人的输出内容还不同,还丧失了用户端的缓存功能,非常的不划算。

  • 第二个场景,对我们来说意义也不一定很大,首先目前我们的工作电脑配置还可以而且用户以高性能的 Chrome 为主,浏览器端渲染并不是大问题,我们还可以把模版和资源做缓存,如果缓存没有刷新,下次用户在再次开的话就不用重新下载了,看起来效果会更好一些。

持续监控

  • 上面这些都是具体执行的手段,如果碰到解决就好了,但如果要想做到持续优化约束,就要指定基准,如果不达到基准不能算测试通过。基准最好前后端分开指定,这样可以有针对性的进行持续自动化测试。这里先说说前端吧,定义基准数据前一定也要把测试机的基准配置定定义好,否则针对端的基准数据就没有任何意义了,一台在奔三 800 定义的基准在 i9-10900K 没有任何意义,反之也毅然,要想有效测试最好在同等配置相同操作系统相同版本相同负载的机器上做。我们再根据实际情况定义好各个指标的数据,比如:scripting、reflow、reapint,自动化测试获取数据,看看每次是否达到预定的要求

非前端优化

  • 上面说的都是前端的优化,除此之外就是后端接口本身业务逻辑耗时以及网络本身因素的影响。不管前端如何优化,如果上面那两部分有问题的话,从整体上来看仍然很难达到预期的目标。当然这个问题还是需要信息收集。我们就要用到最开始介绍的概念了,通过 performance 接口收集这些数据进行分析,看数据加载到底慢在哪个步骤了,本来我司有 Tea 和 Slardar 工具,不过都无法达到我们细致分析的目的,因此有了全量数据收集服务的想法,凑巧和 SRE 的 OWL 联系起来,于是就有了我们 EA 的全链路监控方案,用这套方案可以定位到很具体的问题,继续对症下药。比如我们可以定位到是否为后端本身业务慢,后端可以做一些的优化:比如前置处理并缓存,把要实时计算的东西,找个再早的时机,计算出结果放在缓存里,像审批来说,可以上一节点审批完就可以把下一节点计算好放在缓存里前端直接获取;或者异步化提交,也就是用户提交操作后立刻把数据返回给前端,后端再去离线处理;以前有个弹幕的项目,不需要时实行很高,我们直接把热弹幕放在 cdn 上了,既减轻了服务端的压力,又能用到 CDN 的布点优势,当然我这些都是从逻辑上的技术想法,后端同学做这些还有处理巨多的业务问题,可能就需要个后端优化分享了。再说说网络,用目前的全链路工具也能看出用户本身网络问题,不过还不太能定位具体情况,毕竟 C 端的情况太复杂了。比如用户设备建设本身速度慢,中间经过的链路过长,WIFI 信号不好等等,这些问题就太复杂了,估计就得需要 SRE 的同学来处理了。我还记得在前司,很多同学都私搭烂建自己的 WIFI 热点,搞得物理 WIFI 信号串扰严重,特别影响用户体验。但是我们还可以根据地域方式把这些数据继续细分,找出些端倪,以前做 C 端的时候,运维同学每个月绩效全指着这些数据呢,比如看到重庆慢了,布个点,刷一下速度就上来了,后天云南慢了等等。所以数据越详细越能挖掘出问题,粒度太粗的话,很难定位的。

参考手段

WebWorker

WebAssembly

TreeSharking

接口合并(BFF)

设置服务器缓存

耗时任务切片执行

开启服务器压缩(如 gzip 等)

requestIdleCallback

requestAnimationFrame

开启 HTTP2

dns-prefetch、Preload、Prerender、Preconnect

固定类型的函数

CSS transform

代码压缩

减少 JSON 里的空 Key

media 属性指定加载样式的条件

异步加载

多次操作 DOM 时可以尝试,首先克隆整个 DOM 节点更加高效,操作克隆后的节点,然后替换

合并资源

压缩图片(渐进式、webp)

loading=“lazy”

节流和防抖

减少 base64

一些脑洞

WebRTC 资源 P2P

Websocket 长连接精简数据量和连接数量

Sass less 代码本地转换(PWA)

Http2 push

vue 渲染业务分离

sentry使用及原理

搭建 sentry

如何搭建 sentry 大家可以阅读该篇文章(Sentry 入门实战),在此就不做过多介绍。

本文直接使用官方 sentry 账号进行前端的错误监控。https://sentry.io/

使用 sentry

注册 sentry 账号

image-20200827175753635

创建一个项目

大家可以根据自己项目的技术栈创建对应的 sentry 项目,由于项目我的项目技术栈为 Vue,所以我创建 Vue 的项目。

image-20200827180126891

查看对应配置

image-20200827180649660

查看配置文档

根据对应的配置文档进行引入配置即可,文档已经将该项目的 dsn 已经初始化好了直接 copy。image-20200827181117982

详细配置https://docs.sentry.io/product/performance/getting-started/

查看警告

你可以在开发环境,故意写错一个在运行时会发生的 bug 测试一下,当看到自己收到警告通知时便是引入成功了。

image-20200827181716092

使用注意

生产环境引入

当然平时我们并不需监控开发环境,所以我们通过环境变量进行判断引入,在生产打包时引入。

1
2
3
4
5
6
7
if (process.env.NODE_ENV === "production") {
Sentry.init({
dsn:
"https://3014f57eb2dc490ba8c6ffe191690781@o434427.ingest.sentry.io/5391506",
integrations: [new VueIntegration({ Vue, attachProps: true })],
});
}

指定分支

我们可以指定上线分支配置,详细分支配置:https://docs.sentry.io/product/releases/

1
2
3
4
5
Sentry.init({
...
release: "my-project-name@2.3.12",
...
})

上传 Source Map

https://docs.sentry.io/platforms/javascript/guides/vue/config/sourcemaps/

webpack 引入插件

1
npm install --save-dev @sentry/webpack-plugin
1
2
3
4
5
6
7
8
9
10
11
12
13
const SentryWebpackPlugin = require("@sentry/webpack-plugin");

module.exports = {
// other configuration
plugins: [
new SentryWebpackPlugin({
include: ".",
ignoreFile: ".sentrycliignore",
ignore: ["node_modules", "webpack.config.js"],
configFile: "sentry.properties",
}),
],
};

当然我们还需要配置一下~/.sentryclirc

1
2
3
4
5
6
[auth]
token=your-auth-token

# 可以指定到自己的服务器,默认情况下sentry-cli将连接到sentry.io
[defaults]
url = https://mysentry.invalid/

环境变量设置.env

1
2
3
4
SENTRY_AUTH_TOKEN=your-auth-token

# 可以指定到自己的服务器,默认情况下sentry-cli将连接到sentry.io
SENTRY_URL=https://mysentry.invalid/

监控原理

window.onerror 劫持

每当代码在 runtime 时发生错误时,JavaScript 引擎就会抛出一个 Error 对象,并且触发 window.onerror 函数。

Sentry 对 window.onerror 函数进行了改写,在这里实现了错误监控的逻辑,添加了很多运行时信息帮助进行错误定位,对错误处理进行跨浏览器的兼容等等。

在这里 Sentry 使用了 TraceKit 来帮助它劫持 window.onerror 函数。TraceKit 主要是用来进行抹平各浏览器之间的差异,使得错误处理的逻辑统一。

监听 unhandledrejection 事件

在我们使用 Promise 的时候,如果发生错误而我们没有去 catch 的话,window.onerror 是不能监控到这个错误的。但是这个时候,JavaScript 引擎会触发 unhandledrejection 事件,只要我们监听这个事件,那么就能够监控到 Promise 产生的错误。

_attachPromiseRejectionHandler 的实现很简单,就是为 unhandledrejection 事件挂载一个事件处理函数。这里最核心的逻辑其实是 captureException 函数。

  1. 如果接收到的是一个 ErrorEvent 对象,那么直接取出它的 error 属性即可,这就是对应的 error 对象。
  2. 如果接收到的是一个 DOMError 或者 DOMException,那么直接解析出 name 和 message 即可,因为这类错误通常是使用了已经废弃的 DOMAPI 导致的,并不会附带上错误堆栈信息。
  3. 如果接收到的是一个标准的错误对象,不做处理
  4. 如果接收到的是一个普通的 JavaScript 对象

Sentry 会将这个对象的 Key 序列化为字符串,然后会触发报错

1
"Non-Error exception captured with keys: " + serializeKeysForMessage(exKeys);

所以触发报错,是因为在代码当中,Promise 进行 reject 时并没有传入一个错误对象,而是传入了一个普通对象。

错误代码大致上是这样

1
javascript reject(json)// json => {data:{},result:{}}

以上都不是,那么证明这就是一个普通的字符串,直接作为 message 即可。