前端工程化是一个很是普遍的议题,包含的技术和解决方案也是很是丰富的。一个前端工程的生命周期能够大体划分为这四个过程:
css
前端工程的生命周期html
任何在这四个过程当中应用的系统化、严格约束、可量化的方法均可以称之为工程化。工程化的程度越高,在工做中因人的个体差别性致使的缺陷或者短板就会越少,项目质量能够获得更有效的保障。对上面四个过程的工程化并非彻底分隔的,而是相辅相成,好比开发阶段的优化也会对测试、部署和维护产生很大的影响。
前端
下面从模块化、组件化、规范化和自动化这四个方面进行具体介绍。node
模块化react
模块化能够对复杂逻辑进行有效分割,每一个模块更关注自身的功能,模块内部的数据和实现是私有的,经过向外部暴露一些接口来实现各模块间的通讯。开发阶段前端须要关注JS、CSS和HTML,下面咱们将分别对JS、CSS、HTML的模块化进行简单介绍。jquery
JS模块化是一个逐渐演变的过程,开始的namespace概念实现了简单对象封装,约定私有属性使用_开头,到后来的IIFE模式,利用匿名函数闭包的原理解决模块的隔离与引用,下面介绍如今比较流行的几种模块化标准。android
Nodejs中的模块化方案,就是基于CommonJS规范实现的。一个文件就是一个模块,有本身的做用域,没有export的变量和方法都是私有的,不会污染全局做用域,模块的加载是运行时同步加载的。CommonJS能够细分为CommonJS1和CommonJS2,两者的模块导出方式不一样,CommonJS2兼容CommonJS1,增长了module.exports的导出方式,如今通常所指的都是CommonJS2。webpack
每一个文件一个模块,有本身的做用域,不会污染全局;git
使用require同步加载依赖的其余模块,经过module.exports导出须要暴露的接口;es6
屡次require的同一模块只会在第一次加载时运行,并将运行结果缓存,后续直接读取缓存结果,若是须要从新执行,须要先清理缓存;
Nodejs环境下能够直接运行,各个模块按引入顺序依次执行。
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定义一个模块,使用require加载模块;
异步加载,能够并行请求依赖模块;
原生JavaScript运行环境没法直接执行AMD规范的模块代码,须要引入第三方库支持,如requirejs等;
// 定义一个模块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都宣布会原生支持,愈来愈受开发者青睐。
使用import引入模块,export导出模块;
与CommonJS的执行时机不一样,只是个只读引用,只会在真正调用的地方开始执行,而不是像CommonJS那样,在require的时候就会执行代码;
支持度暂不完善,须要进行代码转换成上面介绍的某一种模块化规范。
在浏览器中能够经过下面的方式引入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代码,有两个主要原则,它们都是用来规定应该把什么属性定义在什么样式类中。
Separate structure and skin(分离结构和主题)
Separate container and content(分离容器和内容)
SMACSS(Scalable and Modular Architecture for CSS)是可扩展模块化的CSS,它的核心就是结构化CSS代码,则有三个主要规则:
Categorizing CSS Rules (CSS分类规则):将CSS分红Base、Layout、Module、State、Theme这5类。
Naming Rules(命名规则):考虑用命名体现样式对应的类别,如layout-这样的前缀。
Minimizing the Depth of Applicability(最小化适配深度):下降对特定html结构的依赖。
/* 依赖html结构,不提倡 */.sidebar ul h3 { }
/* 建议直接定义 */.sub-title { }
BEM是一种CSS命名规范,旨在解决样式名的全局冲突问题。BEM是块(block)、元素(element)、修饰符(modifier)的简写,咱们经常使用这三个实体开发组件。
块(block):一种布局或者设计上的抽象,每个块拥有一个命名空间(前缀)。
元素(element):是.block的后代,和块一块儿造成一个完整的实体。
修饰符(modifier):表明一个块的状态,表示它持有的一个特定属性。
在选择器中,BEM要求只使用类名,不容许使用id,由如下三种符号来表示扩展的关系:
中划线( - ) :仅做为连字符使用,表示某个块或者某个子元素的多单词之间的链接记号。
双下划线( __ ):双下划线用来链接块和块的子元素。
单下划线( _ ):单下划线用来描述一个块或者块的子元素的一种状态。
.type-block__element_modifier {}
从上面BEM的命名要求能够看到,类名都很长,这就致使在对CSS文件进行压缩的时候,咱们没法获得更大的优化空间。并且BEM仅仅是一种规范,须要团队中的开发者自行遵照,在可靠性上没法获得有效保障,并且还可能和第三方库的命名冲突。
CSS in JS是一种比较激进的方案,完全抛弃了CSS,彻底使用JS来编写CSS,又用起了行内样式(inline style),它的发展得益于React的出现,具体的缘由能够参见组件化这部份内容。
解决全局命名污染的问题;
更贴近Web组件化的思想;
能够在一些没法解析CSS的运行环境下执行,好比React Native等;
JS赋予CSS更多的编程能力,实现了CSS和JS间的变量共享;
支持CSS单元测试,提升CSS的安全性;
原生JS编写CSS没法支持到不少特性,好比伪类、media query等,须要引入额外的第三方库来支持,各类库的对比详见css-in-js;
有运行时损耗,性能比直接class要差一些;
不容易debug;
下面以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。
解决全局命名污染的问题;
默认是局部的,能够用:global声明全局样式;
受CSS的限制,只能一层嵌套,和JS没法共享变量;
能支持如今全部的CSS技术。
以webpack为例,使用css-loader就能够实现CSS module:
module.exports = { ... module: { rules: [ ... { loader: 'css-loader', options: { importLoaders: 1, modules: { localIdentName: "[name]__[local]--[hash:base64:5]" },
} } ... ] } ...}
下面是一个组件开发的例子:
/* 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写在一块儿,这种思想能够很好地解决隔离的问题,每一个组件相关的代码都在一块儿,便于维护和管理。
咱们回想一下原有引用组件的步骤:
引入这个组件的JS;
引入这个组件的样式CSS(若是有);
在页面中引入这个组件的;
最后是编写初始化组件的代码。
这种引入方式很繁琐,一个组件的代码分布在多个文件里面,并且做用域暴露在全局,缺少内聚性容易产生冲突。
组件化就是将页面进行模块拆分,将某一部分独立出来,多个组件能够自由组合造成一个更复杂的组件。组件将数据、视图和逻辑封装起来,仅仅暴露出须要的接口和属性,第三方能够彻底黑盒调用,不须要去关注组件内部的实现,很大程度上下降了系统各个功能的耦合性,而且提升了功能内部的聚合性。
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,这些所有交给框架来实现,开发者仅仅须要声明每一个组件“想要画成什么样子”。
JSX vs 模板DSL
React使用JSX,很是灵活,与JS的做用域一致。Vue、Angular采用模板DSL,可编程性受到限制,做用域和JS是隔离的,但也是这个缺点使得咱们能够在构建期间对模板作更多的事情,好比静态分析、更好地代码检查、性能优化等等。两者都没有浏览器原生支持,须要通过Transform才能运行。
Web Component是W3C专门为组件化建立的标准,一些Shadow DOM等特性将完全的、从浏览器的层面解决掉一些做用域的问题,并且写法一致,它有几个概念:
Custom Element: 带有特定行为且用户自命名的 HTML 元素,扩展HTML语义;
<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');
Shadow DOM:对标签和样式的一层 DOM 封装,能够实现局部做用域;当设置{mode: closed}后,只有其宿主才可定义其表现,外部的api是没法获取到Shadow DOM中的任何内容,宿主的内容会被Shadow DOM掩盖。
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的示例
HTML Imports: 打包机制,将HTML代码以及Web Componnet导入到页面中,这个规范目前已经不怎么推进了,在参考了ES6 module的机制后,FireFox团队已经不打算继续支持。
<link rel="import" href="/path/to/imports/stuff.html">
Polymer
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工具提供下面两大类的规则:
格式化规则:好比 max-len, no-mixed-spaces-and-tabs等等,这些规则只是用来统一书写格式的。
代码质量规则:好比 no-unused-vars, no-extra-bind, no-implicit-globals等等,这些规则能够帮助提高代码质量,减小bug。
在实际的项目中能够引入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中的示例:
{ "husky": { "hooks": { "pre-commit": "lint-staged", } }, "lint-staged": { "src/**/*.js": "eslint" }}
JavaScript是很是灵活的,这得益于它的弱类型语言特色,但也是由于这个缘由,咱们只有在运行时才知道变量究竟是什么类型,没法在编译阶段做出任何类型错误的提示,同时因为函数参数类型的不肯定性,编译器的编译结果极可能没法被复用,好比下面的例子中,在执行add(1,2)时对add函数的编译结果没法直接被下面的add('1', '2')复用,第二次调用必须得再从新编译一次,这对性能也是有很大影响。
function add(a, b) { return a + b;}add(1, 2);add('1', '2');
类型检查可让咱们编写出更高质量的代码,减小类型错误的bug,同时明确了类型也让代码更好维护。
PropTypesReact在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。
TypeScriptTypeScript则是一种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语法,好比ES六、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的方案。
构建工具的选择,主要对比了Webpack四、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` 控制组件是否可见。```jsximport 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()。
(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; }; }()));