浅析JavaScript编程之道

原文链接: https://my.oschina.net/u/3830333/blog/3096925

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混合在一起设计显得不十分恰当,还是给前端开发带来了极大的便利。

转载于:https://my.oschina.net/u/3830333/blog/3096925

你可能感兴趣的:(浅析JavaScript编程之道)