QQ 音乐商业化 Web 团队:前端工程化实践总结(三)

阅读数:373 2019 年 10 月 31 日 16:37

QQ音乐商业化Web团队:前端工程化实践总结(三)

前端如何做单元测试?

测试环境
和后端不同,前端有运行环境的差异性,需要考虑兼容性,如何模拟浏览器环境,如何支持到 BOM API 的调用,这些都是需要考虑的。可以考虑以下几种测试环境的解决方案:

QQ音乐商业化Web团队:前端工程化实践总结(三)

测试工具
测试框架就是运行测试用例的工具,常见的有 Macha、Jasmine、Jest、AVA 等等。

断言库主要提供语义化方法,用于对参与测试的值做各种各样的判断。这些语义化方法会返回测试的结果,要么成功、要么失败。Node 内置断言库 assert,常见的断言库还有有 chai.js、should.js。断言库可以支持不同的开发模式,比如 chai.js 就是一个 BDD/TDD 模式的断言库。

测试覆盖率工具是用于统计测试用例对代码的测试情况,生成相应的报表,如 Istanbul(Jest 内置集成)。

Karma 是一个测试平台,可以在多种真实浏览器(e.g Chrome Firefox Safari IE 等等)中运行 JavaScript 代码,可以和很多测试框架集成,比如 Mocha、Jasmine 等等,还可以使用 Istanbul 自动生成覆盖率报告。

CI/CD
首先先看一张图片,来理解 Agile(敏捷开发)、CI(持续集成),CD(持续交付 / 部署) 和 DevOps(开发运维一体化) 涵盖的生命周期范围。CI/CD 并不等同于 DevOps,它们只是 DevOps 的部分流程中的一种解决方案。

DevOps 是 Development 和 Operations 的组合,是一种方法论,是一组过程、方法与系统的统称,用于促进应用开发、应用运维和质量保障(QA)部门之间的沟通、协作与整合。以期打破传统开发和运营之间的壁垒和鸿沟。

QQ音乐商业化Web团队:前端工程化实践总结(三)

各个术语涵盖的生命周期范围

持续集成(Continuous Integration)中开发人员需要频繁地向主干提交代码,这些新提交的代码在最终合并到主干前,需要经过编译和自动化测试(通常是单元测试)进行验证。

CI 的好处在于可以防止分支偏离主干太久,这种持续集成可以实现产品快速迭代,但是由于要频繁集成,所以需要支持自动化构建、代码检查和测试,实现这些自动化流程是 CI 的核心。

QQ音乐商业化Web团队:前端工程化实践总结(三)

持续集成

持续交付(Continuous Delivery)指的是,频繁地将软件的新版本,交付给质量团队或者用户,以供评审。如果评审通过,代码就进入生产阶段。

CD 是 CI 的下一步,它的目标是拥有一个可随时部署到生产环境的代码库。

QQ音乐商业化Web团队:前端工程化实践总结(三)

持续交付

持续部署是持续交付的延伸,实现自动将应用发布到生产环境。

QQ音乐商业化Web团队:前端工程化实践总结(三)

持续部署

公司内部常用的解决方案有:蓝盾 DevOps 平台 、orange-ci、QCI,各花入各眼,详情可以阅读这篇文章 CI 工具哪家强。

这些 CI 平台是怎样将 git 仓库中的代码变动和自动化构建流程相关联起来的呢?答案就是 Webhook,它与异步编程中“订阅 - 发布模型”非常类似,一端触发事件,一端监听执行。

在 web 开发过程中的 Webhook,是一种通过通常的 callback,去增加或者改变 web page 或者 web app 行为的方法。这些 callback 可以由第三方用户和开发者维持当前,修改,管理,而这些使用者与网站或者应用的原始开发没有关联。Webhook 这个词是由 Jeff Lindsay 在 2007 年在计算机科学 hook 项目第一次提出的。

  • Webhooks 是”user-defined HTTP 回调”。它们通常由一些事件触发,这里可以查看 GitHub 上面支持的 Event 类型,比如 git push、fork 等等,也就是说这些代码托管平台首先要支持 Webhook 的功能。
  • 当事件发生时,源网站可以发起一个 HTTP 请求到 Webhook 配置的 URL。通常这里配置的 URL 指向某个 CI 系统,这意味着当 git 仓库中“订阅”的事件发生时,CI 系统可以收到通知。
  • CI 系统在收到通知后就可以触发 build 等流程。

CI 自动化构建只是应用 Webhook 的一个案例,Webhook 的应用远不止这些,由于 webhook 使用 HTTP 协议,因此可以直接被集成到 web service,有时会被用来构建消息队列服务,例如一些 RESTful 的例子:IronMQ 和 RestMS。

我们的项目构建现状

介绍完了前端工程化的一些概念和技术后,下面结合我们团队中的具体项目具体分析。

1. 现状分析

这是目前团队移动端基础库的项目结构:主要有 9 个模块,其中 3 个 UI 组件依赖框架。

QQ音乐商业化Web团队:前端工程化实践总结(三)

基础库项目结构
  • 模块化

我们团队在移动端基础库的开发中,最初采用的是 IIFE 模式。从严格意义上来说,这并不是一种标准的模块化方式,只是通过闭包实现了私有数据,将数据和行为封装到一个函数内部, 通过给全局对象 window.M 添加属性来向外暴露接口,我们无法确认每个模块间的依赖关系,模块合并时还要关注依赖顺序。在新的方案中,我们引入了 ES6 的模块化标准来解决这个问题。

  • 重复开发,复制粘贴

由于业务特点,对于一些快速上线的活动页使用 Zepto 库,而对常驻页面进行了技术升级,社交团队使用了 Preact 框架,这导致基础库的开发有了两个版本,分别在不同的代码仓库维护,但实际上二者 90%+ 的代码都是一样的,仅仅是三个 UI 组件不同。在基于 TSW 的同构直出项目中,有些基础库方法又要在 node 端执行,这个时候也是复制粘贴了一份 m.js 放到了该项目目录中。在新的方案中,我们使用差异化的构建在一份代码仓库中分别构建出多个版本。

  • 组件 css 的问题

对于组件的样式,我们是有专门的重构组进行开发维护的,他们遵循 BEM 规范,开发组件的时候当字符串引入:

复制代码
var css ='.qui_dialog__mask{position:fixed;top:0;left:0;bottom:0;right:0;}...';
appendToHead(css);

这种模式对 CSS 的开发维护很不友好,虽然我们不需要关注样式的细节,但还是每次要把重构发给我们的.css 文件中的样式 copy 出来。新方案中,我们引入 CSS module 的方案。

2. 技术选型

主流构建工具
构建工具的选择,主要对比了 Webpack4、Rollupjs 和 Parcel,因为基础库的构建文件只有 js,而且从构建体积来说,rollupjs 是有绝对优势的,所以选择了 rollupjs。

QQ音乐商业化Web团队:前端工程化实践总结(三)

主流构建工具对比

CSS 模块化
由于 CSS in JS 需要引入额外的依赖,在对比了 CSS Module 和 CSS in JS 后,我们选择 CSS Module 的方案。

QQ音乐商业化Web团队:前端工程化实践总结(三)

CSS 模块化方案对比

单元测试框架
单元测试框架我们选择了 Jest,主要是因为开箱即用,不需要再引入断言库,生态也很好,较多用于 React 项目,而且组内的 UI 自动化测试系统是支持 Jest 的,这篇文章 Migrating from Mocha to Jest 中介绍了 Airbnb 的尝试。

QQ音乐商业化Web团队:前端工程化实践总结(三)

单元测试框架对比

Lint 方案
由于接入了 CI 系统进行 lint 自动化检查,为了减少“无效”的 commit,我们选择了 husky+lint-staged 来进行本地代码提交前的 lint。

QQ音乐商业化Web团队:前端工程化实践总结(三)

Lint 方案

工作流和 CI?
各种工作流中,首先需要在各自的开发分支进行开发测试,然后将代码合并到追踪生成环境的长期分支进行持续地发布部署,这意味着对这个长期分支要有完善的自动化测试能力,因为谁也不能保证 merge 的代码就一定不会有问题,目前新的方案引入了单元测试,对 UI 组件引入了基于 puppeteer 的截图测试,但一些功能缺乏在更多设备、更多平台上的自动化验证,因此我们认为在自动化测试方面的建设还不是非常完善,所以新方案接入了 CI,但是对发布外链基础库 music.js 这种会直接影响到全量业务的并没有接入,还是使用 ARS 发布,除非紧急 bug,其他的代码更改会在测试环境验证一段时间 (一般 2-3 天) 后才会发布外网。

我们的工程化实践

1. 构建方案

新旧方案对比
首先可以看一下新旧构建方案的对比,在新方案中推广使用 ES6,增加了对代码质量的控制:代码检查 + 单元测试,并接入了 CI 系统。

QQ音乐商业化Web团队:前端工程化实践总结(三)

新旧方案对比

打包方案
这是我们整体的打包方案,核心是一份源码开发维护,通过构建工具的差异化配置实现多种版本的构建。

QQ音乐商业化Web团队:前端工程化实践总结(三)

打包方案

开发流程
这是整体的开发流程,本地开发使用 package.json 管理项目依赖,规范代码格式,接入单元测试;提交之前 git hook 设置保证代码检查和测试通过后才能提交成功;使用 QCI 自动进行项目的构建、检查和测试,通过后将 JSDOC 文档推送到文档服务器,并发布 npm 包,外链 js 还是使用 ars 发布。

QQ音乐商业化Web团队:前端工程化实践总结(三)

开发流程

2.UI 组件开发和文档

我们选择 react-styleguide 作为 UI 组件开发调试工具以及文档生成器,这是一个组件的 MD 文件示例:

组件式引入

  • 可以提前插入 dom 结构,如果浮层中有图片的话会先加载;
  • 属性中的 visible 控制组件是否可见。
复制代码
import Button from '../../basic/Button/Button'
import QMDialog from './QMDialog';
class QMDialogExample extends React.Component {
constructor(props) {
super(props);
this.state = {visible1: false}
}
render() {
const {visible1} = this.state;
return (
<div>
<Button onClick={() => {
this.setState({
visible1: true
})
}}> 基本使用 </Button>
<Button onClick={() => {
this.setState({
visible2: true
})
}}> 带头图的浮层 </Button>
<Button onClick={() => {
this.setState({
visible3: true
})
}}> 传入一个 react 节点 </Button>
<QMDialog
visible={visible1}
title="QQ 音乐 "
message=" 这是一段描述 "
btn={'我知道了'}
handleTap={index => {
if(index === -1) {
this.setState({
visible1: false
})
} else {
console.log('我知道了按钮被点击,index=', index)
}
}}
/>
</div>
)
}
}
<QMDialogExample />

react-styleguide 会根据组件的源码和这个 md 文件生成文档和 demo,开发调试阶段支持 webpack 配置 HMR,非常方便。

QQ音乐商业化Web团队:前端工程化实践总结(三)

demo 文档截图

3.Jest 单元测试

Jest 可以设置全局的 Setup,会在所有 test 执行之前运行,也可以设置全局 Teardown,会在所有 test 执行完毕之后运行,比如这里就可以设置一些测试需要的 Global 对象、运行环境等等。describe 可以将测试用例进行分组,beforeEach、afterEach、beforeAll、afterAll 这些方法可以定义在测试用例之前或者之后运行的方法。

测试方案
根据上面介绍的打包方案和业务特点,基础库需要分别运行在 node 端和浏览器端,因此需要考虑到不同运行环境下的测试结果。

浏览器端

  • npm 命令

    • jest --coverage --config ./config/jest/music.jest.config.js
    • 设置–coverage 生成测试覆盖率。
  • 配置文件 (music.jest.config.js):

    • 基于 jsdom 设置全局环境:jest-environment-jsdom-fourteen,提供浏览器端 BOM 对象。
    • 设置 cookie 操作权限的 domain:testURL: “ https://y.qq.com/m/demo.html ”,仅可以操作此域名下的 cookie。
复制代码
module.exports = {
clearMocks: true,
coverageDirectory: "jest-coverage/coverage-music-node",
preset: null,
rootDir: '../../',
testEnvironment: "jest-environment-jsdom-fourteen",
testMatch: [
"**/tests/music-node/**/*.test.[jt]s?(x)",
],
testURL: "https://y.qq.com/m/demo.html",
transformIgnorePatterns: []
};

Node 端
node 端和浏览器端的不同在于运行环境 testEnvironment 不同,jest 提供 jest-environment-node,我们为 node 端单独配置了 music-node.jest.config.js。

UI 组件
Jest 支持对 React App 的测试,可以采用截图测试 (Snapshot Testing)、模拟 DOM 操作 (DOM Testing) 等方法详见文档。在组件文档和 demo 这一章节中我们已经有了组件示例,并构建了文档页,可以直接接入团队的自动化测试系统,结合使用 puppeteer 进行截图对比。

下面是对 QMDialog 组件的测试用例,首先准备一张基准图片,然后写测试流程:打开页面——点击按钮触发组件——截图对比。screeshotDiff 方法的实现参考了这篇 KM 文件通过 puppeteer 实现页面监控,图片 diff 核心算法由 pixelmatch 库实现。

复制代码
const iPhone = devices['iPhone 6'];
await page.emulate(iPhone);
await log(" 进入页面 ");
await page.goto('http://[host]/reactui/index.html#/QMDialog', {
waitUntil: 'load'
});
await timeout(3000);
let dom = await page.$('#QMPreload-container .rsg--preview-35 .button');
await dom.click();
await timeout(200)
let diff = await screenshotDiff({
img: 'https://y.gtimg.cn/music/common/upload/t_cm3_photo_publish/1677163.png'
});
if (diff > 10) {
fail();
return;
}
success();

这是一次测试运行结果,从左到右依次是:基准图、测试截图、diff 结果图,screeshotDiff 根据第三张图片返回差异点的占比,由于 QMPreload 组件的特点,加载进度受网络影响,设置阈值为 10%,即只要差异率在 10% 以内就可以认为是正常的。

QQ音乐商业化Web团队:前端工程化实践总结(三)

QMPreload 测试结果

和上面 QMPreload 不同,对 QMDialog 组件的判断则是需要差异值为 0,如下面第三张图所示,没有差异点。

QQ音乐商业化Web团队:前端工程化实践总结(三)

QMDialog 测试结果

mock
这是我们参照官网的文档接入的 mock 示例,这里需要注意 __mock__ 的目录结构,详见文档。

复制代码
.
├── config
├── src
│ ├── music
│ │ ├── utils
│ │ │ ├── __mock__
│ │ │ └── loadUrl.js
│ │ └── loadUrl.js
├── node_modules
├── ...
└── tests

loadURL 方法用来动态加载 js,使用 jest.fn().mockImplementation 对 loadUrl 进行 mock,并 mock 了 window.pgvMain 和 window.pgvSendClick。

复制代码
export const loadUrl = jest.fn().mockImplementation((url, callback) => {
if (/ping.js/.test(url)) {
let pvCount = 0;
window.pgvMain = jest.fn().mockImplementation( (p1, p2) => {
expect(p1).toBe('');
expect(p2.virtualDomain).toBe('y.qq.com');
if (pvCount === 1) {
expect(p2.ADTAG).toBe('all');
}
pvCount++;
})
window.pgvSendClick = jest.fn().mockImplementation( (p) => {
expect(p.hottag).toEqual(expect.stringContaining('.android'));
});
}
callback();
});
export default loadUrl;

因为使用了 ES module 的 import,需要 jest.mock 对整个模块进行 mock。对于 mock 的函数才能调用 toHaveBeenCalledTimes 的断言。

复制代码
import tj from '../../src/music/tj';
import loadUrl from '../../src/music/utils/loadUrl'
jest.mock('../../src/music/utils/loadUrl');
describe('【tj.js】点击上报', () => {
test('tj.pv tj.sendClick', () => {
expect(typeof window.pgvMain).toBe('undefined');
expect(loadUrl).toHaveBeenCalledTimes(0);
tj.pv();
expect(loadUrl).toHaveBeenCalledTimes(1);
expect(typeof window.pgvMain).toBe('function');
expect(window.pgvMain).toHaveBeenCalledTimes(1);
tj.sendClick();
tj.sendClick('tjtag.click');
window.tj_param = {
ADTAG: 'all'
}
tj.pv();
expect(loadUrl).toHaveBeenCalledTimes(1);
expect(window.pgvSendClick).toHaveBeenCalledTimes(1);
});
})

测试覆盖率
这是某一次的测试报告,上面有每个模块详细的测试覆盖率。为了便于对各个模块灵活处理,我们将每个函数细分拆成一个文件,如下面的 src/music/type 目录下的各个文件。

QQ音乐商业化Web团队:前端工程化实践总结(三)

测试覆盖率 -1

QQ音乐商业化Web团队:前端工程化实践总结(三)

测试覆盖率 -2

QQ音乐商业化Web团队:前端工程化实践总结(三)

测试覆盖率 -3

通过单元测试发现的代码 bug
这些都是我们通过单元测试发现的之前一些函数的 bug,仅举例一部分:

QQ音乐商业化Web团队:前端工程化实践总结(三)

4. 一些 Tips

声明 pkg.module
声明 pkg.module 可以让构建工具利用到 ES Moudle 的很多特性来提高打包性能,比如利用 Tree Shaking 的机制减少文件体积,这篇文章 package.json 中的 Module 字段是干嘛的有详细介绍。

sideEffects
Tree Shaking 可以在构建的时候去除冗余代码,减少打包体积,但这是一个非常危险的行为,在 webpack4 中,可以在 package.json 中明确声明该包 / 模块是否包含 sideEffects(副作用),从而指导 webpack4 作出正确的行为。如果在 package.json 中设置了 sideEffects: false,webpack4 会将 import {a} from 'moduleName’转换为 import a from ‘moduleName/a’,从而自动修剪掉不必要的 import,作用机制同 babel-plugin-import。这个功能亲测是很有效的

对于 rollupjs 来说,有时候 Tree Shaking 并不有效,这是官网的一段解释,大意就是静态代码分析很难,为了安全 rollupjs 可能会无法应用 Tree Shaking,这个时候建议最好还是明确 import 的 PATH,这里可以结合适应上面的 babel-plugin-import 插件。

Tree-Shaking Doesn't Seem to Be Working

插件

  • @babel/plugin-transform-runtime

这个插件可以避免每一个 js 文件分别引入胶水代码,而是整个构建文件引入一份胶水代码,减少代码体积。

  • eslint-friendly-formatter

对 eslint 的错误输出进行格式化,方便查看和定位问题。

  • babel-plugin-transform-react-remove-prop-types

由于运行时的性能原因,RN 已经在 production 模式下移除了 PropTypes,我们引入这个 babel 插件在生产模式中移除组件属性的类型校验相关的代码。

—noConflict
在将外链 js 用 rollupjs 构建成 umd 规范的时候,我们设置了–noConflict,可以解决全局变量 M 冲突的问题,类似于 jQuery.noConflict()。

复制代码
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ?
factory(exports) :
typeof define === 'function' && define.amd ?
define(['exports'], factory) :
(global = global || self, (function () {
var current = global.M;
var exports = global.M = {};
factory(exports);
exports.noConflict = function () {
global.M = current;
return exports;
};
}())
);

本文转载自公众号云加社区(ID:QcloudCommunity)。

原文链接:

https://mp.weixin.qq.com/s/INlxjk4DnBFZynmbUkYGJA

评论

发布