Git Product home page Git Product logo

blog's People

Contributors

aooy avatar

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

blog's Issues

解析vue2.0的diff算法

作者:杨敬卓

转载请注明出处

目录

  • 前言
  • virtual dom
  • 分析diff
  • 总结

前言

vue2.0加入了virtual dom,有向react靠拢的意思。vue的diff位于patch.js文件中,我的一个小框架aoy也同样使用此算法,该算法来源于snabbdom,复杂度为O(n)。
了解diff过程可以让我们更高效的使用框架。
本文力求以图文并茂的方式来讲明这个diff的过程。

virtual dom

如果不了解virtual dom,要理解diff的过程是比较困难的。虚拟dom对应的是真实dom, 使用document.CreateElementdocument.CreateTextNode创建的就是真实节点。

我们可以做个试验。打印出一个空元素的第一层属性,可以看到标准让元素实现的东西太多了。如果每次都重新生成新的元素,对性能是巨大的浪费。

var mydiv = document.createElement('div');
for(var k in mydiv ){
  console.log(k)
}

virtual dom就是解决这个问题的一个思路,到底什么是virtual dom呢?通俗易懂的来说就是用一个简单的对象去代替复杂的dom对象。
举个简单的例子,我们在body里插入一个class为a的div。

var mydiv = document.createElement('div');
mydiv.className = 'a';
document.body.appendChild(mydiv);

对于这个div我们可以用一个简单的对象mydivVirtual代表它,它存储了对应dom的一些重要参数,在改变dom之前,会先比较相应虚拟dom的数据,如果需要改变,才会将改变应用到真实dom上。

//伪代码
var mydivVirtual = { 
  tagName: 'DIV',
  className: 'a'
};
var newmydivVirtual = {
   tagName: 'DIV',
   className: 'b'
}
if(mydivVirtual.tagName !== newmydivVirtual.tagName || mydivVirtual.className  !== newmydivVirtual.className){
   change(mydiv)
}

// 会执行相应的修改 mydiv.className = 'b';
//最后  <div class='b'></div>

读到这里就会产生一个疑问,为什么不直接修改dom而需要加一层virtual dom呢?

很多时候手工优化dom确实会比virtual dom效率高,对于比较简单的dom结构用手工优化没有问题,但当页面结构很庞大,结构很复杂时,手工优化会花去大量时间,而且可维护性也不高,不能保证每个人都有手工优化的能力。至此,virtual dom的解决方案应运而生,virtual dom很多时候都不是最优的操作,但它具有普适性,在效率、可维护性之间达平衡。

virtual dom 另一个重大意义就是提供一个中间层,js去写ui,ios安卓之类的负责渲染,就像reactNative一样。

分析diff

一篇相当经典的文章React’s diff algorithm中的图,react的diff其实和vue的diff大同小异。所以这张图能很好的解释过程。比较只会在同层级进行, 不会跨层级比较。

举个形象的例子。
<!-- 之前 -->
<div>           <!-- 层级1 -->
  <p>            <!-- 层级2 -->
    <b> aoy </b>   <!-- 层级3 -->   
    <span>diff</Span>
  </P> 
</div>

<!-- 之后 -->
<div>            <!-- 层级1 -->
  <p>             <!-- 层级2 -->
      <b> aoy </b>        <!-- 层级3 -->
  </p>
  <span>diff</Span>
</div>

我们可能期望将<span>直接移动到<p>的后边,这是最优的操作。但是实际的diff操作是移除<p>里的<span>在创建一个新的<span>插到<p>的后边。
因为新加的<span>在层级2,旧的在层级3,属于不同层级的比较。

源码分析

文中的代码位于aoy-diff中,已经精简了很多代码,留下最核心的部分。

diff的过程就是调用patch函数,就像打补丁一样修改真实dom。

function patch (oldVnode, vnode) {
	if (sameVnode(oldVnode, vnode)) {
		patchVnode(oldVnode, vnode)
	} else {
		const oEl = oldVnode.el
		let parentEle = api.parentNode(oEl)
		createEle(vnode)
		if (parentEle !== null) {
			api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl))
			api.removeChild(parentEle, oldVnode.el)
			oldVnode = null
		}
	}
	return vnode
}

patch函数有两个参数,vnodeoldVnode,也就是新旧两个虚拟节点。在这之前,我们先了解完整的vnode都有什么属性,举个一个简单的例子:

// body下的 <div id="v" class="classA"><div> 对应的 oldVnode 就是

{
  el:  div  //对真实的节点的引用,本例中就是document.querySelector('#id.classA')
  tagName: 'DIV',   //节点的标签
  sel: 'div#v.classA'  //节点的选择器
  data: null,       // 一个存储节点属性的对象,对应节点的el[prop]属性,例如onclick , style
  children: [], //存储子节点的数组,每个子节点也是vnode结构
  text: null,    //如果是文本节点,对应文本节点的textContent,否则为null
}

需要注意的是,el属性引用的是此 virtual dom对应的真实dom,patchvnode参数的el最初是null,因为patch之前它还没有对应的真实dom。

来到patch的第一部分,

if (sameVnode(oldVnode, vnode)) {
	patchVnode(oldVnode, vnode)
} 

sameVnode函数就是看这两个节点是否值得比较,代码相当简单:

function sameVnode(oldVnode, vnode){
	return vnode.key === oldVnode.key && vnode.sel === oldVnode.sel
}

两个vnode的key和sel相同才去比较它们,比如pspandiv.classAdiv.classB都被认为是不同结构而不去比较它们。

如果值得比较会执行patchVnode(oldVnode, vnode),稍后会详细讲patchVnode函数。

当节点不值得比较,进入else中

else {
		const oEl = oldVnode.el
		let parentEle = api.parentNode(oEl)
		createEle(vnode)
		if (parentEle !== null) {
			api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl))
			api.removeChild(parentEle, oldVnode.el)
			oldVnode = null
		}
	}

过程如下:

  • 取得oldvnode.el的父节点,parentEle是真实dom
  • createEle(vnode)会为vnode创建它的真实dom,令vnode.el =真实dom
  • parentEle将新的dom插入,移除旧的dom
    当不值得比较时,新节点直接把老节点整个替换了

最后

return vnode

patch最后会返回vnode,vnode和进入patch之前的不同在哪?
没错,就是vnode.el,唯一的改变就是之前vnode.el = null, 而现在它引用的是对应的真实dom。

var oldVnode = patch (oldVnode, vnode)

至此完成一个patch过程。

patchVnode

两个节点值得比较时,会调用patchVnode函数

patchVnode (oldVnode, vnode) {
    const el = vnode.el = oldVnode.el
    let i, oldCh = oldVnode.children, ch = vnode.children
    if (oldVnode === vnode) return
    if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
        api.setTextContent(el, vnode.text)
    }else {
        updateEle(el, vnode, oldVnode)
    	if (oldCh && ch && oldCh !== ch) {
	    	updateChildren(el, oldCh, ch)
	    }else if (ch){
	    	createEle(vnode) //create el's children dom
	    }else if (oldCh){
	    	api.removeChildren(el)
	    }
    }
}

const el = vnode.el = oldVnode.el 这是很重要的一步,让vnode.el引用到现在的真实dom,当el修改时,vnode.el会同步变化。

节点的比较有5种情况

  1. if (oldVnode === vnode),他们的引用一致,可以认为没有变化。

  2. if(oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text),文本节点的比较,需要修改,则会调用Node.textContent = vnode.text

  3. if( oldCh && ch && oldCh !== ch ), 两个节点都有子节点,而且它们不一样,这样我们会调用updateChildren函数比较子节点,这是diff的核心,后边会讲到。

  4. else if (ch),只有新的节点有子节点,调用createEle(vnode)vnode.el已经引用了老的dom节点,createEle函数会在老dom节点上添加子节点。

  5. else if (oldCh),新节点没有子节点,老节点有子节点,直接删除老节点。

updateChildren

updateChildren (parentElm, oldCh, newCh) {
    let oldStartIdx = 0, newStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let oldStartVnode = oldCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    let newEndIdx = newCh.length - 1
    let newStartVnode = newCh[0]
    let newEndVnode = newCh[newEndIdx]
    let oldKeyToIdx
    let idxInOld
    let elmToMove
    let before
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
            if (oldStartVnode == null) {   //对于vnode.key的比较,会把oldVnode = null
                oldStartVnode = oldCh[++oldStartIdx] 
            }else if (oldEndVnode == null) {
                oldEndVnode = oldCh[--oldEndIdx]
            }else if (newStartVnode == null) {
                newStartVnode = newCh[++newStartIdx]
            }else if (newEndVnode == null) {
                newEndVnode = newCh[--newEndIdx]
            }else if (sameVnode(oldStartVnode, newStartVnode)) {
                patchVnode(oldStartVnode, newStartVnode)
                oldStartVnode = oldCh[++oldStartIdx]
                newStartVnode = newCh[++newStartIdx]
            }else if (sameVnode(oldEndVnode, newEndVnode)) {
                patchVnode(oldEndVnode, newEndVnode)
                oldEndVnode = oldCh[--oldEndIdx]
                newEndVnode = newCh[--newEndIdx]
            }else if (sameVnode(oldStartVnode, newEndVnode)) {
                patchVnode(oldStartVnode, newEndVnode)
                api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
                oldStartVnode = oldCh[++oldStartIdx]
                newEndVnode = newCh[--newEndIdx]
            }else if (sameVnode(oldEndVnode, newStartVnode)) {
                patchVnode(oldEndVnode, newStartVnode)
                api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
                oldEndVnode = oldCh[--oldEndIdx]
                newStartVnode = newCh[++newStartIdx]
            }else {
               // 使用key时的比较
                if (oldKeyToIdx === undefined) {
                    oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
                }
                idxInOld = oldKeyToIdx[newStartVnode.key]
                if (!idxInOld) {
                    api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                    newStartVnode = newCh[++newStartIdx]
                }
                else {
                    elmToMove = oldCh[idxInOld]
                    if (elmToMove.sel !== newStartVnode.sel) {
                        api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                    }else {
                        patchVnode(elmToMove, newStartVnode)
                        oldCh[idxInOld] = null
                        api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
                    }
                    newStartVnode = newCh[++newStartIdx]
                }
            }
        }
        if (oldStartIdx > oldEndIdx) {
            before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
            addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
        }else if (newStartIdx > newEndIdx) {
            removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
        }
}

代码很密集,为了形象的描述这个过程,可以看看这张图。

过程可以概括为:oldChnewCh各有两个头尾的变量StartIdxEndIdx,它们的2个变量相互比较,一共有4种比较方式。如果4种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx表明oldChnewCh至少有一个已经遍历完了,就会结束比较。

具体的diff分析

设置key和不设置key的区别:
不设key,newCh和oldCh只会进行头尾两端的相互比较,设key后,除了头尾两端的比较外,还会从用key生成的对象oldKeyToIdx中查找匹配的节点,所以为节点设置key可以更高效的利用dom。

diff的遍历过程中,只要是对dom进行的操作都调用api.insertBeforeapi.insertBefore只是原生insertBefore的简单封装。
比较分为两种,一种是有vnode.key的,一种是没有的。但这两种比较对真实dom的操作是一致的。

对于与sameVnode(oldStartVnode, newStartVnode)sameVnode(oldEndVnode,newEndVnode)为true的情况,不需要对dom进行移动。

总结遍历过程,有3种dom操作:

  1. oldStartVnodenewEndVnode值得比较,说明oldStartVnode.el跑到oldEndVnode.el的后边了。

图中假设startIdx遍历到1。

  1. oldEndVnodenewStartVnode值得比较,说明 oldEndVnode.el跑到了newStartVnode.el的前边。(这里笔误,应该是“oldEndVnode.el跑到了oldStartVnode.el的前边”,准确的说应该是oldEndVnode.el需要移动到oldStartVnode.el的前边”)
  1. newCh中的节点oldCh里没有, 将新节点插入到oldStartVnode.el的前边。

在结束时,分为两种情况:

  1. oldStartIdx > oldEndIdx,可以认为oldCh先遍历完。当然也有可能newCh此时也正好完成了遍历,统一都归为此类。此时newStartIdxnewEndIdx之间的vnode是新增的,调用addVnodes,把他们全部插进before的后边,before很多时候是为null的。addVnodes调用的是insertBefore操作dom节点,我们看看insertBefore的文档:parentElement.insertBefore(newElement, referenceElement)
    如果referenceElement为null则newElement将被插入到子节点的末尾。如果newElement已经在DOM树中,newElement首先会从DOM树中移除。所以before为null,newElement将被插入到子节点的末尾。
  1. newStartIdx > newEndIdx,可以认为newCh先遍历完。此时oldStartIdxoldEndIdx之间的vnode在新的子节点里已经不存在了,调用removeVnodes将它们从dom里删除。

下面举个例子,画出diff完整的过程,每一步dom的变化都用不同颜色的线标出。

  1. a,b,c,d,e假设是4个不同的元素,我们没有设置key时,b没有复用,而是直接创建新的,删除旧的。
  1. 当我们给4个元素加上唯一key时,b得到了的复用。

这个例子如果我们使用手工优化,只需要3步就可以达到。

总结

  • 尽量不要跨层级的修改dom
  • 设置key可以最大化的利用节点
  • diff的效率并不是每种情况下都是最优的

关于event loop的task 和microtask的问题 求解惑

event loop会不断循环的去取tasks队列的中最老的一个任务推入栈中执行,并在当次循环里依次执行并清空microtask队列里的任务
--------------------------------------------我是分割线--------------------------------
tasks队列和microtask队列是互相独立的吗?我的理解是 tasks队列执行完毕,然后才会求执行microtask队列。是这样吗?

aoy的说明书

作者:杨敬卓

转载请注明出处

目录

  • 前言
  • 动机
  • 原理
  • 使用
  • 总结

前言

随着移动端的崛起,现代标准浏览器的推广,为解决浏览器兼容问题的jQuery渐渐没落。vue和react这些新的前端框架带来了完全不一样的开发体验,不再需要手动去处理dom节点,只需要关心数据层面的问题。

动机

流行的框架和库往往因为功能齐全导致体积巨大,有时我们只用到它们很少的功能,尝试去学习原理,自己实现特定功能有益于学习。

aoy深受Vue和react的影响,是使用Vue和react后,去实践写的一个简单框架。在Vue和react中,每个组件都可以有自己的数据存储容器,当父子组件或不同层级组件需要频繁大量的通信数据时,如果没有Vuex和redux这些状态管理框架,往往最后会变得一团糟。

把所有组件的数据存储在一个唯一的容器里,数据只在容器和组件之间流动。 这是aoy的设计理念。现在aoy只有最基本的功能。只有600行代码,想了解Virtual DOM和diff算法的同学可以试着去阅读源码。

原理

使用了Object.defineProperty绑定store的数据,当观察到数据改变,比较新旧数据产生的Virtual DOM,将差异应用到真实dom上。练练ps,给大家画张图。

使用

//初始化aoy实例,取得store
var aoy = Aoy.init();
var store = aoy.store;
var el = aoy.el;

//数据
var userData = [
	{name: 'c', age: 19, city: 'Beijin'},
	{name: 'a', age: 20, city: 'Xian'},
	{name: 'b', age: 12, city: 'Hangzhou'},
	{name: 'd', age: 30, city: 'Wuhan'}
];

//将数据添加进store
store.add('table',{
	userData: userData
});

//封装的节点函数,便于复用
function Table(childern){
	return el('table',childern);
}

function Tr(childern){
	return el('tr', childern);
}
function Th(txt){
	return el('td', txt);
}
function Td(txt){
	return el('td', txt);
}

//记录字段的排序
var sortStore = Object.create(null);

//创建组件
var mytable = aoy.createComponent({
	el: document.body,
	sortData: function(field,e){  //字段的排序函数
		let i;
		userData.sort(function(a, b){
			if(i = (sortStore[field] === 'down')){		
				if(a[field] < b[field]) return 1;
			}else{
				if(a[field] > b[field]) return 1;
			}
		});
		sortStore[field] = i?'up':'down';
		this.table.userData = userData;
	},
	render: function(){
		var trs = [
				el('tr',[
						 el('th',{onclick: this.sortData.bind(this, 'name') },'name'), 
						 el('th',{onclick: this.sortData.bind(this, 'age') },'age'), 
						 el('th',{onclick: this.sortData.bind(this, 'city') },'city')
						]
					)
				];
		this.table.userData.forEach(function(v, i){
				var tdName = Td(v.name);
				var tdAge = Td(v.age);
				var tdCity = Td(v.city);
				var tr = Tr([tdName, tdAge, tdCity]);
				trs.push(tr);
			});	
		var table = Table(trs);
		return table;
	}
});
// 连接组件与数据,同时渲染进文档
aoy.connect(mytable,'table');
  • 如果想最大化的利用dom节点,例如 ul > li *n 结构,可以给节点加上key属性。
var userData = [
	{name: 'c', key: '3'},
	{name: 'a', key: '1'},
	{name: 'b', key: '2'},
	{name: 'd', key: '4'}
];
function Li (v) {
   return el('li',{ key :  v.key}, v.name);
}
var list = [];
userData.forEach(function ( v ){
  list.push( Li(v) );
})
function render () {
  return el('ul',[ list ]);
}

Note

  • 只在需要大量重绘的地方使用。

  • 每个aoy实例都会初始化自己的store, 建议一个document下只实例化一个aoy。

  • 在组件中使用this时,this均指向它的调用者。如果需要在调用时取得组件,可以使用bind绑定。

var myinput = aoy.createComponent({
	el: document.body,
	inputFn: function(){
		console(this)      //input
	},
	render: function(){
		return el('Input', {oninput: this.inputFn,type: 'text' });
	}
});
var myinputComponent= aoy.createComponent({
	el: document.body,
	inputFn: function(){
		console(this)      //myinputComponent
	},
	render: function(){
		return el('Input', {oninput: this.inputFn.bind(this),type: 'text' });
	}
});
  • 依赖计算只绑定到最高层级, 也就是shore的key,当你修改shore的一个深层对象时,视图不会有反应。aoy并没有像vue那样为组件所调用的每一个数据做依赖计算,甚至覆盖数组的原生方法达到双向绑定的目的。这意味着优化的钥匙掌握在使用者手中,可以将深层次的数据结构拆分,也可以搭配immutable-js使用。
var aoy = Aoy.init()
var store = aoy.store
store.add('a' , {b: {text: 'this is p' }})
var myP = aoy.createComponent({
	el: document.body,
	render: function(){
		return el('p',this.a.b.text);
	}
});
aoy.connect(myP, 'a')
aoy.store.get('a').b.text = 'new'  //视图不会改变
aoy.store.get('a').b ={text: 'new'} //视图更新

总结

aoy可以说是学习Vue和react原理再结合自己想法的产物,还有很多不足之处。

6种图片灰度转换算法

作者:杨敬卓

转载请注明出处

前言

黑白照片的时代虽然已经过去,但现在看到以前的照片,是不是有一种回到过去的感觉,很cool有木有~
看完这篇文章,就可以把彩色照片变成各种各样的黑白的照片啦。

本文完整的在线例子图片灰度算法例子,例子的图片有点多,可能有些慢。

例子的源码位于blog/demo

三原色与灰度

原色是指不能透过其他颜色的混合调配而得出的“基本色”。一般来说叠加型的三原色是红色绿色蓝色,以不同比例将原色混合,可以产生出其他的新颜色。这套原色系统常被称为“RGB色彩空间”,亦即由红(R)绿(G)蓝(B)所组合出的色彩系统。

当这三种原色以等比例叠加在一起时,会变成灰色;若将此三原色的强度均调至最大并且等量重叠时,则会呈现白色。灰度就是没有色彩,RGB色彩分量全部相等。

获取图片的像素数据

算法不区分语言,这里以前端举例。可以使用canvas取得图片某个区域的像素数据

//伪代码
var img = new Image();
img.src = 'xxx.jpg';
var myCanvas = document.querySelector(canvasId);
var canvasCtx = myCanvas.getContext("2d");
canvasCtx.drawImage(img, 0, 0, img.width, img.height);
//图片的像素数据
var data = canvasCtx.getImageData(0, 0, img.width, img.height);

使用getImageData()返回一个ImageData对象,此对象有个data属性就是我们要的数据了,数据是以Uint8ClampedArray 描述的一个一维数组,包含以 RGBA 顺序的数据,数据使用 0 至 255(包含)的整数表示。 所以,一个像素会有4个数据(RGBA),RGB是红绿蓝,A指的是透明度。

举个例子:本文720*480的水果图片,一共有720 * 480 = 259200像素,每个像素又有4个数据,所以数据数组的总长度为259200 * 4 = 1036800。

可以看到图片的数据很长,如果一次性处理很多图片的时候,计算量相当可观,所以例子中会使用worker,把繁重的计算任务交给后台线程。

算法的基本步骤

  1. 取得每一个像素的red,green,blue值。

  2. 使用灰度算法,算出一个灰度值。

  3. 用这个灰度值代替像素原始的red,green,blue值。

比如我们的灰度算法是:

Gray = (Red + Green + Blue) / 3

计算过程:

//伪代码
for(var Pixel in Image){
  var Red = Image[Pixel].Red
  var Green = Image[Pixel].Green
  var Blue = Image[Pixel].Blue

  var Gray = (Red + Green + Blue) / 3

  Image[Pixel].Red = Gray
  Image[Pixel].Green = Gray
  Image[Pixel].Blue = Gray
}

很简单对吧。

很多好吃的鲜艳水果,但是它们马上要变灰了!!

fruits

算法1 - 平均法

使用算法1:

算法1

这是最常见的灰度算法,简单暴力,把它放到第一位。公式是:

 Gray = (Red + Green + Blue) / 3

这个算法可以生成不错灰度值,因为公式简单,所以易于维护和优化。然而它也不是没有缺点,因为简单快速,从人眼的感知角度看,图片的灰度阴影和亮度方面做的还不够好。所以,我们需要更复杂的运算。

算法2 - 基于人眼感知

使用算法2:

算法2

算法1与算法2生成的图片似乎没太大差别,所以增加一个例子,将图片上半部分用算法1,下半部分用算法2。

上半部分是算法1,下半部分是算法2:

算法1+算法2

仔细看的话,中间有一根黑线。上半部分(算法1)比下半部分(算法2)更苍白一些。如果还是看不出来,注意最右边的柠檬,算法1的柠檬反光更强烈,算法2的柠檬更柔和。

第二种算法考虑到了人眼对不同光感知程度不同。人的眼睛内有几种辨别颜色的锥形感光细胞,分别对黄绿色、绿色和蓝紫色的光最敏感。虽然眼球中的椎状细胞并非对红、绿、蓝三色的感受度最强,但是由肉眼的椎状细胞所能感受的光的带宽很大,红、绿、蓝也能够独立刺激这三种颜色的受光体。

人类对红绿蓝三色的感知程度依次是: 绿>红>蓝,所以平均算法从这个角度看是不科学的。应该按照人类对光的感知程度为每个颜色设定一个权重,它们的之间的地位不应该是平等的。

一个图像处理通用的公式是:

Gray = (Red * 0.3 + Green * 0.59 + Blue * 0.11)

可以看到,每个颜色的系数相差很大。

现在对图像灰度处理的最佳公式还存在争议,有一些类似的公式:

Gray = (Red * 0.2126 + Green * 0.7152 + Blue * 0.0722)

or

Gray = (Red * 0.299 + Green * 0.587 + Blue * 0.114)

它们只是在系数上存在一些偏差,大体的比值差不多。

算法3 - 去饱和

使用算法3:

算法3

在说这个算法之前,先说说RGB,大多数程序员都使用RGB模型,每一种颜色都可以由红绿蓝组成,RGB对计算机来说可以很好的描述颜色,但对于人类而言就很难理解了。如果升国旗的时候说,“五星红旗多么RGB(255, 0, 42)”,可能会被暴打一顿。但我说鲜红的五星红旗,老师可能会点头称赞。

所以为了更通俗易懂,有时我们选择HLS模型描述颜色,这三个字母分别表示Hue(色调)、Saturation(饱和度)、Lightness(亮度)。色调,取值为:0 - 360,0(或360)表示红色,120表示绿色,240表示蓝色,也可取其他数值来指定颜色。饱和度,取值为:0.0% - 100.0%,它通常指颜色的鲜艳程度。亮度,取值为:0.0% - 100.0%,黑色的亮度为0。

去饱和的过程就是把RGB转换为HLS,然后将饱和度设为0。因此,我们需要取一种颜色,转换它为最不饱和的值。这个数学公式比本文介绍的更复杂,这里提供一个简单的公式,一个像素可以被去饱和通过计算RGB中的最大值和最小值的中间值:

Gray = ( Math.max(Red, Green, Blue) + Math.min(Red, Green, Blue) ) / 2

去饱和后,图片立体感减弱,但是更柔和。对比算法2,可以很明显的看出差异,从效果上看,可能大多数人都喜欢算法2,算法3是目前为止,处理的图片立体感最弱,最黑暗的。

算法4 - 分解

取最大值

算法4max

取最小值

算法4min

分解算法可以认为是去饱和更简单一种的方式。分解是基于每一个像素的,只取RGB的最大值或者最小值。

最大值分解:

Gray = Math.max(Red, Green, Blue)

最小值分解:

Gray = Math.min(Red, Green, Blue)

正如上面展现的,最大值分解提供了更明亮的图,而最小值分解提供了更黑暗的图。

算法5 - 单一通道

取红色通道

算法5red

取绿色通道

算法5green

取蓝色通道

算法5blue

图片变灰更快捷的方法,这个方法不用做任何计算,取一个通道的值直接作为灰度值。

Gray = Red

or

Gray = Green

or

Gray = Blue

不管相不相信,大多数数码相机都用这个算法生成灰度图片。很难预测这种转换的结果,所以这种算法多用于艺术效果。

算法6 - 自定义灰度阴影

NumberOfShades = 4

算法6a

这是到目前为止最有趣的算法,允许用户提供一个灰色阴影值,值的范围在2-256。2的结果是一张全白的图片,256的结果和算法1一样。

NumberOfShades = 16

算法6b

该算法通过选择阴影值来工作,它的公式有点复杂

ConversionFactor = 255 / (NumberOfShades - 1)
AverageValue = (Red + Green + Blue) / 3
Gray = Math.round((AverageValue / ConversionFactor) + 0.5) * ConversionFactor
  • NumberOfShades 的范围在2-256。
  • 从技术上说,任何灰度算法都可以计算AverageValue,它仅仅提供一个初始灰度的估计值。
  • “+ 0.5” 是一个可选参数,用于模拟四舍五入。

小节

这是一篇很有趣的文章,不仅仅是介绍灰度算法,对了解图片的处理过程也很有帮助。

References

滑动效果的原理及实践一个滑动小插件

作者:杨敬卓

转载请注明出处

目录

  • 前言
  • 基本原理
  • html结构
  • 实践
  • 小结

前言

移动端,滑动是很常见的需求。很多同学都用过swiper.js,本文从原理出发,实践出一个类swiper的滑动小插件ice-skating

小插件的例子:

在写代码的过程中产生的一些思考:

  • 滑动的原理是什么
  • 怎么判断动画完成
  • 事件绑定到哪个元素,可否使用事件委托优化
  • pc端和移动端滑动有何不同
  • 正在进行的动画触摸时怎么取得当前样式
  • 如何实现轮播

基本原理

滑动就是用transform: translate(x,y)或者transform: translate3d(x,y,z)去控制元素的移动,在松手的时候判定元素最后的位置,元素的样式应用transform: translate3d(endx , endy, 0)transition-duration: time来达到一个动画恢复的效果。标准浏览器提供transitionend事件监听动画结束,在结束时将动画时间归零。

Note: 这里不讨论非标准浏览器的实现,对于不支持transformtransition的浏览器,可以使用position: absolute配合lefttop进行移动,然后用基于时间的动画的算法来模拟动画效果。

html结构

举例一个基本的结构:

//example
<div class="ice-container">
    <div class="ice-wrapper" id="myIceId">
        <div class="ice-slide">Slide 1</div>
        <div class="ice-slide">Slide 2</div>
        <div class="ice-slide">Slide 3</div>
    </div>
</div>

transform: translate3d(x,y,z)就是应用在className为ice-slide的元素上。这里不展示css代码,可以在ice-skatingexample文件中里查看完整的css。css代码并不是唯一的,简单说只要实现下图的结构就可以。

从图中可以直观的看出,移动的是绿色的元素。className为ice-slide的元素的宽乘于当前索引(offsetWidth * index),就是每次稳定时的偏移量。例如最开始transform: translate3d(offsetWidth * 0, 0, 0),切换到slide2后,transform: translate3d(offsetWidth * 1, 0, 0),大致就是这样的过程。

实践

源码位于ice-skatingdist/iceSkating.js。我给插件起名叫ice-skating,希望它像在冰面一样顺畅^_^

兼容各模块标准的容器

以前我们会将代码包裹在一个简单的匿名函数里,现在需要加一些额外的代码来兼容各种模块标准。

(function (global, factory) {
	typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
	typeof define === 'function' && define.amd ? define(['exports'], factory) :
	(factory((global)));
}(this, (function (exports) { 
'use strict';

})));

状态容器

用两个对象来存储信息

  • 一个页面可以实例化很多滑动对象,store存储的是每个对象的信息,比如宽高,配置参数之类的。
  • state存储的是触摸之类的临时信息,每次触摸后都会清空。
var state = Object.create(null);

function iceSkating(){
  this.store = { 
     ...
  };
}

Object.create(null)创建的对象不会带有Object.prototype上的方法,因为我们不需要它们,例如toStringvalueOfhasOwnProperty之类的。

构造函数

function iceSkating(option){
	if (!(this instanceof iceSkating)) return new iceSkating(option);
}
iceSkating.prototype = { 
}

if (!(this instanceof iceSkating)) return new iceSkating(option);很多库和框架都有这句,简单说就是不用new生成也可以生成实例。

触摸事件

对于触摸事件,在移动端,我们会用touchEvent,在pc端,我们则用mouseEvent。所以我们需要检测支持什么事件。

iceSkating.prototype = {
	support: {
		touch: (function(){
			return !!(('ontouchstart' in window) || window.DocumentTouch && document instanceof DocumentTouch);
		})()
	}

支持touch则认为是移动端,否则为pc端

var events = ic.support.touch ? ['touchstart', 'touchmove', 'touchend']:['mousedown','mousemove','mouseup'];

声明事件函数

pc端和移动端这3个函数是通用的。

var touchStart = function(e){};
var touchMove = function(e){};
var touchEnd = function(e){};

初始化事件

var ic = this;
var initEvent = function(){
    var events = ic.support.touch ? ['touchstart', 'touchmove', 'touchend']:  ['mousedown','mousemove','mouseup'];
    var transitionEndEvents = ['webkitTransitionEnd', 'transitionend', 'oTransitionEnd',   'MSTransitionEnd', 'msTransitionEnd'];
    for (var i = 0; i < transitionEndEvents.length; i++) {
            ic.addEvent(container, transitionEndEvents[i], transitionDurationEndFn, false);
     } 
    ic.addEvent(container, events[0], touchStart, false);
    //默认阻止容器元素的click事件,将其绑定在捕获阶段。
    if(ic.store.preventClicks) ic.addEvent(container, 'click', ic.preventClicks, true);
    if(!isInit){
	ic.addEvent(document, events[1], touchMove, false);
	ic.addEvent(document, events[2], touchEnd, false);
	isInit = true;
	}
};

touchStarttransitionDurationEndFn函数每个实例的容器都会绑定,但是所有实例共用touchMovetouchEnd函数,它们只绑定在document,并且只会绑定一次。使用事件委托有两个好处:

  1. 减少了元素绑定的事件数,提高了性能。
  2. 如果将touchMovetouchEnd也绑定在容器元素上,当鼠标移出容器元素时,我们会“失去控制”。在document上意味着可以“掌控全局”。

过程分析

不会把封装的函数的代码都一一列出来,但会说明它的作用。

触碰瞬间

touchStart函数:

会在触碰的第一时间调用,基本都在初始化state的信息

var touchStart = function(e){
    //mouse事件会提供which值, e.which为3时表示按下鼠标右键,鼠标右键会触发mouseup,但右键不允许移动滑块
     if (!ic.support.touch && 'which' in e && e.which === 3) return;
    //获取起始坐标。TouchEvent使用e.targetTouches[0].pageX,MouseEvent使用e.pageX。
	state.startX = e.type === 'touchstart' ? e.targetTouches[0].pageX : e.pageX;
        state.startY = e.type === 'touchstart' ? e.targetTouches[0].pageY : e.pageY;
	//时间戳
       state.startTime = e.timeStamp;
       //绑定事件的元素
	state.currentTarget = e.currentTarget;
	state.id = e.currentTarget.id;
        //触发事件的元素
	state.target = e.target;
	//获取当前滑块的参数信息
        state.currStore = mainStore[e.currentTarget.id];
       //state的touchStart 、touchMove、touchEnd代表是否进入该函数
	state.touchEnd = state.touchMove = false;
	state.touchStart = true;
       //表示滑块移动的距离
	state.diffX = state.diffY = 0;
       //动画运行时的坐标与动画运行前的坐标差值
	state.animatingX = state.animatingY = 0;
};

移动

在移动滑块时,可能滑块正在动画中,这是需要考虑一种特殊情况。滑块的移动应该依据现在的位置计算。
如何知道动画运行中的信息呢,可以使用window.getComputedStyle(element, [pseudoElt]),它返回的样式是一个实时的 CSSStyleDeclaration 对象。用它取transform的值会返回一个 2D 变换矩阵,像这样matrix(1, 0, 0, 1, -414.001, 0),最后两位就是x,y值。

简单封装一下,就可以取得当前动画translate的x,y值了。

var getTranslate = function(el){
	var curStyle = window.getComputedStyle(el);
	var curTransform = curStyle.transform || curStyle.webkitTransform;
	var x,y; x = y = 0;
	curTransform = curTransform.split(', ');
	if (curTransform.length === 6) {
		x = parseInt(curTransform[4], 10);
		y = parseInt(curTransform[5], 10);
	}
       return {'x': x,'y': y};
};

touchMove函数:

移动时会持续调用,如果只是点击操作,不会触发touchMove。

var touchMove = function(e){
   // 1. 如果当前触发touchMove的元素和触发touchStart的元素不一致,不允许滑动。
   // 2. 执行touchMove时,需保证touchStart已执行,且touchEnd未执行。
   if(e.target !== state.target || state.touchEnd || !state.touchStart) return;
  state.touchMove = true;
  //取得当前坐标
  var currentX = e.type === 'touchmove' ? e.targetTouches[0].pageX : e.pageX;
   var currentY = e.type === 'touchmove' ? e.targetTouches[0].pageY : e.pageY;
   var currStore = state.currStore;
   //触摸时如果动画正在运行
    if(currStore.animating){
       // 取得当前元素translate的信息
        var animationTranslate = getTranslate(state.currentTarget);
        //计算动画的偏移量,currStore.translateX和currStore.translateY表示的是滑块最近一次稳定时的translate值
        state.animatingX = animationTranslate.x - currStore.translateX;
        state.animatingY = animationTranslate.y - currStore.translateY;
        currStore.animating = false;
        //移除动画时间
        removeTransitionDuration(currStore.container);
     }
      //如果轮播进行中,将定时器清除
       if(currStore.autoPlayID !== null){
        	clearTimeout(currStore.autoPlayID);
        	currStore.autoPlayID = null;
        }
        //判断移动方向是水平还是垂直
	if(currStore.direction === 'x'){
                //currStore.touchRatio是移动系数
		state.diffX = Math.round((currentX - state.startX) * currStore.touchRatio);
                //移动元素
		translate(currStore.container, state.animatingX + state.diffX +       state.currStore.translateX, 0, 0);
        }else{
        	state.diffY = Math.round((currentY - state.startY) * state.currStore.touchRatio);
        	translate(currStore.container, 0, state.animatingY + state.diffY + state.currStore.translateY, 0);
        }
	};

translate函数:

如果支持translate3d,会优先使用它,translate3d会提供硬件加速。有兴趣可以看看这篇blog两张图解释CSS动画的性能

	var translate = function(ele, x, y, z){
		if (ic.support.transforms3d){
			transform(ele, 'translate3d(' + x + 'px, ' + y + 'px, ' + z + 'px)');
		} else {
			transform(ele, 'translate(' + x + 'px, ' + y + 'px)');
		}
	};

触摸结束

touchEnd函数:

在触摸结束时调用。

var touchEnd = function(e){
	state.touchEnd = true;
	if(!state.touchStart) return;
	var fastClick ;
	var currStore = state.currStore;
        //如果整个触摸过程时间小于fastClickTime,会认为此次操作是点击。但默认是屏蔽了容器的click事件的,所以提供一个clickCallback参数,会在点击操作时调用。
	if(fastClick = (e.timeStamp - state.startTime) < currStore.fastClickTime && !state.touchMove && typeof currStore.clickCallback === 'function'){
		currStore.clickCallback();
	}
	if(!state.touchMove) return;
        //如果移动距离没达到切换页的临界值,则让它恢复到最近的一次稳定状态
	if(fastClick || (Math.abs(state.diffX) < currStore.limitDisX && Math.abs(state.diffY) < currStore.limitDisY)){
        //在transitionend事件绑定的函数中判定是否重启轮播,但是如果transform前后两次的值一样时,不会触发transitionend事件,所以在这里判定是否重启轮播
        if(state.diffX === 0 && state.diffY === 0 && currStore.autoPlay) autoPlay(currStore);
	   //恢复到最近的一次稳定状态
            recover(currStore, currStore.translateX, currStore.translateY, 0);
	}else{
                //位移满足切换
		if(state.diffX > 0 || state.diffY > 0) {
                       //切换到上一个滑块
			moveTo(currStore, currStore.index - 1);
		}else{
                        //切换到下一个滑块
			moveTo(currStore, currStore.index + 1);
		}	
	}
};

transitionDurationEndFn函数:

动画执行完成后调用

var transitionDurationEndFn = function(){
       //将动画状态设置为false
	ic.store.animating = false;
       //执行自定义的iceEndCallBack函数
	if(typeof ic.store.iceEndCallBack === 'function')  ic.store.iceEndCallBack();
	//将动画时间归零
        transitionDuration(container, 0);
        //清空state
	if(ic.store.id === state.id) state = Object.create(null);
};

至此,一个完整的滑动过程结束。

实现轮播

第一时间想到的是使用setInterval或者递归setTimeout实现轮播,但这样做并不优雅。

事件循环(EventLoop)中setTimeoutsetInterval会放入macrotask 队列中,里面的函数会放入microtask,当这个 macrotask 执行结束后所有可用的 microtask 将会在同一个事件循环中执行。

我们极端的假设setInterval设定为200ms,动画时间设为1000ms。每隔200ms, macrotask 队列中就会插入setInterval,但我们的动画此时没有完成,所以用setInterval或者递归setTimeout的轮播在这种情况下是有问题的。

最佳思路是在每次动画结束后再将轮播开启。

//动画结束执行的函数:
var transitionDurationEndFn = function(){
      ...
      //检测是否开启轮播
      if(ic.store.autoPlay) autoPlay(ic.store);
};

轮播函数也相当简单

var autoPlay = function(store){
       store.autoPlayID = setTimeout(function(){
                //当前滑块的索引
		var index = store.index;
		++index;
                //到最后一个了,重置为0
		if(index === store.childLength){
	              index = 0;
	        }
                //移动
		moveTo(store, index);
		},store.autoplayDelay);		
};

小结

本文记录了我思考的过程,代码应该还有很多地方值得完善。

从event loop规范探究javaScript异步及浏览器更新渲染时机

作者:杨敬卓

转载请注明出处

异步的思考

event loops隐藏得比较深,很多人对它很陌生。但提起异步,相信每个人都知道。异步背后的“靠山”就是event loops。这里的异步准确的说应该叫浏览器的event loops或者说是javaScript运行环境的event loops,因为ECMAScript中没有event loops,event loops是在HTML Standard定义的。

event loops规范中定义了浏览器何时进行渲染更新,了解它有助于性能优化。

思考下边的代码运行顺序:

console.log('start')

setTimeout( function () {
  console.log('setTimeout')
}, 0 )

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('end')

// start
// end
// promise1
// promise2
// setTimeout

上面的顺序是在chrome运行得出的,有趣的是在safari 9.1.2中测试,promise1 promise2会在setTimeout的后边,而在safari 10.0.1中得到了和chrome一样的结果。为何浏览器有不同的表现,了解tasks, microtasks队列就可以解答这个问题。

很多框架和库都会使用类似下面函数:

function flush() {
...
}
function useMutationObserver() {
  var iterations = 0;
  var observer = new MutationObserver(flush);
  var node = document.createTextNode('');
  observer.observe(node, { characterData: true });

  return function () {
    node.data = iterations = ++iterations % 2;
  };
}

初次看这个useMutationObserver函数总会很有疑惑,MutationObserver不是用来观察dom的变化的吗,这样凭空造出一个节点来反复修改它的内容,来触发观察的回调函数有何意义?

答案就是使用Mutation事件可以异步执行操作(例子中的flush函数),一是可以尽快响应变化,二是可以去除重复的计算。但是setTimeout(flush, 0)同样也可以执行异步操作,要知道其中的差异和选择哪种异步方法,就得了解event loop。

定义

先看看它们在规范中的定义。

Note:本文的引用部分,就是对规范的翻译,有的部分会概括或者省略的翻译,有误请指正。

event loop

event loop翻译出来就是事件循环,可以理解为实现异步的一种方式,我们来看看event loopHTML Standard中的定义章节:

第一句话:

为了协调事件,用户交互,脚本,渲染,网络等,用户代理必须使用本节所述的event loop

事件,用户交互,脚本,渲染,网络这些都是我们所熟悉的东西,他们都是由event loop协调的。触发一个click事件,进行一次ajax请求,背后都有event loop在运作。

task

一个event loop有一个或者多个task队列。

当用户代理安排一个任务,必须将该任务增加到相应的event loop的一个tsak队列中。

每一个task都来源于指定的任务源,比如可以为鼠标、键盘事件提供一个task队列,其他事件又是一个单独的队列。可以为鼠标、键盘事件分配更多的时间,保证交互的流畅。

task也被称为macrotask,task队列还是比较好理解的,就是一个先进先出的队列,由指定的任务源去提供任务。

哪些是task任务源呢?

规范在Generic task sources中有提及:

DOM操作任务源:
此任务源被用来相应dom操作,例如一个元素以非阻塞的方式插入文档

用户交互任务源:
此任务源用于对用户交互作出反应,例如键盘或鼠标输入。响应用户操作的事件(例如click)必须使用task队列。

网络任务源:
网络任务源被用来响应网络活动。

history traversal任务源:
当调用history.back()等类似的api时,将任务插进task队列。

task任务源非常宽泛,比如ajaxonloadclick事件,基本上我们经常绑定的各种事件都是task任务源,还有数据库操作(IndexedDB ),需要注意的是setTimeoutsetIntervalsetImmediate也是task任务源。总结来说task任务源:

  • setTimeout
  • setInterval
  • setImmediate
  • I/O
  • UI rendering

microtask

每一个event loop都有一个microtask队列,一个microtask会被排进microtask队列而不是task队列。

有两种microtasks:分别是solitary callback microtasks和compound microtasks。规范值只覆盖solitary callback microtasks。

如果在初期执行时,spin the event loop,microtasks有可能被移动到常规的task队列,在这种情况下,microtasks任务源会被task任务源所用。通常情况,task任务源和microtasks是不相关的。

microtask 队列和task 队列有些相似,都是先进先出的队列,由指定的任务源去提供任务,不同的是一个
event loop里只有一个microtask 队列。

HTML Standard没有具体指明哪些是microtask任务源,通常认为是microtask任务源有:

  • process.nextTick
  • promises
  • Object.observe
  • MutationObserver

NOTE:
Promise的定义在 ECMAScript规范而不是在HTML规范中,但是ECMAScript规范中有一个jobs的概念和microtasks很相似。在Promises/A+规范的Notes 3.1中提及了promise的then方法可以采用“宏任务(macro-task)”机制或者“微任务(micro-task)”机制来实现。所以开头提及的promise在不同浏览器的差异正源于此,有的浏览器将then放入了macro-task队列,有的放入了micro-task 队列。在jake的博文Tasks, microtasks, queues and schedules中提及了一个讨论vague mailing list discussions,一个普遍的共识是promises属于microtasks队列。

进一步了解event loops

知道了event loops大致做什么的,我们再深入了解下event loops

有两种event loops,一种在浏览器上下文,一种在workers中。

每一个用户代理必须至少有一个浏览器上下文event loop,但是每个单元的相似源浏览器上下文至多有一个event loop。

event loop 总是具有至少一个浏览器上下文,当一个event loop的浏览器上下文全都销毁的时候,event loop也会销毁。一个浏览器上下文总有一个event loop去协调它的活动。

Worker的event loop相对简单一些,一个worker对应一个event loop,worker进程模型管理event loop的生命周期。

反复提到的一个词是browsing contexts(浏览器上下文)。

浏览器上下文是一个将 Document 对象呈现给用户的环境。在一个 Web 浏览器内,一个标签页或窗口常包含一个浏览上下文,如一个 iframe 或一个 frameset 内的若干 frame。

结合一些资料,对上边规范给出一些理解(有误请指正):

  • 每个线程都有自己的event loop
  • 浏览器可以有多个event loopbrowsing contextsweb workers就是相互独立的。
  • 所有同源的browsing contexts可以共用event loop,这样它们之间就可以相互通信。

event loop的处理过程(Processing model)

在规范的Processing model定义了event loop的循环过程:

一个event loop只要存在,就会不断执行下边的步骤:
1.在tasks队列中选择最老的一个task,用户代理可以选择任何task队列,如果没有可选的任务,则跳到下边的microtasks步骤。
2.将上边选择的task设置为正在运行的task
3.Run: 运行被选择的task。
4.将event loop的currently running task变为null。
5.从task队列里移除前边运行的task。
6.Microtasks: 执行microtasks任务检查点。(也就是执行microtasks队列里的任务)
7.更新渲染(Update the rendering)...
8.如果这是一个worker event loop,但是没有任务在task队列中,并且WorkerGlobalScope对象的closing标识为true,则销毁event loop,中止这些步骤,然后进行定义在Web workers章节的run a worker
9.返回到第一步。

event loop会不断循环上面的步骤,概括说来:

  • event loop会不断循环的去取tasks队列的中最老的一个任务推入栈中执行,并在当次循环里依次执行并清空microtask队列里的任务。
  • 执行完microtask队列里的任务,有可能会渲染更新。(浏览器很聪明,在一帧以内的多次dom变动浏览器不会立即响应,而是会积攒变动以最高60HZ的频率更新视图)

microtasks检查点(microtask checkpoint)

event loop运行的第6步,执行了一个microtask checkpoint,看看规范如何描述microtask checkpoint

当用户代理去执行一个microtask checkpoint,如果microtask checkpoint的flag(标识)为false,用户代理必须运行下面的步骤:
1.将microtask checkpoint的flag设为true。
2.Microtask queue handling: 如果event loop的microtask队列为空,直接跳到第八步(Done)。
3.在microtask队列中选择最老的一个任务。
4.将上一步选择的任务设为event loop的currently running task
5.运行选择的任务。
6.将event loop的currently running task变为null。
7.将前面运行的microtask从microtask队列中删除,然后返回到第二步(Microtask queue handling)。
8.Done: 每一个environment settings object它们的 responsible event loop就是当前的event loop,会给environment settings object发一个 rejected promises 的通知。
9.清理IndexedDB的事务
10.将microtask checkpoint的flag设为flase。

microtask checkpoint所做的就是执行microtask队列里的任务。什么时候会调用microtask checkpoint呢?

执行栈(JavaScript execution context stack)

task和microtask都是推入栈中执行的,要完整了解event loops还需要认识JavaScript execution context stack,它的规范位于https://tc39.github.io/ecma262/#execution-context-stack。

javaScript是单线程,也就是说只有一个主线程,主线程有一个栈,每一个函数执行的时候,都会生成新的execution context(执行上下文),执行上下文会包含一些当前函数的参数、局部变量之类的信息,它会被推入栈中, running execution context(正在执行的上下文)始终处于栈的顶部。当函数执行完后,它的执行上下文会从栈弹出。

执行调用栈

举个简单的例子:

function bar() {
console.log('bar');
}

function foo() {
console.log('foo');
bar();
}

foo();

执行过程中栈的变化:
执行栈示意图

完整异步过程

规范晦涩难懂,做一个形象的比喻:
主线程类似一个加工厂,它只有一条流水线,待执行的任务就是流水线上的原料,只有前一个加工完,后一个才能进行。event loops就是把原料放上流水线的工人。只要已经放在流水线上的,它们会被依次处理,称为同步任务。一些待处理的原料,工人会按照它们的种类排序,在适当的时机放上流水线,这些称为异步任务

过程图:

eventLoop示意图

举个简单的例子,假设一个script标签的代码如下:

Promise.resolve().then(function promise1 () {
       console.log('promise1');
    })
setTimeout(function setTimeout1 (){
    console.log('setTimeout1')
    Promise.resolve().then(function  promise2 () {
       console.log('promise2');
    })
}, 0)

setTimeout(function setTimeout2 (){
   console.log('setTimeout2')
}, 0)

运行过程:

script里的代码被列为一个task,放入task队列。

循环1:

  • 【task队列:script ;microtask队列:】
  1. 从task队列中取出script任务,推入栈中执行。
  2. promise1列为microtask,setTimeout1列为task,setTimeout2列为task。
  • 【task队列:setTimeout1 setTimeout2;microtask队列:promise1】
  1. script任务执行完毕,执行microtask checkpoint,取出microtask队列的promise1执行。

循环2:

  • 【task队列:setTimeout1 setTimeout2;microtask队列:】
  1. 从task队列中取出setTimeout1,推入栈中执行,将promise2列为microtask。
  • 【task队列:setTimeout2;microtask队列:promise2】
  1. 执行microtask checkpoint,取出microtask队列的promise2执行。

循环3:

  • 【task队列:setTimeout2;microtask队列:】
  1. 从task队列中取出setTimeout2,推入栈中执行。
  2. setTimeout2任务执行完毕,执行microtask checkpoint。
  • 【task队列:;microtask队列:】

event loop中的Update the rendering(更新渲染)

这是event loop中很重要部分,在第7步会进行Update the rendering(更新渲染),规范允许浏览器自己选择是否更新视图。也就是说可能不是每轮事件循环都去更新视图,只在有必要的时候才更新视图。

https://www.html5rocks.com/zh/tutorials/internals/howbrowserswork 这篇文章较详细的讲解了渲染机制。

渲染的基本流程:
渲染的基本流程

  1. 处理 HTML 标记并构建 DOM 树。
  2. 处理 CSS 标记并构建 CSSOM 树, 将 DOM 与 CSSOM 合并成一个渲染树。
  3. 根据渲染树来布局,以计算每个节点的几何信息。
  4. 将各个节点绘制到屏幕上。

Note: 可以看到渲染树的一个重要组成部分是CSSOM树,绘制会等待css样式全部加载完成才进行,所以css样式加载的快慢是首屏呈现快慢的关键点。

下面讨论一下渲染的时机。规范定义在一次循环中,Update the rendering会在第六步Microtasks: Perform a microtask checkpoint 后运行。

验证更新渲染(Update the rendering)的时机

不同机子测试可能会得到不同的结果,这取决于浏览器,cpu、gpu性能以及它们当时的状态。

例子1

我们做一个简单的测试

<div id='con'>this is con</div>
<script>
var t = 0;
var con = document.getElementById('con');
con.onclick = function () {
  setTimeout(function setTimeout1 () {
           con.textContent = t;
   }, 0)
};
</script>

用chrome的Developer tools的Timeline查看各部分运行的时间点。
当我们点击这个div的时候,下图截取了部分时间线,黄色部分是脚本运行,紫色部分是更新render树、计算布局,绿色部分是绘制。

绿色和紫色部分可以认为是Update the rendering。

例子1

在这一轮事件循环中,setTimeout1是作为task运行的,可以看到paint确实是在task运行完后才进行的。

例子2

现在换成一个microtask任务,看看有什么变化

<div id='con'>this is con</div>
<script>
var con = document.getElementById('con');
con.onclick = function () {
      Promise.resolve().then(function Promise1 () {
           con.textContext = 0;
      })
};
</script>

例子2

和上一个例子很像,不同的是这一轮事件循环的task是click的回调函数,Promise1则是microtask,paint同样是在他们之后完成。

标准就是那么定义的,答案似乎显而易见,我们把例子变得稍微复杂一些。

例子3

<div id='con'>this is con</div>
<script>
var con = document.getElementById('con');
con.onclick = function click1() {
  setTimeout(function setTimeout1() {
           con.textContent = 0;
   }, 0)
  setTimeout(function setTimeout2() {
           con.textContent = 1;
   }, 0)
};
</script>

当点击后,一共产生3个task,分别是click1、setTimeout1、setTimeout2,所以会分别在3次event loop中进行。
下面截取的是setTimeout1、setTimeout2的部分。

例子3

我们修改了两次textContent,奇怪的是setTimeout1、setTimeout2之间没有paint,浏览器只绘制了textContent=1,难道setTimeout1、setTimeout2在同一次event loop中吗?

例子4

在两个setTimeout中增加microtask。

<div id='con'>this is con</div>
<script>
var con = document.getElementById('con');
con.onclick = function () {
    setTimeout(function setTimeout1() {
       con.textContent = 0;
       Promise.resolve().then(function Promise1 () {
            console.log('Promise1')
      })
    }, 0)
    setTimeout(function setTimeout2() {
       con.textContent = 1;
       Promise.resolve().then(function Promise2 () {
            console.log('Promise2')
       })
    }, 0)
};
</script>

例子4

从run microtasks中可以看出来,setTimeout1、setTimeout2应该运行在两次event loop中,textContent = 0的修改被跳过了。

setTimeout1、setTimeout2的运行间隔很短,在setTimeout1完成之后,setTimeout2马上就开始执行了,我们知道浏览器会尽量保持每秒60帧的刷新频率(大约16.7ms每帧),是不是只有两次event loop间隔大于16.7ms才会进行绘制呢?

例子5

将时间间隔加大一些。

<div id='con'>this is con</div>
<script>
var con = document.getElementById('con');
con.onclick = function () {
   setTimeout(function  setTimeout1() {
        con.textContent = 0;
   }, 0);
    setTimeout(function  setTimeout2() {
            con.textContent = 1;
    }, 16.7);
};
</script>

例子5

两块黄色的区域就是 setTimeout,在1224ms处绿色部分,浏览器对con.textContent = 0的变动进行了绘制。在1234ms处绿色部分,绘制了con.textContent = 1。

可否认为相邻的两次event loop的间隔很短,浏览器就不会去更新渲染了呢?继续我们的实验

例子6

我们在同一时间执行多个setTimeout来模拟执行间隔很短的task。

<div id='con'>this is con</div>
<script>
var con = document.getElementById('con');
con.onclick = function () {
   setTimeout(function(){
      con.textContent = 0;
   },0)
   setTimeout(function(){
     con.textContent = 1;
   },0)
   setTimeout(function(){
     con.textContent = 2;
   },0)
   setTimeout(function(){
     con.textContent = 3;
   },0)
   setTimeout(function(){
      con.textContent = 4;
   },0)
    setTimeout(function(){
     con.textContent = 5;
   },0)
   setTimeout(function(){
      con.textContent = 6;
   },0)
};
</script>

例子6

图中一共绘制了两帧,第一帧4.4ms,第二帧9.3ms,都远远高于每秒60HZ(16.7ms)的频率,第一帧绘制的是con.textContent = 4,第二帧绘制的是 con.textContent = 6。所以两次event loop的间隔很短同样会进行绘制。

例子7

有说法是一轮event loop执行的microtask有数量限制(可能是1000),多余的microtask会放到下一轮执行。下面例子将microtask的数量增加到25000。

<div id='con'>this is con</div>
<script>
var con = document.getElementById('con');
con.onclick = function () {
   setTimeout(function  setTimeout1() {
        con.textContent = 'task1';
        for(var i = 0; i  < 250000; i++){
            Promise.resolve().then(function(){
                 con.textContent = i;
            });
        }
   }, 0);
    setTimeout(function  setTimeout2() {
            con.textContent = 'task2';
    }, 0);
};
</script>

总体的timeline:
例子7-1

可以看到一大块黄色区域,上半部分有一根绿线就是点击后的第一次绘制,脚本的运行耗费大量的时间,并且阻塞了渲染。

看看setTimeout2的运行情况。
例子7-2
可以看到setTimeout2这轮event loop没有run microtasks,microtasks在setTimeout1被全部执行完了。

25000个microtasks不能说明event loop对microtasks数量没有限制,有可能这个限制数很高,远超25000,但日常使用基本不会使用那么多了。

对microtasks增加数量限制,一个很大的作用是防止脚本运行时间过长,阻塞渲染。

例子8

使用requestAnimationFrame。

<div id='con'>this is con</div>
<script>
var con = document.getElementById('con');
var i = 0;
var raf =  function(){
    requestAnimationFrame(function() {
         con.textContent = i;
         Promise.resolve().then(function(){
            i++;
            if(i < 3) raf();
         });
    });
}
con.onclick = function () {
   raf();
};
</script>

总体的Timeline:
例子8-1
点击后绘制了3帧,把每次变动都绘制了。

看看单个 requestAnimationFrame的Timeline:
例子8-2

和setTimeout很相似,可以看出requestAnimationFrame也是一个task,在它完成之后会运行run microtasks。

例子9

验证postMessage是否是task

setTimeout(function setTimeout1(){
        console.log('setTimeout1')
}, 0)
var channel = new MessageChannel();
channel.port1.onmessage = function onmessage1 (){
    console.log('postMessage')
    Promise.resolve().then(function promise1 (){
        console.log('promise1')
    })
};
channel.port2.postMessage(0);
setTimeout(function setTimeout2(){
        console.log('setTimeout2')
}, 0)
  console.log('sync')
}

执行顺序:

sync
postMessage
promise1
setTimeout1
setTimeout2

timelime:

例子9

第一个黄块是onmessage1,第二个是setTimeout1,第三个是setTimeout2。显而易见,postMessage属于task,因为setTimeout的4ms标准化了,所以这里的postMessage会优先setTimeout运行。

小结

上边的例子可以得出一些结论:

  • 在一轮event loop中多次修改同一dom,只有最后一次会进行绘制。
  • 渲染更新(Update the rendering)会在event loop中的tasks和microtasks完成后进行,但并不是每轮event loop都会更新渲染,这取决于是否修改了dom和浏览器觉得是否有必要在此时立即将新状态呈现给用户。如果在一帧的时间内(时间并不确定,因为浏览器每秒的帧数总在波动,16.7ms只是估算并不准确)修改了多处dom,浏览器可能将变动积攒起来,只进行一次绘制,这是合理的。
  • 如果希望在每轮event loop都即时呈现变动,可以使用requestAnimationFrame。

应用

event loop的大致循环过程,可以用下边的图表示:

event loop 过程

假设现在执行到currently running task,我们对批量的dom进行异步修改,我们将此任务插进task:
event loop 插入dom修改2

此任务插进microtasks:
event loop 插入dom修改3

可以看到如果task队列如果有大量的任务等待执行时,将dom的变动作为microtasks而不是task能更快的将变化呈现给用户。

同步简简单单就可以完成了,为啥要异步去做这些事?

对于一些简单的场景,同步完全可以胜任,如果得对dom反复修改或者进行大量计算时,使用异步可以作为缓冲,优化性能。

举个小例子:

现在有一个简单的元素,用它展示我们的计算结果:

<div id='result'>this is result</div>

有一个计算平方的函数,并且会将结果响应到对应的元素

function bar (num, id) {
  var  product  = num  * num;
  var resultEle = document.getElementById( id );
  resultEle.textContent = product;
}

现在我们制造些问题,假设现在很多同步函数引用了bar,在一轮event loop里,可能bar会被调用多次,并且其中有几个是对id='result'的元素进行操作。就像下边一样:

...
bar( 2, 'result' )
...
bar( 4, 'result' )
...
bar( 5, 'result' )
...

似乎这样的问题也不大,但是当计算变得复杂,操作很多dom的时候,这个问题就不容忽视了。

用我们上边讲的event loop知识,修改一下bar。

var store = {}, flag = false;
function bar (num, id) {
  store[ id ] = num;
  if(!flag){
    Promise.resolve().then(function () {
       for( var k in store ){
           var num = store[k];
            var product  = num  * num;
            var resultEle = document.getElementById( k );
            resultEle.textContent = product;
       }
    });
    flag = true;
  }
}

现在我们用一个store去存储参数,统一在microtasks阶段执行,过滤了多余的计算,即使同步过程中多次对一个元素修改,也只会响应最后一次。

写了个简单插件asyncHelper,可以帮助我们异步的插入task和microtask。

例如:

//生成task
var myTask = asyncHelper.task(function () {
    console.log('this is task')
});
//生成microtask
var myMicrotask = asyncHelper.mtask(function () {
    console.log('this is microtask')
});

//插入task
myTask()
//插入microtask
myMicrotask();

对之前的例子的使用asyncHelper

var store = {};
//生成一个microtask
var foo = asyncHelper.mtask(function () {
        for( var k in store ){
            var num = store[k];
            var product  = num  * num;
            var resultEle = document.getElementById( k );
            resultEle.textContent = product;
       }
}, {callMode: 'last'});

function bar (num, id) {
  store[ id ] = num;
  foo();
}

如果不支持microtask将回退成task。

结语

event loop涉及到的东西很多,本文有误的地方请指正。

references

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.