JS是为浏览器而诞生,知道它的工作环境才能写出出色的代码。
浏览器工作原理
浏览器核心分为渲染引擎和JavaScript引擎。
渲染引擎
从用户输入URL开始,渲染引擎处理网页将会经历如下过程:
1、Loader:处理所有的HTTP请求以及网络资源的缓存。
Loader有两条资源加载路径:主资源加载路径和派生资源加载路径。主资源在传输下分段到达,Parser模块解析主资源生成DOM结构,然后根据需求触发派生资源加载流程。主资源往往立刻发起,内容下载失败会有报错提示,而派生资源可能会为了优化网络在队列中等待。
在浏览器中主要有三种缓存:内存缓存,用于缓存页面使用的各种派生资源;页面缓存,用来缓存用户访问过的DOM树等数据;磁盘缓存,浏览器将下载的资源保存到本地,下次请求相同资源时直接从本地取出。
2、Parser:解析HTML为DOM,解析CSS为CSSOM。JS引擎解析JavaScript。
DOM树的构建:渲染引擎分解码、分词、解析、建树,将HTML文档解析成DOM Tree、Render Tree、Render Layer Tree。解码,将网络上收到的经过编码的字节流解码;分词,按照切词规则将码流切成一个个Token;解析,根据词意创建相应的Node;建树,将节点关联在一起。
Render树的构建:Render树用于表述文档的可视信息,记录了每个文档中可视元素的布局及渲染方式,与DOM树同时创建。
Render Layer树创建:Render Layer树基于Render Object树构建,以层为节点组织文档的可视信息。它的作用是在绘制时实现合成加速。
CSS解析:将原始的CSS文件中包含的CSS规则解析成CCSOM,其原理和DOM树类似。
JS执行:渲染引擎处理过程中遇到script标签就会停下来交给JS引擎处理,直到JS引擎处理完毕交回处理权。
3、Layout:关联DOM树和CSSOM,生成Render Tree,计算出布局。
4、Paint:将渲染树绘制到屏幕。
渲染树转换为网页布局,称为布局流(flow);布局显示到页面这个过程叫绘制(paint):都会阻塞,耗费时间和资源。页面生成后,脚本和样式表操作都会触发回流(reflow)和重绘(repaint),用户的互动也会触发回流和重绘,比如页面滚动、鼠标悬停等等。重绘不一定回流,如改变元素颜色;回流必然会重绘,如改变元素的布局。
作为开发者应该尽量降低重绘的次数和成本:
- 读写DOM尽量写在一起,不要混杂,一会儿读一个,一会儿写一个,一会儿再读一个。
- 缓存DOM信息。
- 在内存中多次操作节点,完成后再添加进文档。
- 不要一项一项地改变样式,而是使用class一次改变样式。
- 只在必要时才显示隐藏元素。
- 对于一个元素进行复杂操作时可以先隐藏它,操作完成后再显示。
- 不要使用Table进行布局。
- 动画使用absolute定位或fixed定位,可减少对其他元素的影响。
- 使用window.requestAnimationFrame(),因为它可以把代码推迟到下一次回流时执行,而不是立即要求页面回流。
- 使用documentFragment操作DOM。
- 使用虚拟DOM库。
JavaScript引擎
JS引擎主要作用是读取解析网页中的JS代码。JS是解释型语言不需要编译,由解释器实时运行,修改运行方便,但是系统开销大、速度慢于编译型语言。为了提高运行速度,目前的浏览器都将JS进行一定程度的编译,生成类似字节码的中间代码。
早期浏览器对JS的处理:读取代码进行词法分析,将代码分解成词元;对词元进行词法分析,整理成词法树;使用转译器将代码转为字节码;使用解释器将字节码转为机器码。这样是很低效的,为了提高速度,现代浏览器改为即时编译JIT。
前面提到渲染引擎执行的四个阶段,但他们并不是一个阶段执行完才执行下一个阶段。浏览器一边下载HTML资源,一边解析,它不是等到完全下载才开始解析。解析过程中,浏览器发现script标签就暂停解析,控制权交给JS引擎,如果是外部引用就下载该脚本,否则就执行代码。JS执行完毕控制权交回渲染引擎继续往下解析。
加载外部脚本会使页面渲染暂停,如果加载脚本时间过长,就会造成页面失去响应的假死状态。之所以JS引擎要阻塞渲染,主要原因是JS代码可以修改DOM,必须把控制权交给它。因此,为了避免假死现象,较好的做法是将script标签放到页面底部。如果非常重要的代码一定要放顶部执行,应直接写入页面,缩短加载时间。script放底部还可以防止DOM未加载完而报错,当然可以用DOMContentLoaded事件来避免。或者在依赖script标签上用onload事件,待依赖脚本完成后执行。
为了解决脚本阻塞网页渲染,script加入了defer和async属性。defer的作用是延迟脚本执行,等待DOM加载生成后再执行加载脚本。async的作用是使用另一个进程下载脚本,下载时不会阻塞渲染。下载完成浏览器暂停解析开始执行脚本,脚本执行完毕恢复解析网页。如果脚本之间没有依赖关系用async,有依赖用defer,同时使用defer不起作用。
虚拟DOM
前面粗略地了解浏览器的原理,我们知道用传统的方法去操作DOM可能造成回流、重绘。错误的操作DOM节点会使浏览器开销巨大,运行缓慢,操作效率低。这样的操作越频繁,应用程序就会越卡顿。这对于年轻化的CPU、GPU芯片来说,没有什么太大的感觉。
举个例子,我家里电脑是10年买的AMD Athlon II X2,公司电脑是16年买的Intel Core I5。同样运行同版本的xx0浏览器,我家电脑直接卡爆,公司电脑没所谓。就不用chrome举例,chrome相对来说好很多,开同样的视频网站明显内存开销小很多。举这个例子不是为了吐槽,说明两点:第一,两家公司关注点不一样,一个关注运行效率,一个关注华而不实的功能;第二,我们开发的程序不止是针对高端客户,还有低端客户存在。
jQuery的出现让我们在操作DOM上更方便快捷,也更好地处理了兼容性,强大的缓存机制减轻了对DOM的不当操作。当然,有些人只管实现,要动态插入一组数据就组装完一组数据便往DOM里插入一组数据。这些人一直存在,他们是真的不知道用方正电脑的人等他的代码运行要等多久,与用的是JS还是jQuery无关。
jQuery虽然降低了对DOM的操作难度,可以集中处理样式和节点,但是在DOM操作上仍需要从头到尾执行一遍流程。直接操作DOM效率低,但是JS的运行速度快,于是facebook提出了virtual DOM的概念,用JS来模拟DOM。通过新旧DOM的对比得出差异对象,然后将差异部分渲染到DOM上,从而减少DOM操作达到优化性能的目的。
虚拟DOM的核心思想就是:对复杂的DOM树提供一种方便的工具进行最小化操作。
用JS模拟DOM树
function VElement(tagName, props, children) {
if(!(this instanceof VElement)) {
return new VElement(tagName, props, children)
}
if(Array.isArray(props)) {
children = props
props = {}
}
this.tagName = tagName
this.props = props || {}
this.chiildren = children || []
this.key = props ? props.key : void 0
let count = 0
this.children.forEach(this.children, function(child, i) {
if(child instanceof VElement) {
count += child.count
} else {
children[i] = '' + child
}
count++
})
this.count = count
}
通过VElement,我们可以简单地表示DOM结构:
let vdom = VElement('div', {
'id': 'container'
}, [
VElement('h1', {
style: 'color:red'
}, [
'simple virtual dom'
]),
VElement('p', ['hello dom']),
VElement('ul', [
VElement('li', ['item #1']),
VElement('li', ['item #2'])
])
])
上面的JS代码表示的DOM结构:
simple virtual dom
hello dom
- item #1
- item #2
根据虚拟DOM构建真实的DOM树:
VElement.prototype.render = function() {
let el = document.createElement(this.tagName);
let props = this.props;
for (var propName in props) {
let propValue = props[propName]
setAttr(el, propName, propValue);
}
this.child.forEach(this.children, function(child) {
let childEl
if(child instanceof VElement) {
child.render()
} else {
document.createTextNode(child)
}
el.appendChild(childEl)
})
return el
}
比较新旧DOM树差异
let vdom = VElement('div', {
'id': 'container'
}, [
VElement('h1', {
style: 'font-size: 20px' // PROPS
}, [
'virtual dom' // TEXT
]),
VElement('h2', ['hello dom']), // REPLACE
VElement('ul', [
// VElement('li', ['item #1']), // REMOVE
VElement('li', ['item #2'])
])
])
新的DOM结构:
virtual dom
hello dom
- item #2
比较DOM树:
// 比较DOM树
function diff(oldTree, newTree) {
let index = 0
let patches = {}
diffWalk(oldTree, newTree, index, patches);
return patches
}
// 深度比较
function deepWalk(oldNode, newNode, index, patches) {
let currentPatch = []
// ...差异比较
diffChildren(oldNode.children, newNode.children, index, patches, currentPatch)
if (currentPatch.length) {
patches[index] = currentPatch
}
}
// 子树比较
function diffChildren (oldChildren, newChildren, index, patches, currentPatch) {
const diffs = listDiff(oldChildren, newChildren)
newChildren = diffs.children
// ...差异记录
let leftNode = null
let currentNodeIndex = index
oldChildren.forEach((child, i) => {
const newChild = newChildren[i]
currentNodeIndex = if(leftNode && leftNode.count) {
currentNodeIndex + leftNode.count + 1
} else {
currentNodeIndex + 1
}
deepWalk(child, newChild, currentNodeIndex, patches)
leftNode = child
})
}
两个节点之间的差异:替换原有节点、调整节点,包括移动删除等、修改节点属性、修改节点文本内容。
对真实DOM进行修改
function applyPatches(node, currentPatches) {
currentPatches.forEach((currentPatch) => {
switch (currentPatch.type) {
case REPLACE: {
const newNode = (typeof currentPatch.node === 'string')
? document.createTextNode(currentPatch.node)
: currentPatch.node.render()
node.parentNode.replaceChild(newNode, node)
break
}
case REORDER:
reorderChildren(node, currentPatch.moves)
break
case PROPS:
setProps(node, currentPatch.props)
break
case TEXT:
if (node.textContent) {
node.textContent = currentPatch.content
} else {
node.nodeValue = currentPatch.content
}
break
default:
throw new Error(`Unknown patch type ${currentPatch.type}`)
}
})
}
虚拟DOM就简单介绍到这儿,我不做教程,所以粗略地总结了一下。在当下比较流行的ReactJS、VueJS对虚拟DOM进行了应用,通过数据驱动使得我们更少地操作DOM,开发效率也大大提高。尽管HTML、JS、CSS混合在一起设计显得不十分恰当,还是给前端开发带来了极大的便利。