蓝字关注,回复“加群”加入前端技术群 与大家一起成长
| 导语 本文主要介绍在前端工程化的一些探索和实践,结合移动端的基础库重构和UI组件库开发这两个项目详细介绍工程化方案 。
随着业务的不断扩展,团队的项目越来越多,面对日益复杂的业务场景和代码逻辑,我们发现在前端工程化方面团队还有很多需要优化的地方。现有的解决方案已经无法满足各种复杂的场景,我们每天都在疲于应付很多重复的工作,为此我们基于移动端基础库重构和UI组件库的建设这两个项目对团队的项目构建流程进行了详细的分析和梳理,并制定了一套适用于团队的工程化方案。
前端工程化是一个非常广泛的议题,包含的技术和解决方案也是非常丰富的。一个前端工程的生命周期可以大致划分为这四个过程:
前端工程的生命周期
任何在这四个过程中应用的系统化、严格约束、可量化的方法都可以称之为工程化。工程化的程度越高,在工作中因人的个体差异性导致的缺陷或者短板就会越少,项目质量可以得到更有效的保障。对上面四个过程的工程化并不是完全分隔的,而是相辅相成,比如开发阶段的优化也会对测试、部署和维护产生很大的影响。
下面从模块化、组件化、规范化和自动化这四个方面进行具体介绍。
模块化
模块化可以对复杂逻辑进行有效分割,每个模块更关注自身的功能,模块内部的数据和实现是私有的,通过向外部暴露一些接口来实现各模块间的通信。开发阶段前端需要关注JS、CSS和HTML,下面我们将分别对JS、CSS、HTML的模块化进行简单介绍。
JS模块化是一个逐渐演变的过程,开始的namespace概念实现了简单对象封装,约定私有属性使用_开头,到后来的IIFE模式,利用匿名函数闭包的原理解决模块的隔离与引用,下面介绍现在比较流行的几种模块化标准。
Nodejs中的模块化方案,就是基于CommonJS规范实现的。一个文件就是一个模块,有自己的作用域,没有export的变量和方法都是私有的,不会污染全局作用域,模块的加载是运行时同步加载的。CommonJS可以细分为CommonJS1和CommonJS2,二者的模块导出方式不同,CommonJS2兼容CommonJS1,增加了module.exports的导出方式,现在一般所指的都是CommonJS2。
module.exports.add = function (a, b) {
return a + b;
}
exports.add = function (a, b) {
return a + b;
}
const sum = require('sum');
sum.add(1, 2);
浏览器加载js文件需要进行网络请求,而网络请求的耗时是不可预期的,这使得CommonJS同步加载模块的机制在浏览器端并不适用,我们不能因为要加载某个模块js而一直阻塞浏览器继续执行下面的代码。AMD规范则采用异步的方式加载模块,允许指定回调函数,这非常适合用于浏览器端的模块化场景。
// 定义一个模块
define(id ? , dependencies ? , factory);
// 引用一个模块
require([module], callback)
类似于AMD规范,是应用在浏览器端的JS模块化方案,由sea.js提出,详见 https://www.zhihu.com/question/20351507 。
UMD规范兼容AMD和CommonJS,在浏览器和Nodejs中均可以运行。
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
define(['jquery', 'underscore'], factory);
} else if (typeof exports === 'object') {
module.exports = factory(require('jquery'), require('underscore'));
} else {
root.returnExports = factory(root.jQuery, root._);
}
}(this, function ($, _) {
function a() {};
function b() {};
function c() {};
return {
b: b,
c: c
}
}));
ES6从语言标准的层面上实现了模块化,是ECMA提出的模块化标准,后续浏览器和Nodejs都宣布会原生支持,越来越受开发者青睐。
在浏览器中可以通过下面的方式引入es6规范的模块js:
<script type="module" src="foo.mjs"></script>
<script type="module" src="foo.mjs" defer></script>
defer和async不同,它会阻塞DomContentLoaded事件,每个模块js会根据引入的顺序依次执行。
随着更多浏览器对ES6的支持,现在有一些方案开始提出直接使用ES2015+的代码在浏览器中直接执行来提高运行效果,这篇文章《Deploying ES2015+ Code in Production Today》中有详细的介绍,可以结合这份性能测试报告综合评估ES6在node以及各种浏览器环境下的执行效率对比。
CSS 自诞生以来,基本语法和核心机制一直没有本质上的变化,它的发展几乎全是表现力层面上的提升。不同于JS,CSS本身不具有高级编程属性,无法使用变量、运算、函数等,无法管理依赖,全局作用域使得在编写CSS样式的时候需要更多人工去处理优先级的问题,样式名还有压缩极限的问题,为此,出现了很多“编译工具”和“开发方案”为CSS赋予“编程能力”。
随着页面越来越复杂,为了便于开发和维护,我们常常会将CSS文件进行切分,然后再将需要的文件进行合并。诸如LESS、SASS、Stylus等预处理器为CSS带来了编程能力,我们可以使用变量、运算、函数,@import指令可以轻松合并文件。但各种预处理器并不能完全解决全局作用域的问题,需要结合namespace的思想去命名。
OOCSS和SMACSS都是有关css的方法论。OOCSS(Object Oriented CSS)即面向对象的CSS,旨在编写高可复用、低耦合和高扩展的CSS代码,有两个主要原则,它们都是用来规定应该把什么属性定义在什么样式类中。
SMACSS(Scalable and Modular Architecture for CSS)是可扩展模块化的CSS,它的核心就是结构化CSS代码,则有三个主要规则:
/* 依赖html结构,不提倡 */
.sidebar ul h3 { }
/* 建议直接定义 */
.sub-title { }
BEM是一种CSS命名规范,旨在解决样式名的全局冲突问题。BEM是块(block)、元素(element)、修饰符(modifier)的简写,我们常用这三个实体开发组件。
在选择器中,BEM要求只使用类名,不允许使用id,由以下三种符号来表示扩展的关系:
从上面BEM的命名要求可以看到,类名都很长,这就导致在对CSS文件进行压缩的时候,我们无法得到更大的优化空间。而且BEM仅仅是一种规范,需要团队中的开发者自行遵守,在可靠性上无法得到有效保障,而且还可能和第三方库的命名冲突。
CSS in JS是一种比较激进的方案,彻底抛弃了CSS,完全使用JS来编写CSS,又用起了行内样式(inline style),它的发展得益于React的出现,具体的原因可以参见组件化这部分内容。
下面以styled-components为例:
import React from 'react';
import styled from 'styled-components';
const Container = styled.div`
text-align: center;
`;
const App = () => (
<Container>
It is a test!
</Container>
);
render(<App />, document.getElementById('content'));
构建后的结果如下,我们发现不会再有.css文件,一个.js文件包含了组件相关的全部代码:
var _templateObject = _taggedTemplateLiteral(['\n text-align: center;\n'], ['\n text-align: center;\n']);
function _taggedTemplateLiteral(strings, raw) {
return Object.freeze(Object.defineProperties(strings, {
raw: { value: Object.freeze(raw) } }));
}
var Container = _styledComponents2.default.div(_templateObject);
var App = function App() {
return _react2.default.createElement(
Container,
null,
'It is a test!'
);
};
CSS module则最大化地结合了现有CSS生态和JS模块化的能力,以前用于CSS的技术都可以继续使用。CSS module最终会构建出两个文件:一个.css文件和一个.js。
以webpack为例,使用css-loader就可以实现CSS module:
/* style.css */
.color {
color: green;
}
:local .className .subClass :global(.global-class-name) {
color: blue;
}
/* component.js */
import styles from './style.css';
elem.outerHTML = `<h1 class=${styles.color}>It is a test title</h1>`;
构建运行后生成的dom结构如下:
<h1 class="style__color--rUMvq">It is a test title</h1>
component.js中styles变量的值如下,我们看到声明成:global的类名.global-class-name没有被转换,具有全局作用域。
const styles = {
"color": "style__color--rUMvq",
"className": "style__className--3n_7c",
"subClass": "style__subClass--1lYnt"
}
说明:React对样式如何定义并没有明确态度,无论是BEM规范,还是CSS in JS或者CSS module都是支持的,选择何种方案是开发者自行决定的。
组件化
最初,网页开发一般都会遵循一个原则”关注点分离”,各个技术只负责自己的领域,不能混合在一起,形成耦合。HTML只负责结构,CSS负责样式,JS负责逻辑和交互,三者完全隔离,不提倡写行内样式(inline style)和行内脚本(inline script)。React的出现打破了这种原则,它的考虑维度变成了一个组件,要求把组件相关的HTML、CSS和JS写在一起,这种思想可以很好地解决隔离的问题,每个组件相关的代码都在一起,便于维护和管理。
我们回想一下原有引用组件的步骤:
这种引入方式很繁琐,一个组件的代码分布在多个文件里面,而且作用域暴露在全局,缺乏内聚性容易产生冲突。
组件化就是将页面进行模块拆分,将某一部分独立出来,多个组件可以自由组合形成一个更复杂的组件。组件将数据、视图和逻辑封装起来,仅仅暴露出需要的接口和属性,第三方可以完全黑盒调用,不需要去关注组件内部的实现,很大程度上降低了系统各个功能的耦合性,并且提高了功能内部的聚合性。
React、Vue、Angular等框架的流行推动了Web组件化的进程。它们都是数据驱动型,不同于DOM操作是碎片的命令式,它允许将两个组件通过声明式编程建立内在联系。
<!-- 数据驱动的声明式Declarative-->
<pagination
current={current} total={maxCount/20}
on-nav={this.nav(1)}>
</pagination>
<!-- DOM操作的命令式Imprective -->
<pagination id='pagination'></pagination>
<script>
// 获取元素
var pagination = document.querySelector('#pagination');
// 绑定事件
pagination.addEventListener('pagination-nav', function(event){
...
})
// 设置属性
$.ajax('/blogs').then(function( json ){
pagination.setAttribute('current', 0)
pagination.setAttribute('total', json.length / 20)
})
</script>
从上面的例子可以看到,声明式编程让组件更简单了,我们不需要去记住各种DOM相关的API,这些全部交给框架来实现,开发者仅仅需要声明每个组件“想要画成什么样子”。
React使用JSX,非常灵活,与JS的作用域一致。Vue、Angular采用模板DSL,可编程性受到限制,作用域和JS是隔离的,但也是这个缺点使得我们可以在构建期间对模板做更多的事情,比如静态分析、更好地代码检查、性能优化等等。二者都没有浏览器原生支持,需要经过Transform才能运行。
Web Component是W3C专门为组件化创建的标准,一些Shadow DOM等特性将彻底的、从浏览器的层面解决掉一些作用域的问题,而且写法一致,它有几个概念:
<x-foo>Custom Element</x-foo>
/* 定义新元素 */
var XFooProto = Object.create(HTMLElement.prototype);
// 生命周期相关
XFooProto.readyCallback = function() {
this.textContent = "I'm an x-foo!";
};
// 设置 JS 方法
XFooProto.foo = function() { alert('foo() called'); };
var XFoo = document.register('x-foo', { prototype: XFooProto });
// 创建元素
var xFoo = document.createElement('x-foo');
var host = document.getElementById('js_host');
var shadow = host.attachShadow({mode: 'closed'});
shadow.innerHTML = '<p>Hello World</p>';
Chrome调试工具:DevTool > Settings > Preferences> Show user agent shadow DOM
Chrome调试工具查看shadow DOM
HTML Template & Slots: 可复用的 HTML 标签,提供了和用户自定义标签相结合的接口,提高组件的灵活性。定义了template的标签,类似我们经常用的<script type='tpl'>,它不会被解析为dom树的一部分,template的内容可以被塞入到Shadow DOM中并且反复使用;template中定义的style只对该template有效,实现了隔离。
<template id="tpl">
<style>
p {
color:red;
}
</style>
<p>hello world</p>
</template>
<script>
var host = document.getElementById('js_host');
var shadow = host.attachShadow({mode: 'open'});
var tpl = document.getElementById("tpl").content.cloneNode(true);
shadow.appendChild(tpl);
</script>
dom树中的template标签,不解析:
HTML template-1
最终插入的影子节点效果:
HTML template-2
由于Shadow DOM中宿主元素的内容会被影子节点掩盖,如果想将宿主中某些内容显示出来的话就需要借助slot,它是定义在宿主和template中的一个插槽,用来“占位”。
<div id="host">
<span>Test1</span>
<span slot="s1">slot1</span>
<span slot="s2">slot2</span>
<span>Test2</span>
</div>
<template id="tpl">
<span>tpl1</span>
<slot name="s1"></slot>
<slot name="s2"></slot>
<span>tpl2</span>
</template>
宿主元素中设置了slot属性的节点被“保留”了下来,并且插入到了template中定义的slot的位置。
slot的示例
Polymer是基于Web Componet的一种数据驱动型开发框架,可以使用ES6 class来定义一个Web Component,由于现在浏览器对Web Component的支持度还不是很好,需要引入一些polyfill才能使用。
React和Web Component并不是对立的,它们解决组件化的角度是不同,二者可以相互补充。与Web Component不同的是React中的HTML标签运行在Virtual DOM中,在非标准的浏览器环境,React的这种机制可以更好地实现跨平台,Web Component则更有可能实现浏览器大统一,是浏览器端更彻底的一种解决方案。
规范化
规范化是保障项目质量的一个重要环节,可以很好地降低团队中个体的差异性。
代码规范是一个老生常谈的话题,我们需要制定一些原则来统一代码风格,虽然不遵守规范的代码也是可以运行的,但是这会对代码的维护带来很多麻烦。
根据维基百科的介绍,首先看一下lint的定义:
lint最初是一个特定程序的名称,它在C语言源代码中标记了一些可疑的和不可移植的构造(可能是bug)。这个术语(lint或者linter)现在一般用于称呼那些可以标记任何计算机语言编写的软件中可疑用法的工具,这些工具通常执行源代码的静态分析。
一般代码的Linter工具提供下面两大类的规则:
在实际的项目中可以引入lint的机制来提升代码质量,可以参考GitHub 官方出品的 Lint 工具列表 ,下面简单介绍几个常用工具。
Prettier
Prettier是一个代码格式化工具,可以统一团队中的书写风格,比下面Eslint这类工具的功能要弱,因为只是对格式上的约束,无法对代码质量进行检测。
ESlint
ESLint是一款非常常用的JS编程规范库,当然还有很多其他的lint工具。下面的表格里简单介绍了3种常用的规范标准,可以在ESLint中配置选择哪一种标准,每一种标准都会包含很多编程规则。各个标准没有绝对的孰优孰劣,选择适用于团队的编程风格和规范就好。
标准 | 简介 |
---|---|
Airbnb JavaScript Style Guide | 目前最受欢迎的JS编程规范之一,对很多JS框架都有支持,比如React等。 |
Google JavaScript Style Guide | Google Style的JS编程规范。 |
JavaScript Standard Style Guide | 很强大,自带linter和自动代码纠正,无需配置,自动格式化代码。很多知名公司所采用,比如 Nodejs、npm、express、GitHub、mongoDB 等。 |
husky
如果我们把Lint放在了持续集成CI阶段,就会遇到这样一个问题:CI系统在Lint时发现了问题导致构建失败,这个时候我们需要根据错误重新修改代码,然后重复这个过程直到Lint成功,整个过程可能会浪费掉不少时间。针对这个问题,我们发现只在CI阶段做Lint是不够的,需要把Lint提前到本地来缩短整个修改链路。但是将Lint放在本地仅仅依靠开发者的自觉遵守是不够的,我们需要更好的方案,需要依靠流程来保障而不是人的自觉性。
Lint的问题
husky可以注册git hooks,拦截一些错误的提交,比如我们就可以在pre-commit这个hook中增加Lint的校验,这里可以查看支持的git hooks。
lint-staged
通过husky注册的git hook会对仓库中的全部文件都执行设置的npm命令,但我们仅仅需要对提交到staged区的文件进行处理来减少校验时间,lint-staged可以结合husky实现这个功能,在package.json中的示例:
JavaScript是非常灵活的,这得益于它的弱类型语言特点,但也是因为这个原因,我们只有在运行时才知道变量到底是什么类型,无法在编译阶段作出任何类型错误的提示,同时由于函数参数类型的不确定性,编译器的编译结果很可能无法被复用,比如下面的例子中,在执行add(1,2)时对add函数的编译结果无法直接被下面的add('1', '2')复用,第二次调用必须得再重新编译一次,这对性能也是有很大影响。
function add(a, b) {
return a + b;
}
add(1, 2);
add('1', '2');
类型检查可以让我们编写出更高质量的代码,减少类型错误的bug,同时明确了类型也让代码更好维护。
PropTypes
React在15.5的版本后将类型检查React.PropTypes移除后使用prop-types库代替,它是一种运行时的类型检测机制,包含一整套验证器,可用于确保组件属性接收的数据是正确的类型。
import React, { Component } from 'react';
import PropTypes from 'prop-types';
class App extends Component {
}
App.propTypes = {
title: PropTypes.string.isRequired
}
Flow
和PropTypes不同,Flow是一种静态类型检查器,由Facebook开源,赋予JS强类型的能力,在编译阶段就可以检测出是否有类型错误,可以被用于任何JavaScript项目。
Flow主要有两个工作方式:
function split(str) {
return str.split(' ')
}
split(11);
function square(n: number): number {
return n * n;
}
square("2");
Flow风格的代码不能直接在JS运行环境中执行,需要使用babel进行转换。就目前的发展和生态而言,Flow离TypeScript的差距已经越来越遥远了,Vue在2.0版本开始使用flow.js,但从3.0起已经替换成了TypeScript。
TypeScript
TypeScript则是一种JavaScript语言的超集,强类型、支持静态类型检查,更像是一门“新语言”。Deno已经支持直接运行tcs了,不需要进行转换。
interface Person {
firstName: string;
lastName: string;
}
function greeter(person: Person) {
return "Hello, " + person.firstName + " " + person.lastName;
}
高质量的项目文档可以提高团队协作效率,便于后期优化维护。维护文档很重要,但是也很繁琐,我们经常会看到代码和文档南辕北辙互相矛盾,下面介绍几种文档构建工具,它们可以很好地帮助我们构建文档,而对于React、Vue等组件而言,使用MDX可以非常便捷构建demo,极大减少人工保证代码和文档一致性的工作:
当团队在开发时,通常会使用版本控制系统来管理项目,常用的有svn和git,如何合并代码、如何发布版本都需要相应的流程规范,这可以让我们规避很多问题,比如合并代码后出现代码丢失,又或者将别人未经测试的代码发布出去等等。下面主要介绍几种基于git的协作开发模式:
以部署为中心的开发模式,持续且高速安全地进行部署,具体流程如下:
github-flow的最大特点就是简单,只有一个master长期分支,但是由于要持续部署,当一个部署还未完成的时候,往往下一个Pull Request已经完成,这就导致在开发速度越来越快的时候,必须要让部署所需的一系列流程都是自动化的,比如有自动化测试、接入CI等。
有两个长期分支master和develop,这意味着不要直接在这两个分支上进行push操作,所有的开发都在feature分支上进行,详见文档。
git-flow工作流
功能开发:首先从develop分支创建feature分支,然后和上面github-flow的流程类似,开发测试完毕后向develop分支发起Pull Request,其他开发者review完毕后将此次PR合并至develop分支。
管理Release:当develop分支可以release的时候,首先创建一个release/版本号分支,然后对这个release分支打上tag后再合并到develop和master中去。
hotfix:当出现了紧急bug的时候,需要开启“hotfix”流程,和release不同的是,这个hotfix分支是基于master创建的,修复bug后提交到这个hotfix分支,然后又和release分支的处理非常类似,改动会被同时合并到develop和master中去,最后这个hotfix分支被删除掉。
github-flow有一个问题,它要求master分支和生产环境是完全一致,一旦PR通过被合并到了master分支,就要立刻部署发布到生成环境,但是往往受限于产品发布时间,master分支很可能会先于生产环境,这个时候不能依靠master分支来追踪线上版本。
git-flow的流程比较复杂,需要维护两个长期分支master和develop,开发过程要以develop分支为准,但是很多开发工具将master当做默认分支,就需要频繁切换分支。git-flow的模式是基于“版本发布”,这对一些持续发布部署的项目不太适用。
gitlab-flow则是上面两个工作流的综合,推出一个“上游优先”的最大原则,即只存在一个master主分支,它是所有分支的上游,只有合并到master上的代码才能应用到其他分支,详见文档。
持续发布
对于这种模式的项目,master分支对应开发环境,然后需要再创建pre-production和production两个分支,它们的上游链路依次是:master分支—>pre-production分支—>production分支,只有合并进入master分支的代码修改才能依次应用合并到”下游”。
版本发布
在这种模式下,首先基于master分支创建某个版本的stable分支,然后将代码改动合并进master分支,当需要发版本的时候,将master分支使用cherry-pick合并到stable分支中去,然后基于stable分支进行项目的发布部署。
自动化
自动化是前端工程化的一个重要组成部分,可以减少重复的工作,提高工作效率。
在前端项目开发中我们使用了模块化的方案,有可能还引入了组件化的机制,依赖一些开发框架,这个时候就需要对项目进行构建,构建一般可以包括这几个步骤:
代码转换:允许使用更高级的JavaScript语法,比如ES6、TypeScript等等,这对代码的开发和可维护性来说是非常有好处的。
模块合并:按模块化开发的代码需要进行打包合并。
文件优化:常见的有代码压缩和Code Splitting,使用ES6 module的模块化机制的还可以考虑构建工具的Tree Shaking功能,进一步减少代码体积。
自动刷新:在开发过程中支持file watch和HMR都是可以很好地提升开发效率。
在软件的生命周期中,不同的测试阶段,针对的测试问题是不一样的:
JavaScript 单元测试,我们真的需要吗?答案是需要结合项目看实际情况。如果是基础库或者公共组件这样的项目,单元测试还是很有必要的。而对于那种就上线几天的活动页,写详细的单元测试可能真的会有点入不敷出。引用这篇文章结尾处是思考:
“怎么单元测试写起来这么麻烦” ——说明项目模块之间存在耦合度高,依赖性强的问题。 “怎么要写这么长的测试代码啊” ——这是一劳永逸的,并且每次需求变更后,你都可通过单元测试来验证,逻辑代码是否依旧正确。 “我的模块没问题的,是你的模块出了问题” ——程序中每一项功能我们都用测试来验证的它的正确性,快速定位出现问题的某一环。 “上次修复的 bug 怎么又出现了 ” ——单元测试能够避免代码出现回归,编写完成后,可快速运行测试。
TDD (测试驱动开发Test-Driven Development)和 BDD (行为驱动开发Behavior Driven Development)是两种开发模式,并不是单单指如何进行代码测试,它们定义了一种软件开发模式来将用户需求、开发人员和测试人员进行有效的联合,减少三者之间的脱节。TDD要求开发者先写测试用例,然后根据测试用例的结果再写真正实现功能的代码,接下来继续运行测试用例,再根据结果修复代码,该过程重复多次,直到每个测试用例运行正确。BDD则是对TDD的一种补充,我们无法保证在TDD中的测试用例可以完全达到用户的期望,那么BDD就以用户期望为依据,从用户的需求出发,强调系统行为。具体区别可以详见文章The Difference Between TDD and BDD。
前端如何做单元测试?
和后端不同,前端有运行环境的差异性,需要考虑兼容性,如何模拟浏览器环境,如何支持到BOM API的调用,这些都是需要考虑的。可以考虑以下几种测试环境的解决方案:
运行环境 | 特点 |
---|---|
jsdom | node端直接运行,伪浏览器环境,速度快,内置BOM对象,目前也有了对sessionStorage、localStorage和cookie的支持。 |
puppeteer | 在真实的浏览器中运行测试,很方便,但是运行速度会慢一点。 |
phantomjs | 无头浏览器,在puppeteer发布后,作者已经宣布不维护了。 |
测试框架就是运行测试用例的工具,常见的有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自动生成覆盖率报告。
首先先看一张图片,来理解Agile(敏捷开发)、CI(持续集成),CD(持续交付/部署)和DevOps(开发运维一体化)涵盖的生命周期范围。CI/CD并不等同于DevOps,它们只是DevOps的部分流程中的一种解决方案。
DevOps是Development和Operations的组合,是一种方法论,是一组过程、方法与系统的统称,用于促进应用开发、应用运维和质量保障(QA)部门之间的沟通、协作与整合。以期打破传统开发和运营之间的壁垒和鸿沟。
各个术语涵盖的生命周期范围
持续集成(Continuous Integration)中开发人员需要频繁地向主干提交代码,这些新提交的代码在最终合并到主干前,需要经过编译和自动化测试(通常是单元测试)进行验证。
CI的好处在于可以防止分支偏离主干太久,这种持续集成可以实现产品快速迭代,但是由于要频繁集成,所以需要支持自动化构建、代码检查和测试,实现这些自动化流程是CI的核心。
持续集成
持续交付(Continuous Delivery)指的是,频繁地将软件的新版本,交付给质量团队或者用户,以供评审。如果评审通过,代码就进入生产阶段。
CD是CI的下一步,它的目标是拥有一个可随时部署到生产环境的代码库。
持续交付
持续部署是持续交付的延伸,实现自动将应用发布到生产环境。
持续部署
公司内部常用的解决方案有:蓝盾DevOps平台 、orange-ci、QCI,各花入各眼,详情可以阅读这篇文章CI工具哪家强。
这些CI平台是怎样将git仓库中的代码变动和自动化构建流程相关联起来的呢?答案就是Webhook,它与异步编程中“订阅-发布模型”非常类似,一端触发事件,一端监听执行。
在web开发过程中的Webhook,是一种通过通常的callback,去增加或者改变web page或者web app行为的方法。这些callback可以由第三方用户和开发者维持当前,修改,管理,而这些使用者与网站或者应用的原始开发没有关联。Webhook这个词是由Jeff Lindsay在2007年在计算机科学hook项目第一次提出的。
CI自动化构建只是应用Webhook的一个案例,Webhook的应用远不止这些,由于webhook使用HTTP协议,因此可以直接被集成到web service,有时会被用来构建消息队列服务,例如一些RESTful的例子:IronMQ和RestMS。
我们的项目构建现状
介绍完了前端工程化的一些概念和技术后,下面结合我们团队中的具体项目具体分析。
这是目前团队移动端基础库的项目结构:主要有9个模块,其中3个UI组件依赖框架。
基础库项目结构
我们团队在移动端基础库的开发中,最初采用的是IIFE模式。从严格意义上来说,这并不是一种标准的模块化方式,只是通过闭包实现了私有数据,将数据和行为封装到一个函数内部, 通过给全局对象window.M添加属性来向外暴露接口,我们无法确认每个模块间的依赖关系,模块合并时还要关注依赖顺序。在新的方案中,我们引入了ES6的模块化标准来解决这个问题。
由于业务特点,对于一些快速上线的活动页使用Zepto库,而对常驻页面进行了技术升级,社交团队使用了Preact框架,这导致基础库的开发有了两个版本,分别在不同的代码仓库维护,但实际上二者90%+的代码都是一样的,仅仅是三个UI组件不同。在基于TSW的同构直出项目中,有些基础库方法又要在node端执行,这个时候也是复制粘贴了一份m.js放到了该项目目录中。在新的方案中,我们使用差异化的构建在一份代码仓库中分别构建出多个版本。
对于组件的样式,我们是有专门的重构组进行开发维护的,他们遵循BEM规范,开发组件的时候当字符串引入:
var css ='.qui_dialog__mask{position:fixed;top:0;left:0;bottom:0;right:0;}...';
appendToHead(css);
这种模式对CSS的开发维护很不友好,虽然我们不需要关注样式的细节,但还是每次要把重构发给我们的.css文件中的样式copy出来。新方案中,我们引入CSS module的方案。
构建工具的选择,主要对比了Webpack4、Rollupjs和Parcel,因为基础库的构建文件只有js,而且从构建体积来说,rollupjs是有绝对优势的,所以选择了rollupjs。
主流构建工具对比
由于CSS in JS需要引入额外的依赖,在对比了CSS Module和CSS in JS后,我们选择CSS Module的方案。
CSS模块化方案对比
单元测试框架我们选择了Jest,主要是因为开箱即用,不需要再引入断言库,生态也很好,较多用于React项目,而且组内的UI自动化测试系统是支持Jest的,这篇文章Migrating from Mocha to Jest中介绍了Airbnb的尝试。
单元测试框架对比
由于接入了CI系统进行lint自动化检查,为了减少“无效”的commit,我们选择了husky+lint-staged来进行本地代码提交前的lint。
Lint方案
各种工作流中,首先需要在各自的开发分支进行开发测试,然后将代码合并到追踪生成环境的长期分支进行持续地发布部署,这意味着对这个长期分支要有完善的自动化测试能力,因为谁也不能保证merge的代码就一定不会有问题,目前新的方案引入了单元测试,对UI组件引入了基于puppeteer的截图测试,但一些功能缺乏在更多设备、更多平台上的自动化验证,因此我们认为在自动化测试方面的建设还不是非常完善,所以新方案接入了CI,但是对发布外链基础库music.js这种会直接影响到全量业务的并没有接入,还是使用ARS发布,除非紧急bug,其他的代码更改会在测试环境验证一段时间(一般2-3天)后才会发布外网。
我们的工程化实践
首先可以看一下新旧构建方案的对比,在新方案中推广使用ES6,增加了对代码质量的控制:代码检查+单元测试,并接入了CI系统。
新旧方案对比
这是我们整体的打包方案,核心是一份源码开发维护,通过构建工具的差异化配置实现多种版本的构建。
打包方案
这是整体的开发流程,本地开发使用package.json管理项目依赖,规范代码格式,接入单元测试;提交之前git hook设置保证代码检查和测试通过后才能提交成功;使用QCI自动进行项目的构建、检查和测试,通过后将JSDOC文档推送到文档服务器,并发布npm包,外链js还是使用ars发布。
开发流程
我们选择react-styleguide作为UI组件开发调试工具以及文档生成器,这是一个组件的MD文件示例:
### 组件式引入
- 可以提前插入dom结构,如果浮层中有图片的话会先加载;
- 属性中的 `visible` 控制组件是否可见。
```jsx
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,非常方便。
demo文档截图
Jest可以设置全局的Setup,会在所有test执行之前运行,也可以设置全局Teardown,会在所有test执行完毕之后运行,比如这里就可以设置一些测试需要的Global对象、运行环境等等。describe可以将测试用例进行分组,beforeEach、afterEach、beforeAll、afterAll这些方法可以定义在测试用例之前或者之后运行的方法。
根据上面介绍的打包方案和业务特点,基础库需要分别运行在node端和浏览器端,因此需要考虑到不同运行环境下的测试结果。
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端和浏览器端的不同在于运行环境testEnvironment不同,jest提供jest-environment-node,我们为node端单独配置了music-node.jest.config.js。
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%以内就可以认为是正常的。
QMPreload测试结果
和上面QMPreload不同,对QMDialog组件的判断则是需要差异值为0,如下面第三张图所示,没有差异点。
QMDialog测试结果
这是我们参照官网的文档接入的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目录下的各个文件。
测试覆盖率-1
测试覆盖率-2
测试覆盖率-3
这些都是我们通过单元测试发现的之前一些函数的bug,仅举例一部分:
测试用例 | 错误输出 | 正确输出 |
---|---|---|
M.type(undefined) | "nan" | "undefined" |
M.isPlainObject(Object.creact({})) | false | true |
Mozilla/5.0 (Linux; U; en-us; KFTT Build/IML74K) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.21 Safari/535.19 Silk-Accelerated=true<br />M.os.tablet | false | true |
M.param({a: 1, b: {c: 1}}) | "a=1&b=c%3D1" | "a=1&b%5Bc%5D=1" |
声明pkg.module可以让构建工具利用到ES Moudle的很多特性来提高打包性能,比如利用Tree Shaking的机制减少文件体积,这篇文章package.json中的Module字段是干嘛的有详细介绍。
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
这个插件可以避免每一个js文件分别引入胶水代码,而是整个构建文件引入一份胶水代码,减少代码体积。
对eslint的错误输出进行格式化,方便查看和定位问题。
由于运行时的性能原因,RN已经在production模式下移除了PropTypes,我们引入这个babel插件在生产模式中移除组件属性的类型校验相关的代码。
在将外链js用rollupjs构建成umd规范的时候,我们设置了--noConflict,可以解决全局变量M冲突的问题,类似于jQuery.noConflict()。
参考文献
前端模块化详解(完整版) 我们是怎么进行前端工程化的 我对前端工程化的理解 浅谈 CSS 预处理器(一):为什么要使用预处理器? CSS BEM 书写规范 CSS Modules使用详解 深入探讨前端组件化开发 CSS-in-JS,向Web组件化再迈一大步 jsx与模板dsl的优劣思考 从年会看声明式编程(Declarative Programming) React & Web Components The state of Web Components 用 husky 和 lint-staged 构建超溜的代码检查工作流 JEST 值得参考的css理论:OOCSS、SMACSS与BEM 浅谈 shadow dom 中的 template 和 slot GitHub Flow & Git Flow 基于Git 的两种协作开发模式 git-flow的工作流程 Git工作流程 Introduction to GitLab Flow 软件测试的四个阶段,单元测试、集成测试、系统测试、验收测试 The Product Managers’ Guide to Continuous Delivery and DevOps 一文收录16张DevOps ”拍照神图” DevOps漫谈之一:DevOps、CI、CD都是什么鬼? Webhook到底是个啥? Webpack中的sideEffects到底该怎么用?
想了解参考资料的可以前往:
https://cloud.tencent.com/developer/article/1500013