Giter Site home page Giter Site logo

blog's People

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

blog's Issues

剖析Promise内部结构,一步一步实现一个完整的、能通过所有Test case的Promise类

本文适用的读者

本文写给有一定Promise使用经验的人,如果你还没有使用过Promise,这篇文章可能不适合你,建议先了解Promise的使用

Promise标准解读

  1. 只有一个then方法,没有catchraceall等方法,甚至没有构造函数

    Promise标准中仅指定了Promise对象的then方法的行为,其它一切我们常见的方法/函数都并没有指定,包括catchraceall等常用方法,甚至也没有指定该如何构造出一个Promise对象,另外then也没有一般实现中(Q, $q等)所支持的第三个参数,一般称onProgress

  2. then方法返回一个新的Promise

    Promise的then方法返回一个新的Promise,而不是返回this,此处在下文会有更多解释

    promise2 = promise1.then(alert)
    promise2 != promise1 // true
  3. 不同Promise的实现需要可以相互调用(interoperable)

  4. Promise的初始状态为pending,它可以由此状态转换为fulfilled(本文为了一致把此状态叫做resolved)或者rejected,一旦状态确定,就不可以再次转换为其它状态,状态确定的过程称为settle

  5. 更具体的标准见这里

一步一步实现一个Promise

下面我们就来一步一步实现一个Promise

构造函数

因为标准并没有指定如何构造一个Promise对象,所以我们同样以目前一般Promise实现中通用的方法来构造一个Promise对象,也是ES6原生Promise里所使用的方式,即:

// Promise构造函数接收一个executor函数,executor函数执行完同步或异步操作后,调用它的两个参数resolve和reject
var promise = new Promise(function(resolve, reject) {
  /*
    如果操作成功,调用resolve并传入value
    如果操作失败,调用reject并传入reason
  */
})

我们先实现构造函数的框架如下:

function Promise(executor) {
  var self = this
  self.status = 'pending' // Promise当前的状态
  self.data = undefined  // Promise的值
  self.onResolvedCallback = [] // Promise resolve时的回调函数集,因为在Promise结束之前有可能有多个回调添加到它上面
  self.onRejectedCallback = [] // Promise reject时的回调函数集,因为在Promise结束之前有可能有多个回调添加到它上面

  executor(resolve, reject) // 执行executor并传入相应的参数
}

上面的代码基本实现了Promise构造函数的主体,但目前还有两个问题:

  1. 我们给executor函数传了两个参数:resolve和reject,这两个参数目前还没有定义

  2. executor有可能会出错(throw),类似下面这样,而如果executor出错,Promise应该被其throw出的值reject:

    new Promise(function(resolve, reject) {
      throw 2
    })

所以我们需要在构造函数里定义resolve和reject这两个函数:

function Promise(executor) {
  var self = this
  self.status = 'pending' // Promise当前的状态
  self.data = undefined  // Promise的值
  self.onResolvedCallback = [] // Promise resolve时的回调函数集,因为在Promise结束之前有可能有多个回调添加到它上面
  self.onRejectedCallback = [] // Promise reject时的回调函数集,因为在Promise结束之前有可能有多个回调添加到它上面

  function resolve(value) {
    // TODO
  }

  function reject(reason) {
    // TODO
  }

  try { // 考虑到执行executor的过程中有可能出错,所以我们用try/catch块给包起来,并且在出错后以catch到的值reject掉这个Promise
    executor(resolve, reject) // 执行executor
  } catch(e) {
    reject(e)
  }
}

有人可能会问,resolve和reject这两个函数能不能不定义在构造函数里呢?考虑到我们在executor函数里是以resolve(value)reject(reason)的形式调用的这两个函数,而不是以resolve.call(promise, value)reject.call(promise, reason)这种形式调用的,所以这两个函数在调用时的内部也必然有一个隐含的this,也就是说,要么这两个函数是经过bind后传给了executor,要么它们定义在构造函数的内部,使用self来访问所属的Promise对象。所以如果我们想把这两个函数定义在构造函数的外部,确实是可以这么写的:

function resolve() {
  // TODO
}
function reject() {
  // TODO
}
function Promise(executor) {
  try {
    executor(resolve.bind(this), reject.bind(this))
  } catch(e) {
    reject.bind(this)(e)
  }
}

但是众所周知,bind也会返回一个新的函数,这么一来还是相当于每个Promise对象都有一对属于自己的resolvereject函数,就跟写在构造函数内部没什么区别了,所以我们就直接把这两个函数定义在构造函数里面了。不过话说回来,如果浏览器对bind的所优化,使用后一种形式应该可以提升一下内存使用效率。

另外我们这里的实现并没有考虑隐藏this上的变量,这使得这个Promise的状态可以在executor函数外部被改变,在一个靠谱的实现里,构造出的Promise对象的状态和最终结果应当是无法从外部更改的。

接下来,我们实现resolvereject这两个函数

function Promise(executor) {
  // ...

  function resolve(value) {
    if (self.status === 'pending') {
      self.status = 'resolved'
      self.data = value
      for(var i = 0; i < self.onResolvedCallback.length; i++) {
        self.onResolvedCallback[i](value)
      }
    }
  }

  function reject(reason) {
    if (self.status === 'pending') {
      self.status = 'rejected'
      self.data = reason
      for(var i = 0; i < self.onRejectedCallback.length; i++) {
        self.onRejectedCallback[i](reason)
      }
    }
  }

  // ...
}

基本上就是在判断状态为pending之后把状态改为相应的值,并把对应的value和reason存在self的data属性上面,之后执行相应的回调函数,逻辑很简单,这里就不多解释了。

then方法

Promise对象有一个then方法,用来注册在这个Promise状态确定后的回调,很明显,then方法需要写在原型链上。then方法会返回一个Promise,关于这一点,Promise/A+标准并没有要求返回的这个Promise是一个新的对象,但在Promise/A标准中,明确规定了then要返回一个新的对象,目前的Promise实现中then几乎都是返回一个新的Promise(详情)对象,所以在我们的实现中,也让then返回一个新的Promise对象。

关于这一点,我认为标准中是有一点矛盾的:

标准中说,如果promise2 = promise1.then(onResolved, onRejected)里的onResolved/onRejected返回一个Promise,则promise2直接取这个Promise的状态和值为己用,但考虑如下代码:

promise2 = promise1.then(function foo(value) {
  return Promise.reject(3)
})

此处如果foo运行了,则promise1的状态必然已经确定且为resolved,如果then返回了this(即promise2 === promise1),说明promise2和promise1是同一个对象,而此时promise1/2的状态已经确定,没有办法再取Promise.reject(3)的状态和结果为己用,因为Promise的状态确定后就不可再转换为其它状态。

另外每个Promise对象都可以在其上多次调用then方法,而每次调用then返回的Promise的状态取决于那一次调用then时传入参数的返回值,所以then不能返回this,因为then每次返回的Promise的结果都有可能不同。

下面我们来实现then方法:

// then方法接收两个参数,onResolved,onRejected,分别为Promise成功或失败后的回调
Promise.prototype.then = function(onResolved, onRejected) {
  var self = this
  var promise2

  // 根据标准,如果then的参数不是function,则我们需要忽略它,此处以如下方式处理
  onResolved = typeof onResolved === 'function' ? onResolved : function(v) {}
  onRejected = typeof onRejected === 'function' ? onRejected : function(r) {}

  if (self.status === 'resolved') {
    return promise2 = new Promise(function(resolve, reject) {

    })
  }

  if (self.status === 'rejected') {
    return promise2 = new Promise(function(resolve, reject) {

    })
  }

  if (self.status === 'pending') {
    return promise2 = new Promise(function(resolve, reject) {

    })
  }
}

Promise总共有三种可能的状态,我们分三个if块来处理,在里面分别都返回一个new Promise。

根据标准,我们知道,对于如下代码,promise2的值取决于then里面函数的返回值:

promise2 = promise1.then(function(value) {
  return 4
}, function(reason) {
  throw new Error('sth went wrong')
})

如果promise1被resolve了,promise2的将被4 resolve,如果promise1被reject了,promise2将被new Error('sth went wrong') reject,更多复杂的情况不再详述。

所以,我们需要在then里面执行onResolved或者onRejected,并根据返回值(标准中记为x)来确定promise2的结果,并且,如果onResolved/onRejected返回的是一个Promise,promise2将直接取这个Promise的结果:

Promise.prototype.then = function(onResolved, onRejected) {
  var self = this
  var promise2

  // 根据标准,如果then的参数不是function,则我们需要忽略它,此处以如下方式处理
  onResolved = typeof onResolved === 'function' ? onResolved : function(value) {}
  onRejected = typeof onRejected === 'function' ? onRejected : function(reason) {}

  if (self.status === 'resolved') {
    // 如果promise1(此处即为this/self)的状态已经确定并且是resolved,我们调用onResolved
    // 因为考虑到有可能throw,所以我们将其包在try/catch块里
    return promise2 = new Promise(function(resolve, reject) {
      try {
        var x = onResolved(self.data)
        if (x instanceof Promise) { // 如果onResolved的返回值是一个Promise对象,直接取它的结果做为promise2的结果
          x.then(resolve, reject)
        }
        resolve(x) // 否则,以它的返回值做为promise2的结果
      } catch (e) {
        reject(e) // 如果出错,以捕获到的错误做为promise2的结果
      }
    })
  }

  // 此处与前一个if块的逻辑几乎相同,区别在于所调用的是onRejected函数,就不再做过多解释
  if (self.status === 'rejected') {
    return promise2 = new Promise(function(resolve, reject) {
      try {
        var x = onRejected(self.data)
        if (x instanceof Promise) {
          x.then(resolve, reject)
        }
      } catch (e) {
        reject(e)
      }
    })
  }

  if (self.status === 'pending') {
  // 如果当前的Promise还处于pending状态,我们并不能确定调用onResolved还是onRejected,
  // 只能等到Promise的状态确定后,才能确实如何处理。
  // 所以我们需要把我们的**两种情况**的处理逻辑做为callback放入promise1(此处即this/self)的回调数组里
  // 逻辑本身跟第一个if块内的几乎一致,此处不做过多解释
    return promise2 = new Promise(function(resolve, reject) {
      self.onResolvedCallback.push(function(value) {
        try {
          var x = onResolved(self.data)
          if (x instanceof Promise) {
            x.then(resolve, reject)
          }
        } catch (e) {
          reject(e)
        }
      })

      self.onRejectedCallback.push(function(reason) {
        try {
          var x = onRejected(self.data)
          if (x instanceof Promise) {
            x.then(resolve, reject)
          }
        } catch (e) {
          reject(e)
        }
      })
    })
  }
}

// 为了下文方便,我们顺便实现一个catch方法
Promise.prototype.catch = function(onRejected) {
  return this.then(null, onRejected)
}

至此,我们基本实现了Promise标准中所涉及到的内容,但还有几个问题:

  1. 不同的Promise实现之间需要无缝的可交互,即Q的Promise,ES6的Promise,和我们实现的Promise之间以及其它的Promise实现,应该并且是有必要无缝相互调用的,比如:

    // 此处用MyPromise来代表我们实现的Promise
    new MyPromise(function(resolve, reject) { // 我们实现的Promise
      setTimeout(function() {
        resolve(42)
      }, 2000)
    }).then(function() {
      return new Promise.reject(2) // ES6的Promise
    }).then(function() {
      return Q.all([ // Q的Promise
        new MyPromise(resolve=>resolve(8)), // 我们实现的Promise
        new Promise.resolve(9), // ES6的Promise
        Q.resolve(9) // Q的Promise
      ])
    })

    我们前面实现的代码并没有处理这样的逻辑,我们只判断了onResolved/onRejected的返回值是否为我们实现的Promise的实例,并没有做任何其它的判断,所以上面这样的代码目前是没有办法在我们的Promise里正确运行的。

  2. 下面这样的代码目前也是没办法处理的:

    new Promise(resolve=>resolve(8))
      .then()
      .then()
      .then(function foo(value) {
        alert(value)
      })

    正确的行为应该是alert出8,而如果拿我们的Promise,运行上述代码,将会alert出undefined。这种行为称为穿透,即8这个值会穿透两个then(说Promise更为准确)到达最后一个then里的foo函数里,成为它的实参,最终将会alert出8。

下面我们首先处理简单的情况,值的穿透

Promise值的穿透

通过观察,会发现我们希望下面这段代码

new Promise(resolve=>resolve(8))
  .then()
  .catch()
  .then(function(value) {
    alert(value)
  })

跟下面这段代码的行为是一样的

new Promise(resolve=>resolve(8))
  .then(function(value){
    return value
  })
  .catch(function(reason){
    throw reason
  })
  .then(function(value) {
    alert(value)
  })

所以如果想要把then的实参留空且让值可以穿透到后面,意味着then的两个参数的默认值分别为function(value) {return value}function(reason) {throw reason}
所以我们只需要把then里判断onResolvedonRejected的部分改成如下即可:

onResolved = typeof onResolved === 'function' ? onResolved : function(value) {return value}
onRejected = typeof onRejected === 'function' ? onRejected : function(reason) {throw reason}

于是Promise神奇的值的穿透也没有那么黑魔法,只不过是then默认参数就是把值往后传或者抛

不同Promise的交互

关于不同Promise间的交互,其实标准里是有说明的,其中详细指定了如何通过then的实参返回的值来决定promise2的状态,我们只需要按照标准把标准的内容转成代码即可。

这里简单解释一下标准:

即我们要把onResolved/onRejected的返回值,x,当成一个可能是Promise的对象,也即标准里所说的thenable,并以最保险的方式调用x上的then方法,如果大家都按照标准实现,那么不同的Promise之间就可以交互了。而标准为了保险起见,即使x返回了一个带有then属性但并不遵循Promise标准的对象(比如说这个x把它then里的两个参数都调用了,同步或者异步调用(PS,原则上then的两个参数需要异步调用,下文会讲到),或者是出错后又调用了它们,或者then根本不是一个函数),也能尽可能正确处理。

关于为何需要不同的Promise实现能够相互交互,我想原因应该是显然的,Promise并不是JS一早就有的标准,不同第三方的实现之间是并不相互知晓的,如果你使用的某一个库中封装了一个Promise实现,想象一下如果它不能跟你自己使用的Promise实现交互的场景。。。

建议各位对照着标准阅读以下代码,因为标准对此说明的非常详细,所以你应该能够在任意一个Promise实现中找到类似的代码:

/*
resolvePromise函数即为根据x的值来决定promise2的状态的函数
也即标准中的[Promise Resolution Procedure](https://promisesaplus.com/#point-47)
x为`promise2 = promise1.then(onResolved, onRejected)`里`onResolved/onRejected`的返回值
`resolve`和`reject`实际上是`promise2`的`executor`的两个实参,因为很难挂在其它的地方,所以一并传进来。
相信各位一定可以对照标准把标准转换成代码,这里就只标出代码在标准中对应的位置,只在必要的地方做一些解释
*/
function resolvePromise(promise2, x, resolve, reject) {
  var then
  var thenCalledOrThrow = false

  if (promise2 === x) { // 对应标准2.3.1节
    return reject(new TypeError('Chaining cycle detected for promise!'))
  }

  if (x instanceof Promise) { // 对应标准2.3.2节
    // 如果x的状态还没有确定,那么它是有可能被一个thenable决定最终状态和值的
    // 所以这里需要做一下处理,而不能一概的以为它会被一个“正常”的值resolve
    if (x.status === 'pending') {
      x.then(function(value) {
        resolvePromise(promise2, value, resolve, reject)
      }, reject)
    } else { // 但如果这个Promise的状态已经确定了,那么它肯定有一个“正常”的值,而不是一个thenable,所以这里直接取它的状态
      x.then(resolve, reject)
    }
    return
  }

  if ((x !== null) && ((typeof x === 'object') || (typeof x === 'function'))) { // 2.3.3
    try {

      // 2.3.3.1 因为x.then有可能是一个getter,这种情况下多次读取就有可能产生副作用
      // 即要判断它的类型,又要调用它,这就是两次读取
      then = x.then 
      if (typeof then === 'function') { // 2.3.3.3
        then.call(x, function rs(y) { // 2.3.3.3.1
          if (thenCalledOrThrow) return // 2.3.3.3.3 即这三处谁选执行就以谁的结果为准
          thenCalledOrThrow = true
          return resolvePromise(promise2, y, resolve, reject) // 2.3.3.3.1
        }, function rj(r) { // 2.3.3.3.2
          if (thenCalledOrThrow) return // 2.3.3.3.3 即这三处谁选执行就以谁的结果为准
          thenCalledOrThrow = true
          return reject(r)
        })
      } else { // 2.3.3.4
        resolve(x)
      }
    } catch (e) { // 2.3.3.2
      if (thenCalledOrThrow) return // 2.3.3.3.3 即这三处谁选执行就以谁的结果为准
      thenCalledOrThrow = true
      return reject(e)
    }
  } else { // 2.3.4
    resolve(x)
  }
}

然后我们使用这个函数的调用替换then里几处判断x是否为Promise对象的位置即可,见下方完整代码。

最后,我们刚刚说到,原则上,promise.then(onResolved, onRejected)里的这两相函数需要异步调用,关于这一点,标准里也有说明

In practice, this requirement ensures that onFulfilled and onRejected execute asynchronously, after the event loop turn in which then is called, and with a fresh stack.

所以我们需要对我们的代码做一点变动,即在四个地方加上setTimeout(fn, 0),这点会在完整的代码中注释,请各位自行发现。

事实上,即使你不参照标准,最终你在自测试时也会发现如果then的参数不以异步的方式调用,有些情况下Promise会不按预期的方式行为,通过不断的自测,最终你必然会让then的参数异步执行,让executor函数立即执行。本人在一开始实现Promise时就没有参照标准,而是自己凭经验测试,最终发现的这个问题。

至此,我们就实现了一个的Promise,完整代码如下:

try {
  module.exports = Promise
} catch (e) {}

function Promise(executor) {
  var self = this

  self.status = 'pending'
  self.onResolvedCallback = []
  self.onRejectedCallback = []

  function resolve(value) {
    if (value instanceof Promise) {
      return value.then(resolve, reject)
    }
    setTimeout(function() { // 异步执行所有的回调函数
      if (self.status === 'pending') {
        self.status = 'resolved'
        self.data = value
        for (var i = 0; i < self.onResolvedCallback.length; i++) {
          self.onResolvedCallback[i](value)
        }
      }
    })
  }

  function reject(reason) {
    setTimeout(function() { // 异步执行所有的回调函数
      if (self.status === 'pending') {
        self.status = 'rejected'
        self.data = reason
        for (var i = 0; i < self.onRejectedCallback.length; i++) {
          self.onRejectedCallback[i](reason)
        }
      }
    })
  }

  try {
    executor(resolve, reject)
  } catch (reason) {
    reject(reason)
  }
}

function resolvePromise(promise2, x, resolve, reject) {
  var then
  var thenCalledOrThrow = false

  if (promise2 === x) {
    return reject(new TypeError('Chaining cycle detected for promise!'))
  }

  if (x instanceof Promise) {
    if (x.status === 'pending') { //because x could resolved by a Promise Object
      x.then(function(v) {
        resolvePromise(promise2, v, resolve, reject)
      }, reject)
    } else { //but if it is resolved, it will never resolved by a Promise Object but a static value;
      x.then(resolve, reject)
    }
    return
  }

  if ((x !== null) && ((typeof x === 'object') || (typeof x === 'function'))) {
    try {
      then = x.then //because x.then could be a getter
      if (typeof then === 'function') {
        then.call(x, function rs(y) {
          if (thenCalledOrThrow) return
          thenCalledOrThrow = true
          return resolvePromise(promise2, y, resolve, reject)
        }, function rj(r) {
          if (thenCalledOrThrow) return
          thenCalledOrThrow = true
          return reject(r)
        })
      } else {
        resolve(x)
      }
    } catch (e) {
      if (thenCalledOrThrow) return
      thenCalledOrThrow = true
      return reject(e)
    }
  } else {
    resolve(x)
  }
}

Promise.prototype.then = function(onResolved, onRejected) {
  var self = this
  var promise2
  onResolved = typeof onResolved === 'function' ? onResolved : function(v) {
    return v
  }
  onRejected = typeof onRejected === 'function' ? onRejected : function(r) {
    throw r
  }

  if (self.status === 'resolved') {
    return promise2 = new Promise(function(resolve, reject) {
      setTimeout(function() { // 异步执行onResolved
        try {
          var x = onResolved(self.data)
          resolvePromise(promise2, x, resolve, reject)
        } catch (reason) {
          reject(reason)
        }
      })
    })
  }

  if (self.status === 'rejected') {
    return promise2 = new Promise(function(resolve, reject) {
      setTimeout(function() { // 异步执行onRejected
        try {
          var x = onRejected(self.data)
          resolvePromise(promise2, x, resolve, reject)
        } catch (reason) {
          reject(reason)
        }
      })
    })
  }

  if (self.status === 'pending') {
    // 这里之所以没有异步执行,是因为这些函数必然会被resolve或reject调用,而resolve或reject函数里的内容已是异步执行,构造函数里的定义
    return promise2 = new Promise(function(resolve, reject) {
      self.onResolvedCallback.push(function(value) {
        try {
          var x = onResolved(value)
          resolvePromise(promise2, x, resolve, reject)
        } catch (r) {
          reject(r)
        }
      })

      self.onRejectedCallback.push(function(reason) {
          try {
            var x = onRejected(reason)
            resolvePromise(promise2, x, resolve, reject)
          } catch (r) {
            reject(r)
          }
        })
    })
  }
}

Promise.prototype.catch = function(onRejected) {
  return this.then(null, onRejected)
}

Promise.deferred = Promise.defer = function() {
  var dfd = {}
  dfd.promise = new Promise(function(resolve, reject) {
    dfd.resolve = resolve
    dfd.reject = reject
  })
  return dfd
}

测试

如何确定我们实现的Promise符合标准呢?Promise有一个配套的测试脚本,只需要我们在一个CommonJS的模块中暴露一个deferred方法(即exports.deferred方法),就可以了,代码见上述代码的最后。然后执行如下代码即可执行测试:

npm i -g promises-aplus-tests
promises-aplus-tests Promise.js

关于Promise的其它问题

Promise的性能问题

可能各位看官会觉得奇怪,Promise能有什么性能问题呢?并没有大量的计算啊,几乎都是处理逻辑的代码。

理论上说,不能叫做“性能问题”,而只是有可能出现的延迟问题。什么意思呢,记得刚刚我们说需要把4块代码包在setTimeout里吧,先考虑如下代码:

var start = +new Date()
function foo() {
  setTimeout(function() {
    console.log('setTimeout')
    if((+new Date) - start < 1000) {
      foo()
    }
  })
}
foo()

运行上面的代码,会打印出多少次'setTimeout'呢,各位可以自己试一下,不出意外的话,应该是250次左右,我刚刚运行了一次,是241次。这说明,上述代码中两次setTimeout运行的时间间隔约是4ms(另外,setInterval也是一样的),实事上,这正是浏览器两次Event Loop之间的时间间隔,相关标准各位可以自行查阅。另外,在Node中,这个时间间隔跟浏览器不一样,经过我的测试,是1ms。

单单一个4ms的延迟可能在一般的web应用中并不会有什么问题,但是考虑极端情况,我们有20个Promise链式调用,加上代码运行的时间,那么这个链式调用的第一行代码跟最后一行代码的运行很可能会超过100ms,如果这之间没有对UI有任何更新的话,虽然本质上没有什么性能问题,但可能会造成一定的卡顿或者闪烁,虽然在web应用中这种情形并不常见,但是在Node应用中,确实是有可能出现这样的case的,所以一个能够应用于生产环境的实现有必要把这个延迟消除掉。在Node中,我们可以调用process.nextTick或者setImmediate(Q就是这么做的),在浏览器中具体如何做,已经超出了本文的讨论范围,总的来说,就是我们需要实现一个函数,行为跟setTimeout一样,但它需要异步且尽早的调用所有已经加入队列的函数,这里有一个实现。

如何停止一个Promise链?

在一些场景下,我们可能会遇到一个较长的Promise链式调用,在某一步中出现的错误让我们完全没有必要去运行链式调用后面所有的代码,类似下面这样(此处略去了then/catch里的函数):

new Promise(function(resolve, reject) {
  resolve(42)
})
  .then(function(value) {
    // "Big ERROR!!!"
  })
  .catch()
  .then()
  .then()
  .catch()
  .then()

假设这个Big ERROR!!!的出现让我们完全没有必要运行后面所有的代码了,但链式调用的后面即有catch,也有then,无论我们是return还是throw,都不可避免的会进入某一个catchthen里面,那有没有办法让这个链式调用在Big ERROR!!!的后面就停掉,完全不去执行链式调用后面所有回调函数呢?

一开始遇到这个问题的时候我也百思不得其解,在网上搜遍了也没有结果,有人说可以在每个catch里面判断Error的类型,如果自己处理不了就接着throw,也有些其它办法,但总是要对现有代码进行一些改动并且所有的地方都要遵循这些约定,甚是麻烦。

然而当我从一个实现者的角度看问题时,确实找到了答案,就是在发生Big ERROR后return一个Promise,但这个Promise的executor函数什么也不做,这就意味着这个Promise将永远处于pending状态,由于then返回的Promise会直接取这个永远处于pending状态的Promise的状态,于是返回的这个Promise也将一直处于pending状态,后面的代码也就一直不会执行了,具体代码如下:

new Promise(function(resolve, reject) {
  resolve(42)
})
  .then(function(value) {
    // "Big ERROR!!!"
    return new Promise(function(){})
  })
  .catch()
  .then()
  .then()
  .catch()
  .then()

这种方式看起来有些山寨,它也确实解决了问题。但它引入的一个新问题就是链式调用后面的所有回调函数都无法被垃圾回收器回收(在一个靠谱的实现里,Promise应该在执行完所有回调后删除对所有回调函数的引用以让它们能被回收,在前文的实现里,为了减少复杂度,并没有做这种处理),但如果我们不使用匿名函数,而是使用函数定义或者函数变量的话,在需要多次执行的Promise链中,这些函数也都只有一份在内存中,不被回收也是可以接受的。

我们可以将返回一个什么也不做的Promise封装成一个有语义的函数,以增加代码的可读性:

Promise.cancel = Promise.stop = function() {
  return new Promise(function(){})
}

然后我们就可以这么使用了:

new Promise(function(resolve, reject) {
  resolve(42)
})
  .then(function(value) {
    // "Big ERROR!!!"
    return Promise.stop()
  })
  .catch()
  .then()
  .then()
  .catch()
  .then()

看起来是不是有语义的多?

Promise链上返回的最后一个Promise出错了怎么办?

考虑如下代码:

new Promise(function(resolve) {
  resolve(42)
})
  .then(function(value) {
    alter(value)
  })

乍一看好像没什么问题,但运行这段代码的话你会发现什么现象也不会发生,既不会alert出42,也不会在控制台报错,怎么回事呢。细看最后一行,alert被打成了alter,那为什么控制台也没有报错呢,因为alter所在的函数是被包在try/catch块里的,alter这个变量找不到就直接抛错了,这个错就正好成了then返回的Promise的rejection reason。

也就是说,在Promise链的最后一个then里出现的错误,非常难以发现,有文章指出,可以在所有的Promise链的最后都加上一个catch,这样出错后就能被捕获到,这种方法确实是可行的,但是首先在每个地方都加上几乎相同的代码,违背了DRY原则,其次也相当的繁琐。另外,最后一个catch依然返回一个Promise,除非你能保证这个catch里的函数不再出错,否则问题依然存在。在Q中有一个方法叫done,把这个方法链到Promise链的最后,它就能够捕获前面未处理的错误,这其实跟在每个链后面加上catch没有太大的区别,只是由框架来做了这件事,相当于它提供了一个不会出错的catch链,我们可以这么实现done方法:

Promise.prototype.done = function(){
  return this.catch(function(e) { // 此处一定要确保这个函数不能再出错
    console.error(e)
  })
}

可是,能不能在不加catch或者done的情况下,也能够让开发者发现Promise链最后的错误呢?答案依然是肯定的。

我们可以在一个Promise被reject的时候检查这个Promise的onRejectedCallback数组,如果它为空,则说明它的错误将没有函数处理,这个时候,我们需要把错误输出到控制台,让开发者可以发现。以下为具体实现:

function reject(reason) {
  setTimeout(function() {
    if (self.status === 'pending') {
      self.status = 'rejected'
      self.data = reason
      if (self.onRejectedCallback.length === 0) {
        console.error(reason)
      }
      for (var i = 0; i < self.rejectedFn.length; i++) {
        self.rejectedFn[i](reason)
      }
    }
  })
}

上面的代码对于以下的Promise链也能处理的很好:

new Promise(function(){ // promise1
  reject(3)
})
  .then() // returns promise2
  .then() // returns promise3
  .then() // returns promise4

看起来,promise1,2,3,4都没有处理函数,那是不是会在控制台把这个错误输出4次呢,并不会,实际上,promise1,2,3都隐式的有处理函数,就是then的默认参数,各位应该还记得then的默认参数最终是被push到了Promise的callback数组里。只有promise4是真的没有任何callback,因为压根就没有调用它的then方法。

事实上,Bluebird和ES6 Promise都做了类似的处理,在Promise被reject但又没有callback时,把错误输出到控制台。

Q使用了done方法来达成类似的目的,$q在最新的版本中也加入了类似的功能。

Angular里的$q跟其它Promise的交互

一般来说,我们不会在Angular里使用其它的Promise,因为Angular已经集成了$q,但有些时候我们在Angular里需要用到其它的库(比如LeanCloud的JS SDK),而这些库或是封装了ES6的Promise,或者是自己实现了Promise,这时如果你在Angular里使用这些库,就有可能发现视图跟Model不同步。究其原因,是因为$q已经集成了Angular的digest loop机制,在Promise被resolve或reject时触发digest,而其它的Promise显然是不会集成的,所以如果你运行下面这样的代码,视图是不会同步的:

app.controller(function($scope) {
  Promise.resolve(42).then(function(value) {
    $scope.value = value
  })
})

Promise结束时并不会触发digest,所以视图没有同步。$q上正好有个when方法,它可以把其它的Promise转换成$q的Promise(有些Promise实现中提供了Promise.cast函数,用于将一个thenable转换为它的Promise),问题就解决了:

app.controller(function($scope, $q) {
  $q.when(Promise.resolve(42)).then(function(value) {
    $scope.value = value
  })
})

当然也有其它的解决方案比如在其它Promise的链的最后加一个digest,类似下面这样:

Promise.prototype.$digest = function() {
  $rootScope.$digest()
  return this
}
// 然后这么使用
OtherPromise
  .resolve(42)
  .then(function(value) {
    $scope.value = value
  })
  .$digest()

因为使用场景并不多,此处不做深入讨论。

出错时,是用throw new Error()还是用return Promise.reject(new Error())呢?

这里我觉得主要从性能和编码的舒适度角度考虑:

性能方面,throw new Error()会使代码进入catch块里的逻辑(还记得我们把所有的回调都包在try/catch里了吧),传说throw用多了会影响性能,因为一但throw,代码就有可能跳到不可预知的位置。

但考虑到onResolved/onRejected函数是直接被包在Promise实现里的try里,出错后就直接进入了这个try对应 的catch块,代码的跳跃“幅度”相对较小,我认为这里的性能损失可以忽略不记。有机会可以测试一下。

而使用Promise.reject(new Error()),则需要构造一个新的Promise对象(里面包含2个数组,4个函数:resolve/rejectonResolved/onRejected),也会花费一定的时间和内存。

而从编码舒适度的角度考虑,出错用throw,正常时用return,可以比较明显的区分出错与正常,throwreturn又同为关键字,用来处理对应的情况也显得比较对称(-_-)。另外在一般的编辑器里,Promise.reject不会被高亮成与throwreturn一样的颜色。最后,如果开发者又不喜欢构造出一个Error对象的话,Error的高亮也没有了。

综上,我觉得在Promise里发现显式的错误后,用throw抛出错误会比较好,而不是显式的构造一个被reject的Promise对象。

最佳实践

这里不免再啰嗦两句最佳实践

  1. 一是不要把Promise写成嵌套结构,至于怎么改进,这里就不多说了

    // 错误的写法
    promise1.then(function(value) {
      promise1.then(function(value) {
        promise1.then(function(value) {
    
        })
      })
    })
  2. 二是链式Promise要返回一个Promise,而不只是构造一个Promise

    // 错误的写法
    Promise.resolve(1).then(function(){
      Promise.resolve(2)
    }).then(function(){
      Promise.resolve(3)
    })

Promise相关的convenience method的实现

请到这里查看Promise.race, Promise.all, Promise.resolve, Promise.reject等方法的具体实现,这里就不具体解释了,总的来说,只要then的实现是没有问题的,其它所有的方法都可以非常方便的依赖then来实现。

结语

最后,如果你觉得这篇文章对你有所帮助,欢迎分享给你的朋友或者团队,记得注明出处哦~

Pure CSS implementation of Google Photos / 500px image layout

This post is translated from Chinese, thanks to my sister Tian Qiong and my friend Tang Shuang for their hard work, and the original Chinese edition is here: #4

Pure CSS implementation of Google Photos / 500px image layout

For sharing the secrets of getting most perfect effect in every step to the end, this can be a very long post.
So you guys can jump to the part of code, demo and HOWs, which follow the last main title, if you don’t want to read it from the beginning. Or you can just check the source code of the bellow link.

But my suggestion is to read it from the beginning, because it will help you understand the HOWs better with the information provided in the former part, which mainly solves the problem of some corner cases.

First, let’s take a look at the result to make you more interested, resize or zoom the page to check the dynamic effect.

https://xieranmaya.github.io/images/cats/cats.html

image

Besides, to show the source code, jsbin.com is used in some demos that we will talk about later. But the jsbin.com sometimes may not work. If that happens, just rewrite the source code in the edit box without changing the meaning. For example, press the “enter” button in the last line.

PS: All the images used in the demo are from 500px.com, the the images' copyright are belonging to the author, the images' id are in the images' url and you can access the 500px original page with https://500px.com/photo/[id].

Ok, let’s start.

Firstly, let’s see the differences between three common images layout:

  1. Pinterest.com
    • This is a common layout with equal width, which all images share a same width, we often call it waterfall layout.
    • Due to the stretching of images in same proportion and the same width of each, all images have different heights.
    • The disadvantages of this layout is that some images might be missed by readers, because every image has different height thus start from different top position and is not displayed in common browsing order, plus people usually scan things in horizontal direction.
    • The image displaying wall usually has uneven bottom.
    • Though the bottom can not be entirely even, JS is applied to make it with reordering the photos. So JS must be involved when the page is reflowed, resized or zoomed in this layout.
  2. Images layout in which image has unequal width and height with Google Photos as representation, such else as 500px(Note this is a dynamic page and may have pictures that is NSFW~) and tuchong.com.
    • Images are stretched in the same proportion too.
    • The display area is fully occupied by every row of images in horizontal direction, leaving no extra blank.
    • Due to the above two rules, every image in the same row has different height, or the second rule will be broken.
    • The images are displayed in order, making it more convenient for readers to browse. This rule has to be obeyed by Apps like Google Photos because the taken time is a very important property and thus the photos should displayed in order.
    • This bottom of this layout is even.
    • In the layout of Google Photos, the images will be displayed in same row in spite of different date when there is no enough images of same date. PS. This is a secondary point we are going to talk about(which now I desided to write another post to deal with it).
  3. Instangram
    • Which is a square layout, which is unnecessary to explain more.

The above mentioned two layouts have onething in common is that the images are stretched in same proportion with the original image. That is to say, the images are just zoomed out and in without any change of the pictures' contents. This is the tendency of common picture web App now days. In fact, few websites will adopt the method of magnified or minified in unequal proportion(which is very ugly). The last method is to clip the image to square and put it in a square box, which can be achieved by the background-size: cover; CSS property.

What's more, in the Pinterest layout, the display area will be smaller for the wider images. In the second layout, however, the display area will be smaller for the taller images.

In the second layout, because different rows have different height, the higher image can have a chance to show larger when it resides in a row which has larger row height.

In whole, the image layout leaded by "Google Photos" has better effect. About how to achieve the Google Photos / 500px algorithm is JS, readers can think about it by yourself, we'll talk about CSS implementation here.

After you think about the algorithm, you can open this link to see the dynamic demo of the layout in action, be sure to click the Layout button after ALL the images have loaded.

image

The demo showed the layout method: First lay all the images by a fixed height i.e. 200px, and then zoom in the images by row till they fill the parent's width ideally.

Next, let's summarize the standards of evaluating images layout based on the above analysis.

  1. Whether it can output in the oringal list in sequence
  2. Whether it can output in our eye's scan sequence, namely the same line-height
  3. Whether the picture can display in the original proportion, or try to display as much as possible
  4. Whether the picture can display it's full content, or try to display as much as possible
  5. Whether the area of every image can be the same, or at least try to be the more closer(in fact, it is hard to achieve this effect in Pinterest or 500px)
  6. Whether the image can be display vividly without any non-proportional stretch

When I saw "Google Photos" image layout for the first time, I guess it is much probably JS involved. Because it's impossible justified on both sides under the condition of the same picture height, and in the same separation distance between the different pictures.(If each picture have the different separation distance while it can be justified on the both sides, we can use inline picture and text-justify to achieve this. And it's a choice when the images are small). After my observation, every row have different height, so I am sure there must be JS involved.

But as a frontend developer, I perfer CSS to JS. However, when I saw more and mroe websites starts to use this layout, an idea occours to me: whether we can achieve this layout only by CSS instead of JS, especially not recalculate the layout in the process of resizing/zoom.

After some trial, I found a way to achieve similar layout only by CSS in some extent.(Only use CSS to achieve layout, what I mentioned here, referring to the subsequent steps of resizing and zooming can be stable without JS involved after rendering completed. That's to say, initial rendering can be achieved on the server, and the whole process is not involved JS. So it's natural to say that we only use CSS to achieve this layout).

Implementation process

Next, I will introduce the the entire attempt I tried step by step to achieve this layout with pure CSS.

First of all, let's set the same height for all the images

<style>
  img {
    height: 200px;
  }
</style>
<div>
   <img src="" />
   <img src="" />
   <img src="" />
   <img src="" />
   <img src="" />
</div>

This method can't fill the image very appropriately in the horizontal space. The flex-grow property cames to my mind, it can make images' box to grow up to fill in the container's extra horizontal space. Naturally, the whole layout become flex.

div {
  display: flex;
  flex-wrap: wrap;
}
img {
  height: 200px;
  flex-grow: 1;
}

Set the flex-wrap of flex container into wrap. In this way, line wrap will happens if one line can not lay one more image. Every line's images will fill in the container's horinontal space due to the grow. It seems that it's almost achieved our expectation effect, but every image has been stretched in non-proportion so that the images are out of shape. This is easy for us, we can fix it with object-fit: cover;. However, part of the image will be clipped.

Final demo: http://jsbin.com/tisaluy/1/edit?html,css,js,output
image

Actually, the above mentioned DOM structure can't be used in the practice

  • It does not support browser which lack the object-fit support, so the picture will be distorted. The reason is the images has no container, and it's not posible to use background-size to solve this problem.
    *By using the object-fit porperty in supported browser, part of images will be cliped( these two rules have been mentioned before).
    • We can't show some information with the images together, because of the bare image tag with no container.
    • In a real network environment, pictures loading are rather slow. So if we try to use the images' box to streth the layout, there must be many flash/blink or sth like FOUC, it should already occoured in demo.

Thus, the above mentioned layout can't be used in any kind of production situation.

Add container for the img tag

Next, we change the DOM structure to this:

<section>
   <div>
      <img src=""/>
   </div>
   <div>
      <img src=""/>
   </div>
   <div>
      <img src=""/>
   </div>
   <div>
      <img src=""/>
   </div>
</section>

We added a container for each image. Firstly, the image height is set 200px still, and every div will be stretched by its image child node. Next, if we set a flex-grow: 1 for the divs, extra spaces in each row will be divided equally by each div. Then the div will be wider, but the image width can't fill in the div completely, it's obvious.

Then, if we set the image width to 100%, the growed space will not be redivid by div in IE and FF.(I personally think this is a very interesting , the image stretchs div first, then the div's grow stretchs the image). But in Chrome, setting 100% width for image will cause the extra space in each row to reallocate. Which in result makes every container's width to more closer. However, it’s not what we want.

After several combinations of css properties, I almost did it. If we set the image's min-width and max-width to 100% at the same time, the display effect in Chrome will be the same with IE and FF. Lastly, we set the image's object-fit property to cover, and the image will fill in the container with equal proportional stretch.

This method still is not perfect just like the former one. The images height is all same, and partial image has been clip for obvious reason.

The entire demo of the above solution: http://jsbin.com/tisaluy/2/edit?html,css,output
image

If the image height value is set to a small value, the above layout is roughly right. There will be more images in every row, so extra space in each row will be less and divided by more images. In this way, the ratio of every container will be more close to the real image ratio. Most of images can display the main content.

The nasty last row

The only problem occurs in the last row. When the last row only has few images, it will fill in the whole line due to flex-grow. However, our height is set to 200px, so the display area will show less images content. The situation will be worse when the image is higher and the display area(which is the container) is wider.

The solution for this situation is to let the last few images to not grow. We can calculate the average images in every row in case of distorting terribly.

div:nth-last-child(5),
div:nth-last-child(4),
div:nth-last-child(3),
div:nth-last-child(2),
div:nth-last-child(1){
  flex-grow: 0;
}

And media query is adopted here when the screen have different width, the number of elemnets in the last row will change according to the window's width:

@media (max-width: 1000px) and (min-width: 900px) {
  div:nth-last-child(5),
  div:nth-last-child(4),
  div:nth-last-child(3),
  div:nth-last-child(2),
  div:nth-last-child(1){
    flex-grow: 0;
  }
}
@media (max-width: 1100px) and (min-width: 1000px) {
  div:nth-last-child(7),
  div:nth-last-child(6),
  div:nth-last-child(5),
  div:nth-last-child(4),
  div:nth-last-child(3),
  div:nth-last-child(2),
  div:nth-last-child(1){
    flex-grow: 0;
  }
}

Every certain width have to be many “nth-last-child” selectors so that above code is rather complicated. We can use preprocessor to loop out this code, but there are still many repetitious code generated.

Is there any way to just appoint how many elements instead of several “nth-last-child” selectors? The answer is yes. Here we should mention the “~” operator in CSS. Let’s write:

div:nth-last-child(8),
div:nth-last-child(8) ~ div{
  flex-grow: 0;
}

First, we can select the eighth last element. Then the following node after the eighth last element can be selected by the ~ combinator. Finally, the last eight elements are selected successfully. Furthermore, we can rewrite the selector to “div:nth-last-child(9) ~ div”, and we may select the last eight elements only with one selector.

The real effect varies from the different selectors we chose in the above rear elements:

  • “div:nth-last-child(1),div:nth-last-child(2),...,div:nth-last-child(n)”, this method can make sure the last N images to always selected
  • “div:nth-last-child(n), div:nth-last-child(n) ~ div”, this method only make sure there must be more than N div elements, so the last N elements can be selected. The selector will no working if "nth-last-child(n)" select nothing.
  • “div:nth-last-child(n+1) ~ div” , this method have to make sure there must be more than N+1 div elements, so the last N elements can be selected

Actually, the way of choosing last several images is not perfect, because it's not guaranteed that the flex items you select is in the last row. It might be only one image in the last row, and if this happens, first images in the next-to-last row will grow badly. (Because the last N images won’t grow). Or the last images in the last two rows are not up to a certain amount, and then the next-to-last row won’t grow to fill in this row. All may lead to the format disorder.

Is there any way to prevent just the last row's elements' growing? I thought for a long while, even to seek for a last-line or pseudo-class, but I failed. And then one day, I got the answer occasionally:

Adding another element to the last element as the last flex item, and sets its flex-grow to a extremely large value (e.g., 999999999). Then the remaining space in the last line will be taken fully up by this extra element's grow. The other elements are thus not growed, what’s more, we can achieve this by pseudo elements(But IE browser does not support flex properties on pseudo elements, so it is essential to adopt a real element placeholder):

section::after {
  content: '';
  flex-grow: 999999999;
}

Ok, we basically solve all the problems encountered in this layout.

Demo: resize or zoom first, then observe the images in the last row: http://jsbin.com/tisaluy/3/edit?html,css,output

image

But there is one last question, this layout is just like the former one except for it has a container which can show some extra info for the images. If you load the layout pages online, pages will occur a severe blinking issue. Before downloading the images, we have no ideas about the width and height. It’s unpractical to wait images loading completed to stretch the container. What’s worse, we need refresh pages more than ten thousand times at develop which will have a very worse effect on our developer's eyes too.

So, we have to render the images display area(i.e. its container) in advance. (Actually, almost all websites with images adopt this approche.) JS have to be applied in here. Those works can be finished on the server or by any template engines. (Below codes employ template syntax in Angular.)

Once this layout finished, all the subsequent actions(resize,zoom) won’t disorder the layout without JS involved. It did it successfully with CSS.

<style>
  section {
    padding: 2px;
    display: flex;
    flex-wrap: wrap;
    &::after {
      content: '';
      flex-grow: 999999999;
    }
  }
  div {
    margin: 2px;
    position: relative;
    height: 200px;
    flex-grow: 1;
    background-color: violet;
    img {
      max-width: 100%;
      min-width: 100%;
      height: 200px;
      object-fit: cover;
      vertical-align: bottom;
    }
  }
</style>
<section>
    // The expression is used to calculate the width value when image is 200 height
    <div ng-repeat="img in imgs" style="width:{{img.width*200/img.height}}px;"></div>
</section>

We achieve the images layout we expected.

Demo, notice the expression in the template: http://jsbin.com/tisaluy/9/edit?html,css,output

image

So, what about the final effect of the layout?

Acturally I wrote code to calculate the percentage of each image shows. When images height is set to 150px initially, one third images can show 99% content. The worst one or two case of images can display about 70% content. In average, the display percentage of all images is about 90%. The display effect will be better if the image is shorter(less height), and the display effect will be worse if the image is higher(larger height).

I gave up this solution later, so there is no need to present the detailed demo.

You may thought you are played fool of if you don't keep reading. Because it didn’t accomplish the Google Photos images layout as you expected.

Because of the same height in each row, most images are not displayed completely. And this layout are totally unrelated to the marvelous Google Photos / 500px layout.

The real post starts from here, below approche came after the above method after a long while. The above content just introduce the solution for some corner cases.

We can see that the above solution doesn't make every image to display completely. If you need to accomplish 500px layout, the height of each row is most likely to not equal to each other in that layout.

For the start, I guessed CSS can dealt with nothing more than the above extent.
However, what I needed later proved me wrong:

I want to display some content in a square container, and I want the square container to always spread with the window without spare room (except the blank between the elements) regardless of the browser window’s width. At first glance, this may require the JS participation: read the browser window's width first, then calculate the size of a square container, and then render.

Open this demo to see the effect, try to resize or zoom the page: http://jsbin.com/tomipun/4/edit?html,css,output
image

During the process of resize / zoom the page, the square container will grow or shrink in real-time but will always be square, and its size is remaining in a specific range. It will grow to a point, later it wanes. If we only focus on one demo, it’s hard to come up with the solution to achieve the fixed aspect ratios. But if there is a square container and its side length is the half of the browser width, you guys might know the solution.

As we know, if we specify margin or padding value by percentage, the value is relative to it's parent element's width. Namely, if we give a block element 100% for its padding-bottom and its height setting to 0, the element will keep to square and it will change with the container height. If we want to change the size of the square, we only need to change the width of the parent container, the height will change appropriately.

Check this link:
http://jsbin.com/lixece/1/edit?html,css,output
the colored block will grow or shrink but will remains square while we resize the window. If we take browser window as a reference, this effect can be achieved with vw / vh in modern browser. If not, we can choose vertical padding to accomplish.

Then it occurs to me, if we do not set flex item's height and let it stretch by its child element, and the child element's width is 100% and padding-bottom is 100%, then both flex item the child element will keep square at the same time. As a result, the above square grid layout can be finished.

This is far away from the perfect result. If the element number in the last row differs from the number of the previous rows, color block in the last row will be larger due to growing. How to make the last row’s elements stay the same with the previous row? It doesn’t work if we use a extra element and set a large flex-grow value to fill the spare room in the last row. We need keep the last row's element to grow same space with the previous rows.

To be honest, the solution is simple. We just treat the last row to be not the last row, we treat the second last row as last row!

We can add some placeholder elements to let the last elements to be in the visual last row. And the placeholder elements occupied the real last row. Then change the placeholder height to 0. How many placeholders we need? The number should stay the same with the posible maximum of elements per row. Such as, the above demo has 8 extra placeholders which you can check in the source code. For better semantic meaning, we can give them special class name to act as placeholders.

In this way, the square layout which fills in the horizontal width can be accomplished(which is the previous demo,and you can check the source code or inspect to see the placeholder elements).

Although the most advanced flexbox layout is adopted, CSS can’t accomplish the perfect layout without cropping. So, I thought the essay might stop here.

- FAKE EOF -

But when I woke up on the morning of April 2,an idea came into my mind that since you can always keep the container to square, would it mean that you can keep it in any proportion? Certainly yes, we only have to set the padding-bottom of the child element which stretching its parent element a value we want! In this way, maybe the layout can be achieved only by CSS!

Certainly, as I mentioned before, because of the slow loading of images, this layouts should often know the width and height of the image in advance to pre-render the container, and directly put the image into it after the image is loaded or loading.

So we still need JS or server to calculate the images' aspect ratio, and set it in the padding-bottom to ensure that the aspect ratio of the container is always the same to its internal images.

Firstly, we display all the images in the height of 200px, as shown below:

<div style="display:flex;flex-wrap:wrap;">
  //The below formula calculate the value of the image width when its height is 200
  <div ng-repeat="img in imgs" style="width:{{img.width*200/img.height}}px;">

    //This formula set the proportion of the element and its parent element the same as the original proportion of the image. because it's vertical padding, it is the height divided by the width and then multiples 100 because the pencentage mark.
    <div style="padding-bottom:{{img.height/img.width*100}}%"></div>

  </div>
</div>

In the above layout, because of the flex-wrap, every row will be breaked and extra spare room will be left if there are too many images in one row. The aspect ratio of each container will always stays the same with the images which we will put into it later.
For demonstration purpose,I will set the size of the image a quarter of the container, it should be clear that the bottom right corner of the image is at the center of the container.

Demo: http://jsbin.com/tisaluy/5/edit?html,css,output
image

Next, we need to make all the elements to grow. Could we set all their flex-grow to 1?

In fact, if we tried, we will know 1 is not right. Because we need every container can keep its proportion when it grows.

Demo:http://jsbin.com/tisaluy/6/edit?html,css,output
image

If we set the flex-grow of flex item to 1, the container proportion is different from the images then. In this demo I set the images height to the container’s height for better observation.

Through some simple calculations, we will find that the width of each image(which is also the container) in the horizontal direction is just the percentage that its with from the total with of all images in this row.

In the case that it were not growed, the container width of each picture would been prorated. The remaining space of each line, we also want it to distribute in the current proportion of the width of the container, so the value of each containers flex-grow, is just its width, but not with the px unit.

The final code are shwon as:

<div>flex,wrap
  //Acturally, flex-grow is allocated by proportion, so the `*200` in the second formula is not required. So we just need to change the previous “*200” as we need.
  <div style="width:{{img.width*200/img.height}}px;flex-grow:{{img.width*200/img.height}}" ng-repeat="img in imgs">
    <div style="padding-bottom:{{img.height/img.width*100}}%"></div>
  </div>
</div>

As a result, the current line will be filled with the container, and the same aspect ratio will be kept as the internal image which will put into the it:

Demo: http://jsbin.com/tisaluy/8/edit?html,css,output
image

As for how to deal with the last line, you can use a great element of flex-grow to fill the remaining space just as I described above.

After rendering the layout, you can feel free to resize and zoom, even without JS, the layout will not be in disorder.

Here, we finally achieve an image layout similar to Google Photos / 500px .

Summarize of the principles of this solution:

  1. When padding and margin are percentage, the width of its container are used as a reference.
  2. Use the flex-grow to allocate extra horizontal space according to the proportion of the image width
  3. Use the child elements with a fixed aspect ratio to stretch its parent and keep it to the same the aspect ratio.

Advantages of this layout:

  • No special algorithm are required to compute the information like height or width of each image before rendering
  • Do not need to recalculate the layout during resize or zoom
  • CSS solution is easy to integrate with any framework

Finally, we will talk about some of the shortcomings of this solution:

  • Obviously, we can only define a minimum height(e.g. 200px in the above all demos) of the images in each row, and then wait for it to grow, and there is no way to specify the height of each row's upper limit. although we can set up the max-height of them , the images of the container affected by max-height will not display completely. In fact, as long as the ratio of the picture are in a normal range, any single row could hardly have too large height.
  • the highest aspect ratio we allowed can be used when we encounters any image that exceed a certain number or aspect ratio. for example, met with a 1:5 images, we only use a 1:3 area to show it or you can adjust the order of the images, but this requires JS calculation. This search result page of 500px seemingly work in this way(unfortunately the link seems to have changed after I wrote this post). if you try to adjust the window width to a small value, you will find the images behind the first picture cannot display complete, because if it display completely, a single image will take up too large screen space, so it limits the height.
  • In addition, that are not likely to be ideal, because even if the images in last line nearly fill it, it can not because of placeholders, (you can adjust the width of the window to observe the complete demo above). But this situation can be avoided if JS calculation are used : when the last line almost filled, just fill it(I have came up a solution for this situation after two months when I wrote this post, I will probably write another post to address it).
  • When it comes to the standard of image layout, the closer every image dispay, the higher score it should be got. In theory ,if you use JS to compute the layout, the following optimal algorithm can be used: if more taller images are in one row, less images should be put in the row, then more space can leave for the taller images in this row, so that the display areas of the taller and wider images will be closer to each other. But if you do not change the order of the images it would not be able to do this kind of optimization in CSS. And if you do not change the order of the images, even if the JS algorithm are optimized, the display areas of the wider images may be too large in the row which have more taller images, it also not perfect because of imbalance.

The graceful degradation

Since flexbox is not supported in IE9 and below, graceful degradation of this layout is necessary. It should not be too bad to display these pictures in square on a browser which lack the flexbox support. Then float or inline-block can be used to break the line which it is not detailed here.

Finally, float the layout by day will create a layout like Google Photos which images can display in one row if images of consecutive days are few. Again, I came up with another method to address the Google Photos like layout which I'll write it in the next post.

That's all, folks! If you have any thoughts or questions, feel free to leave a comment!

Using CSS to detect and counting Prime Numbers

This post is translated from Chinese by me and my sister Tian Qiong, the main work is done by her and I would thank her much here!

In case you don't want to read all this, the final demo is here: https://xieranmaya.github.io/blog/css-prime.html, check the source code if you like.

Abstract

This article may involve below:

  • How to decide and select prime numbers
  • CSS counters and its scope
  • Pseudo elements
  • Generated content
  • Cascading Style
  • Flexbox layout

Inspiration

One day when I was reading the doc of nth-child pseudo-class, It occurs to me that whether the nth-child pseudo-class can be used to decide prime numbers. On the condition that it can be used to select the specific element that lies in every multiple of any number.

If I select all elements in the multiple location except themselves, the rest of the elements will be on the prime positions.

What an interesting thing! I write them down as soon as I thought of it. Here is the first version:

<style>
  li:first-child {
    color: grey;
  }
  li:nth-child(2n + 4) {
    color: grey;
  }
  li:nth-child(3n + 6) {
    color: grey;
  }
  li:nth-child(4n + 8) {
    color: grey;
  }
  li:nth-child(5n + 10) {
    color: grey;
  }
</style>
<ul>
  <li>01</li>
  <li>02</li>
  <li>03</li>
  <li>04</li>
  <li>05</li>
  <li>06</li>
  <li>07</li>
  <li>08</li>
  <li>09</li>
  <li>10</li>
</ul>

The above code turns all the elements that are not in the prime position into gray.

Notice that the parameter of the nth-child pseudo-classe is Xn + 2X instead of Xn + X, because n starts from 0. What we need to select is all X's multiples except for itself. So the min selected number is 2X. We only need to write until 5n + 10, because the multiples of 6 is more than 10.

All the above declaration block of selectors are the same, thus we can combine all the selectors into one combined selector.

<style>
  li:first-child,
  li:nth-child(2n + 4),
  li:nth-child(3n + 6),
  li:nth-child(4n + 8),
  li:nth-child(5n + 10) {
    color: grey;
  }
</style>
<ul>
  <li>01</li>
  <li>02</li>
  <li>03</li>
  <li>04</li>
  <li>05</li>
  <li>06</li>
  <li>07</li>
  <li>08</li>
  <li>09</li>
  <li>10</li>
</ul>

It looks much better now.

Highlight the prime numbers, Lowlight the non-prime numbers

The question is how to do if we want to highlight all the prime numbers. The selector in the above code didn’t select the prime numbers.

We can do it, easy. Make all the items red, and make non-prime number items into gray. Deal to the selector of non-prime number items have higher priority, thus make the prime numbers highlighted.

<style>
  /*优先级为 0,0,0,1*/
  li {
    color: red;
  }

  /*优先级为 0,0,1,1*/
  li:first-child,
  li:nth-child(2n + 4),
  li:nth-child(3n + 6),
  li:nth-child(4n + 8),
  li:nth-child(5n + 10) {
    color: grey;
  }
</style>
<ul>
  <li>01</li>
  <li>02</li>
  <li>03</li>
  <li>04</li>
  <li>05</li>
  <li>06</li>
  <li>07</li>
  <li>08</li>
  <li>09</li>
  <li>10</li>
</ul>

However a question remained, if we want to highlight all the prime numbers less than 100 by this method,the second combined selector would be 50 lines, it's too much.

Reduce the number of selectors

We can find through observation that: the li:nth-child(4n + 8) selector is not necessary to write: It chooses all the multiples of 4 except 4, but in fact li:nth-child(2n + 4) selector has selected all the 4's multiple items including 4. Similarly, we can deduce that if write li:nth-child(3n + 6) selector, it is not necessary to write li:nth-child(6n + 12), li:nth-child(9n + 18) etc. Selector that is the multiples of 3 in after n are all unnecessory to write.

Actually, if deleting all the unnecessary selectors, you will find that the coefficients before n are all prime numbers among the rest of selectors. If the coefficient before n is a non-prime number,all multiples of this non-prime number will be selected by the multiples of its prime factors. Namely, all multiples of a composite number is subset of the multiples of one prime factor, which makes all the coefficients before n that are composite number do not need to exist. This is similar to the screening method that we studied in primary school to find the prime numbers. And that's the screening process for the sieve of Eratosthenes method.

However, in order to filter more quickly,we can start from Xn + X * X to filter out the numbers for a factor X's multiples. Because if you have filtered out all the prime numbers multiples that are less than X, all the composite numbers smaller than X * X have been screened out. Since any composite number less than X * X must be able to find at least one prime number’s divisor less than X.

And based on the above rule, if we want to filter out all the prime numbers within M, we only need to filter out all the multiples of the prime number less than or equal to the square root of M.

Thus if we want to filter out all the prime numbers within 100, the following combined selector is enough:

<style>
  li {
    color: red;
  }
  li:first-child,
  li:nth-child(2n + 4),
  li:nth-child(3n + 6),
  li:nth-child(4n + 8),
  li:nth-child(5n + 10),
  li:nth-child(7n + 14) {
    color: grey;
  }
</style>

The maximum prime number which is less than or equal to the square root of 100 is 7, so it is enough that our selector writes until li:(7n + 14).

Code Amount Complexity(I invented this word)

In fact, after a big circle around, the principle of prime screening was proved in another form.

As a conclusion, we only need the number of prime numbers within Sqrt(M) selectors to screening all the number less than M.

Then, there is another question, how many prime numbers less than a certain number? In fact, our predecessors have already studied this problem:

The number of prime numbers within n is about n/ln(n). The larger the number n, the number of prime numbers is closer to the value of this formula. See here for more info on Wikipedia: Prime Counting Functions.

So we can probably use O(sqrt(n)/ln(sqrt(n)) CSS codes(more specifially, selectors) to filter out all the prime numbers within n. As for prime numbers less than 1000, we only need a selector of 12 combined selectors:

<style>
  li {
    color: red;
  }
  li:first-child,
  li:nth-child(2n + 4),
  li:nth-child(3n + 6),
  li:nth-child(5n + 10),
  li:nth-child(7n + 14),
  li:nth-child(11n + 22),
  li:nth-child(13n + 26),
  li:nth-child(17n + 34),
  li:nth-child(19n + 38),
  li:nth-child(23n + 46),
  li:nth-child(29n + 58),
  li:nth-child(31n + 62) {
    color: #ddd;
  }
</style>
<ul>
  <li>01</li>
  <li>02</li>
  <li>03</li>
  <li>04</li>
  <li>05</li>
  ...
  <li>996</li>
  <li>997</li>
  <li>998</li>
  <li>999</li>
  <li>1000</li>
</ul>

In the above code, the pseudo-class selector parameters are not written to Xn + X * X, because the use of 2X will make our code amount less. Since square occupies more digits than its double, for example, 4 times 2 is 8 but the square of 4 is 16 which is longer then 8.

Automatic counting

The problem appearing again, the above code, we still have to put a number into the li tag. These tags can be generated with JS. But for a Obsessive-Compulsive Disorderd geek, it makes us uncomfortable. What’s more, I mentioned that we would use CSS to decide and select prime numbers.

You may think that we can replace the ul tag by ol tag. In that case, li tags' list marker will automatically be numbers. It does make sense, but it is difficult to control the list item numbers' style by CSS currently. For example, if I want adjust its position, there will be no method to take.

Besides, even if we use ul tag instead of ol tag, the li tags' list marker can also be set to numbers too. Namely, setting list-style-type attribute of li element to decimal or decimal-leading-zero is the answer.

So, is there any way to generate these numbers with CSS?

Sure, Of course there is.

We can use CSS counters and generated content to insert these numbers.

<style>
  li {
    /*遍历 DOM 的过程中,每遇到 li 就让 nature-count 计数器变量的值加一*/
    /*Every time we encounter li, we make nature-count++*/
    counter-increment: nature-count;
  }
  li::before {
    /*在 li 的 before 伪元素中插入计数器变量 nature-count 当前的值*/
    /*insert the counter value as generated content by pseudo element*/
    content: counter(nature-count);
  }
</style>
<ul>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
</ul>

The rendering result looks like below:

image

About CSS counter, you can refer to MDN documents here

Since it can count numbers, then I wonder if it can count the number of the prime numbers and non-prime numbers.

We can easily count the number of non-prime numbers, because the previous li:nth-child selector selected those non-prime items, and we only need to increase the counter by 1 when we meet them:

<style>
  li {
    counter-increment: nature-count
  }
  li::before {
    content: counter(nature-count);
  }
  li:first-child,
  li:nth-child(2n + 4),
  li:nth-child(3n + 6),
  li:nth-child(5n + 10),
  li:nth-child(7n + 14) {
    color: grey;
    counter-increment: nonprime-count;
  }
</style>
<ul>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
</ul>

However, the rendering results are not as same as we expected:

image

分析原因,我们会发现,是因为非素数选择器的 counter-increment 属性把 li 选择器对应的这个属性覆盖了,CSS 在发生属性覆盖时,是不会将两个相同属性值联合起来的,而是会选择最终生效的那一个,此处对于素数位置上的 li 元素,显然是 counter-increment: nonprime-count; 这一句会生效。所以导致了当解析器遇到合数位置上的 li 元素时,只给 nonprime-count 计数器加了一,知道了原因,就很好解决了,我们让遇到这个元素时同时给自然数计数器和非素数计数器都加一:counter-increment: nature-count nonprime-count;

Moving to the reasons, we find that because the counter-increment attribute of the non-prime selector overwrites the attribute corresponding the li selector, that is, when we encounter a li tag in a non-prime position, only the nonprime-counter will increase, but not nature-counter and nonprime-counter both increase. CSS won’t combine the same two attribute values when attribute is overwritten, and it will choose the one which its selector has higher proitity. Here, for the li element on the non-prime position, obviously is counter-increment: nonprime-count; which will take precedence. So when the parser encountered the li element on the position, it only plus one to the nonprime-count counter. It is easy to solve this after knowing the reason. We can plus one to the nature-count counter and nonprime-count counter if non-prime position li is encountered: counter-increment: nature-count nonprime-count;

And, we got the correct result:

image

Show statistical results

We can add a tag in the back of ul so that the statistics displayed in it.

<style>
  li {
    counter-increment: nature-count
  }
  li::before {
    content: counter(nature-count);
  }
  li:first-child,
  li:nth-child(2n + 4),
  li:nth-child(3n + 6),
  li:nth-child(5n + 10),
  li:nth-child(7n + 14) {
    color: grey;
    counter-increment: nature-count nonprime-count;
  }
  p::before {
    content: '前' counter(nature-count) '个自然数中,有' counter(nonprime-count) '个合数' '' '' ;
  }
</style>
<ul>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
</ul>
<p></p>

But the results is out of our expectations again:

The two CSS counter variables obviously existed values, and it inserted into the li's pseudo element just now. Why it becomes a 0?

The scope of CSS counters

To understand this, we need to understand the concept of the scope of the CSS counters: the scope of a counter is only within the parent element of the outermost element that can have an effect on it.

In the above example, the count elements of the two counters are li, so these two counters are only valid within the parent element of the li, namely within ul. To solve this problem its also easy, we only need to make the outermost element affect the counter. We can make the counter to zero when encountering the body element, and in this way, this counter is available in the whole page:

<style>
  body {
    counter-reset: nature-count nonprime-count;
  }
  li {
    counter-increment: nature-count
  }
  li::before {
    content: counter(nature-count);
  }
  li:first-child,
  li:nth-child(2n + 4),
  li:nth-child(3n + 6),
  li:nth-child(5n + 10),
  li:nth-child(7n + 14) {
    color: grey;
    counter-increment: nature-count nonprime-count;
  }
  p::before {
    content: '前' counter(nature-count) '个自然数中,有' counter(nonprime-count) '个合数' '' '' ;
  }
</style>
<ul>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
</ul>
<p></p>

OK, here is the result what we want

image

Now we have counted the number of natural numbers and the number of composite numbers, but how to know the number of prime numbers?
We all know that CSS can not do subtraction. Moreover, the two values exist in CSS counter, calc function can only implement the calculation of the hard coded literal value, and the results can’t directly display the value.

所以我们必须要找到一种让素数计数器递增的方法。这就意味着,我们必须使用选择器选出素数项才可以!
好像有点无能为力了,nth-child 选出非素数好办,但是选出素数,肯定没有能够实现这件事的选择器了。
然而

So we have to find a way to increase the prime number's counter, which means that we have to use the selector to select prime numbers!
It seems a bit hopeless, nth-child can easily select non-prime numbers. As for the election of prime numbers, there is certainly no selector can achieve this goal.

however----

Every cloud has a silver lining

We still have the not pseudo-class selector! Since we can use nth-child pseudo-class to select all the composite numbers, these selectors can act as not pseudo-class selector's parameters. In that case, all the prime numbers can be selected.

li:not(:first-child):not(:nth-child(2n + 4)):not(:nth-child(3n + 6)) {
  color: red;
  counter-increment: prime-count nature-count;
}

Pseudo-class selectors can be combined together, so we can combine some not pseudo-class selectors, and make the selector of the composite number to be not's parameters. By this method, the purpose of only selecting prime numbers can be achieved. And then we add a counter to the selector, in order to achieve the aim of counting the prime numbers.

Only the last question, that is, the statistics result is always displayed in the bottom. It works good if data is relatively small. But if data is large, it works not so good because you have to scroll to the page bottom if you want to see the statistics result.

Supposing that we move the p tag to the front of ul, the statistics data will show 0, because the value of each counter variable is still 0. This is why the p tag must appear behind ul in the DOM structure.

We can surely use the absolute positioning to move p tag to the top, but it is not so easy to control.

If you can ask the counter to have a value, as well as have no absolute positioning, besides let the contents of the p tag appears in front of ul, it would be nice.

There is still a method, that is we can use flex layout's order attribute. It can change the order of the elements displayed in the document while not changing the DOM structure. Because the counting of counter is only related to the DOM structure, it won’t affect the correctness of the statistical results.

The final code is as follows:

<style>
  body {
    /*用body元素的counter-reset属性重置三个计数器以使它们的作用域在整个body内*/
    /*make the counters global by reset them by body tag*/
    counter-reset: nature-count prime-count nonprime-count;
    display: flex;
    flex-direction: column;
  }

  li {
    list-style-type: none;
    display: inline-block;
  }

  /*在before伪元素中插入计数器的值以实现数值递增*/
  /*insert counter value into li tag*/
  li::before {
    content: counter(nature-count) ',';
  }
  li:last-child::before {
    content: counter(nature-count);/*最后一个元素不需要逗号分隔*/
    /*the last element do not neet a comma after it*/
  }

  /*合数项选择器*/
  /*non-prime selector*/
  li:first-child,
  li:nth-child(2n + 4),
  li:nth-child(3n + 6),
  li:nth-child(5n + 10),
  li:nth-child(7n + 14) {
    /*递增自然数与合数计数器*/
    /*increase nature and non-prime counter*/
    counter-increment: nature-count nonprime-count;
    color: #ddd;/*合数变灰*/
    /*如果想只显示素数项,可以把合数全部隐藏起来*/
    /*display为none并不影响计数器的计数*/
    /*display: none;*/
  }
  /*素数项选择器*/
  /*prime selectors*/
  li:not(:first-child):not(:nth-child(2n + 4)):not(:nth-child(3n + 6)):not(:nth-child(5n + 10)) {
    /*递增自然数与素数计数器*/
    counter-increment: nature-count prime-count;
    color: red;/*素数变红*/
  }

  p {
    order: -1;/*让p元素显示在ul的前面*/
  }
  p::before {
    /*通过p标签的before伪元素插入统计结果*/
    content: '前 ' counter(nature-count) ' 个自然数中,有 ' counter(nonprime-count) ' 个合数,' counter(prime-count) ' 个素数' ;
  }
</style>
<ul>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
</ul>
<p></p>

You can add any number of li tag at any time to the ul to display a wider range of prime numbers and statistical results without changing the code anywhere else.

Rendering results are displayed as follows, the subject is the rendering effect of 1000 numbers:

image

Complete demo is here: CSS Prime

使用纯 CSS 实现 Google Photos 照片列表布局

使用纯 CSS 实现 Google Photos 照片列表布局

文章太长,因为介绍了如何一步一步进化到最后接近完美的效果的,不想读的同学可以直接跳到最后一个大标题之后看代码、demo及原理就好,或者也可以直接看下面这个链接的源代码。

不过还是建议顺序读下去,因为后面的原理需要前面的内容做为铺垫,主要是在处理边角问题上。

先看下效果吧,要不然各位可能没动力读下去了,实在是有点长,可以试着 resize 或者 zoom 一下看看效果: https://xieranmaya.github.io/images/cats/cats.html

image

另外后面一些 demo 为了方便展示源代码用了 jsbin,但 jsbin 偶尔抽风会显示不出效果,试着在源代码编辑框里不改变代码意思的情况下编辑一下(比如在最后打一下回车)应该就可以了,或者查看一下你浏览器的翻墙设置,因为里面引入了 Google CDN 上的文件,有可能是因为 js 加载不成功导致的。

好了,正文开始。

开始之前,先对比一下三种比较常见的图片布局的差异

  1. 花瓣
    • 此种布局为比较常见的等宽布局,所有图片的宽度是一样的
    • 由于图片是等比拉伸(等比拉伸的意思是图片的宽和高变化相同的比例,也就是图片展示的宽高比与原始宽高比一致),而每张图片的宽度又是一样的,所以图片的高度就必然不一样了
    • 这种布局的缺点是,由于每张图片展示的高度的不一致,图片不是按一般的阅读顺序展示的,因为可能连续的多张图片顶部的高度不一样,而人眼又习惯于水平扫描,所以用户就有可能漏看某些照片
    • 图片瀑布的底部一般是对不齐的
    • 虽然底部很难完全对齐,但使用 JS 对图片顺序进行重排能够让底部尽量对齐,所以此种布局在 reflow 的时候(比如resize,zoom)必然要有 JS 的参与
  2. Google Photos,500px,图虫等,以 Google Photos 为代表的即不等宽也不等高的图片布局有如下特点:
    • 图片也没有被非等比拉伸
    • 每行的图片在水平方向上也占满了屏幕,没有多余的空白
    • 因为以上两个条件,所以每行的图片高度必然会不一样,否则无法做到图片在水平方向上占满屏幕
    • 图片是按顺序展示的,比较符合人眼阅读顺序,Google Photos 因为照片有拍摄时间这个属性,必须满足这个条件
    • 底部是对齐的
    • Google Photos 的布局中,当某几个日期的照片太少时,多个日期的照片会合并展示在同一行,当然这不是本文讨论的重点
  3. Instangram
    • 正方形图片布局,就不多说了。。。

以上介绍的前两种布局都有一个共同点,那就是图片没有经过非等比拉伸,也就是说图片里的内容没有变形,也没有被裁剪,只是放大或者缩小,这是目前图片类应用在展示图片上的一个趋势,应该说,很少有专做图片的网站会把照片非等比拉伸显示(变形拉伸真的给人一种杀马特的感觉。。。),最次的也就是把图片等比拉伸后展示在一个正方形的区域里,类似于正方形容器的 background-size: cover; 的效果。

另外,在花瓣的布局中,比较宽的图片展示区域会比较小;而在第二种布局中,则是比较高的图片展示区域会比较小。

但是,在第一种布局中,因为宽度是定死了的,所以高宽比小到一定程度的图片,显示区域会非常小。而在第二种布局中,因为不同行的高度是不一样的,如果比较高的图片出现在比较高的行,还是有可能展示的稍大些的。

总体来说,以 Google Photos 为代表的图片布局,在显示效果上更优。关于如何使用 JS 来完成 Google Photos / 500px 布局的算法,这里就不讨论了,读者可以自己思考一下~

下面根据上面的分析稍微总结一下评判图片布局优劣的一些标准:

  1. 是否能尽量按原始列表中的顺序输出
  2. 能否按人眼的扫描顺序输出,即行高相同
  3. 图片能否按照原始比例展示,或者尽量按原始比例展示
  4. 每张图片的展示面积能否尽量接近,实际上在想完全展示照片的布局中,这一条是很难达成的
  5. 图片不被非等比拉伸,内容不变形,内容展示完全

第一次看到类似 Google Photos 照片列表的布局已经不记得是在哪里了,当时只是觉得这种布局肯定需要 JS 参与,因为每行图片高度相同的情况下不可能那么恰到好处的在容器两端对齐,且所有图片之间的间距大小也一样(如果间距大小不一样但两端对齐,可以使用 inline 的图片加上 text-justify 来实现,在图片较小的时候(比如搜索引擎的图片结果)也不失为一种选择),通过观察,发现每行的高度并不相同,就确认了必然需要 JS 参与才能完成那样的布局。

然而当越来越多的开始网站使用这样的布局时,做为一个热衷于能用 CSS 实现就不用 JS 的前端工程师,我就在考虑,能否仅用 CSS 实现这样的布局呢,尤其是不要在 resize 时重新计算布局?

在经过一些尝试后,我发现可在一定程度上用纯 CSS 实现类似的布局,这里说的一定程度上仅使用 CSS 实现布局,我的意思是,布局一但渲染完成,布局后序的 resize,zoom 都可以在没有 JS 参与的情况下保持稳定,也就是说,首次的渲染甚至可以通过服务器完成,整个过程可以没有 JS 参与,所以说是用纯 CSS 实现也不过分。

实现过程

下面就来介绍一下我是如何只通过 CSS 一步一步实现的这个布局的

一开始,我们将图片设置为相同的高度:

<style>
  img {
    height: 200px;
  }
</style>
<div>
   <img src="" />
   <img src="" />
   <img src="" />
   <img src="" />
   <img src="" />
</div>

这样并不能让图片在水平方向上占满窗口,于是我想到了 flex-grow 这个属性,让 img 元素在水平方向变大占满容器,整个布局也变成了 flex 的了:

div {
  display: flex;
  flex-wrap: wrap;
}
img {
  height: 200px;
  flex-grow: 1;
}

把 flex container 的 flex-wrap 设置为 wrap,这样一行放不下时会自动折行,每行的图片图片因为 grow 的关系会在水平方向上占满屏幕,效果看上去已经很接近我们想要的了,但每张图片都会有不同程度的非等比拉伸,图片的内容会变形,这个好办,可以用 object-fit: cover; 来解决,但这么一来图片又会被裁剪一部分。

最终 demo: http://jsbin.com/tisaluy/1/edit?html,css,js,output

不过上述的 DOM 结构显然是没办法在实际中使用的:

  • 不支持 object-fit 的浏览器下图片会变形,因为图片没有容器,所以也没办法用 background-size 来解决这个问题
  • 用了 object-fit 的浏览器下,图片会被裁剪一部分,这两条前面已经说过
  • 没办法跟图片一起展示一些相关的信息,因为是 img 裸标签
  • 另外就是在真实的网络环境中,图片的加载都是比较比较慢的,如果指望用图片自己来把布局撑开,用户肯定会看到非常多的闪烁,demo 里的闪烁应该已经非常明显了

所以我们上面的这个布局事实上是没办法用于任何生产环境的。

接下来我们把 DOM 结构改成下面这样的:

<section>
   <div>
      <img src=""/>
   </div>
   <div>
      <img src=""/>
   </div>
   <div>
      <img src=""/>
   </div>
   <div>
      <img src=""/>
   </div>
</section>

我们为图片增加了一个容器。依然把图片设置为定高,如此一来,每个 div 将被图片撑大,这时如果我们给 div 设置一个 flex-grow: 1; ,每个 div 将平分每行剩余的空间,div 会变宽,于是图片宽度并没有占满 div,如果我们将 img 的 width 设置为 100% 的话,在 IE 和 FF 下,div 已经 grow 的空间将不会重新分配(我觉得这是个很有意思的现象,图片先把 div 撑大,div grow 之后又把图片拉大),但在 Chrome 下,为 img 设置了 width: 100%; 之后,grow 的空间将被重新分配(我并没有深究具体是如何重新分配的),会让每个容器的宽度更加接近,这并不是我们想要的。试了几种样式组合后,我发现把 img 标签的 min-width 和 max-width 都设置为 100% 的话,在 Chrome 下的显示效果就跟 IE 和 FF 一样了。最后我们将 img 的 object-fit 属性设置为 cover,图片就被等比拉伸并占满容器了,不过与前一种布局一样,每行的高度是一样的,另外图片只显示了一部分,上下两边都被裁剪掉了一些。

上面布局完整的 demo: http://jsbin.com/tisaluy/2/edit?html,css,output

在这种布局下,如果图片高度设置的比较小,布局已经没有什么大碍,因为图片越小就意味着每行图片越多而且剩余的空间越小并且剩余空间被更多的图片瓜分,那每个容器的宽高比就越接近图片的真实宽高比,多数图片都能显示出其主要部分。

唯一的问题是最后一行,当最后一行图片太少的时候,比如只有一张,因为 grow 的关系,它将占满一整行,而高度又只有我们设置的 200px,这时图片被展示出来的部分可能是非常少的,更不用说如果图片本身上比较高,而展示区域又非常宽的情况了。

针对这种情况,我们可以让列表最后的几张图片不 grow,这样就不至于出现太大的变形,我们可以算出每行的平均图片数量,然后用

div:nth-last-child(5),
div:nth-last-child(4),
div:nth-last-child(3),
div:nth-last-child(2),
div:nth-last-child(1){
  flex-grow: 0;
}

然后配合 media query,在屏幕不同宽度时,让"最后一行"的元素个数在窗口宽度变化时也动态变化:

@media (max-width: 1000px) and (min-width: 900px) {
  div:nth-last-child(5),
  div:nth-last-child(4),
  div:nth-last-child(3),
  div:nth-last-child(2),
  div:nth-last-child(1){
    flex-grow: 0;
  }
}
@media (max-width: 1100px) and (min-width: 1000px) {
  div:nth-last-child(7),
  div:nth-last-child(6),
  div:nth-last-child(5),
  div:nth-last-child(4),
  div:nth-last-child(3),
  div:nth-last-child(2),
  div:nth-last-child(1){
    flex-grow: 0;
  }
}

上面的代码写起来是相当麻烦的,因为每个屏幕宽度范围内又要写多个 nth-last-child 选择器,虽然我们可以用预处理器来循环迭代出这些代码,但最终生成出来的代码还是有不少重复。

有没有办法只指定最后多少个元素就行了,而不是写若干个 nth-last-child 选择器呢?其实办法也是有的,想必大家应该还记得 CSS 的 ~ 操作符吧,a ~ b 将选择在 a 后面且跟 a 同辈的所有匹配 b 的元素,于是我们可以这么写:

div:nth-last-child(8),
div:nth-last-child(8) ~ div{
  flex-grow: 0;
}

先选中倒数第 8 个元素,然后选中倒数第 8 个元素后面的所有同辈结点,这样,就选中了最后的 8 个元素,进一步,我们可以直接将选择器改写为div:nth-last-child(9) ~ div,就可以只用一个选择器选择最后的 8 个元素了。

上面的几种选择尾部一些元素的不同选择器,实际上效果是不太一样的:

  • div:nth-last-child 的选择方法能保证倒数的 n 张图片一定被选中
  • div:nth-last-child(n), div:nth-last-child(n) ~ div 只能保证当 div 元素至少有 n 个时才能选中最后的 n 个元素,因为如果 :nth-last-child(n) 不存在,这个选择器就无效了
  • div:nth-last-child(n+1) ~ div 则需要保证 div 元素至少有 n+1 个时才能选中最后的 n 个元素,原理同上

选择最后若干张图片这种方式还是不够完美,因为你无法确定你选择的 flex item 一定在最后一行,万一最后一行只有一张图片呢,这时倒数第二行的前几张图片就会 grow 的很厉害(因为后面几张不 grow),或者最后两行图片的数量都没有这么多张,那倒数第二行就没有元素 grow 了,就占不满这一行了,布局就会错乱。

那么有没有办法只让最后一行的元素不 grow 呢?一开始我也了很多,甚至在想有没有一个 :last-line 伪类什么的(因为有个 :first-line),始终没有找到能让最后一行不 grow 的方法,然而最后竟然在搜索一个其它话题时找到了办法:

那就是在最后一个元素的后面再加一个元素,让其 flex-grow 为一个非常大的值比如说 999999999,这样最后一行的剩余空间就基本全被这一个元素的 grow 占掉了,其它元素相当于没有 grow,更进一步,我们可以用伪元素来做这件事(不过 IE 浏览器的伪元素是不支持 flex 属性的,所以还是得用一个真实的元素做 placeholder):

section::after {
  content: '';
  flex-grow: 999999999;
}

到这里,我们基本解决这个布局遇到的所有问题。

Demo,resize 或者 zoom 然后观察最后一行的图片:http://jsbin.com/tisaluy/3/edit?html,css,output

但还有最后一个问题,同前一种布局一样,如果你在线上去加载使用这种方式布局的网页,你会发现页面闪动非常厉害,因为图片在下载之前是不知道宽高的,我们并不能指望图片加载完成后让它把容器撑大,用户会被闪瞎眼。其实真正被闪瞎的可能是我们自己,毕竟开发时要刷新一万零八百遍。

所以,我们必须预先渲染出图片的展示区域(实际上几乎所有图片类网站都是这么做的),所以这里还是要小用一些 js,这些工作也可以在服务器端做,或者是用任何一个模板引擎(下面的代码使用了 angular 的模板语法)。

这个布局一旦吐出来,后续对页面所有的动作(resize,zoom)都不会使布局错乱,同时也不需要 JS 参与,符合前文所说的用纯 CSS 实现:

<style>
  section {
    padding: 2px;
    display: flex;
    flex-wrap: wrap;
    &::after {
      content: '';
      flex-grow: 999999999;
    }
  }
  div {
    margin: 2px;
    position: relative;
    height: 200px;
    flex-grow: 1;
    background-color: violet;
    img {
      max-width: 100%;
      min-width: 100%;
      height: 200px;
      object-fit: cover;
      vertical-align: bottom;
    }
  }
</style>
<section>
    // 下一行的**表达式**是计算当图片以 200 的高度等比拉伸展示时宽度的值
    <div ng-repeat="img in imgs" style="width:{{img.width*200/img.height}}px;"></div>
</section>

到这里,我们才算实现了图片的非等宽布局。

Demo,注意 html 模板里计算宽度的表达式:http://jsbin.com/tisaluy/4/edit?html,css,output

那么这个布局的展示效果究竟如何呢?

实际上我专门写了代码计算每张图片被展示出来的比例到底有多少:在图片高度为 150px 左右时,约有三分之一的图片展示比例在 99% 以上。最差的图片展示比例一般在 70% 左右浮动,平均每张图片展示比例在 90% 以上。图片越矮,展示效果会越好;图片越高,展示效果就越差。

因为这种方案最后也被我抛弃了,所以就不放计算展示比例的 demo 了。

看到这里,你应该是觉得被坑了,因为这并没有实现标题中说的 Google Photos 照片列表的布局

因为每行的高度是一样的,就必然导致大部分图片没有完全展示,跟 Google Photos / 500px 那些高大上的布局根本就不一样!

可是正文从现在才正式开始,下面介绍的方式也是我在实现了上面的布局后很久才想出来的,前面的内容只是介绍一些解决边角问题用的。

可以看到,前面的实现方式并没有让每张图片的内容全部都显示出来,因为每行的高度是一样的,而想要实现 500px 的布局,每行图片的高度很多时候是不一样的。

一开始我觉得,CSS 也就只能实现到这种程度了吧,直到我遇到了另一个需求:

我想用一个正方形的容器展示内容,并且希望无论浏览器窗口多宽,这些正方形的容器总是能铺满窗口的水平宽度而不留多余的空间(除了元素之间的空白),乍一看这个需求可能需要 JS 参与:读出当前浏览器窗口的宽度,然后计算正方形容器的 size,然后渲染。

可以看这个 demo,试着拉动一下窗口宽度然后看效果:
http://jsbin.com/tomipun/4/edit?html,css,output

拉动过程中可以看到,正方形的容器会实时变大,大到一定程度后又变小让每行多出一个正方形容器。 如果只看这一个 demo,可能各位不一定能一下子想到如何实现的,但如果只有一个正方形容器,它的边长总是浏览器宽度的一半,想必很多人都知道的,长宽比固定的容器要怎么实现吧?

我们知道(事实上很多人都不确定,所以这可以做为一个面试题),margin 和 padding 的值如果取为百分比的话,这个百分比是相对于父元素的宽度的,也就是说,如果我给一个 block 元素设置 padding-bottom(当然,也完全可以是 padding-top,甚至可以两个一起用~)为 100% 的话,元素本身高度指定为 0,那么这个元素将始终是一个正方形,并且会随着容器宽度的变化而变化,想要改变正方形的大小,只需要改变父容器的宽度就可以了:

看这个的 demo:
http://jsbin.com/lixece/1/edit?html,css,output
拉动窗口可以看到色块会变大,但始终保持正方形。当然,如果参照物是浏览器窗口,那么在现代浏览中,这个效果可以用 vw / vh 实现;但如果参照物不是浏览器窗口,就只能用垂直 padding 来实现了。

于是我就想到,如果不给 flex item 的元素设置高度,而是让其被一个子元素撑开,并且这个子元素的宽度是100%,padding-bottom 也是 100%,那么 flex item 及这个用来撑大父元素的子元素就会同时保持为正方形了,于是就实现了上面的那种正方形阵列布局。

但仅仅这样还不够,最后一行又会出问题,如果最后一行的元素个数跟前面的行不一样的话,它们虽然会保持正方形,但是因为 grow 的关系,会比较大,那如何保证最后一行的元素也跟前面的行大小相同呢,这时使用一个元素并设置很大的 flex-grow 让其占满最后一行剩余空间的做法已经不可行了,因为我们需要让最后一行的元素恰到好处的跟前面行的元素 grow 时多出一样的空间。

其实解决方案也很简单,把最后一行不当最后一行就行了!此话怎讲呢?

在最后添加多个占位符,保证可见的最后一个元素永远处于视觉上的最后一行,而让占位符占据真正的最后一行,然后把这些占位符的高度设置为 0 。具体添加多少个占位符呢?显然是一行最多能显示多少个元素,就添加多少个了,比如前面的 demo 就添加了 8 个占位符,你可以在源代码里面看一下。另外为了更好的语义,其实可以用其它的标签当做占位符。

这样一来,始终能占满水平宽度的正方形阵列布局也实现了。

本来我以为,到这里就结束了,即使用上最先进的 flexbox 布局,CSS 也无法实现图片不裁减的完美布局。

- FAKE EOF -

4 月 2 号的早上我醒来的时候,突然想到,既然可以让一个容器始终保持正方形,那岂不是也可以让这个容器始终保持任何比例?显然是可以的,只要我们把用于撑大父元素的那个元素的 padding-bottom 设置为一个我们想要的值就可以了!这样一来,说不定可以实现图片布局中,所有图片都完全展示且占满水平宽度的布局(也就是 Google Photos / 500px 的布局)!

当然,前面提到过,由于图片加载缓慢,图片布局往往都会提前知道图片的宽高来进行容器的预渲染,然后图片加载完成后直接放进去。

所以这里我们仍然需要用 JS 或者服务器来计算一下图片的宽高比例,然后设置到 padding-bottom 上面去,以保证容器的宽高比始终是其内部图片的宽高比。

我们先让所有图片以 200px 的高度展示,写出如下模板代码:

<div style="display:flex;flex-wrap:wrap;">
  <div ng-repeat="img in imgs" style="width:{{img.width*200/img.height}}px;">这个公式计算了图片高度为200时的宽度的值
    <div style="padding-bottom:{{img.height/img.width*100}}%"></div>这个公式让此元素及其父元素的比例与图片原始比例相同,因为是垂直方向的 padding,所以是高度除以宽度
  </div>
</div>

在上面布局中,因为 flex-wrap 的关系,每一行不够放的时候后面的内容就会折行,并且留出一些空白,每个容器的宽高比都是跟未来放入其内部的图片的宽高比是一样的,为了便于展示,我将图片大小设置为容器大小的四分之一,应该明显可以看出图片的右下角处于容器的中心位置。

Demo: http://jsbin.com/tisaluy/5/edit?html,css,output

下一步,我们只需要让所有的元素 grow 就可以了,那么是把所有的元素的 flex-grow 设置为 1 吗?

实际上如果设置了并看了效果,我们会发现并不是,因为我们希望每行元素在 grow 的时候,保持原有比例且高度相同。

Demo:http://jsbin.com/tisaluy/6/edit?html,css,output
可以看到如果给所有的 flex item 设置 flex-grow: 1; 的话,容器跟图片的比例并不一致,这里我将图片宽度设置了为容器的宽度以便观察。

通过一些简单的计算我们会发现,每行图片高度一致的时候,每张图片在水平方向上占用的宽度正好是其宽度在这一行所有图片宽度之和中所占的比例。

在前面不 grow 的情况下,每张图片的容器的宽度已经是按比例分配了,而每行的剩余空间,我们希望它仍然按照目前容器宽度所占的比例来分配,于是,每个容器的 grow 的值,正好就是它的宽度,只不过不要 px 这个单位。

最终的代码如下:

<div>flex,wrap
  //实际上因为 flex-grow 是按比例分配,所以第二个公式里的 *200 可以不要,这要我们就只需要改前一个 200 了
  <div style="width:{{img.width*200/img.height}}px;flex-grow:{{img.width*200/img.height}}" ng-repeat="img in imgs">
    <div style="padding-bottom:{{img.height/img.width*100}}%"></div>
  </div>
</div>

这样一来,容器会占满当前行,并且保持与未来内部所放入的图片相同的宽高比:

Demo: http://jsbin.com/tisaluy/8/edit?html,css,output

至于最后一行怎么处理,前面已经介绍过了,用一个 flex-grow 极大的元素占满剩余空间就可以了。

这种布局在渲染完成后,你可以放心的 resize 和 zoom,布局都不会错乱,而且没有 JS 的参与。

到这里,我们终于实现了类似 Google Photos / 500px 网站的图片布局。

总结一下这个方案的原理:

  1. padding(以及 margin)为百分比时是以容器的宽度为参照的
  2. 使用 flex-grow 来按图片宽度所占的比例分配水平空间
  3. 使用宽高比固定的子元素撑大带有指定比例 flex-grow 的 flex item 以实现不同行高度不一样并保持宽高比

这种布局的优点:

  • 不需要特殊的算法去计算每张图片渲染之后的宽高等信息
  • 不需要在 resize,room 时重新计算布局
  • CSS 的方案容易跟任何框架集成

最后说一下这种方案的一些缺点:

  • 很明显,我们只能指定每行图片的最低高度,然后等着它 grow,并没有办法指定每行高度的上限是多少。虽然我们可以设置一下容器的 max-height,这样一来被 max-height 影响的那些容器里面的图片就展示不完全了。实际上只要图片的比例都在一个正常的范围,是不会出现某一行的高度过高的
  • 在遇到比例超出某个范围的图片时可以只用我们允许的最大比例展示这张图片,比如说遇到了一张 1:5 的图片,我们只以 1:3 的区域来展示它。或者也可以调整一下图片的顺序,但这就需要 JS 参与了。 500px 的这个搜索结果页面貌似就是这么做的,试着把窗口宽度调小,会发现第一张后面的图片展示不完全,因为如果展示完全的话,单张图片就占用了太大的屏幕面积,所以它限定了高度。
  • 另外,最后一行的图片如果几乎要占满那一行时,因为占位符的存在,并不会占满,会显得不太理想(读者可以调整窗口宽度观察上面的完整 demo)。这种情况如果是使用 JS 计算的话,是可以避免的:在发现最后一行几乎要占满时,直接让其占满最后一行。
  • 我们在提到评价图片布局优劣的标准时候说到,每张图片展示区域越接近,评分应该也越高。理论上如果使用 JS 来计算布局,可以在算法上做如下优化:如果一行中比较高的图片比较多,那么这一行就少放些图片,留出更多的空间用来放大图片,这样就能让高图和宽图显示面积更接近一些了。而用 CSS 的方案如果不改变图片顺序就没法做这种优化了。不过如果不改变图片顺序,即使算法做了这些优化,出现在高图比较多的行里的宽图,展示面积会更大,会出现一种比例失调的情况,也不够完美。

关于降级

由于 IE 9 都是不支持 flexbox 的,所以这个方案必然需要优雅降级,在不支持的浏览器上,让图片都以正方形展示应该也不会太差,然后用 float 或者 inline-block 来折行,这里就不细说了。

最后,多个这种布局 float 一下,就可以实现 Google Photos 那种某几个连续日期的图片太少时,展示在同一行的效果。读者可以自行试一下,就不放 demo 了。

本文到此结束,谢谢围观!文中如有纰漏之处,还请各位大神留言指正

从如何停掉 Promise 链说起

在使用Promise处理一些复杂逻辑的过程中,我们有时候会想要在发生某种错误后就停止执行Promise链后面所有的代码。

然而Promise本身并没有提供这样的功能,一个操作,要么成功,要么失败,要么跳转到then里,要么跳转到catch里。

如果非要处理这种逻辑,一般的想法是抛出一个特殊的Error对象,然后在Promise链后面的所有catch回调里,检查传来的错误是否为该类型的错误,如果是,就一直往后抛,类似下面这样

doSth()
.then(value => {
  if (sthErrorOccured()) {
    throw new Error('BIG_ERROR')
  }
  // normal logic
})
.catch(reason => {
  if (reason.message === 'BIG_ERROR') {
    throw reason
  }
  // normal logic
})
.then()
.catch(reason => {
  if (reason.message === 'BIG_ERROR') {
    throw reason
  }
  // normal logic
})
.then()
.catch(reason => {
  if (reason.message === 'BIG_ERROR') {
    throw reason
  }
  // normal logic
})

这种方案的问题在于,你需要在每一个catch里多写一个if来判断这个特殊的Error,繁琐不说,还增加了耦合度以及重构的困难。

如果有什么办法能直接在发生这种错误后停止后面所有Promise链的执行,我们就不需要在每个catch里检测这种错误了,只需要编写处理该catch块本应处理的错误的代码就可以了。

有没有办法不在每个catch里做这种判断呢?

办法确实是有的,那就是在发生无法继续的错误后,直接返回一个始终不resolve也不reject的Promise,即这个Promise永远处于pending状态,那么后面的Promise链当然也就一直不会执行了,因为会一直等着。类似下面这样的代码

Promise.stop = function() {
  return new Promise(function(){})
}

doSth()
.then(value => {
  if (sthBigErrorOccured()) {
    return Promise.stop()
  }
  // normal logic
})
.catch(reason => {// will never get called
  // normal logic
})
.then()
.catch(reason => {// will never get called
  // normal logic
})
.then()
.catch(reason => {// will never get called
  // normal logic
})

这种方案的好处在于你几乎不需要更改任何现有代码,而且兼容性也非常好,不管你使用的哪个Promise库,甚至是不同的Promise之间相互调用,都可以达到目的。

然而这个方案有一个不那么明显的缺陷,那就是会造成潜在的内存泄露。

试想,当你把回调函数传给Promise的then方法后,如果这时Promise的状态还没有确定下来,那么Promise实例肯定会在内部保留这些回调函数的引用;在一个robust的实现中,回调函数在执行完成后,Promise实例应该会释放掉这些回调函数的引用。如果使用上述方案,那么返回一个永远处于pending状态的Promise之后的Promise链上的所有Promise都将处于pending状态,这意味着后面所有的回调函数的内存将一直得不到释放。在简单的页面里使用这种方案也许还行得通,但在WebApp或者Node里,这种方案明显是不可接受的。

Promise.stop = function() {
  return new Promise(function(){})
}

doSth()
.then(value => {
  if (sthBigErrorOccured()) {
    return Promise.stop()
  }
  // normal logic
})
.catch(reason => {// this function will never got GCed
  // normal logic
})
.then()
.catch(reason => {// this function will never got GCed
  // normal logic
})
.then()
.catch(reason => {// this function will never got GCed
  // normal logic
})

那有没有办法即达到停止后面的链,同时又避免内存泄露呢。

让我们回到一开始的思路,我们在Promise链上所有的catch里都加上一句if,来判断传来的错误是否为一个无法处理的错误,如果是则一直往后面抛,这样就达到了即没有运行后面的逻辑,又避免了内存泄露的问题。

这是一个高度一致的逻辑,我们当然可以把它抽离出来。我们可以实现一个叫next的函数,挂在Promise.prototype上面,然后在里面判断是否是我们能处理的错误,如果是,则执行回调,如果不是,则一直往下传:

var BIG_ERROR = new Error('BIG_ERROR')

Promise.prototype.next = function(onResolved, onRejected) {
  return this.then(function(value) {
    if (value === BIG_ERROR) {
      return BIG_ERROR
    } else {
      return onResolved(value)
    }
  }, onRejected)
}

doSth()
.next(function(value) {
  if (sthBigErrorOccured()) {
    return BIG_ERROR
  }
  // normal logic
})
.next(value => {
  // will never get called
})

进一步,如果把上面代码中“致命错误”的语义换成“跳过后面所有的Promise”,我们就可以得到跳过后续Promise的方式了:

var STOP_SUBSEQUENT_PROMISE_CHAIN = new Error()

Promise.prototype.next = function(onResolved, onRejected) {
  return this.then(function(value) {
    if (value === STOP_SUBSEQUENT_PROMISE_CHAIN) {
      return STOP_SUBSEQUENT_PROMISE_CHAIN
    } else {
      return onResolved(value)
    }
  }, onRejected)
}

doSth()
.next(function(value) {
  if (sthBigErrorOccured()) {
    return STOP_SUBSEQUENT_PROMISE_CHAIN
  }
  // normal logic
})
.next(value => {
  // will never get called
})

为了更明显的语义,我们可以把“跳过后面所有的Promise”单独封装成一个Promise:

var STOP = {}
Promise.stop = function(){
  return Promise.resolve(STOP)
}

Promise.prototype.next = function(onResolved, onRejected) {
  return this.then(function(value) {
    if (value === STOP) {
      return STOP
    } else {
      return onResolved(value)
    }
  }, onRejected)
}

doSth()
.next(function(value) {
  if (sthBigErrorOccured()) {
    return Promise.stop()
  }
  // normal logic
})
.next(value => {
  // will never get called
})

这样就实现了在语义明确的情况下,不造成内存泄露,而且还停止了后面的Promise链。

为了对现有代码尽量少做改动,我们甚至可以不用新增next方法而是直接重写then:

(function() {
  var STOP_VALUE = Symbol()//构造一个Symbol以表达特殊的语义
  var STOPPER_PROMISE = Promise.resolve(STOP_VALUE)

  Promise.prototype._then = Promise.prototype.then

  Promise.stop = function() {
    return STOPPER_PROMISE//不是每次返回一个新的Promise,可以节省内存
  }

  Promise.prototype.then = function(onResolved, onRejected) {
    return this._then(function(value) {
      return value === STOP_VALUE ? STOP_VALUE : onResolved(value)
    }, onRejected)
  }
}())

Promise.resolve(8).then(v => {
  console.log(v)
  return 9
}).then(v => {
  console.log(v)
  return Promise.stop()//较为明确的语义
}).catch(function(){// will never called but will be GCed
  console.log('catch')
}).then(function(){// will never called but will be GCed
  console.log('then')
})

以上对then的重写并不会造成什么问题,闭包里的对象在外界是访问不到,外界也永远也无法构造出一个跟闭包里Symbol一样的对象,考虑到我们只需要构造一个外界无法“===”的对象,我们完全可以用一个Object来代替:

(function() {
  var STOP_VALUE = {}//只要外界无法“===”这个对象就可以了
  var STOPPER_PROMISE = Promise.resolve(STOP_VALUE)

  Promise.prototype._then = Promise.prototype.then

  Promise.stop = function() {
    return STOPPER_PROMISE//不是每次返回一个新的Promise,可以节省内存
  }

  Promise.prototype.then = function(onResolved, onRejected) {
    return this._then(function(value) {
      return value === STOP_VALUE ? STOP_VALUE : onResolved(value)
    }, onRejected)
  }
}())

Promise.resolve(8).then(v => {
  console.log(v)
  return 9
}).then(v => {
  console.log(v)
  return Promise.stop()//较为明确的语义
}).catch(function(){// will never called but will be GCed
  console.log('catch')
}).then(function(){// will never called but will be GCed
  console.log('then')
})

这个方案的另一个好处(好处之一是不会造成内存泄露)是可以让你非常平滑地(甚至是一次性的)从“返回一个永远pending的Promise”过度到这个方案,因为代码及其语义都基本没有变化。在之前,你可以定义一个Promise.stop()方法来返回一个永远pending的Promise;在之后,Promise.stop()返回一个外界无法得到的值,用以表达“跳过后面所有的Promise”,然后在我们重写的then方法里使用。

这样就解决了停止Promise链这样一个让人纠结的问题。

在考察了不同的Promise实现后,我发现Bluebird和浏览器原生Promise都可以在Promise.prototype上直接增加实例方法,但Q和$q(Angular)却不能这么做,具体要在哪个子对象的原型上加或者改方法我就没有深入研究了,但相信肯定是有办法的。

可是这篇文章如果到这里就结束的话,就显得太没有意思了~~

顺着上面的思路,我们甚至可以实现Promise链的多分支跳转

我们知道,Promise链一般来说只支持双分支跳转。

按照Promise链的最佳写法实践,处理成功的回调只用then的第一个参数注册,错误处理的回调只使用catch来注册。这样在任意一个回调里,我们可以通过return或者throw(或者所返回Promise的最终状态的成功与否)跳转到最近的then或者catch回调里:

doSth()
.then(fn1)
.catch(fn2)
.catch(fn3)
.then(fn4)
.then(fn5)
.catch(fn6)

以上代码中,任意一个fn都只能选择往后跳到最近一then或者catch的回调里。

但在实际的使用的过程中,我发现双分支跳转有时满足不了我的需求。如果能在不破坏Promise标准的前提下让Promise实现多分支跳转,将会对复杂业务代码的可读性以及可维护性有相当程度的提升。

顺着上面的思路,我们可以在Promise上定义多个有语义的函数,在Promise.prototype上定义对应语义的实例方法,然后在实例方法中判断传来的值,然后根据条件来执行或者不执行该回调,当这么说肯定不太容易明白,我们来看代码分析:

(function() {
  var STOP = {}
  var STOP_PROMISE = Promise.resolve(STOP)
  var DONE = {}
  var WARN = {}
  var ERROR = {}
  var EXCEPTION = {}
  var PROMISE_PATCH = {}

  Promise.prototype._then = Promise.prototype.then//保存原本的then方法

  Promise.prototype.then = function(onResolved, onRejected) {
    return this._then(result => {
      if (result === STOP) {// 停掉后面的Promise链回调
        return result
      } else {
        return onResolved(result)
      }
    }, onRejected)
  }

  Promise.stop = function() {
    return STOP_PROMISE
  }

  Promise.done = function(value) {
    return Promise.resolve({
      flag: DONE,
      value,
    })
  }

  Promise.warn = function(value) {
    return Promise.resolve({
      flag: WARN,
      value,
    })
  }

  Promise.error = function(value) {
    return Promise.resolve({
      flag: ERROR,
      value,
    })
  }

  Promise.exception = function(value) {
    return Promise.resolve({
      flag: EXCEPTION,
      value,
    })
  }

  Promise.prototype.done = function(cb) {
    return this.then(result => {
      if (result && result.flag === DONE) {
        return cb(result.value)
      } else {
        return result
      }
    })
  }

  Promise.prototype.warn = function(cb) {
    return this.then(result => {
      if (result && result.flag === WARN) {
        return cb(result.value)
      } else {
        return result
      }
    })
  }

  Promise.prototype.error = function(cb) {
    return this.then(result => {
      if (result && result.flag === ERROR) {
        return cb(result.value)
      } else {
        return result
      }
    })
  }

  Promise.prototype.exception = function(cb) {
    return this.then(result => {
      if (result && result.flag === EXCEPTION) {
        return cb(result.value)
      } else {
        return result
      }
    })
  }
})()

然后我们可以像下面这样使用:

new Promise((resolve, reject) => {
    // resolve(Promise.stop())
    // resolve(Promise.done(1))
    // resolve(Promise.warn(2))
    // resolve(Promise.error(3))
    // resolve(Promise.exception(4))
  })
  .done(value => {
    console.log(value)
    return Promise.done(5)
  })
  .warn(value => {
    console.log('warn', value)
    return Promise.done(6)
  })
  .exception(value => {
    console.log(value)
    return Promise.warn(7)
  })
  .error(value => {
    console.log(value)
    return Promise.error(8)
  })
  .exception(value => {
    console.log(value)
    return
  })
  .done(value => {
    console.log(value)
    return Promise.warn(9)
  })
  .warn(value => {
    console.log(value)
  })
  .error(value => {
    console.log(value)
  })

以上代码中:

  • 如果运行第一行被注释的代码,这段程序将没有任何输出,因为所有后面的链都被“停”掉了
  • 如果运行第二行被注释的代码,将输出1 5 9
  • 如果运行第三行被注释的代码,将输出2 6 9
  • 如果运行第四行被注释的代码,将输出3 8
  • 如果运行第五行被注释的代码,将输出4 7

即return Promise.done(value)将跳到最近的done回调里

依次类推。

这样就实现了Promise链的多分支跳转。针对不同的业务,可以封装出不同语义的静态方法和实例方法,实现任意多的分支跳转。

但这个方案目前有一点不足,就是不能用then来捕获任意分支:

new Promise((resolve) => {
  resolve(Promise.warn(2))
})
.then(value => {

})
.warn(value => {

})

这种写法中,从语义或者经验上讲,then应该捕获前面的任意值,然而经过前面的改动,这里的then将捕获到这样的对象:

{
  flag: WARN,
  value: 2
}

而不是2,看看前面的代码就明白了:

Promise.prototype.then = function(onResolved, onRejected) {
  return this._then(result => {
    if (result === STOP) {
      return result
    } else {
      return onResolved(result)// 将会走这条分支,而此时result还是被包裹的对象
    }
  }, onRejected)
}

目前我还没有找到比较好的方案,试了几种都不太理想(也许代码写丑一点可以实现,但我并不想这么做)。所以只能在用到多分支跳转时不用then来捕获传来的值。

不过从有语义的回调跳转到then是可以正常工作的:

doSth()
.warn()
.done()
.exception()
.then()
.then()
.catch()

同样还是可以根据上面的代码看出来。

最后,此文使用到的一个anti pattern是对原生对象做了更改,这在一般的开发中是不被推荐的,本文只是提供一个思路。在真正的工程中,可以继承Promise类以达到几乎相同的效果,此处不再熬述。

多谢各位同僚的阅读,如有纰漏之处还请留言指正~

剖析Promise内部结构,一步一步教你实现一个完整的、能通过所有Test case的Promise

本文适用的读者

本文写给有一定Promise使用经验的人,如果你还没有使用过Promise,这篇文章可能不适合你,建议先了解Promise的使用

Promise标准解读

  1. 只有一个then方法,没有catchraceall等方法,甚至没有构造函数

    Promise标准中仅指定了Promise对象的then方法的行为,其它一切我们常见的方法/函数都并没有指定,包括catchraceall等常用方法,甚至也没有指定该如何构造出一个Promise对象,另外then也没有一般实现中(Q, $q等)所支持的第三个参数,一般称onProgress

  2. then方法返回一个Promise

    Promise的then方法返回一个新的Promise,而不是返回this,此处在下文会有更多解释

    promise2 = promise1.then(alert)
    promise2 != promise1 // true
  3. 不同Promise的实现需要可以相互调用(interoperable)

  4. Promise的初始状态为pending,它可以由此状态转换为fulfilled(本文为了一致把此状态叫做resolved)或者rejected,一旦状态确定,就不可以再次转换为其它状态,状态确定的过程称为settle

  5. 更具体的标准见这里

一步一步实现一个Promise

下面我们就来一步一步实现一个Promise

构造函数

因为标准并没有指定如何构造一个Promise对象,所以我们同样以目前一般Promise实现中通用的方法来构造一个Promise,也是ES6里所使用的方式,即:

var promise = new Promise(function(resolve, reject) {
  /*
    如果操作成功,调用resolve并传入value
    如果操作失败,调用reject并传入reason
  */
})

我们先实现构造函数的框架如下:

function Promise(executor) {
  var self = this
  self.status = 'pending' // Promise当前的状态
  self.data = undefined  // Promise的值
  self.onResolvedCallback = [] // Promise resolve时的回调函数集
  self.onRejectedCallback = [] // Promise reject时的回调函数集

  executor(resolve, reject) // 执行executor
}

上面的代码基本实现了Promise构造函数的主体,但目前还有两个问题:

  1. 我们给executor函数传了两个参数:resolve和reject,这两个参数目前还没有定义

  2. executor有可能会出错(throw),类似下面这样,而如果executor出错,Promise应该被reject:

    new Promise(function(resolve, reject) {
      throw 2
    })

所以我们需要在构造函数里定义resolve和reject这两个函数:

function Promise(executor) {
  var self = this
  self.status = 'pending' // Promise当前的状态
  self.data = undefined  // Promise的值
  self.onResolvedCallback = [] // Promise resolve时的回调函数集,因为在Promise结束之前有可能有多个回调添加到它上面
  self.onRejectedCallback = [] // Promise reject时的回调函数集,因为在Promise结束之前有可能有多个回调添加到它上面

  function resolve(value) {
    // TODO
  }

  function reject(reason) {
    // TODO
  }

  try { // 考虑到执行executor的过程中有可能出错,所以我们用try/catch块给包起来,并且在出错后以catch到的值reject掉这个Promise
    executor(resolve, reject) // 执行executor
  } catch(e) {
    reject(e)
  }
}

有人可能会问,resolve和reject这两个函数能不能不定义在构造函数里呢?考虑到我们在executor函数里是以resolve(value),reject(reason)的形式调用的这两个函数,而不是以resolve.call(),reject.call()这种形式调用的,所以这两个函数在调用时的内部也必然有一个隐含的this,也就是说,要么这两个函数是经过bind后传给了executor,要么它们定义在构造函数的内部,使用self来访问所属的Promise对象。所以如果我们想把这两个函数定义在构造函数的外部,确实是可以这么写的:

function resolve() {
  // TODO
}
function reject() {
  // TODO
}
function Promise(executor) {
  try {
    executor(resolve.bind(this), reject.bind(this))
  } catch(e) {
    reject.bind(this)(e)
  }
}

但是众所周知,bind也会返回一个新的函数,这么一来还是相当于每个Promise对象都有一对属于自己的resolve和reject函数,就跟写在构造函数内部没什么区别了,所以我们就直接把这两个函数定义在构造函数里面了。不过话说回来,如果浏览器对bind的所优化,使用后一种形式应该可以提升一下内存使用效率。

接下来,我们实现resolve和reject这两个函数

function Promise(executor) {
  // ...

  function resolve(value) {
    if (self.status === 'pending') {
      self.status = 'resolved'
      self.data = value
      for(var i = 0; i < self.onResolvedCallback.length; i++) {
        self.onResolvedCallback[i](value)
      }
    }
  }

  function reject(reason) {
    if (self.status === 'pending') {
      self.status === 'rejected'
      self.data = reason
      for(var i = 0; i < self.onRejectedCallback.length; i++) {
        self.onRejectedCallback[i](reason)
      }
    }
  }

  // ...
}

基本上就是在判断状态为pending之后把状态改为相应的值,并把对应的value和reason存在self的data属性上面,之后调用相应的回调函数,逻辑很简单,这里就不多解释了。

then方法

我们知道,Promise对象有一个then方法,用来在这个Promise状态确定时处理相应的逻辑,很明显,then方法需要写在原型链上,then方法会返回一个Promise,关于这一点,Promise/A+标准并没有要求返回的这个Promise是一个新的对象,但在Promise/A标准中,明确规定了要返回一个新的对象,但目前的实现中then几乎都是返回一个新的Promise(详情),所以在我们的实现中,也让then返回一个新的Promise。

关于这一点,我认为标准中是有一点矛盾的:

标准中说,如果promise2 = promise1.then(onResolved,onRejected)里的onResolved/onRejected返回一个Promise,则promise2直接取这个Promise的状态和值为己用(来源),但考虑如下代码:

promise2 = promise1.then(function foo(value){
  return Promise.reject(3)
})

此处如果foo运行了,则promise1的状态必然已经确定且为resolved,如果then返回了this(即promise2 === promise1),说明promise2和promise1是同一个对象,而此时promise1/2的状态已经确定,没有办法再取Promise.reject(3)的状态和结果为己用,因为Promise的状态确定后就不可再转换为其它状态。

另外每个Promise都可以在其上多次调用then方法,而每个then返回的Promise的状态取决于那一次调用then时的参数的返回值,所以then不能返回this,因为then每次返回的Promise的结果都有可能不同。

下面我们来实现then方法:

Promise.prototype.then = function(onResolved, onRejected) {
  var self = this
  var promise2

  // 根据标准,如果then的参数不是function,则我们需要忽略它,此处以如下方式处理
  onResolved = typeof onResolved === 'function' ? onResolved : function(v) {}
  onRejected = typeof onRejected === 'function' ? onRejected : function(r) {}

  if (self.status === 'resolved') {
    return promise2 = new Promise(function(resolve, reject) {

    })
  }

  if (self.status === 'rejected') {
    return promise2 = new Promise(function(resolve, reject) {

    })
  }

  if (self.status === 'pending') {
    return promise2 = new Promise(function(resolve, reject) {

    })
  }
}

Promise总共有三种可能的状态,我们分三个if块来处理,在里面分别都返回一个new Promise。

根据标准,我们知道,对于如下代码,promise2的值取决于then里面函数的返回值:

promise2 = promise1.then(function(value) {
  return 4
}, function(reason) {
  throw new Error('sth went wrong')
})

如果promise1被resolve了,promise2的将被4 resolve,如果promise1被reject了,promise2将被new Error('sth went wrong') reject。

所以,我们需要在then里面执行onResolved或者onRejected,并根据返回值来确定promise1的结果,并且,如果onResolved/onRejected返回的是一个Promise,promise2将直接取这个Promise的结果:

Promise.prototype.then = function(onResolved, onRejected) {
  var self = this
  var promise2

  // 根据标准,如果then的参数不是function,则我们需要忽略它,此处以如下方式处理
  onResolved = typeof onResolved === 'function' ? onResolved : function(value) {}
  onRejected = typeof onRejected === 'function' ? onRejected : function(reason) {}

  if (self.status === 'resolved') {
    // 如果promise1(此处即为this/self)的状态已经确定并且是resolved,我们调用onResolved
    // 因为考虑到有可能throw,所以我们将其包在try/catch块里
    return promise2 = new Promise(function(resolve, reject) {
      try {
        var x = onResolved(self.data)
        if (x instanceof Promise) { // 如果onResolved的返回值是一个Promise对象,直接取它的结果做为promise2的结果
          x.then(resolve, reject)
        }
        resolve(x) // 否则,以它的返回值做为promise2的结果
      } catch (e) {
        reject(e) // 如果出错,以捕获到的错误做为promise2的结果
      }
    })
  }

  // 此处与前一个if块的逻辑几乎相同,区别在于所调用的是onRejected函数,就不再做过多解释
  if (self.status === 'rejected') {
    return promise2 = new Promise(function(resolve, reject) {
      try {
        var x = onRejected(self.data)
        if (x instanceof Promise) {
          x.then(resolve, reject)
        }
      } catch (e) {
        reject(e)
      }
    })
  }

  if (self.status === 'pending') {
  // 如果当前的Promise还处于pending状态,我们并不能确定调用onResolved还是onRejected,
  // 只能等到Promise的状态确定后,才能确实如何处理。
  // 所以我们需要把我们的**两种情况**的处理逻辑做为callback放入promise1(此处即this/self)的回调数组里
  // 逻辑本身跟第一个if块内的几乎一致,此处不做过多解释
    return promise2 = new Promise(function(resolve, reject) {
      self.onResolvedCallback.push(function(value) {
        try {
          var x = onResolved(self.data)
          if (x instanceof Promise) {
            x.then(resolve, reject)
          }
        } catch (e) {
          reject(e)
        }
      })

      self.onRejectedCallback.push(function(reason) {
        try {
          var x = onRejected(self.data)
          if (x instanceof Promise) {
            x.then(resolve, reject)
          }
        } catch (e) {
          reject(e)
        }
      })
    })
  }
}

// 为了下文方便,我们顺便实现一个catch方法
Promise.prototype.catch = function(onRejected) {
  return this.then(null, onRejected)
}

至此,我们基本实现了Promise标准中所涉及到的内容,但还有几个问题:

  1. 不同的Promise实现之间需要无缝的可交互,即Q的Promise,ES6的Promise,和我们实现的Promise之间以及其它的Promise实现,应该并且是有必要无缝相互调用的,比如:

    // 此处用MyPromise来代表我们实现的Promise
    new MyPromise(function(resolve, reject) { // 我们实现的Promise
      setTimeout(function() {
        resolve(42)
      }, 2000)
    }).then(function() {
      return new Promise.reject(2) // ES6的Promise
    }).then(function() {
      return Q.all([ // Q的Promise
        new MyPromise(resolve=>resolve(8)), // 我们实现的Promise
        new Promise.resolve(9), // ES6的Promise
        Q.resolve(9) // Q的Promise
      ])
    })

    我们前面实现的代码并没有处理这样的逻辑,我们只判断了onResolved/onRejected的返回值是否为我们实现的Promise的实例,并没有做任何其它的判断,所以上面这样的代码目前是没有办法在我们的Promise里正确运行的。

  2. 下面这样的代码目前也是没办法处理的:

    new Promise(resolve=>resolve(8))
      .then()
      .then()
      .then(function foo(value) {
        alert(value)
      })

    正确的行为应该是alert出8,而如果拿我们的Promise,运行上述代码,将会alert出undefined。这种行为称为穿透,即8这个值会穿透两个then(说Promise更为准确)到达最后一个then里的foo函数里,成为它的实参,最终将会alert出8。

下面我们首先处理简单的情况,值的穿透

Promise值的穿透

通过观察,会发现我们希望下面这段代码

new Promise(resolve=>resolve(8))
  .then()
  .catch()
  .then(function(value) {
    alert(value)
  })

跟下面这段代码的作用是一样的

new Promise(resolve=>resolve(8))
  .then(function(value){
    return value
  })
  .catch(function(reason){
    throw reason
  })
  .then(function(value) {
    alert(value)
  })

所以如果想要把then的实参留空且让值可以穿透到后面,意味着then的两个参数的默认值分别为function(value) {return value}function(reason) {throw reason}
所以我们只需要把then里判断onResolved和onRejected的部分改成如下即可:

onResolved = typeof onResolved === 'function' ? onResolved : function(value) {return value}
onRejected = typeof onRejected === 'function' ? onRejected : function(reason) {throw reason}

于是Promise神的值的穿也没有那么黑魔法,只不过是then默认参数就是把值往后传或者抛

不同Promise的交互

关于不同Promise间的交互,其实标准里是有说明的,其中详细指定了如何通过then的实参返回的值来决定promise2的状态,我们只需要按照标准把标准的内容转成代码即可。

这里简单解释一下标准:

即我们要把onResolved/onRejected的返回值,x,当成一个可能是Promise的对象,也即标准里所说的thenable,并以最保险的方式调用x上的then方法,如果大家都按照标准实现,那么不同的Promise之间就可以交互了。而标准为了保险起见,即使x返回了一个带有then属性但并不遵循Promise标准的对象(比如说这个x把它then里的两个参数都调用了,同步或者异步调用(PS,原则上then的两个参数需要异步调用,下文会讲到),或者是出错后又调用了它们,或者then根本不是一个函数),也能尽可能正确处理。

建议各位对照着标准阅读以下代码:

/*
resolvePromise函数即为根据x的值来决定promise2的状态的函数
也即标准中的[Promise Resolution Procedure](https://promisesaplus.com/#point-47)
x为`promise2 = promise1.then(onResolved, onRejected)`里`onResolved/onRejected`的返回值
`resolve`和`reject`实际上是`promise2`的`executor`的两个实参,因为很难挂在其它的地方,所以一并传进来。
相信各位一定可以对照标准把标准转换成代码,这里就只标出代码在标准中对应的位置,只在必要的地方做一些解释
*/
function resolvePromise(promise2, x, resolve, reject) {
  var then
  var thenCalledOrThrow = false

  if (promise2 === x) { // 对应标准2.3.1节
    return reject(new TypeError('Chaining cycle detected for promise!'))
  }

  if (x instanceof Promise) { // 对应标准2.3.2节
    // 如果x的状态还没有确定,那么它是有可能被一个thenable决定最终状态和值的
    // 所以这里需要做一下处理,而不能一概的以为它会被一个“正常”的值resolve
    if (x.status === 'pending') {
      x.then(function(value) {
        resolvePromise(promise2, value, resolve, reject)
      }, reject)
    } else { // 但如果这个Promise的状态已经确定了,那么它肯定有一个“正常”的值,而不是一个thenable,所以这里直接取它的状态
      x.then(resolve, reject)
    }
    return
  }

  if ((x !== null) && ((typeof x === 'object') || (typeof x === 'function'))) { // 2.3.3
    try {

      // 2.3.3.1 因为x.then有可能是一个getter,这种情况下多次读取就有可能产生副作用
      // 即要判断它的类型,又要调用它,这就是两次读取
      then = x.then 
      if (typeof then === 'function') { // 2.3.3.3
        then.call(x, function rs(y) { // 2.3.3.3.1
          if (thenCalledOrThrow) return // 2.3.3.3.3 即这三处谁选执行就以谁的结果为准
          thenCalledOrThrow = true
          return resolvePromise(promise2, y, resolve, reject) // 2.3.3.3.1
        }, function rj(r) { // 2.3.3.3.2
          if (thenCalledOrThrow) return // 2.3.3.3.3 即这三处谁选执行就以谁的结果为准
          thenCalledOrThrow = true
          return reject(r)
        })
      } else { // 2.3.3.4
        resolve(x)
      }
    } catch (e) { // 2.3.3.2
      if (thenCalledOrThrow) return // 2.3.3.3.3 即这三处谁选执行就以谁的结果为准
      thenCalledOrThrow = true
      return reject(e)
    }
  } else { // 2.3.4
    resolve(x)
  }
}

最后,我们刚刚说到,原则上,promise.then(onResolved, onRejected)里的这两相函数需要异步调用,关于这一点,标准里也有说明

In practice, this requirement ensures that onFulfilled and onRejected execute asynchronously, after the event loop turn in which then is called, and with a fresh stack.

所以我们需要对我们的代码做一点变动,即在四个地方加上setTimeout(fn, 0),这点会在完整的代码中注释,请各位自行发现。

事实上,即使你不参照标准,最终你在自测试时也会发现如果then的参数不以异步的方式调用,有些情况下Promise会不按预期的方式行为,通过不断的自测,最终你必然会让then的参数异步执行,让executor函数立即执行。本人在一开始实现Promise时就没有参照标准,而是自己撸,最终是通过自测发现的这个问题。

至此,我们就实现了一个的Promise,完整代码如下:

try {
  module.exports = Promise
} catch (e) {}

function Promise(executor) {
  var self = this

  self.status = 'pending'
  self.onResolvedCallback = []
  self.onRejectedCallback = []

  function resolve(value) {
    if (value instanceof Promise) {
      return value.then(resolve, reject)
    }
    setTimeout(function() { // 异步执行所有的回调函数
      if (self.status === 'pending') {
        self.status = 'resolved'
        self.data = value
        for (var i = 0; i < self.onResolvedCallback.length; i++) {
          self.onResolvedCallback[i](value)
        }
      }
    })
  }

  function reject(reason) {
    setTimeout(function() { // 异步执行所有的回调函数
      if (self.status === 'pending') {
        self.status = 'rejected'
        self.data = reason
        for (var i = 0; i < self.onRejectedCallback.length; i++) {
          self.onRejectedCallback[i](reason)
        }
      }
    })
  }

  try {
    executor(resolve, reject)
  } catch (reason) {
    reject(reason)
  }
}

function resolvePromise(promise2, x, resolve, reject) {
  var then
  var thenCalledOrThrow = false

  if (promise2 === x) {
    return reject(new TypeError('Chaining cycle detected for promise!'))
  }

  if (x instanceof Promise) {
    if (x.status === 'pending') { //because x could resolved by a Promise Object
      x.then(function(v) {
        resolvePromise(promise2, v, resolve, reject)
      }, reject)
    } else { //but if it is resolved, it will never resolved by a Promise Object but a static value;
      x.then(resolve, reject)
    }
    return
  }

  if ((x !== null) && ((typeof x === 'object') || (typeof x === 'function'))) {
    try {
      then = x.then //because x.then could be a getter
      if (typeof then === 'function') {
        then.call(x, function rs(y) {
          if (thenCalledOrThrow) return
          thenCalledOrThrow = true
          return resolvePromise(promise2, y, resolve, reject)
        }, function rj(r) {
          if (thenCalledOrThrow) return
          thenCalledOrThrow = true
          return reject(r)
        })
      } else {
        resolve(x)
      }
    } catch (e) {
      if (thenCalledOrThrow) return
      thenCalledOrThrow = true
      return reject(e)
    }
  } else {
    resolve(x)
  }
}

Promise.prototype.then = function(onResolved, onRejected) {
  var self = this
  var promise2
  onResolved = typeof onResolved === 'function' ? onResolved : function(v) {
    return v
  }
  onRejected = typeof onRejected === 'function' ? onRejected : function(r) {
    throw r
  }

  if (self.status === 'resolved') {
    return promise2 = new Promise(function(resolve, reject) {
      setTimeout(function() { // 异步执行onResolved
        try {
          var x = onResolved(self.data)
          resolvePromise(promise2, x, resolve, reject)
        } catch (reason) {
          reject(reason)
        }
      })
    })
  }

  if (self.status === 'rejected') {
    return promise2 = new Promise(function(resolve, reject) {
      setTimeout(function() { // 异步执行onRejected
        try {
          var x = onRejected(self.data)
          resolvePromise(promise2, x, resolve, reject)
        } catch (reason) {
          reject(reason)
        }
      })
    })
  }

  if (self.status === 'pending') {
    return promise2 = new Promise(function(resolve, reject) {
      self.onResolvedCallback.push(function(value) {
        try {
          var x = onResolved(value)
          resolvePromise(promise2, x, resolve, reject)
        } catch (r) {
          reject(r)
        }
      })

      self.onRejectedCallback.push(function(reason) {
          try {
            var x = onRejected(reason)
            resolvePromise(promise2, x, resolve, reject)
          } catch (r) {
            reject(r)
          }
        })
    })
  }
}

Promise.prototype.catch = function(onRejected) {
  return this.then(null, onRejected)
}

Promise.deferred = Promise.defer = function() {
  var dfd = {}
  dfd.promise = new Promise(function(resolve, reject) {
    dfd.resolve = resolve
    dfd.reject = reject
  })
  return dfd
}

测试

如何确定我们实现的Promise符合标准呢?Promise有一个配套的测试脚本,只需要我们在一个CommonJS的模块中暴露一个deferred方法(即exports.deferred方法),就可以了,代码见上述代码的最后。然后执行如下代码即可开始测试:

npm i -g promises-aplus-tests
promises-aplus-tests Promise.js

关于Promise的其它问题

Promise的性能问题

可能各位看官会觉得奇怪,Promise能有什么性能问题呢?并没有大量的计算啊,几乎都是处理逻辑的代码。

理论上说,不能叫做“性能问题”,而只是有可能出现的延迟问题。什么意思呢,记得刚刚我们说需要把4块代码包在setTimeout里吧,先考虑如下代码:

var start = +new Date()
function foo() {
  setTimeout(function() {
    console.log('setTimeout')
    if((+new Date) - start < 1000) {
      foo()
    }
  })
}
foo()

运行上面的代码,会打印出多少次'setTimeout'呢,各位可以自己试一下,不出意外的话,应该是250次左右,我刚刚运行了一次,是241次。这说明,上述代码中两次setTimeout运行的时间间隔约是4ms(另外,setInterval也是一样的),实事上,这正是两次Event Loop之间的时间间隔,相关标准各位可以自行查阅。另外,在Node中,这个时间间隔跟浏览器不一样,经过我的测试,是1ms。

单单一个4ms的延迟可能在一般的web应用中并不会有什么问题,但是考虑极端情况,我们有20个Promise链式调用,加上代码运行的时间,那么这个链式调用的第一行代码跟最后一行代码的运行很可能会超过100ms,如果这之间没有对UI有任何更新的话,虽然本质上没有什么性能问题,但可能会造成一定的卡顿或者闪烁,虽然在web应用中这种情形并不常见,但是在Node应用中,确实是有可能出现这样的case的。所以,我们需要消除这个4ms的延迟,在Node中,我们可以调用process.nextTick或者setImmediate(Q就是这么做的),在浏览器中具体如何做,已经超出了本文的讨论范围,总的来说,就是我们需要实现一个函数,行为跟setTimeout一样,但它需要在某一次Event Loop中调用所有已经加入队列的函数,这里有一个实现。

如何停止一个Promise链?

在一些场景下,我们可能会遇到一个较长的Prmoise链式调用,在某一步中出现的错误让我们完全没有必要去运行链式调用后面所有的代码,类似下面这样(此处略去了then/catch里的函数):

new Promise(function(resolve, reject) {
  resolve(42)
})
  .then(function(value) {
    // "Big ERROR!!!"
  })
  .catch()
  .then()
  .then()
  .catch()
  .then()

假设这个Big ERROR!!!的出现让我们完全没有必要运行后面所有的代码了,但链式调用的后面即有catch,也有then,无论我们是return还是throw,都不可避免的会进入某一个catch或then里面,那有没有办法让这个链式调用在Big ERROR!!!的后面就停掉,完全不去执行链式调用后面所有的代码呢?

一开始遇到这个问题的时候我也百思不得其解,在网上搜遍了也没有结果,有人说可以在每个catch里面判断Error的类型,如果自己处理不了就接着throw,也有些其它办法,但总是要对现有代码进行一些改动并且所有的地方都要遵循这些约定,甚是麻烦。

然而当我从一个实现者的角度看问题时,确实找到了答案,就是在发生Big ERROR后return一个Promise,但这个Promise的executor函数体什么也没有,这就意味着这个Promise将永远处于pending状态,由于then返回的Promise会直接取这个永远处于pending状态的Promise的状态,后面的代码也就一直不会执行了,具体代码如下:

new Promise(function(resolve, reject) {
  resolve(42)
})
  .then(function(value) {
    // "Big ERROR!!!"
    return new Promise(function(){})
  })
  .catch()
  .then()
  .then()
  .catch()
  .then()

这种方式看起来有些山寨,它也确实解决了问题。但它引入的一个新问题就是链式调用后面的所有回调函数都无法被垃圾回收器回收(在一个靠谱的实现里,Promise应该在执行完所有回调后删除对所有回调函数的引用以让它们能被回收,在前文的实现里,为了减少复杂度,并没有做这种处理),但如果我们不使用匿名函数,而是使用函数定义或者函数变量的话,在需要多次执行的Promise链中,这些函数也都只有一份在内存中,不被回收也是可以接受的。

Promise链上返回的最后一个Promise出错了怎么办?

考虑如下代码:

new Promise(function() {
  resolve(42)
})
  .then(function(value) {
    alert(value)
  })

乍一看好像没什么问题,但运行这段代码的话你会发现什么现象也不会发生,既不会alter出42,也不会在控制台报错,怎么回事呢。细看最后一行,alter被打成了alert,那为什么控制台也没有报错呢,因为alert所在的函数是被包在try/catch块里的,alert这个变量找不到就直接抛错了,这个错就正好成了then返回的Promise的rejection reason。

也就是说,在Promise链的最后一个then里出现的错误,非常难以发现,有文章指出,可以在所有的Promise链的最后都加上一个catch,这样出错后就能被捕获到,这种方法确实是可行的,但是首先在每个地方都加上几乎相同的代码,违背了DIY原则,其次也相当的繁琐,在Q中有一个方法叫done,把这个方法链到Promise链的最后,它就能够捕获前面未处理的错误,这其实跟在每个链后面加上catch没有太大的区别,只是由框架来做了这件事。

那么能不能在不加catch或者done的情况下,也能够让开发者发现Promise链最后的错误呢?答案依然是肯定的。

我们可以在一个Promise被reject的时候检查这个Promise的onRejectedCallback,如果它为空,则说明它的错误是没有被处理的,这个时候,我们需要把错误输出到控制台,让开发者可以发现。以下为具体实现:

function reject(reason) {
  setTimeout(function() {
    if (self.status === 'pending') {
      self.status = 'rejected'
      self.data = reason
      if (self.onRejectedCallback.length === 0) {
        console.error(reason)
      }
      for (var i = 0; i < self.rejectedFn.length; i++) {
        self.rejectedFn[i](reason)
      }
    }
  })
}

上面的代码对于以下的Promise链也能处理的很好:

new Promise(function(){ // promise1
  reject(3)
})
  .then() // returns promise2
  .then() // returns promise3
  .then() // returns promise4

看起来,promise1,2,3,4都没有处理函数,那是不是会在控制台把这个错误输出4次呢,并不会,实际上,promise1,2,3都隐式的有处理函数,就是then的默认参数,各位应该还记得then的默认参数最终是被push到了Promise的callback数组里。只有promise4是真的没有任何callback,因为根本就没有调用它的then方法。

事实上,Bluebird和ES6 Promise都做了类似的处理,在Promise被reject但又没有callback时,把错误输出到控制台。

Angular里的$q跟其它Promise的交互

一般来说,我们不会在Angular里使用其它的Promise,因为Angular已经集成了$q,但有些时候我们在Angular里需要用到其它的库,而这些库或是封装了ES6的Promise,或者是自己实现了Promise,这时如果你在Angular里使用这些库,就有可能发现视图跟Model不同步。究其原因,是因为$q已经集成了Angular的digest loop机制,而其它的Promise显然是不会集成的,所以如果你运行下面这样的代码,视图是不会同步的:

app.controller(function($scope) {
  Promise.resolve(42).then(function(value) {
    $scope.value = value
  })
})

Promise结束时并不会触发digest,所以视图没有同步。$q上正好有个when方法,它可以把其它的Promise转换成$q的Promise,问题就解决了:

app.controller(function($scope, $q) {
  $q.when(Promise.resolve(42)).then(function(value) {
    $scope.value = value
  })
})

当然也有其它的解决方案比如在其它Promise链的最后加一个digest,这里就不多深入了。

最佳实践

这里不免再啰嗦两句最佳实践

  1. 一是不要把Promise写成嵌套结构,至于怎么改进,这里就不多说了

    // 错误的写法
    promise1.then(function(value) {
      promise1.then(function(value) {
        promise1.then(function(value) {
    
        })
      })
    })
  2. 二是链式Promise要返回一个Promise,而不只是构造一个Promise

    // 错误的写法
    Promise.resolve(1).then(function(){
      Promise.resolve(2)
    }).then(function(){
      Promise.resolve(3)
    })

Promise相关的convenience method

请到这里查看Promise.race, Promise.all, Promise.resolve, Promise.reject等方法的具体实现,这里就不具体解释了,总的来说,只要then的实现是没有问题的,其它所有的方法都可以非常方便的依赖then来实现。

结语

最后,如果你觉得这篇文章对你有所帮助,欢迎分享给你的朋友或者团队,记得注明出处哦~

如何探测 JS 代码中的死循环

如何探测 JS 代码中的死循环

需求

最近在做一个工具(具体是什么工具,稍后再透露)的时候,我有一个需要探测代码中是否包含死循环的需求,而不管它有没有,我都想要我自己的代码能够继续执行下去。

停机问题

首先需要说明的是,理论上,没有通用的方法在不执行一段代码的情况下探测它是否包含死循环,因为这涉及到停机问题。而即使是执行这段代码,你依然无法判断它是陷入了死循环还是只是暂时没有结束循环而已。

关于为什么没有办法判断一段代码是否是死循环以及它与停机问题的关系,可以稍做一下解释:

假设我写了下面这段代码:

for(var i = 2;hasNextPrime(i); i++) {
    if (isPrime(i)) {
        console.log(i)
    }
}

代码意义很明显,它想把所有的素数都打印出来

现在我想要把它传给一个程序(后称法官程序)让这个程序来帮我判断这段代码运行后会不会停止

理想情况下(即让理想图灵机来运行这段代码)它应该是不会停止的,因为素数有无穷个。

但如果是法官程序来告诉我它会停止或者不会停止,将会发生什么呢?

  1. 如果法官程序告诉我它会停止,则它就是在告诉我,素数是有限个的。
  2. 如果法官程序告诉我它永远不会停止,则它就是在告诉我,素数有无穷个。

如果是第一种情况,显然是法官程序错了。
而如果是第二种情况,则我可以用这种方式证明非常多的数学理论和猜想。只要把这些猜想它们写成程序然后让这个法官程序来判断这段程序会不会结束就好了。如果会,则猜想正确,如果不会,则猜不正确,或者反过来。

而这就形成了一个悖论。关于停机问题,笔者了解的也不够深入,就只解释到这里好了。

判断死循环几种的思路

下面我们言归正传,那么如何判断一段代码是否会产生死循环呢?

一般的做法都是:我执行你的代码,但如果你的代码在一段时间内没有返回结果或没有结束运行,则我认为你的代码是死循环。

在前端(其实最常见的就是前端以及一些脚本语言中了),我们确实会遇到检测一段代码(尤其是别人写的代码)中是否有死循环这样的需求,如果是你,你会怎么做呢?可以先思考一下,思考完成后再继续阅读

直接运行被判断的代码

最容易想到的可能就是把代码运行起来了,然后看它能不能在一定时间内结束,如果能,当然皆大欢喜,如果不能,那就说明这段代码中有死循环。

但是这么做真的就足够了吗?试一下你就会发现这样做并不可行,因为在浏览器中 JS 是在单线程中执行的,而如果你执行了那段需要被 judge 的代码而它进入了死循环,页面会整体卡死,不能进行任何交互,只要这个死循环不结束,页面中任何其它代码都不会执行了,你的 “判断这段代码是否结束的代码” 当然也不会有机会执行。

所以这个方案其实并不可行。

Web Worker

不过当我提到单线程时,你应该想到了在现代浏览器中,提供了类似多线程的能力:Web Worker。其实这种方案之前就有人提过(http://cwestblog.com/2016/03/16/javascript-detecting-infinite-loops/),把代码放进 web Worker 里执行,然后如果在一定时间内没有收到来自这个 Worker 的响应事件,则认为这段代码进入了死循环,于是把这个 Worker 给 kill 掉。

但这个方案其实有一定的局限性:我们知道在 Web Worker 之前页面一直是构建于单线程之上的,Web 为了不引入过多的复杂性,也一直不允许使用多线程,你可能会以为 timer 算是多线程,其实它们只是 Event Loop。而终于在引入了 Web Worker 这个 “真正” 的多线程机制后,其实也有诸多的限制:比如说,在 Worker 线程内不允许访问能能够改变页面 UI 的任何 API。单就这一点,就让用 Worker 来判断代码是否可能进入死循环这条路的可行性大打折扣,因为这段被 judge 的代码可能也是需要访问 DOM 或者至少想要访问一些在 Worker 中不允许被访问的对象的。所以当你有这种场景时,这个方案并不可行。撇开这一点,它还有另外的问题,就是只有高级浏览器支持。

iframe或者新窗口/新标签

如果不用 Worker,顺着多线程的思路,容易想到,我们可以把代码放进 iframe 或者另一个页面(标签)里执行,然后看这个页面能否给我们反馈。这个思路看起来可行,其实还是有问题:

经过测试,如果一个页面的 iframe 里包含死循环,则整个标签都会卡死掉。

而如果你开一个新标签来运行被 judge 的代码,如果它产生了死循环,那么你将无法关闭那个标签页,因为它卡死了。

所以这两种方案也被否定了。

死循环如何才有可能产生?

那么还有没有办法能够即不让主页面卡死,还能 judge 某代码是否死循环呢?而且最好还能让这段代码与主代码在同一个环境下执行。

通过观察我们会发现,死循环只会由循环语句触发,也就是 for,while 和 do while 语句。你可能会说我们是不是漏考虑了递归,其实如果是递归函数中出现所谓的死循环,我们并不用担心,因为如果是纯递归造成的死循环,递归调用栈的深度很快会达到最大值,引擎会主动结束它,并抛出一个错误。

所以我们只需要判断所有的循环语句不会陷入死循环就可以了。

再说的简单点,就是只要保证循环语句不一直执行就可以了。

再说的直白点,就是我们如果发现循环执行的时间太久,就认为它陷入了死循环。

大致代码如下:

var start = new Date()
for (;;) {
    if (Date.now() - start > 1000) {
        throw new Error('Loop running too long!')
    }
    // normal code
}

另一个需要注意的是,如果真有 judge 某代码是否会死循环的这种需求,我们需要 judge 的代码一般来说都是由用户输入的,也就是说这段代码在执行之前,其实是文本。

另一种解决思路

那么我们可以分析这段文本所表示的代码,在里面所有的循环的前后插入一个我们自己的函数,在循环开始的时候计一下时,在循环体每次运行时也计一下时,然后与循环开始的时间进行对比,如果发现这个循环运行了太久(比如说一秒),就抛一个错误出来,这样循环就被这个抛出的错误给结束掉了。

你可能会说,如果这段循环被包在一个 try catch 块里面呢?抛的错误岂不是会被捕获到?确实会被捕获到,但是同时这个运行了太久的循环也结束了。而我们会把所有的循环前后都插入一个我们写好的用于统计时间的函数,同时甚至可以把代码的行号一同传给我们的函数。

大致代码如下,我们把计时函数封装成了一个 tick 函数:

tick(1)
for(;;) {
    tick(1)
    doSth()
    try {
        tick(2)
        while(true) {
            tick(2)
            doSthOther()
        }
    } catch(e) {

    }
}

tick(3,16);do {tick(3,16);//此处16是表示行号
    doAnotherThing()
} while(true)

我们给每一个循环语句的前后都插入一个 tick 函数,并给这两个 tick 函数传入相同的参数以标识循环语句的 id,然后执行这段代码。

注意我对 do while 循环的代码插入,对于典型风格的代码,我们可以在完全不改变代码行数量的前提下插入我们的代码,同时给 tick 传入当前代码的行号(因为在插入代码时,这段代码还是文本)做为其第二个参数,这样在其抛错时可以告诉我们具体是哪一行的循环陷入了死循环。

当第二个 tick(id) 发现自己相对于第一个 tick(id)(其只记录下循环开始的时间)在很长时间(比如一秒)以后还在被调用的时候,它就抛出一个错误,因为第二个 tick(id)是循环体内的第一个语句,它与循环头(for/while/do)之间并没有隔着一个 try,所以其抛错的话,会把死循环结束掉。

在抛错之前,tick 函数还可以告知我们,它在里面发现了一个死循环,甚至还可以告诉我们具体的行号。同时如果它抛出的这个错误没有被里面的代码 catch 到的话,错误则会冒泡直到被我们的代码捕获到,我们的代码既然 judge 这段代码,当然会把执行它的过程放在 try 里面了,如果我们的代码不是直接执行的这段代码,比如是把它放在 script 标签里执行的那我们可以通过 tick 函数主动给我们通知。

但还有一个问题,如何插入我们的代码看起来像是一个难题:如果代码有着良好的风格,所有的循环体都被大括号包着,而且循环判断条件也比较正常,这很好办,用简单的正则表达式就可以完成对代码的插入。但如果你遇到下面这样的代码要怎么办呢:

while (true) foo();

while (true)
    for (;;)
        do
            bar();
        while(true)

for(var i = 0; i<(function(){
    return 8
}()); i++) {
    console.log(i)
}

遇到这种代码,正则就很难正确的把我们的代码插入进去了。不能指望正则了。。。

不过也不是没有办法,我们可以先把代码解析成语法树(AST),然后通过修改语法树的形式来插入我们的代码,然后再把语法树转换回代码字符串。

JS 社区现在如此活跃,除了 Babel,还有不止一个可以解析语法树的工具,就不在此一一列举了。

这样一来就解决了如何 judge 一段代码是否包含死循环的问题。

然而事情并没有结束

等一等,我们是不是忽略了什么?

仅仅判断循环执行的时间真的足够了吗?

如果我们遇到下面这样的代码怎么办?

function * range(start, end) {
    for(var i = start; i<end; i++) {
        yield i // <<<<<<<<<<<<注意这一行
    }
}

如果是在异步场景里,两次 yield 的执行间隔可能是很久的。而如果我们仅仅只是判断循环 “执行” 的时间长度,即总是把循环体每次执行的时间点与循环开始执行的时间点进行对比,则并不能覆盖到这种情况,事实上循环并没有执行几次,但是因为 “执行” 了很久,会被我们误判,有没有什么解决方案呢?

当然是有的。可以很容易的观察到,在这种场景下,循环体执行的次数其实是非常有限的,所以我们可以在 tick 函数内部一边记录循环开始的时刻,一边记录循环体执行的次数,一边记录循环体最后一次执行的时刻。由此来计算出循环体执行的频率(以 Hz 为单位),如果执行频率非常高而且还执行的很久,则我们认为这是一个死循环。

那么问题又来了,如何选择一个合适的频率而尽量不发生误判呢?

其实这两种场景的界线是很明确的:一种是同步一种是异步。

而异步代码的执行间隔,最短也是两个事件循环之间的间隔。在浏览器中,两个事件循环之间的间隔一般来说是 4ms。换算成频率就是 250Hz。所以如果一个循环的执行频率远大于 250Hz 且执行了很长时间,则我们可以认为它陷入了死循环。而如果一个循环的执行频率在 250Hz 以内,则我们认为它其实是一个 generator 或者 async 函数,不对其进行抛错。

在 Node 中,两次事件循环的间隔是 1ms,也就是 1000Hz。如果一个循环真的在不停的同步执行的话,其实它的频率是元大于 1000Hz 的,根据不同的场景,同步循环的循环体执行频率大概在一百万赫兹到一千万赫兹的数量级,所以还是有明显区分的:

如果一个循环的执行频率在 10000Hz 以上,可以认为是同步循环。
而如果在 10000Hz 以下,则可以认为是异步循环。

如此一来,我们可以极大的减少误判的可能性,甚至是能够杜绝。

后续分析

POC

为了验证我的想法,我把这个功能写成了一个小的库,地址在这里:https://github.com/xieranmaya/infinite-loop-detector,已经在我文首提到的产品在使用了。

目前只是最初级的版本,仅用了正则表达式替换代码,用到的正则也非常简单,只能匹配按照典型风格书写的代码。也没有前文提到的检测频率和代码行号的功能。如果你有这方面的需求,欢迎给我提 Pull Request。

主要提供一个方法:infiniteLoopDetector.wrap(codeStr) 用来把一段表示 代码的字符串提换成被插入了 detector 函数的代码。仅支持传入字符串,不能传函数,object 等,就像 eval 一样。因为如果把这些对象转换成字符串,可能会损失词法作用域,暂时这么做,传的类型不对就抛错。大概这么用:

var code = `
for (;;) {
  console.log(1)
}`

code = infiniteLoopDetector.wrap(code)
// Can only wrap plain code string, no function or other things, or it will throw
// There is also an `unwrap` method to restore the converted code to the previous shape

try {
  eval(code)
} catch(e) {
  if (e.type === 'InfiniteLoopError') {
    console.log('infinite loop detected')
  }
}

关于词法作用域的损失,理论上来说这不是一个需要担心的问题,因为如果你要 judge 一段代码是否包含死循环,很大程度上这段代码是首先以字符串形式存在的,比如是用户输入的代码,而不是处于某个作用域内的活代码,于是就不存在词法作用域的问题了。如果真的要判断一段活代码是会陷入死循环,只转换局部的代码(除非它是纯函数)肯定是会让词法作用域损失掉的,能做的只有把那段代码所在的 script 标签的内容或者整个 js 文件给转换了,索性这种情况并不多。

跟 Node 和 require 的集成

如果你在 Node 上有强烈的探测死循环的需求,你甚至可以对 require 做一下 patch,让被 require 进来的代码自动被 wrap 成插入了检测函数的代码,当然,也可以在浏览器中实现类似的机制。具体怎么对 require 进行 patch,相关的技术文章有很多,就不在此文详述了。

不过如果你已经大量使用了 import 来管理自己 js 代码的依赖,那可能就要在编译过程上动刀子了。编译过程在目前基本已经是前端开发必备的步骤了,检测死循环也可以从这个点切入,在编译过程中增加代码变幻。

如果你有更好的思路也欢迎探讨~

全文完,谢谢阅读。如有纰漏之处还请留言指正。

原创文章,禁止转载。

用 DOM 与 CSS 展示二叉树

好久不见,我的日常更新终于来了

本文内容较长,主要涉及如下内容:

  • 二叉树及相关算法
  • Flexbox 布局
  • CSS 背景图片,计数器等应用
  • 使用 SVG 做为背景图片会遇到的问题

最近在讲各种树的算法与题目的时候,为了给学生演示,总是想要看到树的结构,总是画出来又慢又丑,在控制台里展开看的话实在太麻烦,而且不够直观

我就寻思能不能把树给展示在页面里。

什么是二叉树

首先看一下二叉树的定义:
一颗二叉树是由一个根结点和一个左子树和一颗右子树组成的结构,其左右子树分别又是一颗二叉树。

画成图就是下面这种形状:

           1
          / \
         /   \
        2     3
       / \   / \
      4   5 6   7
     / \
    8   9

关于二叉树的更多内容已经超出了本文的讨论范围,有兴趣的同学可以自行维基百科或者找其它相关的资料。

如何展示二叉树

现成的工具当然也有不少,比如 LeetCode 的自测数据输入框。一开始我也想要不自己做个这样的好了,但是细细想,感觉还蛮复杂的,每层的树的数量不确定,而且越往下层树的结点越多,真要想通过一颗树生成一个漂亮的图片,不管是 SVG 还是画在 Canvas 里,都是相当复杂的。

这是其一,其二是展示成图片的话还不利于交互,万一以后想要与展示出来的结构做些简单的交互,图片很显然是不行的,Canvas 实现交互需要计算坐标;SVG 虽然可以为结点绑定事件,但 SVG 的另一个问题是元素之间不能嵌套,虽然有 g 标签,但 g 标签其实只是对 SVG 中的标签进行分组,而不是实现树状(或递归)的嵌套,所以想要容易的在 SVG 里画出树也并不会比在 Canvas 里容易,一样需要计算每个结点的大小和坐标。

于是我就想,能不能用 HTML 跟 CSS 来显示一颗树的视觉结构呢?毕竟 CSS 可以方便的实现多级菜单,而多级菜单的本质其实就是多叉树。

经过简单的分析,我总结出如下几点:

  • 首先,DOM 的结构就是树状的,用它来显示同为树结构的二叉树应该是相当容易的
  • 第二,目前 CSS 有非常强大的布局功能,用上所有 CSS 最新的功能,可以很容易的实现非常灵活的布局
  • 第三,用 DOM 来展示树结构,可以很方便的实现交互

从二叉树的定义来看,它是一个递归结构,根结点的左子树与右子树分别又是一颗二叉树,所以只要把一颗树考虑成其根结点、左子树和右子树就可以了,而左右子树的结构跟根结点一样,就像级联菜单一样,那么不难构想出如下 DOM 结构:

<div class="tree">
  <span>根结点</span>
  <div>【左子树】</div>
  <div>【右子树】</div>
</div>

其中左子树与右子树的 DOM 结构依然是你上面看到的这种,由于左右子树自身已经被一个 div.tree 元素包着,所以上面的结构其实并不需要里面的两个 div,而且去掉两个额外的 div 会在后面为我们带来一些便利,我们可以方便的用 CSS 仅选择表示叶子结点的 span 元素:span:only-child

那么前面那颗二叉树如果按照上面的结构写成 DOM 将会是下面这样的(为了方便观察,把结点用【】括起来了):

<div class="tree">
  <span class="leaf-node">【1】</span>
  <div class="tree">
    <span class="leaf-node">【2】</span>
    <div class="tree">
      <span class="leaf-node">【4】</span>
      <div class="tree">
        <span class="leaf-node">【8】</span>
      </div>
      <div class="tree">
        <span class="leaf-node">【9】</span>
      </div>
    </div>
    <div class="tree">
      <span class="leaf-node">【5】</span>
    </div>
  </div>
  <div class="tree">
    <span class="leaf-node">【3】</span>
    <div class="tree">
      <span class="leaf-node">【6】</span>
    </div>
    <div class="tree">
      <span class="leaf-node">【7】</span>
    </div>
  </div>
</div>

光有这个结构当然是看不出其树形结构的,还得考虑如何用 CSS 展示它。很明显,对于树,我们需要按如下形式展示它————根结点的值位于左子树与右子树的上方且居中,左右子树平分下方的空间:

------------------------
|        根结点        |
------------------------
|  左子树  |  右子树   |
------------------------

根结点独自在一行上占用全部的水平空间,左子树与右子树平分左右的空间,所以 span 元素的宽度应该要是 100%,左右子树的 div 宽度分别为 50%,这里必需要使用百分比的单位来布局,因为越往下层树的结点越多,每个结点的空间就越小,用绝对长度单位肯定是行不通的。

由于左右子树分别又是一个 div.tree,而且它们需要展示在左边和右边各占一半的空间,所在这个 div.tree 必须要能自适应其可用空间,放在多宽的位置它就展示多宽,这样一来,顶层的 div 结构(即根结点)也能自动占满可用空间。

当然是用 flex 布局了,虽然传统布局手段也可以做到想要的效果

div.tree {
  display: flex;
  flex-wrap: wrap;/*span 需要独占一行,所以此 flex 布局必须要折行显示 */
}
div.tree > span {
  width: 100%;
  text-align: center;
}
.tree > .tree {
  width: 50%;
}

对于前面那颗树,展示出来后有点奇怪,右子树都往下偏了一些,就像下面这样:

点击查看实时 Demo

image

究其原因,是因为 flex 布局中元素在侧轴上默认会拉伸,这个好办,给所有的 flex 父元素(即 div.tree)加一个 align-items: flex-start; 就可以了。

每层结点之间有点太近,这个好办,给 span 元素加点高度就可以了。

于是乎我们得到了如下视觉效果的二叉树,看起来很不错!

点击查看实时 Demo

image

但还差一件事,那就是父子结点之间的连线,这个好像不太好办,虽然可以使用边框生成斜线,但真心不太好控制;当然,还可以使用 2D 变幻来实现,但计算量还是有的,主要在于不同层级的连线,倾斜程度不同(如下图),能不能找到一个简单点的办法显示结点间的连线呢?

image

通过观察我们注意到父结点与两个子结点的相对位置比例总是保持不变的,如果把一颗树占用的水平宽度计为 100%,那么父结点总是在上方 50% 的位置,而两颗子树的根结点总是在下方 25% 和 75% 的位置。

所以只要能够实现一个能够随元素自动按比例拉伸的效果就可以了。

很显然,背景图片可以满足我们的这个要求,而且足够简单。

事实上我们只需要一张像下面这样的倒 V 型的图片即可:

     /\
    /  \
   /    \

然后把它展示为 span 元素的背景图片,再做些简单的位置和大小调整就可以了!由于这张图片要占用一定的空间,span 元素的高度也要相应增加一丢丢:

为了方便演示,我给这张图片打了些底色以方便观察:

image

代码也很简单,使用背景图片把元素设置上去就可以了:

div.tree > span {
  width: 100%;
  text-align: center;
  padding-bottom: 3em;

  /* 设置这张黄颜色的背景图片 */
  background-image: url(dom-binary-tree/ud-v.png);
  background-repeat: no-repeat;
  background-size: 100% calc(100% - 1em);
  background-position: 0 1em;
}

由于图片中的倒 V 的顶点总是要在结点文字的下方,这里使用了 background-size 以及 calc 设置了图片的高度总为 100% - 1em,再合适 background-position 让图片从顶部往下偏 1em 的距离;当然,因为垂直方向上图片的高度是固定的,所以给图片留白也是行的。

大功告成!

处理多余的连线

等一下!最后一行的叶子结点怎么还有多余的连线?

理论上这也不过分,因为叶子结点实际上就是左右子树为空的二叉树,这么画出来,没毛病!

不过做为强迫症患者,我是无法接受这种效果的,再说,也从来没人会这么画二叉树。

于是乎前面我们设计的 HTML 结构派上用场了,对于叶子结点来说,它不再有两个 div 的兄弟结点,所以使用 span:only-child 选中它然后把它的背景图片隐藏就可以了!这也为什么我要把用于表示连线的背景图片设置到 span 元素上的原因。

/* 选中做为其父元素唯一子结点的 span 元素 */
div.tree span:only-child {
  background-image: none;

  /* 由于不需要背景,最后一行其实不需要多余的高度了,这样也可以在一些情况下节省空间 */
  padding-bottom: 0;
}

显示效果如下:

image

只有单边子树的情况

你们以为这样就又大功告成了吗?

如下这颗树的展示就有问题了:

          _1_
         /   \
        2     3
       / \     \
      4   5     7
     / 
    8

首先,7 是 3 的右子树,但它却展示在 3 的左边,原因也很明显,因为表示 7 这个颗的 div 是从左往右展示的,这时我开始怀念 float 了,如果布局是使用 float 实现的,那么给用于表示左子树的元素一个左浮动,给表示右子树的元素一个右浮动就可以了,这样即使只有单边的子树,它们也会自动显示在一边。然而我们使用的是 flex 布局。不过 flex 布局一样有办法实现这种效果,比如说 align-self,不过遗憾的是它是在侧轴方向控制位置的。

想要实现让左子树往左偏的效果,我们可以让表示右子树的 div 元素的 margin-left 为 auto(同理让表示左子树的 div 元素的 margin-right 为 auto),在 flex 布局中,如果一个 flex 子元素的在主轴方向上的 margin 为 auto 且该方向还有空间,而且元素自身没有 flex-grow 的话,这个 margin 会尽量的大,可以方便的用这个特性来实现元素的居左或者居右,也可以实现居中。

div.tree > div:nth-child(2) {
  width: 50%;
  margin-right: auto;
}
div.tree > div:nth-child(3) {
  width: 50%;
  margin-right: left;
}

其次,在页面的展示中,因为【4】所在的 span 元素总是有一个倒 V 型的背景图片,所以它总是会展示它与其左右子树的连线,即使它并没有右子树,同样的情况也发生在【3】这个结点上,它展示了与其不存在的左子树的连线。

这当然也是不能接受的,要怎么办呢?

如何选择【只有左子树】或者【只有右子树】的树中的 span 元素呢?

比较奇技淫巧的做法是给表示左子树与表示右子树的元素分别加上相应的类,比如 div.tree.left,div.tree.right,然后把 span 元素放在 div 的后面,然后当一颗树只有左子树时,其结构就是这样:

<div>
  <div class="tree left"></div>
  <span></span>
</div>

然后使用 order 属性把 span 调到前面,通过 div.left:first-child + span 选中只有左子树的 span 元素,然后把它的背景图片调整成相应的只有向左方连线的图片即可,右子树也类似。

但这样总感觉怪怪的,而且如果要实现交互功能的话可能会有些问题,毕竟 DOM 顺序不大对劲。

更简单的做法是,如果一颗树只有左子树或者只有右子树,我们给它加上额外的一个类比如 only-has-left,only-has-right,这样就可以很容易的选中不同情况的 span 了:

div.only-has-left > span {
  background-image: url(left-link.png);
}
div.only-has-right > span {
  background-image: url(right-link.png);
}

这样一来,总算离大功告成又进一步了!!!

自动生成二叉树的 HTML 代码

最后,我们不可能手写出上面的 HTML 结构,而是用程序生成出上面的嵌套 HTML 结构:给定一颗树,程序自动构建出上面说到的 HTML 结构,看起来好像很复杂,其实熟悉树的相关算法的话,这个小函数是很好写的:

function tree2html(root) {
  if (root) {
    let onlyLeft  = (root.left && !root.right)
    let onlyRight = (!root.left && root.right)
    let both = root.left && root.right
    let noSubTree = !root.left && !root.right
    return `
      <div class="
        tree 
        ${both?'both':''}
        ${noSubTree?'no-sub-tree':''}
        ${onlyLeft?'only-has-left':''}
        ${onlyRight?'only-has-right':''}
      ">
        <span>${root.val}</span>
        ${tree2html(root.left)}
        ${tree2html(root.right)}
      </div>
    `
  } else {return''}
}

解释一下,我们根据一颗树是否有左子树、右子树、或者两颗子树都有或都没有,来为它加上相应的 class,以方便我们选择其内的 span 元素:

div.only-has-left > span {
  background-image: url(left-link.png);
}
div.only-has-right > span {
  background-image: url(right-link.png);
}

但是,这样并没有大功告成,很多细节上还是不够完美。

小问题比如说,树中各层之间的连线会随着层次的加深而变的更粗(从前面的示图中是可以看出来的),原因也是很明显的,越往下层,展示的空间越小,而背景图片总是被压缩的显示到那个空间中,线就会显得比较粗。

大的问题比如说,如果给定的一颗树非常的不平衡(平衡树的意思就是一颗树的根结点及任意子树的两颗子树的高度之差都不超过 1),那么我们的展示效果也非常差,会一直往一边挤。类似下面这样的效果。而 LeetCode 的展示中,能够很好的适应这种情况。

image

这两个问题看起来都不太好解决。

先说第一个,使用可能被压缩的图片当做背景图片肯定是行不通了,使用边框或者变幻来模拟我们也不考虑。要是有一张图片设置为背景后不会被压缩,而其中的线条可以按图片大小的百分比显示就好了。

很容易想到使用 SVG 图片来做为背景图片,然后使用 <line/> 标签来生成结点间的连线,然后我写出了如下简单的 SVG 代码:

<svg width="1000"height="200"version="1.1"xmlns="http://www.w3.org/2000/svg">

  <line x1="50%"y1="1.5em"x2="25%"y2="95%"stroke-linecap="round"stroke-linejoin="round"style="stroke:rgb(99,99,99);stroke-width:3"/>
  <line x1="50%"y1="1.5em"x2="75%"y2="95%"stroke-linecap="round"stroke-linejoin="round"style="stroke:rgb(99,99,99);stroke-width:3"/>

</svg>

然后把它展示为 span 元素的背景图片,但是得到的效果并不能让我们满意,线条还是会随着层次的往下而变的粗起来(图就不贴了)。也就是说 SVG 图像还是被拉伸了。

而实际上我们想要的是 SVG 的不位伸大小就与 background-size 所设置的大小一样,盲目的试了几下后我发现好像并不太容易调成功,甚至不确实能否实现我们想要的效果;最终我找到了这个文档:Scaling of SVG backgrounds,里面详细讲述了 SVG 在做背景图片时,其是被变形拉伸还是会让自身尺寸变为 background-size 所设置的大小。情况比较多,我就不在这里解释了,有必要的话各位可以自行阅读该文档。

最终的结果是只要不给 SVG 图片设置明确的宽高,它的大小就将是 background-size 的大小,于是 SVG 图片的源代码如下(与上面的区别就是去掉了 svg 标签的 width 与 height 属性):

<svg version="1.1"xmlns="http://www.w3.org/2000/svg">

  <line x1="50%"y1="1.5em"x2="25%"y2="95%"stroke-linecap="round"stroke-linejoin="round"style="stroke:rgb(99,99,99);stroke-width:3"/>
  <line x1="50%"y1="1.5em"x2="75%"y2="95%"stroke-linecap="round"stroke-linejoin="round"style="stroke:rgb(99,99,99);stroke-width:3"/>

</svg>

这样一来,解决了不同层级连线粗细不一样的问题,最终的效果就是前面的某张非常对称的截图。

下一个问题,树过于不平衡时的展示问题。

如果某一个结点没有左/右子树,那么按照目前的展示方法,不存的子树还是会占用下方整整一半的空间,最终会导致不平衡的树展示效果较差。

其实这个也不难办,当一个结点只有一颗子树时,让这颗子树占用下方几乎所有的空间就可以了(之所以不是所有的是为了呈现出一种向一边偏的效果),比方说对于一个只有左子树的结点来说,其内部只有表示左子树的 div 结点,让这个结点的宽度为 90% 即可,剩余的 10% 留白,可以简单的使用 margin-right: 10% 来实现(此时 10% 取的也是父元素的内容宽度),其实不写或者写成 auto 也可以。

但这样一来如果继续使用之前的连线,就对不齐了,这个好办,换一种连线就可以了,可以算出,线的起点在上方 50% 处,而终点在下方的 45% 处(即左边 90% 空间的中点),对于只有右子树的情况来说,终点则是在下方 55% 处(右边 90% 空间的中点)。

最终上面那颗非常不平衡的树会展示成如下效果:

image

看起来好多了。

到这里,我们处理了遇到的几乎所有问题:

  • 让没有子树的结点不展示连线
  • 让只有左/右子树的结点只展示单方向的连线
  • 让各层之间的连线粗细相同
  • 让只有单边子树的元素的单边子树占用更大的空间

但是还有最后一种情况我们没有处理,即如果一颗树有左子树且左子树依然有后代子树,而右子树没有后代子树,我们的代码还是会让这两边的子树占用相同的空间,实际上此时右子树也应该只占用很少的空间。考虑到此文篇幅已经很长,这个优化我们就不在此文讨论了,留给读者自己思考吧。

最后,完整的 Demo,源代码中有注释:http://xieranmaya.github.io/blog/demo/dom-binary-tree/b-tree.html

详解 flex-grow 与 flex-shrink

详解 flex-grow 与 flex-shrink

自从开始开学习 CSS 布局,想要比较灵活的把父元素的空间分配给各个子元一直是各个前端程序员的梦想。

在 flex 之前,不如果不是专门去搜索相关的解决方案,一般人几乎想不出非常灵活的三(多)栏等高布局方案,而即使看了解决方案,很多人也会大呼奇技淫巧。

不得不感慨在 flex 之前 CSS 的布局功能之弱:基本只能使用一些并非为布局而设计的属性来实现想要的布局——float、inline-block、position、甚至是 table 等。而使用这些属性来实现各种布局效果,往往又会遇到相当多另外的坑:比如浮动的闭合、inline-block 的垂直对齐、position 的定位原点以及 table 的不够灵活等。

直到出现了 flex

flex 可以说是一次性解决了前端布局的所有的问题(当然,并没有完全解决,要不然也不会有 grid layout 了),以前很难实现的布局效果在 flex 下简直不能更简单,以至于一些其它平台也开始吸纳 flex 的布局**,也有些开源项目把 flex 的布局方式移植到其它平台。

中文社区也有不少写 flex 的文章,比如 ruanyifeng。然而个人觉得不少写 flex 的文章都有个通病,就是一上来就整一堆 flex 相关的术语,比如 flex container,flex item,main axis(主轴),cors axis(交叉轴),不禁让人望而生畏,都还没搞清楚怎么回事,就来一堆术语。

然而这还不是最大的问题,最大的问题是很多文章并没有把 flex 布局的计算方式讲清楚,尤其是连 ruanyifeng 的文章也没把这事说清楚,但是在 Google 搜索 flex 相关的文章,他的文章却会出现在第一页。因为我觉得他写的并不好,所以就不贴地址了,想看的同学可以自己搜一下,就在第一页。

即使是 MDN 上也没把 flex-grow 和 flex-shrink 的计算方式说清楚。

所以我决定写这一篇文章,把 flex-grow 与 flex-shrink 的详细计算方式讲清楚。

flex 如何解决传统常见布局问题

在传统布局中最常见也是急需的当然就是在从左往右把父元素的空间分配给子元素以实现多栏布局了:按比例也好,定宽也好,更灵活的定宽加占用剩余空间也好。

那我们就从使用 flex 如何实现三栏布局开始吧。

想要实现三栏等高布局,且两边的侧栏宽度固定而中间一栏占用剩余的空间,如下代码就足够了:

<style>
  section {display: flex;}
  .left-side,
  .right-side {width: 200px;}
  .content {flex-grow: 1;}
</style>
<section>
  <div class="left-side"></div>
  <div class="content"></div>
  <div class="right-side"></div>
</section>

其中 section 元素的宽度将会像 block 元素一样尽量的宽,对外面的元素来说,它的行为很像一个 block 块。三个元素会从左往右占据父元素的空间(这很显然)。左右侧边栏的宽度都是 200px,中间 .content 元素的宽度将会占据 section 元素的剩余宽度。

另外,section 的高度会自动被最高的一个子元素撑开,同时其它子元素的高度也会被拉到跟 section 元素一样高,而如果给 section 元素设置了高度,而所有子元素的高度设置为 auto ,所有的子元素也都会自动跟父元素一样高,这简直就是在传统布局中做梦都想要的功能!

总之,在高度方面,flex 的表现是相当符合直觉的。

另外,如果不给 flex 子元素设置宽度和 flex-grow,它会尽量的窄。

flex-grow 的计算方式

上面 demo 中最值得注意的是 .content 元素的 flex-grow 属性,设置为 1 它就可以占满水平剩余空间。这也是本文的重点:讲清 flex-grow 与 flex-shrink 属性的详细计算方式。

flex-grow 属性决定了父元素在空间分配方向上还有剩余空间时,如何分配这些剩余空间。其值为一个权重(也称扩张因子),默认为 0(纯数值,无单位),剩余空间将会按照这个权重来分配。

比如剩余空间为 x,三个元素的 flex-grow 分别为 a,b,c。设 sum 为 a + b + c。那么三个元素将得到剩余空间分别是 x * a / sum, x * b / sum, x * c / sum,是为权重也。

举个例子:

父元素宽度 500px,三个子元素的 width 分别为 100px,150px,100px。

于是剩余空间为 150px

三个元素的 flex-grow 分别是 1,2,3,于是 sum 为 6
则三个元素所得到的多余空间分别是:

  • 150 * 1 / 6 = 25px
  • 150 * 2 / 6 = 50px
  • 150 * 3 / 6 = 75px

三个元素最终的宽度分别为 125px,200px,175px。

  • 100px + 25px = 125px
  • 150px + 50px = 200px
  • 100px + 75px = 175px

可以打开这个 demo(下文中所有的 demo 都在这个页面) 然后用开发工具查看一下。注意不要用截图工具量,可能量不准,因为高分屏和放大等诸多因素都会影响测量结果。

然而!不止这些,还有一种情况:

当所有元素的 flex-grow 之和小于 1 的时候(注意是 1,也就是说每个元素的 flex-grow 都是一个小数如 0.2 这样的),上面式子中的 sum 将会使用 1 来参与计算,而不论它们的和是多少。也就是说,当所有的元素的 flex-grow 之和小于 1 的时候,剩余空间不会全部分配给各个元素。

实际上用来分配的空间是 sum(flex-grow) / 1 * 剩余空间 ,这些用来分配的空间依然是按 flex-grow 的比例来分配。

还是上面一个例子,但是三个元素的 flex-grow 分别是 0.1,0.2,0.3,那么计算公式将变成下面这样:

  • 150 * 0.1 / 1 = 15px
  • 150 * 0.2 / 1 = 30px
  • 150 * 0.3 / 1 = 45px

150px - 15px - 30px - 45px = 60px,即还有 60px 没有分配给任何子元素。
三个元素的最终宽度分别为:

  • 100px + 15px = 115px
  • 150px + 30px = 180px
  • 100px + 45px = 145px

如上所述即是 flex-grow 的计算方式。

另外,flex-grow 还会受到 max-width 的影响。如果最终 grow 后的结果大于 max-width 指定的值,max-width 的值将会优先使用。同样会导致父元素有部分剩余空间没有分配。

flex-shrink 的计算方式

前文已经说到,flex 几乎一次性解决了前端布局的所有问题。

那么既然可以在空间有多余时把多余空间分配给各个子元素,当然也可以在空间不够时让各个子元素收缩以适应有限的空间了。

这就是 flex-shrink 属性的作用。

你可能会觉得 flex-shrink 的计算方式跟 flex-grow 很类似,然而事情并没有这么简单。

flex-shrink 属性定义空间不够时各个元素如何收缩。其值默认为 1。很多文章对此基本是一笔带过:“flex-shrink 属性定义了元素的收缩系数”,根本就不说它具体是怎么计算的。

flex-shrink 定义的仅仅只是元素宽度变小的一个权重分量。

每个元素具体收缩多少,还有另一个重要因素,即它本身的宽度。

举个例子:

父元素 500px。三个子元素分别设置为 150px,200px,300px。

三个子元素的 flex-shrink 的值分别为 1,2,3。

首先,计算子元素溢出多少:150 + 200 + 300 - 500 = -150px

那这 -150px 将由三个元素的分别收缩一定的量来弥补。

具体的计算方式为:每个元素收缩的权重为其 flex-shrink 乘以其宽度。

所以总权重为 1 * 150 + 2 * 200 + 3 * 300 = 1450

三个元素分别收缩:

  • 150 * 1(flex-shrink) * 150(width) / 1450 = -15.5
  • 150 * 2(flex-shrink) * 200(width) / 1450 = -41.4
  • 150 * 3(flex-shrink) * 300(width) / 1450 = -93.1

三个元素的最终宽度分别为:

  • 150 - 15.5 = 134.5
  • 200 - 41.4 = 158.6
  • 300 - 93.1 = 206.9

同样,当所有元素的 flex-shrink 之和小于 1 时,计算方式也会有所不同:

此时,并不会收缩所有的空间,而只会收缩 flex-shrink 之和相对于 1 的比例的空间。

还是上面的例子,但是 flex-shrink 分别改为 0.1,0.2,0.3。

于是总权重为 145(正好缩小 10 倍,略去计算公式)。

三个元素收缩总和并不是 150px,而是只会收缩 150px 的 (0.1 + 0.2 + 0.3) / 1 即 60% 的空间:90px。

每个元素收缩的空间为:

  • 90 * 0.1(flex-shrink) * 150(width) / 145 = 9.31
  • 90 * 0.2(flex-shrink) * 200(width) / 145 = 24.83
  • 90 * 0.3(flex-shrink) * 300(width) / 145 = 55.86

三个元素的最终宽度分别为:

  • 150 - 9.31 = 140.69
  • 200 - 24.83 = 175.17
  • 300 - 55.86 = 244.14

当然,类似 flex-grow,flex-shrink 也会受到 min-width 的影响。

总结

虽然上面的公式看起来很复杂,其实计算过程还是比较简单的:如果所有元素的 flex-grow/shrink 之和大于等于 1,则所有子元素的尺寸一定会被调整到适应父元素的尺寸(在不考虑 max/min-width/height 的前提下),而如果 flex-grow/shrink 之和小于 1,则只会 grow 或 shrink 所有元素 flex-grow/shrink 之和相对于 1 的比例。grow 时的每个元素的权重即为元素的 flex-grow 的值;shrink 时每个元素的权重则为元素 flex-shrink 乘以 width 后的值。

相关 w3 文档地址:CSS Flexible Box Layout Module Level 1

结语

本来是想写一篇事无巨细关于 flex 的文章,没想到刚刚写完 flex-grow/shrink 的计算方式就花了这么大的篇幅。考虑到 flex 其它方面的文章已经有很多人写了,各位可以看看 ruanyifeng 的相关文章,毕竟配图还是相当好的。

也可以在下面这个页面中看到 flex 实时布局的效果:Flexbox Playground

或者玩一下下面这个以 flex 为背景的游戏:Flexbox Froggy

如果你还觉得意犹未尽,可以看看我的下面几篇博文:

关于 flex 的其它相关问题

下面列一下在 flex 布局中容易忽略的几个问题:

  • flex 上下文中垂直 margin 不会合并
  • flex 主轴方面上子元素的 margin 如果设置为 auto,其该方向上的 margin 是会尽量大的,可以利用这个特性来做对齐
  • flex-basis 与 width/height 同时使用时的优先级问题

最后,广告时间

我叫谢然,网名充电大喵,高二开始接触编程,2011 年毕业于华中师范大学计算机科学与技术专业,曾先后就职于全国百强中学、阿里巴巴、小米。2016 年 9 月创建了自己的前端培训品牌 “大喵教育前端培训”,首期前端培训课程已过半。第二期班将于 2017 年 3 月上旬开班,地点杭州下沙。

我的培训班与众不同,着重培养学生的自学能力,英语能力(部分课程使用英文授课),及计算机基础能力(包括算法与数据结构及计算机网络),让非科班的人也能有机会进入 IT 行业,我为学员设计了与众不同的且有难度的实践项目:

  • 初步实现 lodash
  • 初步实现 JSON Parser
  • 初步实现 jQuery
  • 初步实现 Sizzle
  • ……

如果你想参加,或者想要了解更多,请点击:大喵教育前端培训

以上。

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.