组件化
1. 组件化理解
2. 组件化与模块化的区别
2.1 从历史发展角度来讲
2.2 从整体概念来讲
- 模块化是一种分治的思想,述求是解耦,一般指的是 JavaScript 模块,比如用来格式化时间的模块
- 组件化是模块化思想的实现手段,述求是复用,包含了 template,style,script,script又可以由各种模块组成
2.3 从复用的角度来讲
- 模块一般是项目范围内按照项目业务内容来划分的,比如一个项目划分为子系统、模块、子模块,代码分开就是模块,位于架构业务框架层,横向分块
- 组件是按照一些小功能的通用性和可复用性抽象出来的,可以跨项目的,是可复用的模块,通常位于架构底层,被其他层所依赖
2.4 从划分的角度来讲
- 模块是从代码逻辑的角度进行划分,方便代码分层开发,保证每个功能模块的职能单一
- 组件是从 UI 界面的角度进行划分,前端的组件化,方便 UI 组件的重用
3. 为什么要前端组件化
随着前端项目复杂度的急剧增加,我们很容易遇到以下这些场景:
- 页面逻辑越来越多,代码越写越庞大,容易牵一发而动全身
- 同样的逻辑在多个地方重复编写,改一个问题要在多个地方进行同样的修改
以上场景带来的问题就是:
- 项目复杂度增加
- 重复性劳动多,效率低
- 代码质量差,不可控
因此前端组件化可以给我们带来:
- 增加代码的复用性,灵活性
- 提高开发效率,降低开发成本
- 便于各个开发者之间分工协作、同步开发
- 降低系统各个功能的耦合性,提高了功能内部的聚合性
- 降低代码的维护成本
4. 组件的划分
4.1 划分方法
- 组件之间的依赖应该尽可能的少。
- 单个组件代码量最好不要超过1000行。
- 组件划分的依据通常是业务逻辑、功能,要考虑各组件之间的关系是否明确,以及组件的可复用度。
- 每一个组件都应该有其独特的划分目的,有的是为了复用实现,有的是为了封装复杂度、清晰业务实现。
我经常的做法是:如果看到有多个页面都出现了这个重复元素,则抽取成一个组件。还有在开发之中发现结构相似的也可以考虑抽取成一个组件。没有必要在一开始就把所有都抽取成一个个组件。
4.2 组件分类
4.2.1 基础UI组件
- 在创建基础组件的过程中,要遵循一个基本原则:基础组件是独立存在的。它们可以共享配置,但是不能相互依赖,依赖意味着它不是基础组件。
- 像 antd、iview、element-ui 里提供的基本都是基础 UI 组件。
4.2.2 复合组件
- 表格:表格往往带有复杂的交互,比如固定行、固定列、可编辑、虚拟滚动等。由于其数据量大,往往又对性能有很高的要求。
- 图表:图表的门槛相对比较高,并且种类繁多,对于显示、交互的要求也高。
- 富文本编辑器:几乎是最复杂的组件,其功能需求往往与 Word 进行对比,其代码量可能接近 Word 的数量级。
4.2.3 业务组件
- 通常是根据最小业务状态抽象而出,有些业务组件也具有一定的复用性,但大多数是一次性组件。
- 特点:UI可配置,业务逻辑完整。有完整的后台流程,数据结构。
5. 组件的设计原则
标准性:
任何一个组件都应该遵守一套标准,可以使得不同区域的开发人员据此标准开发出一套标准统一的组件。单一职责原则:
一个组件只专注做一件事,且把这件事做好。一个功能如果可以拆分成多个功能点,那就可以将每个功能点封装成一个组件,当然也不是组件的颗粒度越小越好,只要将一个组件内的功能逻辑控制在一个可控的范围内即可。开闭原则:
对扩展开放,对修改关闭。属性配置等 API 对外开放,组件内部状态对外封闭。追求短小精悍
避免太多参数扁平化参数:
除了数据,避免复杂的对象,尽量只接收原始类型的值。合理的依赖关系:
父组件不依赖子组件,删除某个子组件不会造成功能异常适用SPOT(Single Point of Truth)法则:
尽量不要重复代码追求无副作用
复用与易用
避免暴露组件内部实现
入口处检查参数的有效性,出口处检查返回的正确性
稳定抽象原则(SAP)
- 组件的抽象程度与其稳定程度成正比
- 一个稳定的组件应该是抽象的(逻辑无关的)
- 一个不稳定的组件应该是具体的(逻辑相关的)
- 为降低组件之间的耦合度,我们要针对抽象组件编程,而不是针对业务实现编程
良好的接口设计,API 尽量和已知概念保持一致
模块化
1. 模块化理解
1.1 模块化简介
1.2 模块化进程
全局function模式:将不同的功能封装成不同的全局函数
- 编码: 将不同的功能封装成不同的全局函数
- 问题: 污染全局命名空间, 容易引起命名冲突或数据不安全,而且模块成员之间看不出直接关系
function m1(){
//...
}
function m2(){
//...
}
namespace模式:简单对象封装
- 作用: 减少了全局变量,解决命名冲突
- 问题: 数据不安全(外部可以直接修改模块内部的数据)
let myModule = {
data: 'www.baidu.com',
foo() {
console.log(`foo() ${this.data}`)
},
bar() {
console.log(`bar() ${this.data}`)
}
}
myModule.data = 'other data' //能直接修改模块内部的数据
myModule.foo() // foo() other data
IIFE模式:匿名函数自调用(闭包)
- 作用: 数据是私有的, 外部只能通过暴露的方法操作
- 编码: 将数据和行为封装到一个函数内部, 通过给window添加属性来向外暴露接口
- 问题: 如果当前这个模块依赖另一个模块怎么办?
// index.html文件
<script type="text/javascript" src="module.js"></script>
<script type="text/javascript">
myModule.foo()
myModule.bar()
console.log(myModule.data) //undefined 不能访问模块内部数据
myModule.data = 'xxxx' //不是修改的模块内部的data
myModule.foo() //没有改变
</script>
// module.js文件
(function(window) {
let data = 'www.baidu.com'
//操作数据的函数
function foo() {
//用于暴露有函数
console.log(`foo() ${data}`)
}
function bar() {
//用于暴露有函数
console.log(`bar() ${data}`)
otherFun() //内部调用
}
function otherFun() {
//内部私有的函数
console.log('otherFun()')
}
//暴露行为
window.myModule = { foo, bar } //ES6写法
})(window)
IIFE模式增强:引入依赖
// module.js文件
(function(window, $) {
let data = 'www.baidu.com'
//操作数据的函数
function foo() {
//用于暴露有函数
console.log(`foo() ${data}`)
$('body').css('background', 'red')
}
function bar() {
//用于暴露有函数
console.log(`bar() ${data}`)
otherFun() //内部调用
}
function otherFun() {
//内部私有的函数
console.log('otherFun()')
}
//暴露行为
window.myModule = { foo, bar }
})(window, jQuery)
// index.html文件
<!-- 引入的js必须有一定顺序 -->
<script type="text/javascript" src="jquery-1.10.1.js"></script>
<script type="text/javascript" src="module.js"></script>
<script type="text/javascript">
myModule.foo()
</script>
- 通过jquery方法将页面的背景颜色改成红色,所以必须先引入jQuery库,就把这个库当作参数传入。这样做除了保证模块的独立性,还使得模块之间的依赖关系变得明显
1.3 模块化优点
- 避免命名冲突(减少命名空间污染)
- 更好的分离, 按需加载
- 更高复用性
- 高可维护性
1.4 引入多个 <script>
的问题
请求过多
首先我们要依赖多个模块,那样就会发送多个请求,导致请求过多依赖模糊
我们不知道他们的具体依赖关系是什么,也就是说很容易因为不了解他们之间的依赖关系导致加载先后顺序出错难以维护
以上两种原因就导致了很难维护,很可能出现牵一发而动全身的情况导致项目出现严重的问题。 模块化固然有多个好处,然而一个页面需要引入多个js文件,就会出现以上这些问题。而这些问题可以通过模块化规范来解决
2. 服务端模块化
2.1 Nodejs和CommonJS的关系
- Nodejs的模块化能一种成熟的姿态出现离不开CommonJS的规范的影响
- 在服务器端CommonJS能以一种寻常的姿态写进各个公司的项目代码中,离不开Node的优异表现
- Node并非完全按照规范实现,针对模块规范进行了一定的取舍,同时也增加了少许自身特性
2.2 CommonJS规范简介
2.2.1 模块引用
var add = require('./add.js');
var config = require('config.js');
var http = require('http');
2.2.2 模块定义
module.exports.add = function () {
...
}
module.exports = function () {
return ...
}
可以在一个文件中引入模块并导出另一个模块
var add = require('./add.js');
module.exports.increment = function () {
return add(val, 1);
}
2.2.3 模块标识
模块标识就是require( )函数的参数,规范是这样的:
- 必须是字符串
- 可以是以./ …/开头的相对路径
- 可以是绝对路径
- 可以省略后缀名
CommonJS的模块规范定义比较简单,意义在于将类聚的方法和变量等限定在私有的作用域中,同时支持引入和导出将上下游模块无缝衔接,每个模块具有独立的空间,它们互不干扰。
2.3 Nodejs的模块化实现
// Node中Module构造函数:
function Module(id, parent){
this.id = id;
this.exports = {};
this.parent = parent;
if(parent && parent.children) {
parent.children.push(this);
}
this.filename = null;
this.loaded = false;
this.children = [];
}
//实例化一个模块
var module = new Module(filename, parent);
其中id 是模块id,exports是这个模块要暴露出来的api,parent是父级模块,loaded表示这个模块是否加载完成,因为CommonJS是运行时加载,loaded表示文件是否已经执行完毕返回一个对象。
2.4 Node模块分类
2.4.1 核心模块
- 路径分析
- 文件定位
- 编译执行
核心模块会省略文件定位和编译执行这两步,并且在路径分析中会优先判断,加载速度比一般模块更快。
2.4.2 文件模块
文件模块引入过程以上三个步骤都要经历
2.5 那么NodeJS require的时候是怎么路径分析,文件定位并且编译执行的?
2.5.1 路径分析
前面已经说过,不论核心模块还是文件模块都需要经历路径分析这一步,当我们require一个模块的时候,Node是怎么区分是核心模块还是文件模块,并且进行查找定位呢?
Node支持如下几种形式的模块标识符,来引入模块:
//核心模块
require('http')
----------------------------
//文件模块
//以.开头的相对路径,(可以不带扩展名)
require('./a.js')
//以..开头的相对路径,(可以不带扩展名)
require('../b.js')
//以/开始的绝对路径,(可以不带扩展名)
require('/c.js')
//外部模块名称
require('express')
//外部模块某一个文件
require('codemirror/addon/merge/merge.js');
那么对于这个都是字符串的引入方式,
- Node 会优先去内存中查找匹配核心模块,如果匹配成功便不会再继续查找
(1)比如require http 模块的时候,会优先从核心模块里去成功匹配
- 如果核心模块没有匹配成功,便归类为文件模块
(2) 以.、…和/开头的标识符,require都会根据当前文件路径将这个相对路径或者绝对路径转化为真实路径,也就是我们平时最常见的一种路径解析
(3)非路径形式的文件模块 如上面的’express’ 和’codemirror/addon/merge/merge.js’,这种模块是一种特殊的文件模块,一般称为自定义模块。
自定义模块的查找最费时,因为对于自定义模块有一个模块路径,Node会根据这个模块路径依次递归查找。
模块路径——Node的模块路径是一个数组,模块路径存放在module.paths属性上。
我们可以找一个基于npm或者yarn管理项目,在根目录下创建一个test.js文件,内容为console.log(module.paths),如下:
//test.js
console.log(module.paths);
然后在根目录下用Node执行
node test.js
可以看到模块路径的生成规则如下:
- 当前路文件下的node_modules目录
- 父目录下的node_modules目录
- 父目录的父目录下的node_modules目录
- 沿路径向上逐级递归,直到根目录下的node_modules目录
对于自定义文件比如express,就会根据模块路径依次递归查找。
在查找同时并进行文件定位。
2.5.2 文件定位
- 扩展名分析
我们在使用require的时候有时候会省略扩展名,那么Node怎么定位到具体的文件呢?
这种情况下,Node会依次按照.js、.json、.node的次序一次匹配。(.node是C++扩展文件编译之后生成的文件)
若扩展名匹配失败,则会将其当成一个包来处理,我这里直接理解为npm包
- 包处理
对于包Node会首先在当前包目录下查找package.json(CommonJS包规范)通过JSON.parse( )解析出包描述对象,根据main属性指定的入口文件名进行下一步定位。
如果文件缺少扩展名,将根据扩展名分析规则定位。
若main指定文件名错误或者压根没有package.json,Node会将包目录下的index当做默认文件名。
再依次匹配index.js、index.json、index.node。
若以上步骤都没有定位成功将,进入下一个模块路径——父目录下的node_modules目录下查找,直到查找到根目录下的node_modules,若都没有定位到,将抛出查找失败的异常。
2.5.3 模块编译
- .js文件——通过fs模块同步读取文件后编译执行
- .node文件——用C/C++编写的扩展文件,通过dlopen( )方法加载最后编译生成的文件。
- .json——通过fs模块同步读取文件后,用JSON.parse( ) 解析返回结果。
- 其余扩展名文件。它们都是被当做.js文件载入。
每一个编译成功的文件都会将其文件路径作为索引缓存在Module._cache对象上,以提高二次引入的性能。
这里我们只讲解一下JavaScript模块的编译过程,以解答前面所说的CommonJS模块中的require、exports、module变量的来源。
我们还知道Node的每个模块中都有___filename、_dirname 这两个变量,是怎么来的的呢?
其实JavaScript模块在编译过程中,Node对获取的JavaScript文件内容进行了头部和尾部的包装。在头部添加了(function (exports, require, module,__filename, __dirname){\n,而在尾部添加了\n}); 。
因此一个JS模块经过编译之后会被包装成下面的样子:
(function(exports, require, module, __filename, __dirname){
var express = require('express') ;
exports.method = function (params){
...
};
});
2.6 CommonJS 详解
2.6.1 特点
- 所有代码都运行在模块作用域,不会污染全局作用域。
- 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
- 模块加载的顺序,按照其在代码中出现的顺序。
2.6.2 语法
- 暴露模块:
module.exports = value
或exports.xxx = value
- 引入模块:
require(xxx)
,如果是第三方模块,xxx为模块名;如果是自定义模块,xxx为模块文件路径
此处我们有个疑问:CommonJS暴露的模块到底是什么? CommonJS规范规定,每个模块内部,module变量代表当前模块。这个变量是一个对象,它的exports属性(即module.exports)是对外的接口。加载某个模块,其实是加载该模块的module.exports属性。通过require引入的文件,自动挂载到了当前文件的children属性中。挂载过后,被挂载的文件会随着引入文件执行。同时这个被挂载的文件代码可以理解为引入文件的私有代码,与被引入文件无瓜。此时我们可以通过module.children[0].exports拿到被导入文件的各种方法和变量
// example.js
var x = 5;
var addX = function (value) {
return value + x;
};
module.exports.x = x;
module.exports.addX = addX;
上面代码通过module.exports输出变量x和函数addX。
var example = require('./example.js');//如果参数字符串以“./”开头,则表示加载的是一个位于相对路径
console.log(example.x); // 5
console.log(example.addX(1)); // 6
require命令用于加载模块文件。require命令的基本功能是,读入并执行一个JavaScript文件,然后返回该模块的exports对象。如果没有发现指定模块,会报错。
3. 客户端模块化
3.1 客户端模块化和服务端模块化有什么区别?
3.2 为什么 CommonJS 不适用于前端模块?
3.3 AMD(Asynchronous Module Definition) & RequireJS
模块定义
- 独立模块的定义——不依赖其它模块的模块定义
//独立模块定义
define({
method1: function() {}
method2: function() {}
});
//或者
define(function(){
return {
method1: function() {},
method2: function() {},
}
});
- 非独立模块——依赖其他模块的模块定义
define(['math', 'graph'], function(math, graph){
...
});
模块引用
require(['a', 'b'], function(a, b){
a.method();
b.method();
})
3.4 CMD(Common Module Definition) & SeaJS
define(['dep1', 'dep2'], function(dep1, dep2){
return function(){};
})
与AMD模块规范相比,CMD模块更接近于Node对CommonJS规范的定义:
define(factory);
在依赖示例部分,CMD支持动态引入,require、exports和module通过形参传递给模块,在需要依赖模块时,随时调用require( )引入即可,示例如下:
define(function(require, exports, module){
//依赖模块a
var a = require('./a');
//调用模块a的方法
a.method();
})
3.5 UMD(Universal Module Definition) 通用模块规范
如下是codemirror模块lib/codemirror.js模块的定义方式:
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined'
? module.exports = factory() // Node , CommonJS
: typeof define === 'function' && define.amd
? define(factory) //AMD CMD
: (global.CodeMirror = factory()); //模块挂载到全局
}(this, (function () {
...
})
可以看说所谓的兼容模式是将几种常见模块定义方式都兼容处理。
3.6 什么是运行时加载?
我觉得要从两个点上去理解:
- CommonJS 和AMD模块都只能在运行时确定模块之间的依赖关系
- require一个模块的时候,模块会先被执行,并返回一个对象,并且这个对象是整体加载的
//CommonJS 模块
let { basename, dirname, parse } = require('path');
//等价于
let _path = require('path');
let basename = _path.basename, dirname = _path.dirname, parse = _path.parse;
上面代码实质是整体加载path模块,即加载了path所有方法,生成一个对象,然后再从这个对象上面读取3个方法。这种加载就称为"运行时加载"。
3.7 ES6 module
3.7.1 ES6模块使用——export
- 导出一个变量
export var name = 'pengpeng';
- 导出一个函数
export function foo(x, y){}
- 常用导出方式(推荐)
// person.js
const name = 'dingman';
const age = '18';
const addr = '卡尔斯特森林';
export { firstName, lastName, year };
- As用法
const s = 1;
export {
s as t,
s as m,
}
可以利用as将模块输出多次。
3.7.2 ES6模块使用**——import**
- 一般用法
import { name, age } from './person.js';
- As用法
import { name as personName } from './person.js';
import命令具有提升效果,会提升到整个模块的头部,首先执行,如下也不会报错:
getName();
import { getName } from 'person_module';
- 整体模块加载
*
//person.js
export name = 'xixi';
export age = 23;
//逐一加载
import { age, name } from './person.js';
//整体加载
import * as person from './person.js';
console.log(person.name);
console.log(person.age);
ES6模块使用——export default
其实export default,在项目里用的非常多,一般一个Vue组件或者React组件我们都是使用export default命令,需要注意的是使用export default命令时,import是不需要加{}的。而不使用export default时,import是必须加{},示例如下:
//person.js
export function getName() {
...
}
//my_module
import {getName} from './person.js';
-----------------对比---------------------
//person.js
export default function getName(){
...
}
//my_module
import getName from './person.js';
export default其实是导出一个叫做default的变量,所以其后面不能跟变量声明语句。
//错误
export default var a = 1;
值得注意的是我们可以同时使用 export 和 export default
//person.js
export name = 'dingman';
export default function getName(){
...
}
//my_module
import getName, { name } from './person.js';
前面一直提到,CommonJS是运行时加载,ES6是编译时加载,那么两个有什么本质的区别呢?
3.8 ES6 module 与 CommonJS模块加载区别
//ES6模块
import { basename, dirname, parse } from 'path';
//CommonJS模块
let { basename, dirname, parse } = require('path');
以上这种写法与CommonJS的模块加载有什么不同?
- 当require path模块时,其实 CommonJS会将path模块运行一遍,并返回一个对象,并将这个对象缓存起来,这个对象包含path这个模块的所有API。以后无论多少次加载这个模块都是取这个缓存的值,也就是第一次运行的结果,除非手动清除。
- ES6会从path模块只加载3个方法,其他不会加载,这就是编译时加载。ES6可以在编译时就完成模块加载,当ES6遇到import时,不会像CommonJS一样去执行模块,而是生成一个动态的只读引用,当真正需要的时候再到模块里去取值,所以ES6模块是动态引用,并且不会缓存值。
因为CommonJS模块输出的是值的拷贝,所以当模块内值变化时,不会影响到输出的值。基于Node做以下尝试:
//person.js
var age = 18;
module.exports ={
age: age,
addAge: function () {
age++;
}
}
//my_module
var person = require('./person.js');
console.log(person.age);
person.addAge();
console.log(person.age);
//输出结果
18
18
可以看到内部age的变化并不会影响person.age的值,这是因为person.age的值始终是第一次运行时的结果的拷贝。
再看ES6
//person.js
export let age = 18;
export function addAge(){
age++;
}
//my_module
import { age, addAge } from './person.js';
console.log(age);
addAge();
console.log(age);
//输出结果
18
19
可以看到值被修改了,进一步证明了 ES6 的模块化只是对模块文件的引用
3.9 总结
- CommonJS规范主要用于服务端编程,加载模块是同步的,这并不适合在浏览器环境,因为同步意味着阻塞加载,浏览器资源是异步加载的,因此有了AMD CMD解决方案。
- AMD规范在浏览器环境中异步加载模块,而且可以并行加载多个模块。不过,AMD规范开发成本高,代码的阅读和书写比较困难,模块定义方式的语义不顺畅。
- CMD规范与AMD规范很相似,都用于浏览器编程,依赖就近,延迟执行,可以很容易在Node.js中运行。不过,依赖SPM 打包,模块的加载逻辑偏重
- ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。
规范化
自动化
自动化的意义
- 减少人为失误,提高软件质量
- 效率迭代,便捷部署
- 快速交付,便于管理
自动化的实施
- 通过构建工具快速完成构建、打包
- 采取自动化测试
- 采取自动化部署