蓝字关注,回复“加群”加入前端技术群 与大家一起成长
随着业务的不断扩展,团队的项目越来越多,面对日益复杂的业务场景和代码逻辑,我们发现在前端工程化方面团队还有很多需要优化的地方。现有的解决方案已经无法满足各种复杂的场景,我们每天都在疲于应付很多重复的工作,为此我们基于移动端基础库重构和UI组件库的建设这两个项目对团队的项目构建流程进行了详细的分析和梳理,并制定了一套适用于团队的工程化方案。
前端工程化是一个非常广泛的议题,包含的技术和解决方案也是非常丰富的。一个前端工程的生命周期可以大致划分为这四个过程:
前端工程的生命周期
任何在这四个过程中应用的系统化、严格约束、可量化的方法都可以称之为工程化。工程化的程度越高,在工作中因人的个体差异性导致的缺陷或者短板就会越少,项目质量可以得到更有效的保障。对上面四个过程的工程化并不是完全分隔的,而是相辅相成,比如开发阶段的优化也会对测试、部署和维护产生很大的影响。
下面从模块化、组件化、规范化和自动化这四个方面进行具体介绍。
模块化
模块化可以对复杂逻辑进行有效分割,每个模块更关注自身的功能,模块内部的数据和实现是私有的,通过向外部暴露一些接口来实现各模块间的通信。开发阶段前端需要关注JS、CSS和HTML,下面我们将分别对JS、CSS、HTML的模块化进行简单介绍。
JS模块化是一个逐渐演变的过程,开始的namespace概念实现了简单对象封装,约定私有属性使用_开头,到后来的IIFE模式,利用匿名函数闭包的原理解决模块的隔离与引用,下面介绍现在比较流行的几种模块化标准。
Nodejs中的模块化方案,就是基于CommonJS规范实现的。一个文件就是一个模块,有自己的作用域,没有export的变量和方法都是私有的,不会污染全局作用域,模块的加载是运行时同步加载的。CommonJS可以细分为CommonJS1和CommonJS2,二者的模块导出方式不同,CommonJS2兼容CommonJS1,增加了module.exports的导出方式,现在一般所指的都是CommonJS2。
每个文件一个模块,有自己的作用域,不会污染全局;
使用require同步加载依赖的其他模块,通过module.exports导出需要暴露的接口;
多次require的同一模块只会在第一次加载时运行,并将运行结果缓存,后续直接读取缓存结果,如果需要重新执行,需要先清理缓存;
Nodejs环境下可以直接运行,各个模块按引入顺序依次执行。
浏览器加载js文件需要进行网络请求,而网络请求的耗时是不可预期的,这使得CommonJS同步加载模块的机制在浏览器端并不适用,我们不能因为要加载某个模块js而一直阻塞浏览器继续执行下面的代码。AMD规范则采用异步的方式加载模块,允许指定回调函数,这非常适合用于浏览器端的模块化场景。
使用define定义一个模块,使用require加载模块;
异步加载,可以并行请求依赖模块;
原生JavaScript运行环境无法直接执行AMD规范的模块代码,需要引入第三方库支持,如requirejs等;
类似于AMD规范,是应用在浏览器端的JS模块化方案,由sea.js提出,详见 https://www.zhihu.com/question/20351507 。
UMD规范兼容AMD和CommonJS,在浏览器和Nodejs中均可以运行。
ES6从语言标准的层面上实现了模块化,是ECMA提出的模块化标准,后续浏览器和Nodejs都宣布会原生支持,越来越受开发者青睐。
使用import引入模块,export导出模块;
与CommonJS的执行时机不同,只是个只读引用,只会在真正调用的地方开始执行,而不是像CommonJS那样,在require的时候就会执行代码;
支持度暂不完善,需要进行代码转换成上面介绍的某一种模块化规范。
在浏览器中可以通过下面的方式引入es6规范的模块js:
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结构的依赖。
BEM是一种CSS命名规范,旨在解决样式名的全局冲突问题。BEM是块(block)、元素(element)、修饰符(modifier)的简写,我们常用这三个实体开发组件。
块(block):一种布局或者设计上的抽象,每一个块拥有一个命名空间(前缀)。
元素(element):是.block的后代,和块一起形成一个完整的实体。
修饰符(modifier):代表一个块的状态,表示它持有的一个特定属性。
在选择器中,BEM要求只使用类名,不允许使用id,由以下三种符号来表示扩展的关系:
中划线( - ) :仅作为连字符使用,表示某个块或者某个子元素的多单词之间的连接记号。
双下划线( __ ):双下划线用来连接块和块的子元素。
单下划线( _ ):单下划线用来描述一个块或者块的子元素的一种状态。
从上面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为例:
构建后的结果如下,我们发现不会再有.css文件,一个.js文件包含了组件相关的全部代码:
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 = `It is a test title
`;
构建运行后生成的dom结构如下:
It is a test title
component.js中styles变量的值如下,我们看到声明成:global的类名.global-class-name没有被转换,具有全局作用域。
说明: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操作是碎片的命令式,它允许将两个组件通过声明式编程建立内在联系。
从上面的例子可以看到,声明式编程让组件更简单了,我们不需要去记住各种DOM相关的API,这些全部交给框架来实现,开发者仅仅需要声明每个组件“想要画成什么样子”。
JSX vs 模板DSL
React使用JSX,非常灵活,与JS的作用域一致。Vue、Angular采用模板DSL,可编程性受到限制,作用域和JS是隔离的,但也是这个缺点使得我们可以在构建期间对模板做更多的事情,比如静态分析、更好地代码检查、性能优化等等。二者都没有浏览器原生支持,需要经过Transform才能运行。
Web Component是W3C专门为组件化创建的标准,一些Shadow DOM等特性将彻底的、从浏览器的层面解决掉一些作用域的问题,而且写法一致,它有几个概念:
Custom Element: 带有特定行为且用户自命名的 HTML 元素,扩展HTML语义;
Custom Element
/* 定义新元素 */
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 = 'Hello World
';
Chrome调试工具:DevTool > Settings > Preferences> Show user agent shadow DOM
Chrome调试工具查看shadow DOM
HTML Template & Slots: 可复用的 HTML 标签,提供了和用户自定义标签相结合的接口,提高组件的灵活性。定义了template的标签,类似我们经常用的,它不会被解析为dom树的一部分,template的内容可以被塞入到Shadow DOM中并且反复使用;template中定义的style只对该template有效,实现了隔离。
dom树中的template标签,不解析:
HTML template-1
最终插入的影子节点效果:
HTML template-2
由于Shadow DOM中宿主元素的内容会被影子节点掩盖,如果想将宿主中某些内容显示出来的话就需要借助slot,它是定义在宿主和template中的一个插槽,用来“占位”。
Test1
slot1
slot2
Test2
tpl1
tpl2
宿主元素中设置了slot属性的节点被“保留”了下来,并且插入到了template中定义的slot的位置。
slot的示例
HTML Imports: 打包机制,将HTML代码以及Web Componnet导入到页面中,这个规范目前已经不怎么推动了,在参考了ES6 module的机制后,FireFox团队已经不打算继续支持。
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"
}
}
function add(a, b) {
return a + b;
}
add(1, 2);
add('1', '2');
import React, { Component } from 'react';
import PropTypes from 'prop-types';
class App extends Component {
}
App.propTypes = {
title: PropTypes.string.isRequired
}
function split(str) {
return str.split(' ')
}
split(11);
function square(n: number): number {
return n * n;
}
square("2");
interface Person {
firstName: string;
lastName: string;
}
function greeter(person: Person) {
return "Hello, " + person.firstName + " " + person.lastName;
}
自动化
“怎么单元测试写起来这么麻烦”
——说明项目模块之间存在耦合度高,依赖性强的问题。
“怎么要写这么长的测试代码啊”
——这是一劳永逸的,并且每次需求变更后,你都可通过单元测试来验证,逻辑代码是否依旧正确。
“我的模块没问题的,是你的模块出了问题”
——程序中每一项功能我们都用测试来验证的它的正确性,快速定位出现问题的某一环。
“上次修复的 bug 怎么又出现了 ”
——单元测试能够避免代码出现回归,编写完成后,可快速运行测试。
运行环境 | 特点 |
jsdom
|
node端直接运行,伪浏览器环境,速度快,内置BOM对象,目前也有了对sessionStorage、localStorage和cookie的支持。
|
puppeteer
|
在真实的浏览器中运行测试,很方便,但是运行速度会慢一点。
|
phantomjs
|
无头浏览器,在puppeteer发布后,作者已经宣布不维护了。
|
DevOps是Development和Operations的组合,是一种方法论,是一组过程、方法与系统的统称,用于促进应用开发、应用运维和质量保障(QA)部门之间的沟通、协作与整合。以期打破传统开发和运营之间的壁垒和鸿沟。
持续集成(Continuous Integration) 中开发人员需要频繁地向主干提交代码,这些新提交的代码在最终合并到主干前,需要经过编译和自动化测试(通常是单元测试)进行验证。
持续交付(Continuous Delivery)指的是,频繁地将软件的新版本,交付给质量团队或者用户,以供评审。如果评审通过,代码就进入生产阶段。
在web开发过程中的Webhook,是一种通过通常的callback,去增加或者改变web page或者web app行为的方法。这些callback可以由第三方用户和开发者维持当前,修改,管理,而这些使用者与网站或者应用的原始开发没有关联。Webhook这个词是由Jeff Lindsay在2007年在计算机科学hook项目第一次提出的。
我们的项目构建现状
var css ='.qui_dialog__mask{position:fixed;top:0;left:0;bottom:0;right:0;}...';
appendToHead(css);
我们的工程化实践
### 组件式引入
- 可以提前插入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 (
{
if(index === -1) {
this.setState({
visible1: false
})
} else {
console.log('我知道了按钮被点击,index=', index)
}
}}
/>
)
}
}
```
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: []
};
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();
.
├── config
├── src
│ ├── music
│ │ ├── utils
│ │ │ ├── __mock__
│ │ │ └── loadUrl.js
│ │ └── loadUrl.js
├── node_modules
├── ...
└── tests
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;
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);
});
})
测试用例 | 错误输出 | 正确输出 |
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 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。这个功能亲测是很有效的
(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;
};
}())
);
“ 公众号后台回复“加群”,可加入「前端技术群」共同学习交流”