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

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

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

本文主要介绍在前端工程化的一些探索和实践,结合移动端的基础库重构和 UI 组件库开发这两个项目详细介绍工程化方案 。
随着业务的不断扩展,团队的项目越来越多,面对日益复杂的业务场景和代码逻辑,我们发现在前端工程化方面团队还有很多需要优化的地方。现有的解决方案已经无法满足各种复杂的场景,我们每天都在疲于应付很多重复的工作,为此我们基于移动端基础库重构和 UI 组件库的建设这两个项目对团队的项目构建流程进行了详细的分析和梳理,并制定了一套适用于团队的工程化方案。

浅谈前端工程化

前端工程化是一个非常广泛的议题,包含的技术和解决方案也是非常丰富的。一个前端工程的生命周期可以大致划分为这四个过程:

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

前端工程的生命周期

任何在这四个过程中应用的系统化、严格约束、可量化的方法都可以称之为工程化。工程化的程度越高,在工作中因人的个体差异性导致的缺陷或者短板就会越少,项目质量可以得到更有效的保障。对上面四个过程的工程化并不是完全分隔的,而是相辅相成,比如开发阶段的优化也会对测试、部署和维护产生很大的影响。

下面从模块化、组件化、规范化和自动化这四个方面进行具体介绍。

模块化

模块化可以对复杂逻辑进行有效分割,每个模块更关注自身的功能,模块内部的数据和实现是私有的,通过向外部暴露一些接口来实现各模块间的通信。开发阶段前端需要关注 JS、CSS 和 HTML,下面我们将分别对 JS、CSS、HTML 的模块化进行简单介绍。

1. JS 模块化

JS 模块化是一个逐渐演变的过程,开始的 namespace 概念实现了简单对象封装,约定私有属性使用 _ 开头,到后来的 IIFE 模式,利用匿名函数闭包的原理解决模块的隔离与引用,下面介绍现在比较流行的几种模块化标准。

2. CommonJS

Nodejs 中的模块化方案,就是基于 CommonJS 规范实现的。一个文件就是一个模块,有自己的作用域,没有 export 的变量和方法都是私有的,不会污染全局作用域,模块的加载是运行时同步加载的。CommonJS 可以细分为 CommonJS1 和 CommonJS2,二者的模块导出方式不同,CommonJS2 兼容 CommonJS1,增加了 module.exports 的导出方式,现在一般所指的都是 CommonJS2。

  • 每个文件一个模块,有自己的作用域,不会污染全局;
  • 使用 require 同步加载依赖的其他模块,通过 module.exports 导出需要暴露的接口;
  • 多次 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);

AMD
浏览器加载 js 文件需要进行网络请求,而网络请求的耗时是不可预期的,这使得 CommonJS 同步加载模块的机制在浏览器端并不适用,我们不能因为要加载某个模块 js 而一直阻塞浏览器继续执行下面的代码。AMD 规范则采用异步的方式加载模块,允许指定回调函数,这非常适合用于浏览器端的模块化场景。

  • 使用 define 定义一个模块,使用 require 加载模块;
  • 异步加载,可以并行请求依赖模块;
  • 原生 JavaScript 运行环境无法直接执行 AMD 规范的模块代码,需要引入第三方库支持,如 requirejs 等;
复制代码
// 定义一个模块
define(id ? , dependencies ? , factory);
// 引用一个模块
require([module], callback)

CMD
类似于 AMD 规范,是应用在浏览器端的 JS 模块化方案,由 sea.js 提出,详见 https://www.zhihu.com/question/20351507

UMD
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 Module
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 以及各种浏览器环境下的执行效率对比。

3. CSS 模块化

CSS 自诞生以来,基本语法和核心机制一直没有本质上的变化,它的发展几乎全是表现力层面上的提升。不同于 JS,CSS 本身不具有高级编程属性,无法使用变量、运算、函数等,无法管理依赖,全局作用域使得在编写 CSS 样式的时候需要更多人工去处理优先级的问题,样式名还有压缩极限的问题,为此,出现了很多“编译工具”和“开发方案”为 CSS 赋予“编程能力”。

预处理器
随着页面越来越复杂,为了便于开发和维护,我们常常会将 CSS 文件进行切分,然后再将需要的文件进行合并。诸如 LESS、SASS、Stylus 等预处理器为 CSS 带来了编程能力,我们可以使用变量、运算、函数,@import 指令可以轻松合并文件。但各种预处理器并不能完全解决全局作用域的问题,需要结合 namespace 的思想去命名。

OOCSS & SMACSS
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
BEM 是一种 CSS 命名规范,旨在解决样式名的全局冲突问题。BEM 是块(block)、元素(element)、修饰符(modifier)的简写,我们常用这三个实体开发组件。

  • 块 (block):一种布局或者设计上的抽象,每一个块拥有一个命名空间(前缀)。
  • 元素 (element):是.block 的后代,和块一起形成一个完整的实体。
  • 修饰符 (modifier):代表一个块的状态,表示它持有的一个特定属性。

在选择器中,BEM 要求只使用类名,不允许使用 id,由以下三种符号来表示扩展的关系:

  • 中划线 ( - ) :仅作为连字符使用,表示某个块或者某个子元素的多单词之间的连接记号。
  • 双下划线 ( __ ):双下划线用来连接块和块的子元素。
  • 单下划线 ( _ ):单下划线用来描述一个块或者块的子元素的一种状态。
复制代码
.type-block__element_modifier {}

从上面 BEM 的命名要求可以看到,类名都很长,这就导致在对 CSS 文件进行压缩的时候,我们无法得到更大的优化空间。而且 BEM 仅仅是一种规范,需要团队中的开发者自行遵守,在可靠性上无法得到有效保障,而且还可能和第三方库的命名冲突。

CSS in JS
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 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 写在一起,这种思想可以很好地解决隔离的问题,每个组件相关的代码都在一起,便于维护和管理。

我们回想一下原有引用组件的步骤:

1. 引入这个组件的 JS;
2. 引入这个组件的样式 CSS(如果有);
3. 在页面中引入这个组件的;
4. 最后是编写初始化组件的代码。

这种引入方式很繁琐,一个组件的代码分布在多个文件里面,而且作用域暴露在全局,缺乏内聚性容易产生冲突。

组件化就是将页面进行模块拆分,将某一部分独立出来,多个组件可以自由组合形成一个更复杂的组件。组件将数据、视图和逻辑封装起来,仅仅暴露出需要的接口和属性,第三方可以完全黑盒调用,不需要去关注组件内部的实现,很大程度上降低了系统各个功能的耦合性,并且提高了功能内部的聚合性。

1.React、Vue、Angular…

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 才能运行。

2.Web Component

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');

评论

发布