基于前端微服务的对一款监控平台的技术方案改进

原始方案

后端:Java框架Spring Boot整合数据库数据,推送给Grafana。 前端:完全依靠Grafana做前端,定制化很弱,无法完成大屏看板等刚需。

改进方案1:完全定制化的前端

干掉Grafana,完全定制化开发前端,采用Vue框架,以及数据可视化组件库Echarts和大屏组件库iDataV完成业务开发。

改进方案2:完全配置化的前端

方案1有个明显确定是复用性非常差,以后类似项目又要重头再来,不如Grafana方便。 方案2用Node.js,替代Grafana的后端功能。引入MongoDB存储业务后端推送的数据。

每个页面可配置组件及其组件形式和样式,在运行时依靠配置编译出主题文件并且采用。可推送数据到不同组件实现监控。

改进方案3:基于微服务的对当前方案的改进

方案2相比之前,功能非常齐全,可维护性很佳。难度在于实施工期非常长,并且目前业务Grafana基本能支持绝大多数场景,除了大屏展示效果不佳外是勉强可用的。

如果把大屏功能和Grafana数据可视化分离成两个微服务,然后用一个基座整合在一起,大屏功能和方案2一致,而Grafana完整保留,好处在于开发时间大大降低。得到方案3。

前端微服务技术可选方案

方式 开发成本 维护成本 可行性 同一框架要求 实现难度 潜在风险
路由分发 不支持多种框架
iFrame XSS风险,性能差
应用微服务化 ★★★★ 针对每个框架做定制
微件化 ★★★★★ 针对构建系统,如 webpack 进行 hack
微应用化 ★★★ 统一不同应用的构建规范
纯 Web Components ★★ 新技术,浏览器的兼容问题
结合 Web Components ★★ 新技术,浏览器的兼容问题

我们的业务:Y(不限框架) -> N(不需要支持IE) -> N (不是新项目因为有Grafana了)

得到选型:WebComponent 集成应用的方式

其他关于web component是什么,怎么用,微服务是什么怎么用的。可以靠看参考文章来了解。


参考文章:

egg.js原理剖析:egg-cluster

本文主要是egg-cluster的原理理解,包括了一些前置知识:操作系统进程同学、node创建进程、node进程通信。我对这些前置知识只会略讲,如果看到后没有任何印象或者之前不掌握,请阅读本文最后参考文档中的文章来熟悉这些前置知识再阅读本文。

node-cluster 简介

「cluster」意为“集群”,顾名思义,就是增加node.js这种单进程单线程的有效利用多核系统的能力。 「cluster」 模块可以创建共享服务器端口的子进程。

node.js 进程创建方式

通信方式与进程产生方式有关,而Node有4种创建进程的方式: spawn() , exec() , execFile() 和 fork()。

  • spawn:spawn() 方法默认不会创建shell去执行传入的命令,性能相对较好
  • exec:exec() 方法会创建一个shell,完全支持shell语法 ,可以直接传入任意shell脚本,故可以调用shell脚本
  • execFile:execFile() 相比 exec() 是不会通过shell,而是接受一个可执行文件
  • fork:fork() 是 spawn() 的变体,用来创建Node进程,最大的特点是父子进程自带通信机制——管道

操作系统 进程通信

  • 共享内存:不同进程共享同一段内存空间。通常还需要引入信号量机制,来实现同步与互斥。这种方式性能极高,但实现困难。
  • 消息传递:这种模式下,进程间通过发送、接收消息来实现信息的同步。
  • 信号量:信号量简单说就是系统赋予进程的一个状态值,未得到控制权的进程会在特定地方被强迫停下来,等待可以继续进行的信号到来。如果信号量只有 0 或者 1 两个值的话,又被称作“互斥锁”。这个机制也被广泛用于各种编程模式中。
  • 管道:管道本身也是一个进程,它用于连接两个进程,将一个进程的输出作为另一个进程的输入。可以用 pipe 系统调用来创建管道。我们经常用的“ | ”命令行就是利用了管道机制。
  • socket:网络通信,不仅能跨进程(端口与端口),还能跨机器(IP与IP)

node.js 进程通信

  • 通过stdin/stdout传递消息:属于操作系统进程通信方式的消息传递法。拿到子进程的handle后,可以访问其 stdio 流,然后约定一种 message 格式通信。
  • IPC管道:默认通信方式,父进程与process.on(‘message’) 收 | child.send() 发,子进程process.on(‘message’) 收 | process.send() 发,和
  • socket:借助socket网络通信,不仅能跨进程(端口与端口),还能跨机器(IP与IP)。

node-cluster 工作原理

通常,一个集群由主从架构组成,就是一个「master」主进程和多个「worker」子进程来组成一个集群。 工作进程由「child_process.fork」方法创建,这些「worker」可以用IPC管道和父进程通信。子进程和父进程具有相同的代码段、数据段、堆栈,但是它们的内存空间不共享。父进程「master」负责监听端口,接收到新的请求派发给下面的「worker」进程。

egg-cluster 架构模型

egg-cluster把进程分为三类: master:主进程,负责 agent 的启动、退出、重启;各个 worker 进程调度;负责 agent 和各个 worker 之间的通信;负责各个 worker 之间的通信 agent:「master」 的子进程,可以理解为「master」的“助理”,帮「master」打杂做一些派发之类的工作:处理公共资源的访问,如文件监听,或者帮 「worker」 处理一些公共事务,如一些事情是不需要每个 「worker」 都做一次的,「agent」 帮忙做完之后通知它们执行之后的操作 worker:「master」 的子进程,一般是根据服务器有多少个 CPU 启动多少个这样的 「worker」 进程,主要用于对外服务,处理各种业务层面的事情

进程顺序

  • master 启动后先启动 agent 进程
  • agent 初始化成功后,通过 IPC 通道通知 master
  • master 根据 CPU 的个数启动相同数目的 worker 进程
  • worker 进程初始化成功后,通过 IPC 通道通知 master
  • 所有的进程初始化成功后,master 通知 agent 和各个 worker 进程应用启动成功

进程通信

master 和 agent/worker 是 直接通信的,因为 master 是 agent/worker fork()后的父进程,拥有IPC管道进行通信 agent 和 worker 之间以及各个 worker 之间是 间接通信的,依靠master做中转 https://github.com/eggjs/egg-cluster#readme

参考:
《现代操作系统》
进程间的五种通信方式介绍: https://blog.csdn.net/wh_sjc/article/details/70283843
node.js的cluster文档:http://nodejs.cn/api/cluster.html
Nodejs进程间通信: https://www.cnblogs.com/rubyxie/articles/8949417.html
egg-cluster:https://github.com/eggjs/egg-cluster#readme
Egg 源码解析之 egg-cluster:https://zhuanlan.zhihu.com/p/29374045#

时序数据库Influxdb入门

简介

InfluxDB是一个时间序列数据库,旨在处理高写入和查询负载。它是TICK堆栈的组成部分。InfluxDB旨在用作涉及大量带时间戳数据的任何用例的后备存储,包括DevOps监控,应用程序指标,物联网传感器数据和实时数据分析。

特点

InfluxDB具备以下特点:

  • 专为时间序列数据编写的自定义高性能数据存储。TSM引擎允许高摄取速度和数据压缩
  • 完全写在Go。它编译成单个二进制文件,没有外部依赖项。
  • 简单,高性能的写入和查询HTTP API。
  • 插件支持其他数据提取协议,如Graphite,collectd和OpenTSDB。
  • 为类似SQL的查询语言量身定制,可轻松查询聚合数据。
  • 标签允许对系列进行索引以实现快速有效的查询。
  • 保留策略有效地自动使过时数据过期。
  • 连续查询自动计算聚合数据,以提高频繁查询的效率。

安装

在mac上的安装使用brew

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
brew update
brew install influxdb
ln -sfv /usr/local/opt/influxdb/*.plist ~/Library/LaunchAgents

# 配置文件在/etc/influxdb/influxdb.conf ,如果没有就将/usr/local/etc/influxdb.conf 拷一个过去
配置缓存:cache-max-memory-size

#启动服务
launchctl load ~/Library/LaunchAgents/homebrew.mxcl.influxdb.plist

#停止服务
launchctl unload ~/Library/LaunchAgents/homebrew.mxcl.influxdb.plist

#前台启动
influxd -config /usr/local/etc/influxdb.conf

#查看influxdb运行配置
influxd config

#启动交互式客户端
influx -precision rfc3339

基本概念

time是时间(time),具体格式必须是一个时间戳或者RFC3339时间。 butterflieshoneybees是字段(field),包括字段键(表头)和字段值(表格内容)。 、locationscientist是标签(tag),同样包括了标签建(表头)和标签值(表格内容)。注意标签和字段第一眼看上去非常相似,其区别是标签是一个类似枚举的结构,只有几种可选的标签值。这与字段不同,标记是索引的。这意味着标签上的查询更快,并且该标签非常适合存储常用查询元数据。 在influxDB中,有个叫做series的概念,这个series是数据可视化中的数据,是通过tags排列组合出来的。一般在echarts等库中也可以看到相似的概念。

基本操作

数据库操作

1
2
3
4
5
6
7
8
/*创建数据库*/
CREATE DATABASE mydb

/*使用数据库*/
use DATABASE mydb

/*删除数据库*/
drop DATABASE mydb

数据measurement的操作

1
2
3
4
/*插入数据:插入了一条数据*/
insert testTable<表名字>, butterflies=3 ,honeybees=28 , location=1 ,scientist=perpetua<内容>
/*查询数据:查询最近的3条数据*/
SELECT * FROM weather ORDER BY time DESC LIMIT 3

HTTP接口

InfluxDB直接提供了一套HTTP接口,部分操作如下:

1
2
3
4
5
# 插入数据:插入了一条数据
curl -i -XPOST 'http://localhost:8086/write?db=myDB' --data-binary 'testTable, butterflies=3 ,honeybees=28 , location=1 ,scientist=perpetua'

# 查询数据:查询最近的3条数据
curl -G 'http://localhost:8086/query?pretty=true' --data-urlencode "db=myDB" --data-urlencode "q=SELECT * FROM testTable ORDER BY time DESC LIMIT 3"

node.js中的实用

目前已经有第三方的npm包,所以在node.js中也非常方便

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
/**@type InfluxDB*/
var influx = require('influx')
var async = require("async")
var ut = require("./../../util/util.js")
var dbName = "mydb"
var tableName = "testTable"
var client = influx({
host : '0.0.0.0',
port : 8086, // optional, default 8086
protocol : 'http', // optional, default 'http'
username : '',
password : '',
database : mydb
})
var altitudes = [1000, 5000]
var areas = ["北", "上", "广", "深"]
async.waterfall([
function(cb){ // 创建数据库
client.createDatabase(dbName, function(err,result){
ut.log("createDatabase", result)
cb(err, null)
} )
},
function(result, cb){ // 获取数据库名字
client.getDatabaseNames( function(err, result){
ut.log("getDatabaseNames", result)
cb(err, null)
} )
},
function(result, cb){ // 写入数据
var points = [
[
{
temperature: ut.RandByRange(0, 100), humidity : ut.RandByRange(-15, 30)
},
{
altitude: altitudes[ut.RandByRange(0, altitudes.length)], area : areas[0]
},
],
[
{
temperature: ut.RandByRange(0, 100), humidity : ut.RandByRange(-15, 30)
},
{
altitude: altitudes[ut.RandByRange(0, altitudes.length)], area : areas[1]
},
],
]
client.writePoints(tableName, points, function(err, result){
ut.log("writePoint", result)
cb(err, null)
} )
},
function(result, cb){ // 查询数据
client.query( 'SELECT * FROM weather ORDER BY time DESC LIMIT 3', function(err,result){
ut.log("query", result)
cb(err, null)
} )
},
function(result, cb){
client.getMeasurements( function(err,result){
ut.log("getMeasurements", JSON.stringify(result))
cb(err, null)
})
}
]
, function(err, result){
ut.log("finish...", err, result)
}
)

游览器工作原理:CSS的渲染

CSS样式规则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<html>
<head>
<style>
div {
positon: absolute;
top: 200px;
left: 200px;
width: 200px;
height: 200px;
border: 2px solod black;
padding:20px;
background-color: blue;
color: green;
opacity: 0.8;
}
</style>
</head>
<body>
<div id="simple"></div>
</body>
</html>

上边是一个使用CSS的简单例子,所有的CSS都是按照以下规则书写的: [选择器] { [样式名]: [样式值], [样式名]: [样式值], …}

位置计算

CSS的位置计算包含了两大部分,一部分是众所周知的 盒子模型,另外一部分是比较小众的 包含块模型。对于盒子模型,因为比较简单,本篇博文略过,重点讲 包含块模型。 当游览器计算元素的盒子的位置和大小时,webkit需要计算该元素和另外一个矩形区域的相对位置,这个矩形区域称为该元素的包含块。盒模型就是在包含块内计算和确定各个元素的。

  • 根元素的包含块称为初试包含块,大小就是可视区域。
  • 对于static和relative布局的元素,包含块是最佳父级元素盒子模型的content区域。
  • 对于fixed布局的元素,包含块脱离html流,固定值整个游览器可视区域的一个位置。
  • 对于absolute布局的元素,包含块由最近含有absolute、relative、fixed的祖先决定:如果一个元素具有inline属性,元素的包含块是包含该祖先的第一个和最后一个内联元素的内边距的区域;否则,包含块是该祖先的内边距的区域。

CSSOM:CSS对象模型

可能所有人都知道HTML对应的文档对象模型的DOM,但没多少听过CSS对应CSS对象模型 CSSOM 。实际当游览器要解析外联或者内联的CSS时,为了可以解析这种文档,使之可以用于样式排布和绘制。这种数据结构就是 CSSOM。它的思想是在DOM的一些节点接口中,加入获取和操作CSS属性或者接口的javascript接口,因而实现让javascript可以动态操作CSS样式。 对于样式表,CSSOM提供了一个接口,这个接口在webkit定义是:

1
2
3
4
5
6
7
8
partial interface Document {
readonly attribute StyleSheetList styleSheetLists;
attribute DOMString ? sekectedStyleSheetSet;
readonly attribute DOMString ? lastStyleSheetSet;
readonly attribute DOMString ? preferredStyleSheetSet;
readonly attribute DOMString ? StyleSheeSetsList styleSheetLists;
void enableStyleSheetsForSet(DOMString ? name);
}

通过这些属性,可以动态选择使用哪些样式表。 这个接口可以获取样式表的各种信息,例如css的’href’、css类型’type’、css规则’cssRules’。使用方法是document.stylesheets,可以得到如下结果: CSSOM

CSS解释器与匹配规则

CSS的解释分为三个过程 词法分析: 在上一章节第一节中,简单说了样式规则,在webkit内部, 样式类型有以下几个Class:

  • Style:基本类型,绝大多数属于这个类型
  • Import:引入CSS用
  • Media:对应 @media 类型
  • Fontface:CSS3中自定义字体的类型
  • Keyframes:对应css3中 @keyframes 类型
  • Page:对应 @page 类型

这些样式和值对应的 CSSValue 这个类组成了一个样式规则——被叫做CSSProperly,同一个选择器 CSSSelector 多个样式规则一起组成了 CSSPropertySet。于是形成了如下结构: 这些结构的每个节点就是 token 语法分析:由 CSSParser 类完成,但这个类是一个代理模式,实际完成的是 CSSGrammer 类完成,这个类可以解析startSelector, endSelector, startRuleBody, startProperty, parseValue, endRuleBody。 结果这两步,就生成样式规则StyleRule,会被放入 StyleSheetContents 对象中。

CSS布局计算

布局计算是一个递归的过程,每一个节点的大小都要计算其子女的位置和大小。布局计算依靠内部类 RenderObject 进行计算。整个过程如下: 对于以下几种情况会触发重新布局:

  • 当网页首次被打开的时候,游览器设置网页的可视区域,并调用计算布局的方法;
  • 网页的动画会触发布局计算,因为动画可能改变样式属性;
  • JavaScript代码通过CSSOM等直接修改样式信息;
  • 用户的交互,例如翻滚网页。 重新布局就是所谓的回流,因为不能依靠修改单纯内存中的CSSValues然后调用渲染器实现修改,必须经过布局计算全部计算并渲染,因此非常消耗时间,也是前端老生常谈的优化项之一。

游览器在进行完布局计算后,还会进行布局测试,布局测试是对游览器最重要的测试,它会测试整个网页的渲染结果,包括网页的加载和渲染两个过程。方法是预先准备大量运用测试渲染结果的单元测试用例,然后把得到的结果和已有的用例进行比对检测准确性。

游览器工作原理:HTML的渲染

我们已经熟悉JavaScript提供的一套Dom模型和接口来操作网页元素。这篇文章主要解释作为游览器核心的dom部分如何被表示、渲染和处理。

DOM模型

DOM 是文档对象模型 (Document Object Model) 的缩写。它是 HTML 文档的对象表示,同时也是外部内容(例如 JavaScript)与 HTML 元素之间的接口。 解析树的根节点是“Document”对象。HTML中的Tag也是一种节点,称为元素节点;此外,主要的节点还有属性节点、注释节点等。 DOM 与节点之间几乎是一一对应的关系。比如下面这段HTML:

<html>
  <body>
    <p>
      Hello World
    </p>
    <div> <img src="example.png"/></div>
  </body>
</html>

被转移为DOM树的结果是:

                  +---------------------+
                  |   HTMLHtmlElement   |
                  +---------+-----------+
                            |
                            |
                            |
                  +---------+-----------+
                  |   HTMLBodyElement   |
                  +---------------------+
                            X
                           X X
                          X   X
                         X     X
+------------------------+      +--------------------+
|  HTMLParagraphElement  |      |   HTMLDivElement   |
+-------------+----------+      +----------+---------+
              |                            |
              |                            |
              |                            |
        +-----+----+            +----------+---------+
        |   Text   |            |  HTMLImageElement  |
        +----------+            +--------------------+

HTML解释器

游览器获取到HTML资源后的整体处理过程如下:

词法分析

词法解析主要是将字符流转换为词语(tokens)。在webkit内部有一个HTMLTokenizer的类完成词法分析的工作。主要提供一个nextToken方法,这个方法非常复杂,简单描述一下: 这个算法相对于一个状态机。每一个状态接收来自输入信息流的一个或多个字符,并根据这些字符更新下一个状态。当前的标记化状态和树结构状态会影响进入下一状态的决定。这意味着,即使接收的字符相同,对于下一个正确的状态也会产生不同的结果,具体取决于当前的状态。最终输出结果是 HTML Token。 对于以下的一段HTML代码:

<html>
  <body>
    Hello world
  </body>
</html>

1.初始状态是数据状态。 2.遇到字符 < 时,状态更改为标记打开状态。 3.接收一个 a-z 字符会创建起始标记,状态更改为标记名称状态。这个状态会一直保持到接收 > 字符。在此期间接收的每个字符都会附加到新的标记名称上。 4.遇到 > 标记时,会发送当前的标记,状态改回数据状态<body> 标记也会进行同样的处理 5.接收到 Hello world 中的 H 字符时,将创建并发送字符标记,直到接收 </body> 中的 <。我们将为 Hello world 中的每个字符都发送一个字符标记。 6.接收下一个输入字符 / 时,会创建 end tag token 并改为标记名称状态。我们会再次保持这个状态,直到接收 >。然后将发送新的标记,并回到数据状态</html> 输入也会进行同样的处理。

语法分析

在创建解析器的同时,也会创建 Document 对象。在树构建阶段,以 Document 为根节点的 DOM 树也会不断进行修改,向其中添加各种元素。标记生成器发送的每个节点都会由树构建器进行处理。规范中定义了每个标记所对应的 DOM 元素,这些元素会在接收到相应的标记时创建。这些元素不仅会添加到 DOM 树中,还会添加到开放元素的堆栈中。此堆栈用于纠正嵌套错误和处理未关闭的标记。其算法也可以用状态机来描述。这些状态称为插入模式。 然后状态将改为before head。此时我们接收body标签。即使我们的示例中没有head标记,系统也会隐式创建一个 HTMLHeadElement,并将其添加到树中。 现在进入了in head模式,然后转入after head模式。系统对 body 标记进行重新处理,创建并插入 HTMLBodyElement,同时模式转变为in body。 现在,接收由Hello world 字符串生成的一系列字符标记。接收第一个字符时会创建并插入Text节点,而其他字符也将附加到该节点。 接收 body 结束标记会触发after body模式。现在我们将接收 HTML 结束标记,然后进入after after body模式。接收到文件结束标记后,解析过程就此结束。

浏览器的容错机制

在早些年写html的过程中,我经常纳闷为什么我写的很多并不规范,也被正常解析,例如只写了一个<br></br>,用错了标准的标记等等。HTML 网页时从来不会有语法无效的错误。这是因为浏览器会纠正任何无效内容,然后继续工作。 例如以下HTML代码:

<html>
  <mytag>
  </mytag>
  <div>
  <p>
  </div>
    Really lousy HTML
  </p>
</html>

这段代码已经违反了很多语法规则:“mytag”不是标准的标记,“p”和“div”元素之间的嵌套有误等等。但是因为容错机制的存在,浏览器仍然会正确地显示这些内容。 解析器对标记化输入内容进行解析,以构建文档树。如果文档的格式正确,就直接进行解析。 遗憾的是,我们不得不处理很多格式错误的 HTML 文档,所以解析器必须具备一定的容错性。 我们至少要能够处理以下错误情况: 1.明显不能在某些外部标记中添加的元素。在此情况下,我们应该关闭所有标记,直到出现禁止添加的元素,然后再加入该元素。 2.我们不能直接添加的元素。这很可能是网页作者忘记添加了其中的一些标记(或者其中的标记是可选的)。这些标签可能包括:HTML HEAD BODY TBODY TR TD LI(还有遗漏的吗?)。 3.向 inline 元素内添加 block 元素。关闭所有 inline 元素,直到出现下一个较高级的 block 元素。 4.如果这样仍然无效,可关闭所有元素,直到可以添加元素为止,或者忽略该标记。

线程化的解释器

线程化的解释器就是利用单独的线程来解释 HTML 文档。因为在WebKit中,网络资源的字节流自IO线程传递给渲染线程之后,后面的解释、布局、渲染都工作在该线程。 DOM树必须是单独的线程,但从字节流到tokens的阶段可以由单独的线程去做这个工作,为了提升性能。


参考: http://www.w3.org/TR/html5/syntax.html#html-parser https://www.html5rocks.com/zh/tutorials/internals/howbrowserswork/#The\_HTML\_grammar_definition

JavaScript工作原理:事件循环

单线程异步

js早期对性能无要求,与此一开始就采用了单线程的选择,好处显而易见是可以非常清晰的确定执行顺序,如果多线程则很难判断在需要某个值时其他是否处理完毕。 虽然后来引入了web service,但其主线程一直是单线程的,并且主线程的变量不得操作dom,辅助线程仍然受主线程控制,相对于对主线程进行辅助计算。 引入异步是,避免某个延时任务(如定时器)阻塞了整个进场,使得加载和渲染无法继续下去,如果没有异步的话,在单线程的js中一旦延时,那么必须要等到这个定时器执行完才可以继续进行渲染或者其他操作,这在客户端基本是不可容忍的。

执行栈

执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则 js代码的执行过程就是往执行栈里放入函数然后先进后出的执行。

任务队列

JavaScript将任务分为两类:同步任务和异步任务。同步任务会直接进入调用栈先进后出的执行,而异步任务则会放入任务队列,一旦执行栈为空,EventLoop会从任务队列中取出应该被执行的任务放入执行栈执行,本质上异步代码也是同步执行的 将异步任务又分为了两类:宏任务(macrotask)和微任务(microtask)。执行顺如如下: 1.首先执行同步代码,这属于宏任务 2.当执行完所有同步代码后,执行栈为空,查询是否有异步代码需要执行 3.执行所有微任务 4.当执行完所有微任务后,如有必要会渲染页面 5.然后开始下一轮 Event Loop,执行宏任务中的异步代码,也就是 setTimeout 中的回调函数 微任务包括 process.nextTick ,promise ,MutationObserver。 宏任务包括 script , setTimeout ,setInterval ,setImmediate ,I/O ,UI rendering。 一个常考是示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
console.log('script start')

async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
}
async1()

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

new Promise(resolve => {
console.log('Promise')
resolve()
})
.then(function() {
console.log('promise1')
})
.then(function() {
console.log('promise2')
})

console.log('script end')

// 执行顺序是:
// script start
// async2 end
// Promise
// script end
// promise1
// promise2
// async1 end
// setTimeout

node.js中的事件循环

node.js的异步机制是采用了libuv这个c++的异步库实现的,因此其时间循环就是libuv中规定是循环——分为六个阶段执行:

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘
  • timers
    • 检查 timer 队列是否有到期的 timer 回调,如果有,将到期的 timer 回调按照 timerId 升序执行。
    • 检查是否有 process.nextTick 任务,如果有,全部执行。
    • 检查是否有microtask,如果有,全部执行。
  • pending callbacks
    • 检查是否有 pending 的 I/O 回调。如果有,执行回调。如果没有,退出该阶段。
    • 检查是否有 process.nextTick 任务,如果有,全部执行。
    • 检查是否有microtask,如果有,全部执行。
  • idle, prepare
    • libuv内部为poll做准备工作。
  • poll
    • 首先检查是否存在尚未完成的回调。如果有未完成回调:
    • 如果有未完成回调。
      • 执行所有可用回调。
      • 检查是否有 process.nextTick 回调,如果有,全部执行。
      • 检查是否有微任务,如果有,全部执行。
    • 如果没有未完成回调
      • 检查是否有 immediate 回调,如果有,退出 poll 阶段。如果没有,阻塞在此阶段,等待新的事件通知重启循环。
    • 如果不存在尚未完成的回调,退出poll阶段。
  • check
    • 如果有immediate回调,则执行所有immediate回调。
    • 检查是否有 process.nextTick 回调,如果有,全部执行。
    • 检查是否有 microtaks,如果有,全部执行。
  • close callbacks
    • 如果有immediate回调,则执行所有immediate回调。
    • 检查是否有 process.nextTick 回调,如果有,全部执行。
    • 检查是否有 microtaks,如果有,全部执行。

上述过程比较复杂,对于一个实际的问题而言,自从node.js升级到10后,执行顺序其实是和游览器端一致的,所以不需要非常详细的知道上述过程,安装与游览器游览器端的去编码即可。

JavaScript工作原理:五种JavaScript代码的优化方式

在了解这些优化方式前,先强烈建议阅读本博客前两篇文章,前两篇是对V8原理的一个入门,因为这些优化方式,都是针对原理而引入的可行方式。

对象属性的顺序

始终以相同的顺序实例化对象属性,以便可以共享隐藏的类和随后优化的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// bad
function Point(x, y) {
this.x = x;
this.y = y;
}
var a = new Point(1,2);
var b = new Point(); // 顺序不一致
b.x = 2;
b.y = 1;

// good
function Point(x, y) {
this.x = x;
this.y = y;
}
var a = new Point(1,2);
var b = new Point(2,1);

动态属性

在实例化之后向对象添加属性或者修改属性的类,都会修改隐藏类,使得优化无效。所以对象实例化时尽量添加好属性,并且尽量不再做对属性类型的修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// bad
function Point(x, y) {
this.x = x;
this.y = y;
}
var a = new Point(1,2);
var b = new Point(2,1);
b.z = 3; // 实例化后添加了属性
a.y = null // 实例化后修改了类型

// good
function Point(x, y) {
this.x = x;
this.y = y;
}
var a = new Point(1,2);
var b = new Point(2,1);

对象的方法

对同一个类new出来的对象多次执行同一个方法,第一次后的执行速度会非常快,因为内联缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// bad
function Rectangle(x, y) {
this.x = x;
this.y = y;
}
function Square(a) {
this.a = a;
}

var a = new Rectangle(1, 2);
var b = new Square(2);
function SquareArea(square) {
var a = square.a;
return a * a;
}
function RectangleArea(rectangle) {
var x = rectangle.x;
var y = rectangle.y;
return x * y;
}

// good
function Rectangle(x, y) {
this.x = x;
this.y = y;
}
function Area (rectangle) {
var x = rectangle.x;
var y = rectangle.y;
return x * y;
}
var a = new Rectangle(1, 2);
var b = new Rectangle(2, 2);
var b = new Rectangle(5, 6);
Area(a);
Area(b);
Area(c);

内存回收

有效使用内存总是可以提高代码的性能,对于使用了垃圾回收的语言来说,并不意味着有了垃圾回收就没有内存泄漏的问题,如果及时回收不再使用的内存,可以避免内存泄漏的同时减轻编译器的工作。简单的做法就是把不再使用的对象设置为null,以及通过delete关键字,来删除对象的一个属性。

数组

避免稀疏数组,其中键值不是自增的数字。并没有存储所有元素的稀疏数组是哈希表。这种数组中的元素访问开销较高。另外,尽量避免预分配大数组。最好是按需增长。最后,不要删除数组中间的元素,即使用队列和栈方法删除。这会使键值变得稀疏。

1
2
3
4
5
6
7
8
9
var a = [];
a[0] = 1;
a[5] = 2; // 稀疏数组

var b = [];
b.length = 10000; // 预先分配了大数组

var c = [1,2,3,4,5];
c[3] = undefined; // 删除后变为稀疏数组

数据表示

V8 使用 32 位表示对象和数值。由于数值是 31 位的,它使用了一位来区分它是一个对象(flag = 1)还是一个称为 SMI(SMall Integer)整数(flag = 0)。那么,如果一个数值大于 31 位,V8会将该数字装箱,把它变成一个双精度数,并创建一个新的对象来存放该数字。尽可能使用 31 位有符号数字,以避免对 JS 对象的高开销的装箱操作。 V8 的简单数据存放在句柄,句柄的操作是非常快的,而对大整数和大浮点数会使用堆,因为大整数是不可避免的,所以能使用整数的,尽量不要使用浮点数。

JavaScript工作原理:JavaScript V8引擎的优化

js引擎是将JavaScript处理并运行的环境,jvm是java处理并运行的环境,在JavaScript的V8引擎的设计中,大量借鉴了jvm中的一些方法和思想进行设计,着重起到优化的功能

编译性语言的处理过程

对于c或者c++这种编译兴来说,编辑、编译和运行是全部分离的,互相是一个职责链的形势,这个过程对于学过c语言基础或者与此相关的我们来说是非常简单的: 如上图所示,编辑源代码后、经过编译链接、最后得到本地二进制代码,然后交给操作系统运行。

解释性语言的处理过程

对于python、ruby、perl等解释性语言(通常脚本语言都是解释性语言)来说,脚本并不需要进行编译,而是在运行过程中直接被解释器解释的同时调用操作系统资源运行的。旧的JavaScript引擎,也是采用这样的方式编译JavaScript代码的。 如上图所示,比起编译性的语言,解释语言可以不用得到二进制表示,而被直接解释执行。

Java的处理过程

相比上边两种,Java的处理可谓复杂很多。分为了两个阶段:编译与解释阶段。与c++的编译阶段区别是编译阶段生产的不是直接可以运行的二进制代码,而是一种叫做字节码的中间代码。之所以使用这种字节码的中间代码是因为字节码可以不受操作系统和平台的限制,借助jvm实现“Write once, run anywhere”的跨平台功能,这也是Java最大的卖点与久经不衰的原因之一。 另外一个阶段是类似Python的解释阶段,Python不同的是:首先解释阶段将字节码作为了输入然后被解释器运行;加入了JIT的概念,JIT可以将字节码转为本地代码然后执行,这个JIT主要是起到一个优化性能的作用。

V8对JavaScript的处理过程

V8对js的处理过程与Java非常相似,但把Java的两个阶段合并了起来,全部在JavaScript引擎中执行。此外,因为JavaScript作为弱类型语言,本身没有进行类型的标记,如果直接编译对于编译器来说是有性能损耗的,为此v8借用了类型系统在内部构建了隐性类型系统。

多线程优化

V8引擎内部有多个线程进行处理:

  • 主线程:获取代码并优化。
  • 编译线程:获取主线程获取的代码然后编译并执行,此时主线程正在优化。
  • Profiler线程(我翻译为检查器线程):找到会占用大量性能的部分,以便主线程可以优先优化这部分。
  • CG线程(垃圾扫描与回收器):用来进行垃圾回收的线程。

首次执行JavaScript代码时,V8利用full-codegen直接将解析后的JavaScript转换为机器代码而无需任何转换。这使它可以非常快速地开始执行机器代码。请注意,V8不使用中间字节码表示,因此无需解释器。 当代码运行一段时间后,探查器线程已经收集了足够的数据来告诉应该优化哪个方法。 接下来,Crankshaft优化开始于另一个线程。它将JavaScript抽象语法树转换为名为Hydrogen的高级静态单指派(SSA)表示,并尝试优化氢图。大多数优化都是在这个级别完成的。

隐藏类优化

大多数JavaScript解释器使用类似字典的结构(基于散列函数)来存储对象属性值在内存中的位置。这种结构使得在JavaScript中检索属性的值比在Java或C#等非动态编程语言中的计算成本更高。在Java中,所有对象属性都是在编译之前由固定对象布局确定的,并且无法在运行时动态添加或删除。结果、属性值(或指向这些属性的指针)可以作为连续缓冲区存储在存储器中,每个缓冲区之间具有固定偏移量。可以根据属性类型轻松确定偏移的长度,而在运行时可以更改属性类型的JavaScript中,这是不可能的。 由于使用字典在内存中查找对象属性的位置效率非常低,因此V8使用不同的方法:隐藏类。隐藏类的工作方式类似于Java等语言中使用的固定对象布局(类),除非它们是在运行时创建的。现在,让我们看看它们实际上是什么样的:

1
2
3
4
5
function Pointxy{
this.x = x;
this.y = y;
}
var p1 = new Point(1,2);

一旦new一个新的Point类,V8将创建一个名为“C0”的隐藏类,如图: 此时,尚未定义任何Point的属性,“C0”为空。 一旦执行了第一个语句“this.x = x”(在“Point”函数内),V8将创建一个名为“C1”的第二个隐藏类,它基于“C0”。“C1”描述了可以找到属性x的存储器中的位置(相对于对象指针)。在这种情况下,“x”存储在偏移 0处,这意味着当在存储器中查看点对象作为连续缓冲区时,第一偏移将对应于属性“x”。V8还将使用“类转换”更新“C0”,该类转换指出如果将属性“x”添加到点对象,则隐藏类应该从“C0”切换到“C1”。下面的点对象的隐藏类现在是“C1”。 每次将新属性添加到对象时,旧的隐藏类都会更新为新隐藏类的转换路径。隐藏类转换很重要,因为它们允许在以相同方式创建的对象之间共享隐藏类。如果两个对象共享一个隐藏类并且同一属性被添加到它们中,则转换将确保两个对象都接收相同的新隐藏类以及随其附带的所有优化代码。 执行语句“this.y = y”时重复此过程。 创建一个名为“C2”的新隐藏类,将类转换添加到“C1”,声明如果将属性“y”添加到Point对象(已包含属性“x”),则隐藏类应更改为“C2”,点对象的隐藏类更新为“C2”。 隐藏类转换取决于属性添加到对象的顺序。看一下下面的代码片段:

1
2
3
4
5
6
7
8
9
10
function Pointxy{
this.x = x;
this.y = y;
}
var p1 = new Point(1,2);
p1.a = 5;
p1.b = 6;
var p2 = new Point(3,4);
p2.b = 7;
p2.a = 8;

现在,您将假设对于p1和p2,将使用相同的隐藏类和转换。好吧,不是真的。对于“p1”,首先添加属性“a”,然后添加属性“b”。但是,对于“p2”,首先分配“b”,然后是“a”。因此,作为不同转换路径的结果,“p1”和“p2”以不同的隐藏类结束。在这种情况下,以相同的顺序初始化动态属性要好得多,以便可以重用隐藏的类。

内联缓存

除了JIT、多线程、隐藏类外,V8还是用来内联缓存的方式优化js这种动态语言代码。内联缓存依赖于对相同类的对象的重复调用往往发生在相同类的对象上这一常见的现象。即A类的对象往往会重复调用B类的对象,V8会维护一个作为参数传递的对象类型的缓存,并且以B类为假设的对象类型。之后如果判断得到V8猜想正确,那么就可以绕过访问对象属性的过程。 每当在特定对象上调用方法时,V8引擎必须执行对该对象的隐藏类的查找,以确定访问特定属性的偏移量。在将同一方法成功调用两次到同一个隐藏类之后,V8省略了隐藏类查找,只是将属性的偏移量添加到对象指针本身。对于该方法的所有未来调用,V8引擎假定隐藏类未更改,并使用先前查找中存储的偏移直接跳转到特定属性的内存地址。这大大提高了执行速度。

新的管道机制

新的执行管道建立在Ignition,V8的解释器和TurboFan(V8的最新优化编译器)之上。也大大提升了性能。以下两张图是在引入Ignition前后,V8对于管道的使用过程。


参考: