Vue的依赖注入

依赖注入

定义

在后端领域,依赖注入(DI),控制反转(IoC)等概念是老生常谈的设计模式,它们用于解决一系列的面向对象编程问题。

对于依赖注入而言,需要遵循依赖倒转原则(Dependence Inversion Priciple, DIP):

  • 高层模块不应该依赖低层模块。两个都应该依赖抽象(提取共性,描述特性,模块间解耦)
  • 抽象不应该依赖细节,细节应该依赖抽象(抽象服务类置于底层,细节服务类继承抽象服务类)
  • 针对接口编程,不要针对实现编程(遵循SOLID原则,多态、泛型编程等)

为什么需要依赖注入

前端工程化、模块化的时间相比于后端而言还是比较短的。在此之前,前端的设计方式主要是每个页面都有其对应的JS文件,分别负责这个页面上的交互操作。

然而随着项目的越来越大,这样的文件结构也显得越来越臃肿,特别是由于很多功能的共性没有提取出来,形成模块化,因而产生了很多重复性的代码。这样的结构存在很大的弊端:

  • 一旦需要一些共性方面的修改,就需要逐个文件去检查,修改对应代码,费时费力
  • 重复性的代码增大了项目的体积,代码却不能复用
  • 每次引入一大堆JS文件,需要执行后存入内存方可使用,效率低下

因此,急需一种机制来负责管理这些可复用的底层的操作,单例服务以及共有的特性

传统的依赖注入

传统的依赖注入采取AMDCMDCommanJSUMD等模式。

一个AMD的依赖管理器可以大致理解为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
var ModuleManager = (function () {
// 用于记录模块,作用类似于Map
var _modules = {};

// 根据模块名称,获取对应的模块(服务类实例)
function getModule(name) {
return _modules[name] || {};
}

// 定义一个模块
function define(name, deps, fn) {
if (typeof name !== 'string') {
throw new TypeError('name should be string.');
}
if (_modules[name]) {
throw new Error('This module has been already declared.');
}
if (!deps instanceof Array) {
throw new TypeError('deps should be Array<String>.');
}
if (!fn instanceof Function) {
throw new TypeError('fn should be function.');
}

// 导出它所依赖的模块
var depsModules = [];
for (var i = 0, len = deps.length; i < len; i++) {
depsModules.push(getModule(deps[i]));
}

// 注入依赖,并得到返回的模块,写入Map
_modules[name] = fn.apply(null, depsModules);
return true;
}

// 获取一系列的依赖,从而使用它们进行操作
function require(deps, fn) {
if (!deps instanceof Array) {
throw new TypeError('deps should be Array<String>.');
}
if (!fn instanceof Function) {
throw new TypeError('fn should be function.');
}

// 导出它所依赖的模块
var depsModules = [];
for (var i = 0, len = deps.length; i < len; i++) {
depsModules.push(getModule(deps[i]));
}

// 执行回调
fn.apply(null, depsModules);
}

// 暴露define和require两个方法
return {
define: define,
require: require,
};
})();

通过这段代码可以看出,传统的依赖注入本质上是把依赖存在内存中,然后根据其名称导出,再注入到回调函数中供其运行。

更好的实践

Angular2+内置了依赖注入的机制,利用@Injectable()来修饰一个服务类。

1
2
3
4
5
6
7
8
@Injectable()
export class SomeService {
constructor () {}

dosth () {
// ...
}
}

然后在Component或Module的providers属性中去声明提供的服务类,从而在这一层生成一个实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component({
selector: '...',
template: ``,
// 需要注意,这是一个数组,可以导入数个依赖
providers: [SomeService],
})

// 在Module层导入也是可以的
@NgModule({
imports: [],
exports: [],
declarations: [],
providers: [SomeService],
})

最后,在Component的constructor中,就可以将其作为依赖导入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class TemplateComponent {
constructor(private _someService: SomeService) {
this._someService.dosth();
}
}

// 其实转译为
class TemplateComponent {
// 在构造的时候传入对应的依赖实例
constructor(SomeService) {
this.SomeService = SomeService;
this._someService = this.SomeService;
}
}

对于一个Component中使用的依赖,其寻径过程遵循就近使用的原则:

  • 首先寻找自身Component修饰器中是否有对应provider
  • 自身没有,向上层父Component寻找
  • 父Component逐层向上,一直到其所在的Module层
  • 自身Module层没有,寻找所在Module的上层Component或Module
  • 一直到最高层的App.Component中都没有找到,说明该provider尚未注册,抛出错误

需要注意的是,这些依赖本身就是一个个实例,所以如果其中有一些不能复用的属性(虽然从设计上而言,应当避免这种情况),应当在所操作的Component上新注册一个该服务的实例,从而达到属性不共享。

Vue中实现高复用的依赖注入

1.简单粗暴法,Vue.use()

Vue.use()方法允许将具体的服务类实例挂载到全局对象的原型链上,调用时候直接去全局对象上寻找相应依赖。这种方法虽然不甚美观,而且随着项目越来越大,全局对象也会越来越臃肿,但确实是非常简单实现方式。

2.利用Vuex

利用Vuex.StoreModules属性,可以使用一个独特的DependencyStore来存放依赖。在此基础之上,可以实现模块的分类与分级,从而将其层级化,方便调用。

3.第三方库InversifyJS

InversifyJS的使用方式类似于Angular的依赖注入模式:

创建声明

1
2
3
4
5
6
7
8
9
10
11
12
13
// 声明一系列的接口
export interface Warrior {
fight(): string;
sneak(): string;
}

export interface Weapon {
hit(): string;
}

export interface ThrowableWeapon {
throw(): string;
}
1
2
3
4
5
6
7
8
// 将其打包后导出为一个模块
const TYPES = {
Warrior: Symbol.for("Warrior"),
Weapon: Symbol.for("Weapon"),
ThrowableWeapon: Symbol.for("ThrowableWeapon")
};

export { TYPES };

编写实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 针对先前的声明,创建具体的实现类
@injectable()
class Katana implements Weapon {
public hit() {
return "cut!";
}
}

@injectable()
class Shuriken implements ThrowableWeapon {
public throw() {
return "hit!";
}
}

@injectable()
class Ninja implements Warrior {

private _katana: Weapon;
private _shuriken: ThrowableWeapon;

public constructor(
@inject(TYPES.Weapon) katana: Weapon,
@inject(TYPES.ThrowableWeapon) shuriken: ThrowableWeapon
) {
this._katana = katana;
this._shuriken = shuriken;
}

public fight() { return this._katana.hit(); }
public sneak() { return this._shuriken.throw(); }

}

export { Ninja, Katana, Shuriken };
1
2
3
4
5
6
7
8
// 也可以在服务类中注入其他的服务类
@injectable()
class Ninja implements Warrior {
@inject(TYPES.Weapon) private _katana: Weapon;
@inject(TYPES.ThrowableWeapon) private _shuriken: ThrowableWeapon;
public fight() { return this._katana.hit(); }
public sneak() { return this._shuriken.throw(); }
}

创建一个容器,放置服务类

1
2
3
4
5
6
7
8
9
10
11
12
import { Container } from "inversify";
import { TYPES } from "./types";
import { Warrior, Weapon, ThrowableWeapon } from "./interfaces";
import { Ninja, Katana, Shuriken } from "./entities";

// 在容器中,将依赖进行绑定
const myContainer = new Container();
myContainer.bind<Warrior>(TYPES.Warrior).to(Ninja);
myContainer.bind<Weapon>(TYPES.Weapon).to(Katana);
myContainer.bind<ThrowableWeapon>(TYPES.ThrowableWeapon).to(Shuriken);

export { myContainer };

注入依赖

1
2
3
4
5
6
import { myContainer } from "./inversify.config";
import { TYPES } from "./types";
import { Warrior } from "./interfaces";

// 使用get方法来获取依赖,传入的参数为其键名(Symbol类型)
const ninja = myContainer.get<Warrior>(TYPES.Warrior);

4. 利用TypeScript实现Ultimate Mode

首先声明一个ModuleManager模块来负责依赖管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const ModuleManager = (function () {
// debug模式下可以放在window下进行检查
// 实际生产环境不建议暴露在window下,而是使用内部变量
(window as any).Modules = {};
const modules = window.Modules;

// 使用哈希值来作为键(这个设计可有可无)
function BKDRHash(str: string): number {
const seed: number = 31;
let hash: number = 0,
index: number = 0;
while (str[index]) {
hash = hash * seed + str.charCodeAt(index++);
hash &= 0xFFFFFFFF;
}
return hash;
}

// 对对应模块生成一个单例,注册到modules中,
function addModule(target: any): void {
const hash = BKDRHash(target.toString().replace(/^function\s(.*)?\s.*/, '$1'));
if (!modules[hash]) {
modules[hash] = new target();
}
}

// 从moudules里取出某个模块的单例
function getModule(target: any): any {
const hash = BKDRHash(target.toString().replace(/^function\s(.*)?\s.*/, '$1'));
if (!modules[hash]) {
modules[hash] = new target();
}
return modules[hash];
}

return {
addModule,
getModule,
}
}());

然后需要利用TypeScript提供的Decorator修饰器,来对模块与注入的依赖进行管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* 在需要注入的模块类上添加该修饰器,可以生成一个单例,纳入ModuleManager的管理
* @returns {(target: any) => void}
* @constructor
*/
const Injectable = (): ClassDecorator => (target: Function): any => {
ModuleManager.addModule(target);
};

// 模块注册示例
@Injectable()
export class SomeModule {}

/**
* 改造被注入的类中,某个属性的描述器,从而使其承载被注入的依赖
* @param module
* @returns {PropertyDecorator}
* @constructor
*/
const Provided = (module: any): PropertyDecorator => (target: Object, name: string | symbol): any => {
return {
configurable: true,
enumerable: false,
// 不允许直接对其进行set,应当通过模块暴露的public方法去操作
set: () => {},
// getter设置为返回这个module的单例
get: () => {
return ModuleManager.getModule(module);
},
};
};

// 依赖注入示例
export class Example {
@Provided(SomeModule) private _someModule: SomeModule;
}

图片懒加载

懒加载原理

浏览器在页面呈现之前,会预先获取能够识别的.css.js以及<img>src所指向的图片等资源。如果在浏览器检查资源文件时,无法初次辨认<img>中所指向的资源,那么页面就会在图片资源加载之前就呈现。

懒加载实现

1.暴力法

遵循上述这个原理,可以将懒加载分为以下几步:

  1. <img>src属性全都置为空。
  2. 使用一个data-src="${url}"的属性来记录图片真实的。
  3. 在页面load事件之后,从data-src中读出真实的src(element.dataset.src)
  4. 将原本<img>src进行恢复。

该方法的好处是直接有效,而且对页面元素的修改可以保持最小,方便实现功能与功能之间的正交化。基于dom节点操作的方式也很适合基于jQuery的传统网页开发。

2.改进

  1. 预先使用较小尺寸的缩略图。
  2. 图片预加载。
  3. 使用MVVM框架的前提下,可以直接使用属性绑定的形式,将src绑定url字符串,让MVVM框架来恢复这个src
2.1 图片预加载
  1. CSS:利用height: 0; width: 0;的元素或伪元素,添加background-image: url()的形式进行预加载。
  2. JS:var image = new Image(); image.src = url;实现图片的自动获取。可以使用load事件来回复元素的src
  3. CSS + JS:预定义没有宽度高度,具备background-image的class,使用JS来切换class。

3.Progressive Image Loading

如果有浏览过知乎,或是Medium这样的网站,可以发现这些页面上的图片一开始都是一张很模糊的预览图,然后当用户滚动屏幕的时候,检查图片进入视窗后,才会去真正加载它。

首先我们需要预先定义一种HTML结构,来承载原本的<img>标签。

1
2
3
4
5
<div class="progressive">
<img class="preview" src="${url for thumbnail}"
data-height="${height}" data-width="${width}"
data-actual="${real url}">
</div>

然后对应几个css类,来实现切换的动画效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
.progressive {
overflow: hidden; /* 为了img在scale的时候不溢出 */
outline: none;
}

.progressive>img {
display: block;
width: 100%;
max-width: none;
height: auto;
border: 0 none;
will-change: opacity, filter, transform;
}

/* 预览状态下的效果 */
.progressive>img.preview {
opacity: 0.25;
filter: blur(2vw); /* 预览图需要模糊处理来优化视觉效果 */
transform: scale(1.05); /* 预先放大 */
}

/* 加载完后的效果 */
.progressive>img.reveal {
opacity: 1;
filter: blur(0); /* 恢复清晰 */
transform: scale(1); /* 加载后恢复比例 */
transition: transform 1s ease-out, opacity 1s linear;
}

最后注册事件,来对尚未恢复的<img>内容进行检查,从而判断其是否进入视窗内。对进入视窗的进行加载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
;
(function () {
'use strict';

// 初始图片大小
function initSize() {
var item,
itemInfo;
for (var i = 0; i < pictures.length; i++) {
item = pictures[i];
itemInfo = item.dataset;
item.style.height = itemInfo.height + 'px';
item.style.width = itemInfo.width + 'px';
}
}
// 修改初始大小
window.addEventListener('load', initSize, false);

/**
* 检查图片是否进入视窗
*/
function inView() {
if (pictures.length) {
requestAnimationFrame(function () {
var windowTop = window.pageYOffset,
windowBottom = windowTop + window.innerHeight;
var imageInfo,
pictureTop,
pictureBottom;

// 检查每幅图片
for (var i = 0; i < pictures.length; i++) {
imageInfo = pictures[i].getBoundingClientRect();
pictureTop = windowTop + imageInfo.top;
pictureBottom = pictureTop + imageInfo.height;

// 如果进入视窗,就执行加载
if (windowTop < pictureBottom && windowBottom > pictureTop) {
loadFullImage(pictures[i]);
}
}

// 更新需要恢复的照片(放止infinite load加载更多)
pictures = [].slice.call(document.querySelectorAll('.progressive .preview'));

});
}
}

/**
* 恢复指定图片
* @param {Element} item
*/
function loadFullImage(item) {
var href = item && (item.getAttribute('data-actual') || item.href);
if (!href) return;

// 预加载,放入缓存
var img = new Image();
img.src = href;
img.onload = function () {
requestAnimationFrame(function () {
// 从缓存中去读取
item.src = href;
// 切换class,恢复视觉效果
item.classList.remove('preview');
item.classList.add('reveal');
});
}
}

/**
* 每0.5s执行一次
*/
function throttledLoad() {
throttler = throttler || setTimeout(function () {
throttler = null;
inView();
}, 500)
}


var pictures = [].slice.call(document.querySelectorAll('.progressive .preview')),
throttler = null;

// 初始检查
inView();

window.addEventListener('scroll', throttledLoad, false);
window.addEventListener('resize', throttledLoad, false);

})();

一个可以改进的的地方是,以上情况仅考虑了垂直方向上的操作,对于水平方向的操作可以进行类似的补充。

移动端开发拾遗

事件

click点击

移动端上某些浏览器(某os的默认浏览器)在一些版本下,会发生click失效的情况,特别是使用基于事件冒泡机制的事件委托行为时。
解决方法:

  • css中加上cursor: pointer;
  • 停止委托(因噎废食)
  • 给委托对象加上onclick="void(0)"
  • 改用button和a等标签

:hover伪类

还是移动端上某些浏览器(某os的默认浏览器),对于:hover伪类没有效果,需要使用<a>来hack,或者干脆利用TouchEvent来进行class的切换。

移动端的拖拽

Web端可以使用pointerdown,pointermove,pointerup三个事件的组合(原型链:PointerEvent extends MouseEvent extends UIEvent extends Event ),来实现元素的拖拽效果。
移动端对应的是touchstart,touchmove,touchend这个组合(原型链:TouchEvent extends UIEvent extends Event)。需要注意的是,要获取它们的位置参数,需要访问event.touches这个Array(基于多指触控),通常调取其中第一个touch点,进行演算。

以PointerEvent为例拖拽的具体实现为:

  1. 在pointerdown事件中,记录点击位置的初始点位,并且将对应的状态置为可响应pointermove事件
  2. 在pointerMove事件中,对比当前点击位置与初始点位的差别,使用定位或transfrom等手段修改元素的呈现位置。如有需要,应当将每次move的位置也进行记录(比如TouchEvent中,touchend的touches为空Array)。
  3. 在pointerup事件中,获取最终的位置,可以与初始点位比对,决定最终的操作。之后将对应的状态置为不可响应pointermove事件

布局

流式布局

流式布局(Liquid、Fluid)的特点 是页面元素的宽度按照屏幕分辨率进行适配调整,但整体布局不变。代表作栅栏系统(网格系统)。流式布局中大规模使用了百分比vh,vw来控制尺寸。用于

优势:适配不同大小屏幕的呈现效果。

劣势:不能实现web端与手机端的同时优化。

在3G时代刚刚兴起的时候,很多网站都是采用web端与移动端分离(一套web端,一套m.domain.com的移动端)策略,并且配合流式布局来适配不同屏幕大小的同种类型设备

自适应布局

自适应布局的特点是分别为不同的屏幕分辨率定义布局,即创建多个静态布局,每个静态布局对应一个屏幕分辨率范围。改变屏幕分辨率可以切换不同的静态局部(页面元素位置发生改变),但在每个静态布局中,页面元素不随窗口大小的调整发生变化。可以把自适应布局看作是静态布局的一个系列。

屏幕分辨率变化时,页面里面元素的位置会变化而大小不会变化。

使用 @media 媒体查询给不同尺寸和介质的设备切换不同的样式。在优秀的响应范围设计下可以给适配范围内的设备最好的体验,在同一个设备下实际还是固定的布局

响应式布局

流式布局与自适应布局的组合使用。