前言

上周抽空看了看Vue的源码,设计的精巧让我这个鶸一时吸收不来。如果想写篇既大又全的文章,一劳永逸地介绍Vue2.0的源码,以我的水平显然是做不到的。于是就只取一瓢饮地,简单记录下Vue2.0在响应式原理上的设计。其他的部分等我功力深厚了(其实就是懒)再做总结吧。

基础

这一部分是Vue响应式原理的基础。包含Observer类,Watcher类,Dep类,事件订阅模式,还有最关键的Object.defineProperty方法。

Object.defineProperty

Vue实现数据绑定的方式和其他的MVVM同侪不同,React和backbone(这货不是MVVM)采用的是典型的发布订阅模式,Angular则采用的脏值检测。

Vue使用了更为隐蔽和magical的Object.defineProperty设置对象访问器属性(这也意味着Vue只支持到IE9+)。

把一个普通 Javascript 对象传给 Vue 实例的 data 选项,Vue 将遍历此对象所有的属性,并使用 Object.defineProperty 把这些属性全部转为 getter/setter。Object.defineProperty 是仅 ES5 支持,且无法 shim 的特性,这也就是为什么 Vue 不支持 IE8 以及更低版本浏览器的原因。

Object.defineProperty()可以定义对象的访问器属性,关于访问器属性的更多介绍可以看这里。其中getset方法分别用来指定属性的getter和setter。

1
2
3
4
5
6
7
8
9
10
11
12
13
var person = {
_name: '', //`_`表示只能通过方法访问
nickname: '酱'
};
Object.defineProperty(person, 'name', function () {
get: function () {
return this._name;
},
set: function (newValue) {
this.nickname = newValue + '酱';
this._name = newValue;
}
});

通过getter和setter实现数据劫持是Vue数据绑定的基础。

发布订阅

发布订阅是JavaScript中事件机制的实现方式,也是JavaScript异步编程的实现方式之一。

发布订阅模式中的角色主要有发布者、事件对象、订阅者。发布者和事件对象是一对多的关系,事件对象和订阅者又是一对多的关系。当发布者的状态改变触发事件对象时,相关的订阅者就会收到通知。实现起来就像下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var EventUtil = {
// 全局事件管理
var events = {},
// 注册事件
on = function (type, handler) {
if (events[type]) {
events[type].push(handler);
} else {
events[type] = [handler];
}
},
// 触发事件
emit = function (type) {
if (!events[type]) return;
for (var i = 0, len = events[type].length; i < len; i++) {
events[type][i];
}
};
};

Vue实现视图自动更新的原理也是如此,当然细节上就复杂多了。

Observer,Watcher,Dep

这三个是相辅相成实现Vue数据绑定的组件。

Observer

Vue在组件(Component)初始化过程中,会将数据对象封装为Observer对象,便于监听数据的改变,并绑定依赖在上面。我们来看下源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
constructor (value: any) {
this.value = value
this.dep = new Dep() //一个 Dep对象实例,Watcher 和 Observer 之间的纽带
this.vmCount = 0
def(value, '__ob__', this) //把自身 this 添加到 value 的 __ob__ 属性上
if (Array.isArray(value)) { //对 value 的类型进行判断
const augment = hasProto
? protoAugment
: copyAugment
augment(value, arrayMethods, arrayKeys) // 数组增强方法
this.observeArray(value) //如果是数组则观察数组
} else {
this.walk(value) //否则观察单个元素。
}
}

Observer对象储存在 ob 这个属性,这个属性保存了 Observer 对象自己本身。对象在转化为 Observer 对象的过程中是一个递归的过程,对象的子元素如果是对象或数组的话,也会转化为 Observer 对象。

由于JavaScript本身的原因,Vue不能监测数组的变化,Vue采用的折中方法是增强数组的原生方法push, pop, shift, unshift, splice, sort, reverse,以及建议使用者通过Vue.set的方式显示调用。通过其他方式对数组进行的修改将无法被监听到。

Watcher

Vue中,Watcher和模板渲染紧密相连,它将Observer发生的改变反映到模板内容上。它关键部分的源码是这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
constructor (
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: Object
) {
this.vm = vm
vm._watchers.push(this)// 将当前 Watcher 类推送到对应的 Vue 实例中
......
// parse expression for getter
if (typeof expOrFn === 'function') {
// 如果是函数,相当于指定了当前订阅者获取数据的方法,每次订阅者通过这个方法获取数据然后与之前的值进行对比
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)// 否则的话将表达式解析为可执行的函数
......
}
this.value = this.lazy
? undefined
: this.get() //如果 lazy 不为 true,则执行 get 函数进行依赖收集
}

其中输入参数vm是监听的组件,expOrFn最终将交给getter属性,cb是更新时的回调函数。最后一句中的this.get()完成了依赖的收集工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* Evaluate the getter, and re-collect dependencies.
*/
get () {
pushTarget(this) // 设置全局变量 Dep.target,将 Watcher 保存在这个全局变量中
const value = this.getter.call(this.vm, this.vm) // 调用 getter 函数,进入 get 方法进行依赖收集操作
// "touch" every property so they are all tracked as
// dependencies for deep watching
if (this.deep) {
traverse(value)
}
popTarget() // 将全局变量 Dep.target 置为 null
this.cleanupDeps()
return value
}

第一句的pushTarget(this)设置了Dep.target,getter函数正是通过Dep.target是否为null,判断当前处于依赖收集阶段还是普通数据读取。后面的两句去touch``expOrFn涉及到的每个数据项。从而将expOrFn的依赖收集起来。最后将dep中的内容清空,为下次收集依赖做准备。

Dep

Dep类用于连接Watcher类和Observer类,每个Observer对象中都有一个Dep实例,其中存储了订阅者Watcher。源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//...
constructor () {
this.id = uid++
this.subs = [] //存储 Watcher 实例的数组
}
//...
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}

notify () {
// stablize the subscriber list first
const subs = this.subs.slice()
for (let i = 0, l = subs.length; i < l; i++) { //遍历 Watcher 列表,调用 update 方法进行更新操作
subs[i].update()
}
}

Dep类比较简单,主要是一个存储Watcher实例的数组this.subsdepend()方法用于向Watcher对象中添加这个Dep。notify()方法将遍历Watcher列表,通知订阅者更新视图。

实现

下面从源码角度上看看Vue实现数据绑定的设计。

目录结构

Vue核心部分的代码放在src目录。路径下还有下面这些子文件夹:

  • entries 入口文件,根据编译环境的不同,更改一些配置
  • compiler 编译模板,render函数的实现
  • core 关键部分代码
  • core/observer 响应式设计中的Observer对象实现
  • core/vdom 虚拟DOM,diff算法,patch函数实现
  • core/instance 组件实例生命周期实现,组件初始化入口
  • core/components 全局组件
  • core/global-api 全局API
  • server 服务端渲染
  • platform 平台特定代码,分为webweex
  • sfc 处理单文件组件 解析.vue文件
  • share 工具函数

生命周期

关于Vue的生命周期,这里假设你已经熟悉,就不做介绍了。了解它也将帮助你了解Vue的工作流程。

源码的入口从下面一行代码开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
function Vue (options) {
if (process.env.NODE_ENV !== 'production' &&
!(this instanceof Vue)) {
warn('Vue is a constructor and should be called with the `new` keyword')
}
this._init(options) //开始初始化步骤
}

initMixin(Vue) //插入初始化代码
stateMixin(Vue) //插入数据绑定代码
eventsMixin(Vue) //插入事件相关代码
lifecycleMixin(Vue) //插入生命周期代码
renderMixin(Vue) //插入模板渲染代码

文件为src/core/instance/index.js,关键在于最后一句,通过调用init.js中定义的_init(options)方法初始化Vue实例。这个方法是在下面的initMixin(Vue)中导入的。这种mixin的方式不同于Vue1.x版本,更具模块化适合拓展(同时也增加了寻找代码的难度)。

初始化相关的主要代码如下;

1
2
3
4
5
6
7
8
9
10
11
12
13
function initMixin (Vue: Class<Component>) {
Vue.prototype._init = function (options?: Object) {
//...
initLifecycle(vm) //vm 的生命周期相关变量初始化
initEvents(vm) // vm 的事件监控初始化
initRender(vm) // 模板解析
callHook(vm, 'beforeCreate')
initState(vm) //vm 的状态初始化,prop/data/computed/method/watch 都在这里完成初始化
callHook(vm, 'created')
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}

initLifecycle主要是初始化vm实例上的一些参数;initEvents是事件监控的初始化;initRender是模板解析,2.0的版本中这一块有很大的改动,1.0的版本中Vue使用的是DocumentFragment来进行模板解析,而 2.0 中作者采用的John Resig的HTML Parser将模板解析成可直接执行的render函数。initState是数据绑定的主战场,我们下一节会详细讲到。callHook执行生命周期的钩子函数。

initState

在初始化中,initState函数承担了数据绑定中的最主要的脏活累活。它的源码像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
export function initState (vm: Component) {
vm._watchers = [] //新建一个订阅者列表
const opts = vm.$options
if (opts.props) initProps(vm, opts.props) // 初始化 Props,与 initData 差不多
if (opts.methods) initMethods(vm, opts.methods) // 初始化 Methods,Methods 的初始化比较简单,就是作用域的重新绑定。
if (opts.data) {
initData(vm) // 初始化 Data,响应式关键步
} else {
observe(vm._data = {}, true /* asRootData */) //如果没有 data,则观察一个空对象
}
if (opts.computed) initComputed(vm, opts.computed)// 初始化 computed,这部分会涉及 Watcher 类以及依赖收集,computed 其实本身也是一种特殊的 Watcher
if (opts.watch) initWatch(vm, opts.watch)// 初始化 watch,这部分会涉及 Watcher 类以及依赖收集
}

可以看到,initState将工作拆解成观察props, data, methods, computed, watch几个关键部分。

initData

initData方法为例,它是如何使用上面提到的Observer, Dep, Watcher类的呢,我们看看源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function initData (vm: Component) {
let data = vm.$options.data
data = vm._data = typeof data === 'function'
? data.call(vm)
: data || {}
if (!isPlainObject(data)) {// 保证data必须为纯对象
......
}
// proxy data on instance
const keys = Object.keys(data)
const props = vm.$options.props
let i = keys.length
while (i--) {
if (props && hasOwn(props, keys[i])) {// 是props,则不代理
...... //如果和 props 里面的变量重了,则抛出 Warning
} else {// 否则将属性代理的 vm 上,这样就可以通过 vm.xx 访问到 vm._data.xx
proxy(vm, keys[i]) //proxy方法遍历 data 的 key,把 data 上的属性代理到 vm 实例上
}
}
// observe data
observe(data, true /* asRootData */) //关键一步,observe(data, this)方法来对 data 做监控
}

可以看到,这个函数做了下面的工作:

  • 保证data为纯对象
  • 检查是否与props中属性有重复
  • 进行数据代理,便于我们直接通过vm.xxx的形式访问原本位于vm._data.xxx的属性。
  • 调用observe方法对data进行包装,使之具有响应式的特点。

那我们看看observe方法是怎么写的吧

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
/**
* Attempt to create an observer instance for a value,
* returns the new observer if successfully observed,
* or the existing observer if the value already has one.
* 返回一个 Observer 对象
*/
export function observe (value: any, asRootData: ?boolean): Observer | void {
if (!isObject(value)) { //如果不是对象和数组则不监控,直接返回
return
}
let ob: Observer | void
//判断 value 是否已经添加了 __ob__ 属性,并且属性值是 Observer 对象的实例。避免重复引用导致的死循环
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) { //如果是就直接用
ob = value.__ob__
} else if (
observerState.shouldConvert && //只有 root instance props 需要创建 Observer 对象
!isServerRendering() && //不是服务端渲染
(Array.isArray(value) || isPlainObject(value)) && //数组或者普通对象
Object.isExtensible(value) && //可扩展对象
!value._isVue // 非 Vue 组件
) {
ob = new Observer(value) //关键步!在 value 满足上述条件的情况下创建一个 Observer 对象
}
if (asRootData && ob) {
ob.vmCount++
}
return ob // 返回一个 Observer 对象
}

observe方法主要就是判断value是否满足一些预设条件,并将这个对象转化为Observer对象。

关于Observer类我们上面已经提到,它的构造函数做了下面几个工作:

  • 首先创建了一个Dep对象实例;
  • 然后把自身this添加到value的__ob__属性上;
  • 最后对value的类型进行判断,如果是数组则观察数组,否则观察单个元素(要理解这一步是个递归过程,即value的元素如果符合条件也需要转化为Observer对象)。

不论是基础类型还是数组或对象,最终都会走入到walk方法,方法定义在src/core/observer/index.js中。

1
2
3
4
5
6
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i], obj[keys[i]]) //遍历对象,结合defineReactive方法地柜将属性转化为getter和setter
}
}

defineReactive

在经过一系列的准备工作和铺垫后,终于可以接触到数据绑定最核心部分的defineReactive函数。方法也定义在src/core/observer/index.js中,源码如下:

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
/**
* Define a reactive property on an Object.
*/
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: Function
) {
const dep = new Dep() //每个对象都会有一个 Dep 实例,用来保存依赖 (Watcher 对象)
......
let childOb = observe(val) //结合 observe 函数进行将对象的对象也变成监控对象
// 最重点、基石、核心的部分:通过调用 Object.defineProperty 给 data 的每个属性添加 getter 和 setter 方法。
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
// 依赖收集的重要步骤
if (Dep.target) {//如果存在Dep.target这个全局变量不为空,表示是在新建Watcher的时候调用的,代码已经保证
dep.depend() // 依赖收集
if (childOb) {
childOb.dep.depend() // 处理好子元素的依赖 watcher
}
if (Array.isArray(value)) { // 如果是数组,进一步处理
dependArray(value)
}
}
return value
},
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
......
childOb = observe(newVal) // 对新数据重新 observe,更新数据的依赖关系
dep.notify() // 通知dep进行数据更新,这个方法在前面的 Dep 类中讲过
}
})
}

defineReactive是对Object.defineProperty方法的包装,结合observe方法对数据项进行深入遍历,最终将所有的属性就转化为getter和setter。其中对于Dep的处理用于收集依赖data的Watcher对象。

依赖收集

data的依赖收集是在getter函数中完成的。Observer和Dep是一对一的关系,Dep用来存储依赖Observer的Watcher。Dep和Watcher是多对多的关系,一个Dep中存储了若干Watcher,一个Watcher可能同时依赖于多个Observer。

可以看到Dep是连接Observer(生产者)和Watcher(消费者)的关键纽带。Watcher通过getter函数建立起和Observer中Dep的关联。在Observer的setter函数中会触发dep.notify()方法,根据上文对该方法的讲解,它实际上对数组中每个Watcher执行了update方法。在方法中根据是否同步去执行run方法,这个方法中通过源码可以看到实际上正是通过const value = this.get()获取最新的value。

1
2
3
4
5
6
7
8
9
10
update () {
/* istanbul ignore else */
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}
  1. 模板编译过程中的指令和数据绑定都会生成 Watcher实例,watch函数中的对象也会生成 Watcher实例,在实例化的过程中,会调用watcher.js中的get函数touch这个Watcher的表达式或函数涉及的所有属性;
  2. touch开始之前Watcher会设置Dep的静态属性Dep.target指向其自身,然后开始依赖收集;
  3. touch属性的过程中,属性的getter函数会被访问;
  4. 属性gette 函数中会判断Dep.target(target中保存的是第2步中设置的Watcher实例)是否存在,若存在则将 getter函数所在的Observer 实例的Dep实例保存到Watcher的列表中,并在此Dep实例中添加Watcher为订阅者;
  5. 重复上述过程直至Watcher的表达式或函数涉及的所有属性均touch结束(即表达式或函数中所有的数据的getter函数都已被触发),Dep.target被置为null,依赖收集完成;

总结

上面尝试从源码角度对Vue2.0的响应式设计做了浅析。总结一下就是下面几点:

  • 在生命周期的initState方法中对data, prop, method, computed, watch方法中的数据进行劫持,通过defineReactiveobserve将之转换为Observer对象
  • initRender函数中解析模板,新建Watcher对象通过Dep对象和对应数据建立了依赖关系,通过Dep.target这个全局对象判断是否是依赖收集阶段。
  • 数据变化时,通过setter函数中的dep.notify方法执行Watcher的update方法更新视图

参考

Vue2.0 源码阅读:响应式原理

本文启发于阮一峰老师的深入掌握 ECMAScript 6 异步编程

下篇传送门JavaScript中的异步编程 下

JavaScript是一门单线程的语言。这样的设计减少了线程间同步和统筹的代价。但是,这也意味着,同一时刻只能完成一项工作,不能“多面手”。多个任务出现时,后一个任务需要等待前一个任务完成才可执行。

当一项任务耗时较长时,后继者往往需要等待很久。直观体现在浏览器白屏,假死等。异步执行模式便因此而生。

异步和回调

异步模式区分于同步模式,任务的执行顺序和排列顺序并不完全一致。在前一个任务开始执行时,将之交给环境中其他辅助线程处理,之后立即执行下一个任务。当任务完成后,再以回调的形式执行回调函数。这种执行方式实际上正是Event Loop 的体现。

阮老师博客中提到的回调函数事件驱动发布订阅都能很直观的看到回调的概念。

回调函数

这是最常见的实现异步编程的方式。它的大体形式是将回调函数作为输入参数传入到需要异步完成的任务中。在任务体函数内利用全局环境下内建的异步函数实现异步的目的。

大概是这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//这里是一个需要异步完成的函数asy和回调函数cal
asy(), cal();
//它们的定义和使用像下面这样
function asy (callback) {
setTimeout(function () {
//异步工作代码
callback();
}, 0);
}

function cal () {
console.log('Hello!'); //或其它什么
}

asy(cal);

asy()函数会在完成自己工作后自动调用回调函数cal()。而这个过程是异步完成的。

事件驱动

严格来说,事件驱动是一种异步编程思想。通过事件的触发来执行特定任务的代码。使得代码的执行并不按照顺序来。

使用时,最典型的用法就是DOM2级事件绑定。为DOM元素绑定监听函数,在事件触发时,执行特定代码。推广开来,可以实现JavaScript中自定义事件的监听。

大概是这样

1
2
target.on('someevent', handler); // 注册事件
target.emit('someevent'); //触发事件

在实现时,target实现了(或继承)类似于下图中EventUtil类的定义。内部维护一个对象,存储事件和回调函数数组的键值对对象。在使用on,emit时,向管理器中写入事件和读取事件对应的回调数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var EventUtil = {
// 全局事件管理
var events = {},
// 注册事件
on = function (type, handler) {
if (events[type]) {
events[type].push(handler);
} else {
events[type] = [handler];
}
},
// 触发事件
emit = function (type) {
if (!events[type]) return;
for (var i = 0, len = events[type].length; i < len; i++) {
events[type][i];
}
};
};

发布订阅模式

上面这种事件驱动的方式在React和Vue等MVVM框架中经常用来在组件间传递信息。当组件关系复杂时,发布订阅模式会更有利于管理信息和将信息集中化管理。

也就是ReduxVuex所做的事情。任务状态改变时,向中心传递信号,其他订阅这个信号的任务函数都会受到这个信号。

promise

异步回调好是好,很好理解。但是处理错误的“回调地狱”也为人诟病。

1
2
3
4
5
6
7
8
9
10
11
12
13
function async(request, callback) {
// Do something.
asyncA(request, function (data) {
// Do something
asyncB(request, function (data) {
// Do something
asyncC(request, function (data) {
// Do something
callback(data);
});
});
});
}

这种在回调中嵌套其他异步函数的场景下,错误的捕获变得异常头痛。代码也会变得难以阅读和维护。

ES6中的Promise对象优雅地解决了回调地狱的问题。它由CommonJS工作组提出,通过thencatch方法指定回调函数和错误的捕获函数,同时返回一个Promise对象

它的使用方法像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var promise = new Promise(function (resolve, reject){
if (/* 异步操作成功 */) {
resolve(val);
} else {
reject(err)
}
});

promise.then(function (val) {
//success handler
}, function (val) {
//failure handler
});

// 或是
promise.then(function (val) {
//success handler
}).catch(err) {
//error handler
}

// 处理一批次的异步任务
var p = Promise.all([p1, p2, p3]),
q = Promise.race([p1, p2, p3]);

在实现上,Promise其实和事件订阅模式类似。

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
//constructor
var Promise = function() {
this.callbacks = [];
}

Promise.prototype = {
construct: Promise,
resolve: function(result) {
this.complete("resolve", result);
},

reject: function(result) {
this.complete("reject", result);
},

complete: function(type, result) {
while (this.callbacks[0]) {
this.callbacks.shift()[type](result);
}
},

then: function(successHandler, failedHandler) {
this.callbacks.push({
resolve: successHandler,
reject: failedHandler
});

return this;
}
}

Promise在回调函数较少时,then方法的链式调用无伤大雅。当出现较多异步回调场景下,如异步陆续加载100张图片,then方法的使用仍显得不那么自然。

有自然的使用方法么?答案是有的。下篇中介绍的Generator和async将实现异步编程的更高境界。

参考

异步编程 promise模式 的简单实现

本文启发于阮一峰老师的深入掌握 ECMAScript 6 异步编程

上篇传送门JavaScript中的异步编程 上

上篇说到了使用回调的思路解决JavaScript中异步编程的难题。可不论是显式的指定回调函数,通过事件绑定响应还是通过事件订阅、promise.then,都和逃不出回调的思路。写起来仍不够自然,且在批次回调任务时难以解决。

有没有办法能使我们像平时写同步代码那样,来书写异步代码呢?ES6出现后,Generator对象给了我们这个机会。

生成器函数

提到生成器函数前,需要提到协程(coroutine)这个概念。协程是轻量级用户态的线程。用户可以手动控制协程的暂停和继续,配合线程实现异步任务。协程间通过yield方式切换执行权,并交换信息。就像下面这样:

1
2
3
4
function asyncFunc() {
// 执行someFunc后交出执行权
var t = yield someFunc();
}

协程在遇到yield关键字时交出自己的执行权,直到执行权返回。这里someFunc方法可以是一个异步操作。

ES6中协程体现在Generator函数中。函数在function关键字后添加星号*以示和普通函数的区分。Generator函数是可以通过yield暂停执行的。比如:

1
2
3
4
5
6
7
8
9
function* gen () {
for (let i = 0; i < 10; i++) {
yield i;
}
}

var g = gen();
g.next(); // 1
g.next(); // 2

Generator函数的调用通过next方法完成。每次调用后会将函数流程移动到下一个yield语句处。yield的返回包含两个属性valuedone。前者代表yield的返回值,后者代表生成器函数是否已经执行完毕。

同时,每次调用next方法时,可以输入参数作为上个异步任务的返回值。调用throw方法可以向生成器函数内抛出错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
function* gen () {
var x = yield 1;
try {
yield x;
} catch (e) {
console.log(e);
}
}

var g = gen();
g.next(); // 1
g.next(2); // 2
g.throw('some error') // 'some error'

使用Generator函数封装一个异步操作,再通过执行器管理函数内部的异步流程。通过这种方式,在Generator函数中可以很方便地书写异步操作。例如,利用fetch API发起一次跨域请求。

1
2
3
4
5
6
7
8
9
10
11
function* gen() {
var text = yield fetch('http://www.example.org/data.json');
console.log(text);
}

var g = gen();
g.next().value.then(function (data) {
return data.json();
}).then(function (data) {
g.next(data);
});

fetch API返回一个promise对象,通过为之指定then,处理fetch成功后的返回值。

co和koa

我们上面提到了使用Generator还缺少的一样东西——执行器。使用Generator函数在其中通过yield返回Promise,但是外层还是需要在promise的then方法中书写g.next(data)来通知协程继续操作。co函数库帮助我们完成了执行器的工作

以回调函数中完成读文件操作为例(注意:其中的readFile先被改写成返回thunk函数的格式,即只接收callback作为唯一的输入参数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var fs = require('fs');
var co = require('co');

function readFile(path) {
return function (callback) {
fs.readFile(path, {encoding: 'utf-8'}, cb);
}
}

function* gen() {
var d1 = yield readFile('file1.js');
console.log(d1);
var d2 = yield readFile('file2.js');
console.log(d2);
}

co(gen);

上面的代码里,为co函数传入Generator函数,就会自动依次执行其中的异步任务,并在返回一个Promise对象。因此,可以给co函数通过then的方式添加回调函数。

co

co的代码并不复杂,核心代码只有数十行。摘录如下:

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
function co(gen) {
var ctx = this;
var args = slice.call(arguments, 1);

return new Promise(function(resolve, reject) {
if (typeof gen === 'function') gen = gen.apply(ctx, args);
if (!gen || typeof gen.next !== 'function') return resolve(gen);

onFulfilled();

function onFulfilled(res) {
var ret;
try {
ret = gen.next(res);
} catch (e) {
return reject(e);
}
next(ret);
}

function onRejected(err) {
var ret;
try {
ret = gen.throw(err);
} catch (e) {
return reject(e);
}
next(ret);
}

function next(ret) {
if (ret.done) return resolve(ret.value);
var value = toPromise.call(ctx, ret.value);
if (value && isPromise(value)) return value.then(onFulfilled, onRejected);
return onRejected(new TypeError('You may only yield a function, promise, generator, array, or object, '
+ 'but the following object was passed: "' + String(ret.value) + '"'));
}
});
}

在看co的代码前,我们不妨先想一下它的原理。Generator 函数只是一个异步操作的容器,它的流程和控制是由外部机制完成的。而thunk函数(这个在下面介绍)和Promise对象恰恰可以方便得在回调函数和then方法中交还执行权给Generator函数

这么来看就简单了,co函数库针对thunk函数和Promise对象封装了执行器。以比较好理解的Promise对象为例(co在内部也会将thunk函数转为Promise对象)。

首先将readFile的thunk函数转为Promise对象。其中ctx绑定到co函数体内。(co函数库的thunkToPromise函数)

1
2
3
4
5
6
7
8
9
var readFile = function (path) {
var ctx = this;
return new Promise (function (resolve, reject){
readFile.call(ctx, function (err, data){
if (err) reject(err);
resolve(data);
});
});
}

之后同样使用上节中的生成器函数gen(),并手动执行下一步操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function* gen() {
var d1 = yield readFile('file1.js');
console.log(d1);
var d2 = yield readFile('file2.js');
console.log(d2);
}

var g = gen();

g.next().value.then(function (data) {
g.next(data).value.then(function (data) {
g.next(data);
});
})

发现规律了么?自动执行器实际上就是在g.done == false时,不断地在then方法中嵌套添加回调函数。结果呼之欲出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 执行器
function run (gen) {
var g = gen;

function next(data) {
var res = g.next(data);
if (res.done) return res.value;
res.value.then(function (data) {
next(data);
})
}

next();
}

run(gen);

每执行一次next,检查done的状态,若未结束则在then方法继续指定next方法,等待下一次返回结果。这也是co函数库的基本原理。

源码

理解了原理后,回头看co的源码,就比较好理解了。前6行对传入的gen检测是否为Generator类型。onFulfilled函数和onRejected函数对Generator函数原有的nextthrow函数进行了封装,便于错误捕获和处理。

next方法中,主要做了下面的4步微小的工作:

  1. 查是否已经到Generator函数的最后一步,如果是则返回
  2. 确保每次yield返回值都是Promise对象
  3. 通过then方法,为返回值添加回调函数,并在回调中再次调用自身
  4. 对于类型不合适的gen,将状态修改为rejected

co能接收的yield返回值类型是有限的(尽管Generator函数中的yield后不限制返回值类型),有thunk函数,array,object,Promise对象。其中array和object使co可以胜任并发的操作,即可以在yield中返回多个异步操作任务。

koa

koa是建立在generator和co之上的中间件框架,由Express开发人员打造。它通过组合不同的生成器函数,避免了繁杂易出错的回调函数嵌套。koa中没有绑定任何中间件,仅仅提供了一个轻量级函数库。

Koa 中间件以一种更加传统的方式级联起来, 跟你在其他系统或工具中碰到的方式非常相似。 然而在以往的 Node 开发中, 级联是通过回调实现的, 想要开发用户友好的代码是非常困难的, Koa 借助 generators 实现了真正的中间件架构, 与 Connect 实现中间件的方法相对比,Koa 的做法不是简单的将控制权依次移交给一个又一个的方法直到某个结束,Koa 执行代码的方式有点像回形针,用户请求通过中间件,遇到 yield next 关键字时,会被传递到下游中间件(downstream),在 yield next 捕获不到下一个中间件时,逆序返回继续执行代码(upstream)。

thunk

上文中提到的thunk函数实际上由来已久,它是函数传名调用的一种实现方式,不同于函数的传值调用。就像下面的例子一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 执行器
function f(a, b) {
return a + b;
}

f(x * 3 + 1, 1);

// 可以写成下面这样

function thunk () {
return x * 3 + 1;
}

function f(thunk, 1) {
return thunk() + 1;
}

JavaScript中的thunk函数有着另外的意思,它替换的不是一个输入参数,而是将多参数的函数替换成单参数的版本,且只接受回调函数作为输入参数,正如之前写到的例子一样。

一个简单的thunk函数转换器写起来并不复杂,像下面这样:

1
2
3
4
5
6
7
8
9
var thunk = function(fn) {
return function () {
var args = Array.prototype.slice.call(arguments);
return function (done) {
args.push(done);
fn.apply(this, args);
}
}
}

node-thunkify模块对此又多了一些监测,如在最内层的function添加called变量确保回调函数只执行一次。Thunkify的源码相比co就更短了。

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
function thunkify(fn) {
assert('function' == typeof fn, 'function required');
// 返回一个包含thunk函数的函数,返回的thunk函数用于执行yield,而外围这个函数用于给thunk函数传递参数
return function() {
var args = new Array(arguments.length);
// 缓存当前上下文环境,给fn提供执行环境
var ctx = this;

// 将参数类数组转化为数组(实现方式略显臃肿,可直接用Array.prototype.slice.call(arguments)实现)
for (var i = 0; i < args.length; ++i) {
args[i] = arguments[i];
}

// 真正的thunk函数(有且只有一个参数是callback的函数,且callback的第一个参数为error)
// 类似于:
// function(cb) {fs.readFile(path, {encoding: 'utf8}, cb)}
return function(done) {
var called;

// 将回调函数再包裹一层,避免重复调用;同时,将包裹了的真正的回调函数push进参数数组
args.push(function() {
if (called) return;
called = true;
done.apply(null, arguments);
});

try {
// 在ctx上下文执行fn(一般是异步函数,如:fs.readFile)
// 并将执行thunkify之后返回的函数的参数(含done回调)传入,类似于执行:
// fs.readFile(path, {encoding: 'utf8}, done)
// 关于done是做什么用,则是在co库内
fn.apply(ctx, args);
} catch (err) {
done(err);
}
}
}
};

thunk函数的特点和Promise对象类似,就是将回调函数的绑定单独抽离出来,thunk函数结合Generator函数实现自动流程管理方法和Promise一样。

async

从Promise对象到Generator函数,JavaScript中的异步编程越来越简单,但是还是有戴着镣铐跳舞的感觉,async函数的提出即将把这个镣铐摘掉。

async函数的使用和Generator函数很像,我们改写之前的那个读取文件函数如下:

1
2
3
4
5
6
async function readFile() {
var d1 = await readFile('file1.js');
console.log(d1);
var d2 = await readFile('file2.js');
console.log(d2);
}

虽然和Generator函数很像,但是它有着更清晰易懂的语法,更广的适用性(await后可以跟任何类型,在原始类型时等同于同步操作)。最关键的是,async函数自带执行器!!!

在实现上,async函数和Generator函数是一样的,不过是将执行器放在自身内部而已。

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
function fn(args) {
return spawn(function* () {
//...
});
}
function spawn (genF, self) {
return new Promise(function (resolve, reject) {
var gen = genF.call(self);
step(() => gen.next(undefined));
function step (nextF) {
var next;
try {
next = nextF();
} catch(e) {
// finished with failure, reject the promise
reject(e);
return;
}
if (next.done) {
// finished with success, resolve the promise
resolve(next.value);
return;
}
// not finished, chain off the yielded promise and `step` again
Promise.resolve(next.value).then(
v => step(() => gen.next(v)),
e => step(() => gen.throw(e))
);
}
});
}

在使用上,async函数返回一个Promise对象。可以使用then方法添加回调函数。当遇到await时先返回,等待异步操作完成后再执行函数体后的语句。await只能用在async函数中,在普通函数中使用会报错。同时,async函数目前是ES7的标准,需要通过Babel转码使用。

参考

This API is so Fetching!
co
co和koa
Understanding JavaScript’s async await

前一阵舍友去面试,被问到JavaScript中的事件处理机制。暗自思忖,发现自己也没有深入的了解过。顺带连同常用的HTML元素大小和实际中用到的HTML5中的媒体元素简单整理在下面,方便之后回顾。

事件

JavaScript和HTML的交互是通过事件实现的。可以通过监听器订阅文档或窗口中的事件,在事件发生时执行特定的代码。这种属于设计模式中的观察者模式。

事件相关的API最早出现在IE4和NetScape Nivagator4(后面简称为网景)中。两种浏览器提供了相似却不同的API。在之后的DOM2级标准中对DOM事件进行了标准化。

事件流

事件流描述的是页面中接受时间的顺序。在这点上IE和网景采用了完全相反的两种处理思路。IE采用的是事件冒泡流,网景采用的是事件捕获流

事件冒泡(event bubbling)指从事件开始的最具体的元素接收,再逐步向上传递到最外层的节点,直到document。如下图(来自红宝书)展示的过程,在下面的文档中:

1
2
3
4
5
6
7
8
9
<!DOCTYPE html>
<html>
<head>
<title>Event bubbling</title>
</head>
<body>
<div>Click me</div>
</body>
</html>

如果div标签被点击,click事件会这样依次传递:<div> -> <body> -> <html> -> document。(不同浏览器实现细节上会有不同)

事件捕获(event capturing)则认为应该从父节点开始捕获事件直到事件目标。因此,同样的上面的例子,顺序将是:document -> <html> -> <body> -> <div>

目前很少有人使用事件捕获这种方式作为事件流。

DOM 事件流

“DOM2级标准”中规定事件流包括三个阶段,事件捕获处于目标时间冒泡。在实际的DOM事件流中,实际目标不会接受到事件。因此如下图展示的那样,捕获阶段停止在父目标<body>上,之后事件发生在目标上,并作为事件冒泡的一部分。然后,冒泡阶段发生,事件传回到文档。

事件处理程序

事件处理程序指用户指定响应事件的某种动作。它们都以’on’开头。HTML元素本身都可以使用与之同名的HTML特性。

DOM0级事件处理程序

DOM0级事件处理程序就是将一个函数直接赋值给一个事件处理程序属性。使用这种方法指定的事件处理程序被认为是元素的一种方法,从而其作用域为元素本身,即this指向引用元素。可以通过直接为事件处理程序属性赋值为null删除。

1
2
3
4
5
6
var btn = document.getElementById('button');
btn.onclick = function () {
alert(this.id); // "button"
}
//删除
btn.onclick = null;

所有浏览器都支持DOM0级事件处理程序。这么做的好处是可以保证浏览器兼容性,缺点是使得HTML和JavaScript紧密耦合,不利用后期维护。

DOM2级事件处理程序

伴随DOM2级标准提出,“DOM2级事件”提出了两种方法,用于绑定和解除事件处理程序:addEventListener()removeEventListener()。它接受3个参数:事件名事件处理程序对应的函数表示捕获阶段的布尔值

1
2
3
4
5
6
7
var btn = document.getElementById('button');
btn.addEventListener("click", function () {
alert(this.id);
}, false);
btn.addEventListener("click", function () {
alert(this.id + " again.");
}, false);

使用DOM2级方法绑定事件处理程序的一个优点是,可以添加多个程序到同一个标签上。使用DOM0级方法时则会覆盖上一次的事件处理程序。IE9及以上版本都支持DOM2级事件处理程序。

由于IE事件处理程序在IE8之前,是通过类似的attachEvent()detachEvent()方法。它的第一个参数是事件名(需要带上on),第二个参数是事件处理程序。通过这种方法绑定的处理程序都添加在冒泡阶段,且需要注意的是其中的this等于window对象。支持这种方式有IE和Opera。

因此,一个跨浏览器兼容的事件绑定和解绑应该是下面这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var EventUtil = {
addHandler: function(element, type, handler) {
if (element.addEventListener) {
element.addEventListener(type, handler, false);
} else if (element.attachEvent) {
element.attachEvent("on" + type, handler);
} else {
element["on" + type] = handler;
}
},
removeHandler: function(element, type, handler) {
if (element.removeEventListener) {
element.removeEventListener(type, handler, false);
} else if (element.detachEvent) {
element.detachEvent("on" + type, handler);
} else {
element["on" + type] = null;
}
},
}

===

元素大小与位置

这些属性方法并不属于“DOM2级样式”,但是却经常得到使用。目前所有主流浏览器都支持这些属性。它们大多都是只读的。

偏移量

偏移量描述元素在屏幕中占用的可用空间,由其宽高决定,包括内边距、滚动条和边框(不包括外边距)。有下面4个属性:

  • offsetHeight 元素垂直方向上的占用空间
  • offsetWidth 元素水平方向上的占用空间
  • offsetLeft 元素左边框距offsetParent元素左内边框的像素距离
  • offsetTop 元素上边框距offsetParent元素上内边框的像素距离

可以利用元素的offsetLeftoffsetLeft与其offsetParent对应属性相加直到根元素,获取到元素相对于页面的左偏移值或上偏移值。

客户区大小

客户区大小指元素内容和内边距占据的空间大小,不包括滚动条。clientWidth是元素内容宽度加左右内边距的宽度,clientHeight是元素内容高度加上下内边距的高度。

可以通过对body元素取值来获取当前浏览器视口的大小。

滚动大小

滚动大小包含滚动内容的元素大小。它有下面4个相关属性:

  • scrollHeight 没有滚动条时,元素内容的高度
  • scrollWidth 没有滚动条时,元素内容的宽度
  • scrollLeft 被隐藏在内容区域左侧的像素数,可以设置从而改变元素滚动位置
  • scrollTop 被隐藏在内容区域上侧的像素数,可以设置从而改变元素滚动位置

scrollHeight/scrollWidthclientHeight/clientWidth在不同浏览器下的表现行为并不相同,有的表示视口大小,有的表示元素内容区域大小。使用时可以取较大值。而另外两个属性scrollLeftscrollTop则通常用在document中,获取和滚动相关的属性。

确定元素大小

大多数主流浏览器为元素提供了getBoundingClientRect()方法,返回一个对象,包含leftrighttopbottom四个属性。给出了元素相对于视口的位置。

对不支持这个方法的浏览器,可以通过偏移量的相关属性获取。

===

媒体元素

HTML5出现前,提供富媒体内容的网站多采用Flash的方式保证浏览器兼容性。HTML5新增了两个标签<audio><video>。用于方便地嵌入音频和视频内容。同时,这两个标签也提供了实现常用功能的JavaScript API。允许为媒体创建自定义控件。

1
2
<video src="demo.mpg" id="foo">Video player is not available.</video>
<audio src="song.mp3" id="bar">Audio player is not available.</audio>

其中元素的src属性指定了加载的媒体文件,还可以通过widthheight属性指定播放器大小。controls属性意味浏览器应该显示UI控件用于操作媒体。标签中的内容用于在不支持时显示后备内容。

因为不同浏览器支持的媒体格式集并不完全相同,可以在标签下指定一或多个<source>元素,通过srctype属性指定来源和格式,视频标签下<source>type中甚至可以指定codecs表示解码器。目前现代浏览器(IE9+,对IE说的就是你)都支持这两个标签。

1
2
3
4
5
6
7
8
<video id="myVideo">
<source src="foo.mpg">
<source src="foo.webm" type="video/webm; codecs=vp8, vorbis">
</video>
<audio>
<source src="song.ogg" type="audio/ogg">
<source src="song.mp3" type="audio/mpeg">
</audio>

属性

<video><audio>提供了完善的JavaScript接口,下面是一些可能会用到的它们的属性。其中很多可以直接在标签元素上设置。

  • autoplay 取消或设置当前autoplay标识
  • controls 取消或设置当前controls标识,用于显示和隐藏浏览器内置控件
  • currentTime 获取已经播放的秒数
  • duration 获取媒体的总长度(秒数)
  • ended 获取媒体是否播放完成
  • loop 取消或设置媒体文件是否循环播放
  • muted 取消或设置媒体文件是否静音
  • paused 标识播放器是否暂停
  • playbackRate 取消或设置当前播放速度
  • readyState 标识媒体是否就绪,有0,1,2,3四种情况,表示不可用、可以播放当前帧、可以播放、加载完毕
  • src 媒体文件来源,可重写
  • volume 取消或设置当前音量,值为0.0到1.0

事件

这两个媒体元素还有许多事件,有的是媒体播放的结果,有的是用户操作的结果。

  • abort 下载中断
  • canplay 对应着readyState为2
  • canplaythrough 对应着readyState为3
  • ended 媒体播放完毕
  • error 下载过程网络错误
  • pause 播放暂停
  • play 媒体收到播放指令
  • playing 媒体开始播放
  • ratechange 播放速度改变
  • seeked 移动到新位置
  • seeking 正在移动进度条
  • volumnchange volumnmuted属性值改变
  • waiting 播放因下载未完成暂停

在如此丰富的属性和事件的帮助下,结合play()pause()方法,我们可以很容易构建一个自定义的媒体播放器。

1
2
3
4
5
6
7
8
9
10
11
<div class="player">
<div class="player__content">
<video id="video" src="movie.mov" poster="movie.jpg" width="400" height="200">
Video is not supported.
</video>
</div>
<div class="player__control">
<input type="button" value="Play!" id="video-play">
<span id="curtime">0</span>/<span id="duration">0</span>
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var player = document.getElementById("video"),
btn = document.getElementById("video-play"),
curtime = document.getElementById("curtime"),
duration = document.getElementById("duration");

//更新播放时间
duration.innerHTML = player.duration;

//为按钮添加处理事件
EventUtil.addHandler(btn, "click", function (e) {
if (player.paused) {
player.play();
btn.value = "Pause!";
} else {
player.pause();
btn.value = "Play!";
}
});

//定时更新时间
setInterval(function () {
curtime.innerHTML = player.currentTime;
}, 250);

最后,不是所有浏览器都支持这两个标签的所有解码器,因此有一个API来检测浏览器是否支持某种解码器。通过canPlayType()方法,该方法接收格式/编解码器(如”audio/wav“)字符串,返回”probably”, “maybe”或是空字符串””。像下面这样:

1
2
3
if (audio.canPlayType("audio/mpeg")){
//进一步处理
}

CSS是一种“奇怪”的编程语言,用来描述网页的样式。使用起来很简单,却由于自身的缺陷(只有全局作用域、没有模块化)使得它很难像真正的编程语言那样,有软件工程的办法适用。

BEM

BEM是一个方法论,是一套使用CSS的惯例和约定,用于写出更具有维护性和重用性的CSS代码。BEM由Yandex公司提出,目前已被广泛采用。它和其他的CSS的规范如OOCSSSMACSS并不冲突。它们都用来提高CSS文件的可维护性。

规范

BEM的三个字母分别代表块(block)、元素(element)、修饰符(modifier)。根据官网的介绍:

  • Block代表一个独立的抽象的组件
  • Element代表依附于Block的后代,用来形成一个完整的block
  • Modifier代表Block或是Element的不同状态或版本,用来改变默认样式

另外:

  • 不要使用文档的层级结构
  • 在标签嵌套时,只使用一层嵌套,通过class名标注标签

其中Element命名时在Block后添加两个短横线--ModifierBlock后添加两个下划线__所有的CSS均绑定到标签的class上,确保样式的重用性。

1
2
3
.block{}
.block__element{}
.block--modifier{}

之所以采用两个短划线和下划线,是为了让用户自定义的块命名中可以含有单个短划线和下划线。

下面是官网的样例:

1
2
3
4
5
6
<form class="form form--theme-xmas form--simple">
<input class="form__input" type="text" />
<input
class="form__submit form__submit--disabled"
type="submit" />
</form>
1
2
3
4
5
6
.form { }
.form--theme-xmas { }
.form--simple { }
.form__input { }
.form__submit { }
.form__submit--disabled { }

怎么用

BEM正如上面介绍的那样,只是一套规范。在使用的时候会感觉类名有些冗长和奇怪。不过它带来的好处是很有价值的。

另外,没有必要在每个地方都使用BEM规范。对于独立的一条CSS样式,写成BEM格式的写法并没有必要。对于考虑使用BEM的人来讲,可能最重要的是从哪里到哪里使用BEM。

OOCSS

写CSS代码很简单,但是写出可维护的CSS代码比其他语言就要更难了。因此,大牛们提出了OOCSS、SMACSS这样的设计模式来让事情更容易。OOCSS(Object Oriented CSS)即面向对象的CSS,它的关键在于创建在页面中创建模块化可重用的对象(HTML和CSS的结合体)。

根据OOCSS之父Nicole Sullivan的说法,OOCSS重点在于:

  1. 独立文档结构与样式
  2. 独立文档容器和内容

使用容易理解的话来说,就是从HTML结构上解脱出来,增加CSS class的重复利用。

1
2
3
4
5
6
7
<nav class="nav--main">
<ul>
<li><a>.........</a></li>
<li><a>.........</a></li>
<li><a>.........</a></li>
</ul>
</nav>

上面的例子里,业务代码经常会将CSS选择器写成nav ul li a这样的写法。这么做过渡依赖原有的HTML文档结构。原有的文档结构改变时,CSS就必须跟着重构。因此,建议直接给a标签绑定class,或写成nav--main a的写法。

第二,减少使用id作为CSS的选择器。尽量使用class,类似OOP中的概念,抽出重复的部分,定义在一个class中。像下面这样,定义基本的类button,并通过button-defaultbutton-primary来拓展基本类。

1
2
<div class="button button-default">
<div class="button button-primary">

总结一下,OOCSS的优势在于它可以减少CSS的代码减少加载时间(当然的),语义化的类名增强逻辑性和SEO,CSS样式可以轻松拓展,

缺点在于它适合大型网站的开发,在小型项目中似乎用不到这种40米的长刀,同时没有巧妙地使用,创建的组件会适得其反增加,增加维护难度。

SMACSS

SMACSS(读作”smacks”)全称为Scalable and Modular Architecture for CSS。它也是CSS的框架规范之一,目标是让”keep CSS more organized and more structured, leading to code that is easier to build and easier to maintain(作者Jonathan Snook语)

SMACSS使用了一套5个类别来划分CSS,这种组织和结构规范了CSS写法,提高了CSS使用效率。

  • Base rules 类似与reset.cssnormalize.css的效果,为文档的标签设置默认样式,应该只包含单独的标签选择器
  • Layout rules 将文档分成诸如header,article,footer这样的各个部分,为布局中的每个部分设置样式
  • Module rules 页面中可重用部分的样式,在layout中出现多次,使用时避免出现标签选择器
  • State rules 用于描述element的不同状态,和基本规则组合使用。
  • Theme rules 类似与“皮肤”的概念,更改整个网站的主题。

其他

CSS Modules和上面的思路要来的不大一样。它着眼于解决作用域和模块依赖的问题,采取的做法是重写class名。在React,Vue中每个组件中的CSS样式就做了这样的处理,保证的模块间的CSS文件不相冲突。

在通过JavaScript绑定到特定class的标签上时,也造成了CSS维护的不变。必要的时候可以为HTML标签赋予专为JavaScript使用的类名。如:

1
<li class="nav--main__item js-nav--main__item"><a>whatever</a></li>

总结

CSS是一门看起来很简单的语言,但是它的简单性也提升了工程中的使用难度。为了增强它的可用性。许多名为”xxxCSS”的方法论和机制等被发明,类似Sass,SCSS,Compass,Less,stylus,BEM,SMACSS,OOCSS,ACSS,CCSS等。在使用CSS时,可以尝试使用上面的规范,遵守一些法则,以写出更pure的代码。

扩展阅读

0%