tomoat的笔记


  • 首页

  • 归档

  • 标签

使用Electron构建桌面应用程序

发表于 2017-04-08

使用JavaScript,Node.js和Electron 构建您自己的声音机器的详细指南

JavaScript桌面应用程序的用途和用途

桌面应用程序总是在我心中有一个特别的地方。自从浏览器和移动设备功能强大以来,桌面应用程序的稳步下降,这些应用程序正在被移动和Web应用程序所取代。尽管如此,编写桌面应用程序仍然有很多优势 - 一旦他们在开始菜单或停靠栏中,它们总是存在,它们是alt(cmd)-tabbable(我希望这是一个字),并且大多数与底层操作系统(其快捷方式,通知等)比Web应用程序。

在本文中,我将尝试引导您完成构建简单桌面应用程序的过程,并了解如何使用JavaScript构建桌面应用程序的重要概念。

img

​ GitHub Electron

使用JavaScript开发桌面应用程序的主要思想是您构建一个代码库,并分别为每个操作系统打包。这样可以消除构建本机桌面应用程序所需的知识,并使维护变得更简单。如今,发展与JavaScript的一个桌面应用程序依赖于任何电子或NW.js。虽然这两种工具提供的功能或多或少相同,但我已经和Electron进行了交流,因为它具有一些重要的优势。在一天结束的时候,你也不会出错。

基本假设

我假设你已经安装了基本的文本编辑器(或IDE)和Node.js / npm。我也假设你有HTML / CSS / JavaScript的知识(Node.js的CommonJS模块的知识将是巨大的,但并不重要),所以我们可以专注于学习电子概念,而不用担心构建用户界面(其中,事实证明,只是普通的网页)。如果没有,你可能会感到有些失落,我建议您访问我以前的博客帖子来刷新您的基础知识。

电子的10,000英尺视图

简而言之,Electron提供了使用纯JavaScript构建桌面应用程序的运行时。它的工作原理是 - Electron采用您的package.json文件中定义的主文件并执行它。这个主文件(通常命名为main.js)然后创建包含渲染网页的应用程序窗口,其中增加了与操作系统的本地GUI(图形用户界面)进行交互的功能。**

详细来说,一旦您使用Electron启动应用程序,就会创建一个主要的过程。这个主要过程负责与您的操作系统的本机GUI进行交互,并创建应用程序的GUI(您的应用程序窗口)。

img

纯粹启动的主要过程不会给您的应用程序的用户任何应用程序窗口。这些由主文件中的主进程使用称为BrowserWindow模块的东西创建。然后,每个浏览器窗口运行其自己的渲染器进程。这个渲染器进程需要一个网页(引用通常的CSS文件,JavaScript文件,图像等的HTML文件)并将其呈现在窗口中。您的网页使用Chromium呈现,所以与标准保持非常高的兼容性。

例如,如果您只有一个计算器应用程序,您的主要过程将使用实际网页(计算器)的网页实例化一个窗口。

虽然说只有主进程与操作系统的本机GUI进行交互,但是有一些技术可以将一些工作卸载到渲染器进程(我们将研究如何利用这种技术构建一个特性)。

的主要过程可以通过一系列模块的访问本地GUI 直接在电子提供。您的桌面应用程序可以访问所有节点模块,如优秀的节点通知程序,以显示系统通知,请求进行HTTP呼叫等。


你好,世界!

让我们开始一个传统的问候语,并安装所有必要的先决条件。

随附存储库

本指南附有声音机教程资料库。
使用存储库在某些点跟随或继续。克隆存储库以开始:

1
git clone https://github.com/bojzi/sound-machine-electron-guide.git

然后您可以跳转到sound-machine-tutorial文件夹中的git标签:

1
git checkout <tag-name>

当代码块的代码可用时,我会通知您:

1
2
跟随:
git checkout 00-blank-repository

克隆/检出您想要的标签后,运行:

1
npm安装

以便您没有丢失任何Node模块。

如果您不能切换到另一个标签,最好只需重置存储库状态,然后执行结帐:

1
2
git add -
git reset --hard

开设店铺

1
2
跟随标签00-blank-repository:
git checkout 00-blank-repository

在项目文件夹中创建一个新的package.json文件,其中包含以下内容:

这个准系统package.json:

  • 设置应用程序的名称和版本,
  • 让Electron知道主进程将要运行哪个脚本(main.js)和
  • 设置一个有用的快捷方式 - 通过在CLI(终端或命令提示符)中运行“ npm start ”,可轻松运行应用程序的npm脚本。**

现在是获得电子的时候了。实现这一目标的最简单方法是通过npm为您的操作系统安装一个预先构建的二进制文件,并将其保存为package.json中的开发依赖关系(由–save -dev自动进行)。在CLI中运行以下命令(在项目文件夹中):

1
npm安装--save-dev电子预制

预先构建的二进制码是针对正在安装的操作系统而定制的,并允许运行“ npm启动 ”。我们正在将其安装为开发依赖关系,因为我们只需要在开发过程中。

也就是说,或多或少地,您开始使用Electron开发所需的一切。

问候世界

在该文件夹中创建一个应用程序文件夹和一个index.html文件,其内容如下:

1
<h1>你好, 世界!</h1>

在项目的根目录中创建一个main.js文件。这就是Electron的主要进程将会启动并允许创建我们的“Hello,world!”网页的文件。创建具有以下内容的文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Please update it, I had to do:
'use strict';
const {app, BrowserWindow} = require('electron')
let mainWindow = null
app.on('ready', function() {
mainWindow = new BrowserWindow({
height: 600,
width: 800
});
mainWindow.loadURL('file://' + __dirname + '/app/index.html');
});
// The require line is wrong.
// It's mainWindow.loadURL, not mainWindow.loadUrl.
// I'm using Electron 1.3.4.

没什么可怕的吧?应用程序模块控制您
的应用程序生命周期(例如 - 响应应用程序的就绪状态)。该BrowserWindow模块允许窗口创建。
在主窗口对象将是你的主应用程序窗口,并宣布为无效,因为窗口否则将一次JavaScript的垃圾收集踢关闭。

一旦应用程序获得就绪事件,我们使用BrowserWindow创建一个新的800像素宽和600像素高的窗口。
该窗口的渲染器进程将渲染我们的index.html文件。

运行我们的“Hello,World!”应用程序,在您的CLI中运行以下命令:

1
npm start

并沐浴在您的应用程序的荣耀中。

开发一个真正的应用程序

一台光荣的音响机器

第一件事 - 首先  是什么声音机器?
声音机器是一种小型设备,当您按各种按钮,主要是卡通或反应声音时,会发出声音。这是一个有趣的小工具,以减轻办公室的情绪和一个很好的用例来开发一个桌面应用程序,因为我们将在开发过程中探索很多概念(并获得一个漂亮的声音引导)。

img

我们将要构建的功能和我们将要探索的概念是:

  • 基本声音机(基本浏览器窗口实例化),
  • 关闭所述声机(远程消息之间主要和渲染过程),
  • 播放声音而不使应用程序焦点(全局键盘快捷键),
  • 创建快捷键修改键(Shift,Ctrl和Alt)的设置屏幕(将用户设置存储在主文件夹中)
  • 添加托盘图标(远程创建本机GUI元素并了解菜单和托盘图标)和
  • 打包您的应用程序(打包您的应用程序为Mac,Windows和Linux)。

构建声音机的基本功能

起点和应用组织

在您的腰带下工作的“Hello,world!”应用程序,现在是开始构建音响机器的时候了。

典型的声音机器具有几行按钮,通过制作声音来响应印刷机。声音主要是卡通和/或反应(笑声,鼓掌,玻璃破碎等)。

这也是我们构建的第一个功能 - 响应点击的基本声音机器。

img

基本文件和文件夹结构

我们的应用程序结构将非常简单。

在应用程序的根目录中,我们将保留package.json文件,main.js文件和我们需要的任何其他应用程序范围的文件。

该应用程序文件夹会将我们的各种类型的HTML文件放在像css,js,wav和img这样的文件夹中。

为了使事情更容易,网页设计所需的所有文件已经被包含在存储库的初始状态中。请检查标签01-start-project。如果您遵循并创建了“Hello,world!”应用程序,则必须重新设置存储库,然后执行checkout:

1
2
3
4
5
If you followed along with the "Hello, world!" example:
git add -A
git reset --hard
Follow along with the tag 01-start-project:
git checkout 01-start-project

为了保持简单,我们将只有两个声音,但扩展到完整的16个声音只是一个额外的声音,额外的图标和修改index.html的问题。

定义主要过程的其余部分

我们再来看看main.js来定义声音机的外观。用以下替换文件的内容:

我们通过给它一个维度来定制我们创建的窗口,使其不可调整大小并且无框架。它将看起来像一个真正的声音机悬停在桌面上。

现在的问题是 - 如何移动无框窗口(没有标题栏)并关闭它?
我将很快谈论定制窗口(和应用程序)关闭(并介绍一种在主进程和渲染器进程之间进行通信的方式),但拖动部分很容易。如果您查看index.css文件(在app / css中),您将看到以下内容:

1
2
3
4
5
6
html,
body {
...
-webkit-app-region: drag;
...
}

-webkit-app-region:drag; 允许整个html是一个可拖动的对象。现在有一个问题,但是您不能单击可拖动对象上的按钮。谜题的另一部分是-webkit-app-region:no-drag; 这允许您定义不可破坏(因此可点击的元素)。请考虑以下摘录index.css:

1
2
3
4
.button-sound {
...
-webkit-app-region: no-drag;
}

在自己的窗口中显示声音机

在main.js现在文件可以使一个新的窗口,并显示机器声音。而且真的,如果您以npm开始启动您的应用程序,您将看到声音机器活着。现在没有什么发生,这并不奇怪,因为我们只有一个静态网页。

将以下内容放在index.js文件中(位于app / js中)以获得交互性:

这段代码很简单。我们:

  • 查询声音按钮,
  • 通过读取数据声音属性的按钮,
  • 为每个按钮添加一个背景图像
  • 并为播放音频的每个按钮添加一个点击事件(使用HTMLAudioElement界面)

通过在CLI中运行以下命令来测试应用程序:

1
npm开始

img

​ 一个工作的声音机!

通过远程事件从浏览器窗口关闭应用程序

1
2
# Follow along with the tag 02-basic-sound-machine:
git checkout 02-basic-sound-machine

要重述 - 应用程序窗口(更准确地说,它们的渲染器进程)不应该与GUI进行交互(这就是关闭窗口)。在官方电子快速入门指南说:

在网页中,不允许调用本机GUI相关的API,因为在网页中管理本机GUI资源非常危险,容易泄漏资源。如果要在网页中执行GUI操作,则网页的渲染器进程必须与主进程通信,以请求主进程执行这些操作。

Electron提供用于该类型通信的ipc(进程间通信)模块。ipc允许订阅频道上的消息并向通道的订户发送消息。信道用于区分消息的接收者,并用字符串(例如“channel-1”,“channel-2”…)表示。消息还可以包含数据。收到消息后,用户可以通过做一些工作做出反应,甚至可以回答。消息传递的最大好处是分离问题 - 主进程不必知道哪些渲染器进程有哪些或哪个发送消息。

img

你有邮件。

这正是我们在这里做的 - 将主进程(main.js)订阅到“ 关闭主窗口 ”通道,并在有人点击关闭按钮时从渲染器进程(index.js)在该通道上发送消息。**

将以下内容添加到main.js以订阅频道:

1
2
3
4
5
var ipc = require('ipc');
ipc.on('close-main-window', function () {
app.quit();
});

在要求模块之后,在通道上订阅消息非常简单,并且涉及使用带有通道名和回调函数的on()方法。

要在该频道上发送消息,请将以下内容添加到index.js中:

1
2
3
4
5
6
var ipC = require(' ipc ');
var closeEl = document。querySelector(' .close ');
closeEl。addEventListener(' click ',function(){
ipc。send(' close-main-window ');
});

再次,我们需要ipc模块,并使用关闭按钮将点击事件绑定到元素。点击关闭按钮,我们通过send()方法通过“close-main-window”通道发送消息。

还有一个可能咬你更多的细节和我们已经谈过它- 可点击拖动领域。index.css必须将关闭按钮定义为不可拖动。

1
2
3
4
.settings {
...
-webkit-app-region:no-drag;
}

就是这样,我们的应用程序现在可以通过关闭按钮关闭。通过检查事件或传递参数,通过ipc进行通信可能会变得复杂,我们稍后会看到一个传递参数的例子。

通过全局键盘快捷键播放声音

1
2
# Follow along with the tag 03-closable-sound-machine:
git checkout 03-closable-sound-machine

我们的基本音响机器工作很棒。但是我们确实有一个可用性的问题 - 一个声音机器有什么用途,它必须一直坐在所有窗口的前面,并重复点击?

这是全球键盘快捷键的地方。Electron提供了一个全球快捷键模块,可让您聆听自定义的键盘组合并作出反应。键盘组合被称为加速器,并且是按键组合的字符串表示(例如“Ctrl + Shift + 1”)。

img

插上呃,按播放!

由于我们想要捕获一个本机GUI事件(全局键盘快捷键)并执行一个应用程序窗口事件(播放声音),我们将使用我们的可信ipc模块将消息从主进程发送到渲染器进程。

在潜入代码之前,需要考虑两件事情:

  1. 在应用程序“ready”事件(代码应该在该块中)之后,必须注册全局快捷方式
  2. 当通过ipc从主进程发送消息到渲染器进程时,必须使用该窗口的引用(像“createdWindow.webContents.send(’channel’))

考虑到这一点,让我们改变我们的main.js并 添加以下代码:

首先,我们需要全局快捷方式。然后,一旦我们的应用程序准备就绪,我们会注册两个快捷方式 - 一个将按Ctrl,Shift和1一起响应,另一个会一起响应Ctrl,Shift和2。其中的每一个都将在“ 全局快捷方式 ”通道上发送一条消息,其中包含一个参数。我们将使用该参数来播放正确的声音。将以下内容添加到index.js中:

1
2
3
4
5
//index.js
ipc.on('global-shortcut', function (arg) {
var event = new MouseEvent('click');
soundButtons[arg].dispatchEvent(event);
});

为了简单起见,我们将模拟一个按钮单击,并使用我们在绑定按钮播放声音时创建的soundButtons选择器。一旦一个消息带有参数1,我们将使用soundButtons [1]元素并触发鼠标点击(注意:在生产应用程序中,您将要封装声音播放代码并执行)。

通过新窗口中的用户设置配置修改键

1
2
# Follow along with the tag 04-global-shortcuts-bound:
git checkout 04-global-shortcuts-bound

有许多应用程序在同一时间运行,可能非常好,我们设想的快捷方式已经被采用。这就是为什么我们要介绍一个设置屏幕,并存储我们要使用哪些修饰符(Ctrl,Alt和/或Shift)。

要完成所有这些,我们将需要以下内容:

  • 我们主窗口中的设置按钮,
  • 设置窗口(附带HTML,CSS和JavaScript文件),
  • ipc消息打开和关闭设置窗口并更新我们的全局快捷方式和
  • 从用户系统存储/读取设置JSON文件。

呃,这是一个很好的名单。

设置按钮和设置窗口

类似关闭主窗口,我们要一个上发送消息通道从index.js的设置按钮被点击时。将以下内容添加到index.js中:

1
2
3
4
var settingsEl = document.querySelector('.settings');
settingsEl.addEventListener('click', function () {
ipc.send('open-settings-window');
});

点击设置按钮后,会在频道“ open-settings-window ”上发送一条消息。main.js现在可以对该事件做出反应并打开新窗口。将以下内容添加到main.js中:

没有什么新鲜事可以看到,我们正在打开一个新窗口,就像我们在主窗口中一样。唯一的区别是,我们正在检查设置窗口是否已经打开,以便我们不打开两个实例。

一旦工作,我们需要一种关闭设置窗口的方法。再次,我们将在频道上发送消息,但这次是从settings.js(这是设置关闭按钮所在的位置)。使用以下内容创建(或替换)settings.js的内容:

并在main.js中监听该频道。添加以下内容:**

我们的设置窗口现在可以实现自己的逻辑了。

存储和阅读用户设置

1
2
跟随标签05-settings-window-working:
git checkout 05-settings-window-working

与设置窗口进行交互,存储设置并将其推广到我们的应用程序的过程将如下所示:

  • 创建一种在JSON文件中存储和读取用户设置的方法,
  • 使用这些设置显示设置窗口的初始状态,
  • 更新用户交互时的设置
  • 让主流程知道变化。

我们可以在main.js文件中实现对设置的存储和读取,但它似乎是一个很好的用例来编写一个可以包含在各个地方的小模块。

使用JSON配置

这就是为什么我们要创建configure.js文件 ,并在需要时要求它。Node.js使用CommonJS模块模式,这意味着您仅导出API,而其他文件需要/使用该API上可用的功能。

img

main.js和settings.js都使用了configure.js。

为了使存储和阅读更容易,我们将使用nconf模块,为我们提供JSON文件的读取和写入。这是一个很好的合适。但首先,我们必须将它包含在项目中,并在CLI中执行以下命令:

1
npm install --save nconf

这告诉npm将nconf模块安装为应用程序依赖关系,当我们为最终用户打包应用程序时(与使用仅包含用于开发目的的模块的save-dev参数进行安装相反),它将被包含和使用。

该configuration.js文件是非常简单的,所以让我们充分研究它。在项目根目录中创建一个配置文件,具有以下内容:

nconf只想知道在哪里存储你的设置,我们给它的位置的用户主文件夹和文件名。获取用户主文件夹仅仅是要求Node.js(process.env)和区分各种平台(如在getUserHome()函数中所观察到的)。

然后,使用nconf(set()存储用于使用save()读取的get **()和用于文件操作的load())的内置方法并使用标准的CommonJS 模块导出API来实现存储或读取设置。导出语法。

初始化默认快捷键修饰符

在进行设置交互之前,让我们初始化设置,以防我们第一次启动应用程序。我们将把修饰符键存储为一个带有“ shortcutKeys ” 键的数组,并在main.js中进行初始化。对于所有这些工作,我们必须首先要求我们的配置模块:

如果设置键“ shortcutKeys ” 下有任何东西存储,我们尝试阅读。如果没有,我们设置一个初始值。

作为main.js中的另外一件事,我们将重写全局快捷键的注册,作为我们稍后在更新设置时可以调用的功能。从main.js中删除注册快捷键,并以这种方式更改文件:

该功能重置全局快捷方式,以便我们可以设置新的,从设置读取修饰符键数组,将其转换为Accelerator兼容的字符串,并执行通常的全局快捷键注册。

在设置窗口中进行交互

回到settings.js文件,我们需要绑定将要更改我们的全局快捷方式的点击事件。首先,我们将遍历复选框并标记活动的(从配置模块读取值):

现在我们将绑定复选框行为。考虑到设置窗口(及其渲染器进程)不允许更改GUI绑定。这意味着我们需要从settings.js发送一条ipc消息(稍后再处理该消息):

这是一个更大的代码,但仍然很简单。
我们遍历所有复选框,绑定一个点击事件,并且每次点击检查设置数组是否包含修饰符键 - 并根据该结果修改阵列,将结果保存到设置并向主进程发送消息这应该更新我们的全球快捷方式。

所有剩下要做的就是订阅IPC通道“ 设置全局快捷键 ”,在main.js和更新我们的全球快捷方式:

1
2
3
ipc.on('set-global-shortcuts', function () {
setGlobalShortcuts();
});

就是这样,而且我们的全球快捷键是可配置的!

菜单上有什么?

1
2
跟随标签06-shortcuts可配置:
git checkout 06-shortcuts-configured

桌面应用程序中的另一个重要概念是菜单。有一个永远有用的上下文菜单(AKA右键单击菜单),托盘菜单(绑定到托盘图标),应用程序菜单(在OS X上)等。

img

Megamenu。

在本指南中,我们将添加一个带有菜单的托盘图标。我们也将利用这个机会去探索进程间通信的另一种方式-  在远程模块。

该远程模块,使从RPC风格调用渲染过程的主要过程。您需要模块并在渲染器进程中使用它们,但是它们正在主流程中实例化,您调用它们的方法正在主进程中执行。实际上,这意味着您可以在index.js中远程请求本机GUI模块,并在其上调用方法,但是它们在main.js中执行。这样,您可以要求来自index.js的BrowserWindow模块并实例化一个新的浏览器窗口。幕后,**

img

打电话给我,可能的话?

让我们看看如何创建一个菜单,并将其绑定到托盘图标,同时在渲染器进程中执行。将以下内容添加到index.js中:

本地GUI模块(菜单和托盘)是远程需要的,这样可以安全地使用它们。

托盘图标通过其图标定义。OS X支持图像模板(根据约定,如果文件的文件名以“Template”结尾),则图像被认为是模板图像,这样可以轻松处理黑暗和轻薄的主题。其他操作系统获得常规图标。

电子有多种方式来建立菜单。这样可以创建一个菜单模板(一个带菜单项的简单数组),并从该模板中构建一个菜单。最后,新菜单附加到托盘图标。

包装你的应用程序

1
2
跟随标签07-ready-for-packaging:
git checkout 07-ready-for-packaging

你不能让人下载和使用的应用程序的用途是什么?

img

包起来。

使用电子包装机轻松包装所有平台的应用程序。简而言之,电子包装机将所有的工作全部抽出来,将电子包裹在您的应用程序中,并生成您要发布的所有平台。

它可以用作CLI应用程序或构建过程的一部分。构建更复杂的构建方案不在本文的范围之内,但是我们将利用npm脚本的强大功能使打包更容易。使用电子包装机是微不足道的,包装应用的一般形式是:

1
电子包装机<项目位置> <项目名称> <platform> <architecture> <电子版> <可选选项>

哪里:

  • 项目的位置指向您项目所在的文件夹,
  • 项目名称定义您的项目的名称,
  • 平台决定构建哪些平台(全部为Windows,Mac和Linux构建),
  • 架构决定了要构建的架构(x86或x64,全部为两者)和
  • 电子版可让您选择要使用的电子版本。

第一个包将需要一段时间,因为所有平台的所有二进制文件都必须下载。随后的包快得多。

我通常像这样(在Mac上)打包声音机:

1
电子包装机〜/工程/声音机SoundMachine --all --version = 0.30.2 --out =〜/ Desktop --overwrite --icon =〜/ Projects / sound-machine / app / img / app-icon .icns

命令中包含的新选项是不言自明的。要获得一个漂亮的图标,您首先必须将其转换为.icns(适用于Mac)和/或.ico(Windows)。只要搜索到您的PNG文件转换为这些格式就像一个工具这一块(一定要下载的文件与 .icns扩展,而不是 .HQX)。如果从非Windows操作系统打包Windows,则需要葡萄酒(Mac用户可以使用brew,而Linux用户可以使用apt-get)。

每次运行这个大命令是没有意义的。我们可以在package.json中添加另一个脚本。首先,安装电子包装机作为开发依赖:

1
npm安装--save-dev电子包装机

现在我们可以在我们的package.json文件中添加一个新脚本:

1
2
3
4
"scripts": {
"start": "electron .",
"package": "electron-packager ./ SoundMachine --all --out ~/Desktop/SoundMachine --version 0.30.2 --overwrite --icon=./app/img/app-icon.icns"
}

包装变得容易得多

然后在CLI中运行以下命令:

1
npm start

软件包命令启动电子打包器,查看当前目录并构建到桌面。如果您使用Windows,应该更改脚本,但这是微不足道的。

目前状态下的声音机器最终重量高达100 MB。不要担心,一旦存档(zip或您选择的存档类型),它将失去一半以上的大小。

如果你真的想去镇上,看看电子制造商,它采用电子包装机生产的包装,并创建自动安装。


附加功能添加

随着应用程序打包并准备好,您现在可以开始开发自己的功能。

这里有一些想法:

  • 一个帮助屏幕,包括应用程序的信息,其快捷方式和作者,
  • 添加图标和菜单项以打开信息屏幕,
  • 构建一个漂亮的包装脚本,以加快构建和分配,
  • 使用节点通知器添加通知,让用户知道他们正在播放哪些声音,
  • 在更大程度上使用lodash来实现更清晰的代码库(如通过数组迭代)
  • 在打包之前使用构建工具缩小所有CSS和JavaScript,
  • 将上述节点通知器与服务器调用相结合,以检查应用程序的新版本并通知用户…

对于一个很好的挑战 - 尝试提取您的声音机浏览器窗口逻辑,并使用像browserify这样的一个网页创建一个与刚才创建的相同的声音机器。一个代码库 - 两个产品(桌面应用程序和Web应用程序)。漂亮!


深入浅出Electron

我们只是刮了电子带给桌子的表面。很容易做到像在主机上观看电源事件或在屏幕上获取各种信息(如光标位置)等操作。

对于所有这些内置实用程序(通常在使用Electron开发应用程序时),请查看Electron API文档。

这些Electron API文档是Electron GitHub存储库中docs文件夹的一部分,该文件夹非常值得一试。

Sindre Sorhus指出一系列电子资源,您可以在其中找到非常酷的项目和信息,如一个典型的电子应用程序架构的优秀概述,可以作为我们迄今开发的代码的复习。

最终,Electron基于io.js(将被并入Node.js),大部分Node.js模块是兼容的,可用于扩展应用程序。只需浏览npmjs.com并抓住所需的内容。


这就是全部?

不,这才是开始

现在是时候建立更大的应用程序了。我主要跳过在本指南中使用额外的库或构建工具来专注于重要问题,但您可以轻松地在ES6或Typescript中编写应用程序,使用Angular或React并简化您的构建与gulp或grunt。

使用您最喜欢的语言,框架和构建工具,为什么不使用Flickr API和node-flickrapi或使用Google官方Node.js客户端库的GMail客户端构建Flickr同步桌面应用程序?

选择一个想法点子,它将促进激励你进一步学习,初始化一个存储库,马上开始行动吧!

[Electron已经经历了一些API流失。特别:const {app} = require('electron')const {BrowserWindow} = require('electron')此外,loadUrl函数也被重命名为loadURL

Building a desktop application with Electron – Developers Writing – Medium

JavaScript 中 apply 、call 的详解

发表于 2017-03-24

apply 和 call 的区别

ECMAScript 规范给所有函数都定义了 call 与 apply 两个方法,它们的应用非常广泛,它们的作用也是一模一样,只是传参的形式有区别而已。

apply( )

apply 方法传入两个参数:一个是作为函数上下文的对象,另外一个是作为函数参数所组成的数组。

1
2
3
4
5
6
7
8
9
var obj = {
name : 'linxin'
}
function func(firstName, lastName){
console.log(firstName + ' ' + this.name + ' ' + lastName);
}
func.apply(obj, ['A', 'B']); // A linxin B

可以看到,obj 是作为函数上下文的对象,函数 func 中 this 指向了 obj 这个对象。参数 A 和 B 是放在数组中传入 func 函数,分别对应 func 参数的列表元素。

call( )

call 方法第一个参数也是作为函数上下文的对象,但是后面传入的是一个参数列表,而不是单个数组。

1
2
3
4
5
6
7
8
9
var obj = {
name: 'linxin'
}
function func(firstName, lastName) {
console.log(firstName + ' ' + this.name + ' ' + lastName);
}
func.call(obj, 'C', 'D'); // C linxin D

对比 apply 我们可以看到区别,C 和 D 是作为单独的参数传给 func 函数,而不是放到数组中。

对于什么时候该用什么方法,其实不用纠结。如果你的参数本来就存在一个数组中,那自然就用 apply,如果参数比较散乱相互之间没什么关联,就用 call。

apply 和 call 的用法

1.改变 this 指向

1
2
3
4
5
6
7
8
9
var obj = {
name: 'linxin'
}
function func() {
console.log(this.name);
}
func.call(obj); // linxin

我们知道,call 方法的第一个参数是作为函数上下文的对象,这里把 obj 作为参数传给了 func,此时函数里的 this 便指向了 obj 对象。此处 func 函数里其实相当于

1
2
3
function func() {
console.log(obj.name);
}

2.借用别的对象的方法

先看例子

1
2
3
4
5
6
7
8
9
10
11
var Person1 = function () {
this.name = 'linxin';
}
var Person2 = function () {
this.getname = function () {
console.log(this.name);
}
Person1.call(this);
}
var person = new Person2();
person.getname(); // linxin

从上面我们看到,Person2 实例化出来的对象 person 通过 getname 方法拿到了 Person1 中的 name。因为在 Person2 中,Person1.call(this) 的作用就是使用 Person1 对象代替 this 对象,那么 Person2 就有了 Person1 中的所有属性和方法了,相当于 Person2 继承了 Person1 的属性和方法。

3.调用函数

apply、call 方法都会使函数立即执行,因此它们也可以用来调用函数。

1
2
3
4
function func() {
console.log('linxin');
}
func.call(); // linxin

call 和 bind 的区别

在 EcmaScript5 中扩展了叫 bind 的方法,在低版本的 IE 中不兼容。它和 call 很相似,接受的参数有两部分,第一个参数是是作为函数上下文的对象,第二部分参数是个列表,可以接受多个参数。 它们之间的区别有以下两点。

1.bind 发返回值是函数

1
2
3
4
5
6
7
8
9
10
var obj = {
name: 'linxin'
}
function func() {
console.log(this.name);
}
var func1 = func.bind(obj);
func1(); // linxin

bind 方法不会立即执行,而是返回一个改变了上下文 this 后的函数。而原函数 func 中的 this 并没有被改变,依旧指向全局对象 window。

2.参数的使用

1
2
3
4
5
6
7
8
9
function func(a, b, c) {
console.log(a, b, c);
}
var func1 = func.bind(null,'linxin');
func('A', 'B', 'C'); // A B C
func1('A', 'B', 'C'); // linxin A B
func1('B', 'C'); // linxin B C
func.call(null, 'linxin'); // linxin undefined undefined

call 是把第二个及以后的参数作为 func 方法的实参传进去,而 func1 方法的实参实则是在 bind 中参数的基础上再往后排。

在低版本浏览器没有 bind 方法,我们也可以自己实现一个。

1
2
3
4
5
6
7
8
9
10
if (!Function.prototype.bind) {
Function.prototype.bind = function () {
var self = this, // 保存原函数
context = [].shift.call(arguments), // 保存需要绑定的this上下文
args = [].slice.call(arguments); // 剩余的参数转为数组
return function () { // 返回一个新函数
self.apply(context,[].concat.call(args, [].slice.call(arguments)));
}
}
}

http://www.html-js.com/article/aiqianduan-%204077

动态规划方法(算法)

发表于 2017-03-18
  1. LCS(最长公共子序列)O(n^2)的时间复杂度,O(n^2)的空间复杂度;
  2. 与之类似但不同的最长公共子串方法。
    最长公共子串用动态规划可实现O(n^2)的时间复杂度,O(n^2)的空间复杂度;还可以进一步优化,用后缀数组的方法优化成线性时间O(nlogn);空间也可以用其他方法优化成线性。
    3.LIS(最长递增序列)DP方法可实现O(n^2)的时间复杂度,进一步优化最佳可达到O(nlogn)

一些定义:
字符串 X, Y 长度 分别m,n

子串:字符串S的子串r[i,...,j],i<=j,表示r串从i到j这一段,也就是顺次排列r[i],r[i+1],...,r[j]形成的字符串

前缀:Xi =﹤x1,⋯,xi﹥ 即 X 序列的前 i 个字符 (1≤i≤m);
Yj=﹤y1,⋯,yj﹥即 Y 序列的前 j 个字符 (1≤j≤n);
假定 Z=﹤z1,⋯,zk﹥∈LCS(X , Y)

有关后缀数组的定义

LCS

问题描述

定义:
一个数列 S,如果分别是两个或多个已知数列的子序列,且是所有符合此条件序列中最长的,则 S 称为已知序列的最长公共子序列。
例如:输入两个字符串 BDCABA 和 ABCBDAB,字符串 BCBA 和 BDAB 都是是它们的最长公共子序列,则输出它们的长度 4,并打印任意一个子序列. (Note: 不要求连续)

判断字符串相似度的方法之一 - LCS 最长公共子序列越长,越相似。

复杂度

对于一般性的 LCS 问题(即任意数量的序列)是属于 NP-hard。但当序列的数量确定时,问题可以使用动态规划(Dynamic Programming)在多项式时间解决。可达时间复杂度:O(m*n)
July 10分钟讲LCS视频,

暴力方法

img

动态规划方法

最优子结构性质:
设序列 X=<x1, x2, …, xm> 和 Y=<y1, y2, …, yn> 的一个最长公共子序列 Z=<z1, z2, …, zk>,则:

  1. 若 xm = yn,则 zk = xm = yn 则 Zk-1 是 Xm-1 和 Yn-1 的最长公共子序列;
    img
  2. 若 xm ≠ yn, 要么Z是 Xm-1 和 Y 的最长公共子序列,要么 Z 是X和 Yn-1 的最长公共子序列。
    2.1 若 xm ≠ yn 且 zk≠xm ,则 Z是 Xm-1 和 Y 的最长公共子序列;
    2.2 若 xm ≠ yn 且 zk ≠yn ,则 Z 是X和 Yn-1 的最长公共子序列。
    综合一下2 就是求二者的大者

递归结构:
img 递归结构容易看到最长公共子序列问题具有子问题重叠性质。例如,在计算 X 和 Y 的最长公共子序列时,可能要计算出 X 和 Yn-1 及 Xm-1 和 Y的最长公共子序列。而这两个子问题都包含一个公共子问题,即计算 Xm-1 和 Yn-1 的最长公共子序列。
img

递归结构容易看到最长公共子序列问题具有子问题重叠性质。例如,在计算 X 和 Y 的最长公共子序列时,可能要计算出 X 和 Yn-1及 Xm-1 和 Y 的最长公共子序列。而这两个子问题都包含一个公共子问题,即计算Xm-1 和 Yn-1 的最长公共子序列。

计算最优值:
子问题空间中,总共只有O(m*n) 个不同的子问题,因此,用动态规划算法自底向上地计算最优值能提高算法的效率。

长度表C 和 方向变量B:
img java实现:

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
/* 动态规划
* 求最长公共子序列
* @ author by gsm
* @ 2015.4.1
*/
import java.util.Random;
public class LCS {
public static int[][] lengthofLCS(char[] X, char[] Y){
/* 构造二维数组c[][]记录X[i]和Y[j]的LCS长度 (i,j)是前缀
* c[i][j]=0; 当 i = j = 0;
* c[i][j]=c[i-1][j-1]+1; 当 i = j > 0; Xi == Y[i]
* c[i][j]=max(c[i-1][j],c[i][j+1]); 当 i = j > 0; Xi != Y[i]
* 需要计算 m*n 个子问题的长度 即 任意c[i][j]的长度
* -- 填表过程
*/
int[][]c = new int[X.length+1][Y.length+1];
// 动态规划计算所有子问题
for(int i=1;i<=X.length;i++){
for (int j=1;j<=Y.length;j++){
if(X[i-1]==Y[j-1]){
c[i][j] = c[i-1][j-1]+1;
}
else if(c[i-1][j] >= c[i][j-1]){
c[i][j] = c[i-1][j];
}
else{
c[i][j] = c[i][j-1];
}
}
}
// 打印C数组
for(int i=0;i<=X.length;i++){
for (int j=0;j<=Y.length;j++){
System.out.print(c[i][j]+" ");
}
System.out.println();
}
return c;
}
// 输出LCS序列
public static void print(int[][] arr, char[] X, char[] Y, int i, int j) {
if(i == 0 || j == 0)
return;
if(X[i-1] == Y[j-1]) {
System.out.print("element " + X[i-1] + " ");
// 寻找的
print(arr, X, Y, i-1, j-1);
}else if(arr[i-1][j] >= arr[i][j-1]) {
print(arr, X, Y, i-1, j);
}else{
print(arr, X, Y, i, j-1);
}
}
public static void main(String[] args) {
// TODO Auto-generated method stub
char[] x ={'A','B','C','B','D','A','B'};
char[] y ={'B','D','C','A','B','A'};
int[][] c = lengthofLCS(x,y);
print(c, x, y, x.length, y.length);
}
}

最长公共子串

一个问题

定义 2 个字符串 query 和 text, 如果 query 里最大连续字符子串在 text 中存在,则返回子串长度. 例如: query=”acbac”,text=”acaccbabb”, 则最大连续子串为 “cba”, 则返回长度 3.

方法

时间复杂度:O(m*n)的DP

这个 LCS 跟前面说的最长公共子序列的 LCS 不一样,不过也算是 LCS 的一个变体,在 LCS 中,子序列是不必要求连续的,而子串则是 “连续” 的

我们还是像之前一样 “从后向前” 考虑是否能分解这个问题,类似最长公共子序列的分析,这里,我们使用c[i,j] 表示 以 Xi 和 Yj结尾的最长公共子串的长度,因为要求子串连续,所以对于 Xi 与 Yj 来讲,它们要么与之前的公共子串构成新的公共子串;要么就是不构成公共子串。故状态转移方程

1
2
3
X[i-1] == Y[j-1],c[i,j] = c[i-1,j-1] + 1;
X[i-1] != Y[j-1],c[i,j] = 0;

对于初始化,i==0 或者 j==0,c[i,j] = 0
代码:

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
public class LCString {
public static int lengthofLCString(String X, String Y){
/* 构造二维数组c[][]记录X[i]和Y[j]的LCS长度 (i,j)是前缀
* c[i][j]=0; 当 i = j = 0;
* c[i][j]=c[i-1][j-1]+1; 当 i = j > 0; Xi == Y[i]
* c[i][j]=0; 当 i = j > 0; Xi != Y[i]
* 需要计算 m*n 个子问题的长度 即 任意c[i][j]的长度
* -- 填表过程
*/
int[][]c = new int[X.length()+1][Y.length()+1];
int maxlen = 0;
int maxindex = 0;
for(int i =1;i<=X.length();i++){
for(int j=1;j<=Y.length();j++){
if(X.charAt(i-1) == Y.charAt(j-1)){
c[i][j] = c[i-1][j-1]+1;
if(c[i][j] > maxlen)
{
maxlen = c[i][j];
maxindex = i + 1 - maxlen;
}
}
}
}
return maxlen;
}
public static void main(String[] args) {
String X = "acbac";
String Y = "acaccbabb";
System.out.println(lengthofLCString(X,Y));
}
}

时间复杂度O(nlogn)的后缀数组的方法

有关后缀数组以及求最长重复子串
前面提过后缀数组的基本定义,与子串有关,可以尝试这方面思路。由于后缀数组最典型的是寻找一个字符串的重复子串,所以,对于两个字符串,我们可以将其连接到一起,如果某一个子串 s 是它们的公共子串,则 s 一定会在连接后字符串后缀数组中出现两次,这样就将最长公共子串转成最长重复子串的问题了,这里的后缀数组我们使用基本的实现方式。

值得一提的是,在找到两个重复子串时,不一定就是 X 与 Y 的公共子串,也可能是 X 或 Y 的自身重复子串,故在连接时候我们在 X 后面插入一个特殊字符‘#’,即连接后为 X#Y。这样一来,只有找到的两个重复子串恰好有一个在 #的前面,这两个重复子串才是 X 与 Y 的公共子串

各方案复杂度对比

1
2
3
4
5
6
7
8
9
设字符串 X 的长度为 m,Y 的长度为 n,最长公共子串长度为 l。
对于基本算法(brute force),X 的子串(m 个)和 Y 的子串(n 个)一一对比,最坏情况下,复杂度为 O(m*n*l),空间复杂度为 O(1)。
对于 DP 算法,由于自底向上构建最优子问题的解,时间复杂度为 O(m*n);空间复杂度为 O(m*n),当然这里是可以使用滚动数组来优化空间的,滚动数组在动态规划基础回顾中多次提到。
对于后缀数组方法,连接到一起并初始化后缀数组的时间复杂度为 O(m+n),对后缀数组的字符串排序,由于后缀数组有 m+n 个后缀子串,子串间比较,故复杂度为 O((m+n)*l*lg(m+n)),求得最长子串遍历后缀数组,复杂度为 O(m+n),所以总的时间复杂度为 O((m+n)*l*lg(m+n)),空间复杂度为 O(m+n)。
总的来说使用后缀数组对数据做一些 “预处理”,在效率上还是能提升不少的。

LIS 最长递增子序列

问题描述:找出一个n个数的序列的最长单调递增子序列: 比如A = {5,6,7,1,2,8} 的LIS是5,6,7,8

1. O(n^2)的复杂度:

1.1 最优子结构:
LIS[i] 是以arr[i]为末尾的LIS序列的长度。则:
LIS[i] = {1+Max(LIS(j))}; j<i, arr[j]<arr[i];
LIS[i] = 1, j<i, 但是不存在arr[j]<arr[i];
所以问题转化为计算Max(LIS(j)) 0<i<n

1.2 重叠的子问题:
以arr[i] (1<= i <= n)每个元素结尾的LIS序列的值是 重叠的子问题。
所以填表时候就是建立一个数组DP[i], 记录以arr[i]为序列末尾的LIS长度。

1.3 DP[i]怎么计算?
遍历所有j<i的元素,检查是否DP[j]+1>DP[i] && arr[j]<arry[i] 若是,则可以更新DP[i]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int maxLength = 1, bestEnd = 0;
DP[0] = 1;
prev[0] = -1;
for (int i = 1; i < N; i++)
{
DP[i] = 1;
prev[i] = -1;
for (int j = i - 1; j >= 0; j--)
if (DP[j] + 1 > DP[i] && array[j] < array[i])
{
DP[i] = DP[j] + 1;
prev[i] = j;
}
if (DP[i] > maxLength)
{
bestEnd = i;
maxLength = DP[i];
}

2. O(nlog)的复杂度

基本思想:

首先通过一个数组MaxV[nMaxLength]来缓存递增子序列LIS的末尾元素最小值;通过nMaxLength 记录到当前遍历为止的最长子序列的长度;

然后我们从第2元素开始,遍历给定的数组arr,

  1. arr[i] > MaxV[nMaxLength], 将arr[i]插入到MaxV[++nMaxLength]的末尾 – 意味着我们找到了一个新的最大LIS
  2. arr[i] <= MaxV[nMaxLength], 找到MaxV[]中刚刚大于arr[i]的元素,arr[j].arr[i]替换arr[j]
    因为MaxV是一个有序数组,查找过程可以使用log(N)的折半查找。
    这样运行时间: n个整数和每个都需要折半查找 – n*logn = O(nlogn)
  • if > 说明j能够放在最长子序列的末尾形成一个新的最长子序列.
  • if< 说明j需要替换前面一个刚刚大与array[j]的元素

最后,输出LIS时候,我们会用一个LIS[]数组,这边LIS[i]记录的是以元素arr[i]为结尾的最长序列的长度


初始化准备工作:

MaxV[1]首先会被设置成序列第一个元素 即 MaxV[1] = arr[0],在遍历数组的过程中会不断的更新。
nMaxLength = 1


举个栗子:
arr = {2 1 5 3 6 4 8 9 7}

  • 首先i=1, 遍历到1, 1 通过跟MaxV[nMaxLength]比较: 1<MaxV[nMaxLength],
    发现1更有潜力(更小的有潜力,更小的替换之)
    1 更有潜力, 那么1就替换MaxV[nMaxLength] 即 MaxV[nMaxLength] =1 ;
    这个时候 MaxV={1}, nMaxlength = 1,LIS[1] = 1;
  • 然后 i =2, 遍历到5, 5通过跟MaxV[nMaxLength]比较, 5>MaxV[nMaxLength],
    发现5 更大; 链接到目前得到的LIS尾部;
    这个时候 MaxV={1,5}, nMaxlength++ = 2, MaxV[nMaxLength]=5, LIS[i] = 1+1 = 2;
  • 然后 i =3,遍历到3, 3 通过跟MaxV[nMaxLength]比较, 3<MaxV[nMaxLength],
    发现3更有 潜力,然后从 nMaxLength往前比较,找到第一个刚刚比3大元素替换之。(稍后解释什么叫刚刚大)
    这个时候 MaxV={1,3}, nMaxlength = 2; 3只是替换, LIS[i]不变 = LIS[3]= 2;
  • 然后 i =4,遍历到6, 6 通过跟 MaxV[nMaxLength]比较, 6>MaxV[nMaxLength],
    发现6更大; 6就应该链接到目前得到的LIS尾部;
    这个时候,MaxV={1,3,6} ,nMaxlength = 3,MaxV[nMaxLength+1]=6 , LIS[4] = 3
  • 然后i =5,遍历到4, 4 通过跟MaxV[nMaxLength] = 6比较, 4<MaxV[nMaxLength],
    发现4更有潜力,然后从nMaxLength往前比较,找到刚刚比4大元素 也就是 6替换之。
    这个时候 MaxV={1,3,4}, nMaxlength = 3,4只是替换, LIS[i]不变 = LIS[5]= 3;
  • 然后i=6, 遍历到8, 8通过跟MaxV[nMaxLength]比较, 8>MaxV[nMaxLength],
    发现8更大; 8就应该链接到目前得到的LIS尾部;
    这个时候 MaxV={1,3,4,8}, nMaxlength = 4, Maxv[nMaxlength]=8 LIS[6]=4,
  • 然后i=7, 遍历到9, 9通过跟MaxV[nMaxLength]比较, 9>MaxV[nMaxLength],
    发现9更大; 9就应该链接到目前得到的LIS尾部;
    这个时候 MaxV={1,3,4,8,9}, nMaxlength = 5, Maxv[nmaxlength]=9, LIS[7] = 5;
  • 然后i=8, 遍历到7, 7 通过跟MaxV[nMaxLength] = 9比较, 7<MaxV[nMaxLength],
    发现7更有潜力,然后从nMaxLength往前比较,找到第一个比7大元素 也就是 8替换之。
    这个时候 MaxV={1,3,4,7,9}, nMaxLength = 5, Maxv[nMaxlength]=9
    LIS[8] = LIS[替换掉的index] = 4;
– 2 1 5 3 6 4 8 9 7
i 1 2 3 4 5 6 7 8 9
LIS 1 1 2 2 3 3 4 5 4
MaxV 2 1 1,5 1,3 1,3,6 1,3,4 1,3,4,8 1,3,4,8,9 1,3,4,7

java实现:

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
import java.util.*;
public class LIS {
public static int lengthofLCS(int[] arr){
// 辅助变量
int[] MaxV = new int [arr.length+1]; // 记录递增子序列 LIS 的末尾元素最小值
int nMaxLength = 1; // 当前LIS的长度
int [] LIS = new int[arr.length+1]; //LIS[i]记录的是以第i个元素为结尾的最长序列的长度
// 初始化
MaxV[0] = -100;
MaxV[nMaxLength] = arr[0];
LIS[0] = 0;LIS[1] = 1;
for(int i=1;i<arr.length;i++){
if(arr[i] >MaxV[nMaxLength]){
MaxV[++nMaxLength] = arr[i];
LIS[i] = LIS[i-1]+1;
}
else{
// 新元素 更小,更有“潜力”,替换大的元素
int index = binarySearch(MaxV,arr[i],0,nMaxLength);
//*
LIS[i] =index;
MaxV[index] = arr[i];
}
}
Arrays.sort(LIS);
return LIS[LIS.length-1];
}
// 在MaxV数组中查找一个元素刚刚大于arr[i]
// 返回这个元素的index
public static int binarySearch(int []arr, int n, int start, int end){
while(start<end){
int mid = (start + end)/2;
if(arr[mid]< n){
start = mid+1;
}
else if(arr[mid]> n) {
end = mid -1;
}
else
return mid;
}
return end;
}
public static void main(String[] args) {
int[] arr = {2,1,5,3,6,4,8,9,7};
System.out.println(lengthofLCS(arr));
}
}

* : MaxV里面的数组下标代表了长度为index的最长子序列末尾元素,反过来就是末尾元素在MaxV里对应的下标就是他子序列的长度


可以转化为LCS的问题

  • 给一个字符串,求这个字符串最少增加几个字符能变成回文
  • 要在一条河的南北两边的各个城市之间造若干座桥.桥两边的城市分别是 a(1)…a(n) 和 b(1)…b(n). 且南边 a(1)…a(n) 是乱序的,北边同理,但是要求 a(i) 只可以和 b(i) 之间造桥, 同时两座桥之间不能交叉. 希望可以得到一个尽量多座桥的方案.

总结:

- 通常DP是一个不算最好,但是比最直接的算法好很多的方法。 DP一般是O(n^2);但是如果想进一步优化 O(nlogn)就要考虑其他的了

- 对,要想更好的方法就是要挖掘题目本身更加隐匿的性质了

算法设计 - LCS 最长公共子序列&&最长公共子串 &&LIS 最长递增子序列

http://segmentfault.com/blog/exploring/

在Node.js中查找JavaScript内存泄漏简略指南

发表于 2017-03-16

目录

  • 介绍
  • 最小理论
  • 步骤1.重现并确认问题
  • 第2步。至少取3堆堆
  • 步骤3.查找问题
  • 步骤4.确认问题已解决
  • 链接到一些其他资源
  • 概要

你可能想要的书签:简单指南查找JavaScript内存泄漏在Node.js由@ akras14 https://t.co/oRyQboa8Uw

- Node.js(@nodejs)2016年1月6日

请考虑在亚马逊上查看本指南,如果你会发现它有所帮助。

介绍

几个月前,我不得不调试Node.js中的内存泄漏。我发现了很多文章专门的主题,但即使仔细阅读其中一些,我仍然很困惑,我究竟应该做什么来调试我们的问题。

我的目的是这个职位是一个简单的指南,在节点中查找内存泄漏。我将概述一个易于遵循的方法,应该(在我看来)成为任何内存泄漏调试在节点的起点。在某些情况下,这种方法可能不够。我将链接到您可能想要考虑的一些其他资源。

最小理论

JavaScript是一种垃圾收集语言。因此,Node进程使用的所有内存都由V8 JavaScript引擎自动分配和取消分配。

V8如何知道何时解除分配内存?V8保留程序中所有变量的图形,从根节点开始。JavaScript中有4种类型的数据类型:Boolean,String,Number和Object。前3个是简单类型,它们只能保留分配给它们的数据(即文本字符串)。对象和JavaScript中的一切都是一个对象(即数组是对象),可以保持引用(指针)到其他对象。

内存图

周期性地,V8将遍历存储器图,尝试识别从根节点不再能够到达的数据组。如果从根节点无法访问,V8假定数据不再使用并释放内存。这个过程称为垃圾收集。

什么时候发生内存泄漏?

当一些不再需要的数据仍然可以从根节点到达时,在JavaScript中发生内存泄漏。V8将假设数据仍在使用,并且不会释放内存。为了调试内存泄漏,我们需要找到错误保存的数据,并确保V8能够清理它。

还有一点很重要,要注意的是,垃圾回收不会一直运行。通常V8可以在认为合适时触发垃圾收集。例如,它可以定期运行垃圾收集,或者它可以触发垃圾收集,如果它感测到可用内存量越来越低。节点对每个进程可用的内存数量有限,因此V8必须明智地使用它。

节点错误

后来的情况下,垃圾收集可能是性能明显下降的来源。

想象一下,你有一个应用程序有很多内存泄漏。很快,Node进程会开始耗尽内存,这将导致V8触发一个无法回收的垃圾收集。但是由于大多数数据仍然可以从根节点到达,非常少的内存将被清理,保持大部分的位置。

比以后更快,Node进程会再次运行内存,触发另一个垃圾收集。在你知道它之前,你的应用程序进入一个不断的垃圾收集周期,只是为了保持过程的功能。由于V8花费大部分时间来处理垃圾收集,因此只剩下很少的资源来运行实际程序。

步骤1.重现并确认问题

正如我前面指出的,V8 JavaScript引擎有一个复杂的逻辑,它用于确定何时运行垃圾收集。记住这一点,即使我们可以看到Node进程的内存继续上升,我们不能确定我们目睹了内存泄漏,直到我们知道Garbage Collection已经运行,允许未使用的内存被清除。

幸运的是,Node允许我们手动触发垃圾收集,这是我们在尝试确认内存泄漏时应该做的第一件事。这可以通过运行带有--expose-gc标志(ie node --expose-gc index.js)的Node来实现。一旦节点在该模式下运行,您可以随时通过global.gc()从您的程序调用来以编程方式触发垃圾收集。

您还可以通过调用来检查进程使用的内存量process.memoryUsage().heapUsed。

通过手动触发垃圾收集和检查使用的堆,你可以确定你是否实际上观察你的程序中的内存泄漏。

示例程序

我创建了一个简单的内存泄漏程序,你可以在这里看到:https : //github.com/akras14/memory-leak-example

您可以克隆它,运行npm install,然后运行node --expose-gc index.js以查看它的操作。

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849 “use strict”;require(‘heapdump’); var leakyData = [];var nonLeakyData = []; class SimpleClass { constructor(text){ this.text = text; }} function cleanUpData(dataStore, randomObject){ var objectIndex = dataStore.indexOf(randomObject); dataStore.splice(objectIndex, 1);} function getAndStoreRandomData(){ var randomData = Math.random().toString(); var randomObject = new SimpleClass(randomData); leakyData.push(randomObject); nonLeakyData.push(randomObject); // cleanUpData(leakyData, randomObject); //<– Forgot to clean up cleanUpData(nonLeakyData, randomObject);} function generateHeapDumpAndStats(){ //1. Force garbage collection every time this function is called try { global.gc(); } catch (e) { console.log(“You must run program with ‘node –expose-gc index.js’ or ‘npm start’”); process.exit(); } //2. Output Heap stats var heapUsed = process.memoryUsage().heapUsed; console.log(“Program is using “ + heapUsed + “ bytes of Heap.”) //3. Get Heap dump process.kill(process.pid, ‘SIGUSR2’);} //Kick off the programsetInterval(getAndStoreRandomData, 5); //Add random data every 5 millisecondssetInterval(generateHeapDumpAndStats, 2000); //Do garbage collection and heap dump every 2 seconds

程序将:

  1. 每5毫秒生成一个随机对象并将其存储在2个数组中,一个名为leakyData和另一个nonLeakyData。我们将每5毫秒清除nonLeakyData数组,但是我们会“忘记”清理leakyData数组。
  2. 每2秒,程序将输出所使用的内存量(并生成堆转储,但我们将在下一节中讨论更多)。

如果用node --expose-gc index.js(或npm start)运行程序,它将开始输出内存统计信息。让它运行一两分钟,并杀死它Ctr + c。

你会看到内存快速增长,即使我们每2秒触发一次垃圾收集,在我们得到统计数据之前:

123456789101112 //1. Force garbage collection every time this function is calledtry { global.gc();} catch (e) { console.log(“You must run program with ‘node –expose-gc index.js’ or ‘npm start’”); process.exit();} //2. Output Heap statsvar heapUsed = process.memoryUsage().heapUsed;console.log(“Program is using “ + heapUsed + “ bytes of Heap.”)

使用stats输出看起来像下面:

12345678910111213141516 Program is using 3783656 bytes of Heap.Program is using 3919520 bytes of Heap.Program is using 3849976 bytes of Heap.Program is using 3881480 bytes of Heap.Program is using 3907608 bytes of Heap.Program is using 3941752 bytes of Heap.Program is using 3968136 bytes of Heap.Program is using 3994504 bytes of Heap.Program is using 4032400 bytes of Heap.Program is using 4058464 bytes of Heap.Program is using 4084656 bytes of Heap.Program is using 4111128 bytes of Heap.Program is using 4137336 bytes of Heap.Program is using 4181240 bytes of Heap.Program is using 4207304 bytes of Heap.

如果你绘制数据,内存增长变得更加明显。

带内存泄漏

注意:如果你好奇我如何绘制数据,请继续阅读。如果没有,请跳到下一节。

我将输出的统计信息保存到一个JSON文件中,然后读入它并用几行Python绘制它。我把它保持在单独的早午餐以避免混乱,但你可以在这里查看:https://github.com/akras14/memory-leak-example/tree/plot

相关部分为:

1234567891011121314151617181920212223 var fs = require(‘fs’);var stats = []; //— skip — var heapUsed = process.memoryUsage().heapUsed;stats.push(heapUsed); //— skip — //On ctrl+c save the stats and exitprocess.on(‘SIGINT’, function(){ var data = JSON.stringify(stats); fs.writeFile(“stats.json”, data, function(err) { if(err) { console.log(err); } else { console.log(“\nSaved stats to stats.json”); } process.exit(); });});

和

1234567891011121314 #!/usr/bin/env python import matplotlib.pyplot as pltimport json statsFile = open(‘stats.json’, ‘r’)heapSizes = json.load(statsFile) print(‘Plotting %s’ % ‘, ‘.join(map(str, heapSizes))) plt.plot(heapSizes)plt.ylabel(‘Heap Size’)plt.show()

你可以检查出plot分支,并像往常一样运行程序。一旦你完成运行python plot.py生成情节。您需要在您的机器上安装Matplotlib库才能正常工作。

或者可以在Excel中绘制数据。

第2步。至少取3堆堆

好了,所以我们重现了问题,现在是什么?现在我们需要弄清楚问题在哪里,并解决它

您可能已经注意到我的示例程序中的以下行:

12345678 require(‘heapdump’);// —skip— //3. Get Heap dumpprocess.kill(process.pid, ‘SIGUSR2’); // —skip—

我使用一个node-heapdump模块,你可以在这里找到:https : //github.com/bnoordhuis/node-heapdump

为了使用node-heapdump,你只需要:

  1. 安装它。
  2. 要求它在你的程序的顶部
  3. kill -USR2 在Unix上调用像平台

如果你从来没有看到该kill部分,它是Unix中的一个命令,它允许你(除了别的以外)发送一个自定义信号(aka User Signal)给任何正在运行的进程。Node-heapdump配置为进行进程的堆转储,任何时候它接收用户信号两个因此-USR2,后跟进程id。

在我的示例程序中,我kill -USR2 通过运行自动化命令process.kill(process.pid, 'SIGUSR2');,其中process.kill是一个kill命令的节点包装器,SIGUSR2是Node的说法-USR2,并process.pid获取当前Node进程的ID。我在每个垃圾收集之后运行此命令以获得干净的堆转储。

我不认为process.kill(process.pid, 'SIGUSR2');会在Windows上工作,但你可以运行heapdump.writeSnapshot()。

这个例子可能会更容易一些heapdump.writeSnapshot(),但是我想提一提的是,你可以kill -USR2 在Unix上像平台一样触发堆 信号,这样可以派上用场。

下一节将讨论如何使用生成的堆转储来隔离内存泄漏。

步骤3.查找问题

在第2步中,我们生成了一堆堆转储,但我们至少需要3个,你很快就会明白为什么。

一旦你有你的堆转储。转到Google Chrome浏览器,打开Chrome开发工具(Windows上为F12或Mac上为Command + Options + i)。

一旦进入开发工具导航到“配置文件”选项卡,选择屏幕底部的“加载”按钮,导航到您采取的第一个堆转储,并选择它。堆转储将加载到Chrome视图中,如下所示:

第一堆

继续加载2个堆转储到视图中。例如,您可以使用您所采取的最后2个堆转储。最重要的是,堆转储必须按照它们被采用的顺序加载。您的“配置文件”选项卡应类似于以下内容。

3堆堆

从上面的图像可以看出,堆随着时间的推移继续增长。

3堆倾销法

一旦堆转储被加载,您将在“个人档案”选项卡中看到很多子视图,并且很容易丢失它们。然而,有一种观点,我发现特别有帮助。

点击你已经采取的最后一个堆转储,它会立即将你进入“摘要”视图。在“摘要”下拉列表的左侧,您应该会看到另一个显示“全部”的下拉菜单。点击它并选择“在heapdump-YOUR-FIRST-HEAP-DUMP和heapdump-YOUR-SECOND-TO-LAST-HEAP-DUMP之间分配的对象”,如下图所示。

3堆堆视图

它将显示有时在您的第一个堆转储和第二个到最后一个堆转储之间分配的所有对象。这些事情,这些对象仍然挂在你的最后堆转储是引起关注,应该调查,因为他们应该被拾起由垃圾收集。

相当惊人的东西实际上,但不是很直观,发现和容易忽视。

忽略括号中的任何内容,例如(字符串),至少在开头

完成示例应用程序的概述步骤后,我结束了以下视图。

注意,浅尺寸表示对象本身的大小,而保留尺寸表示对象的尺寸和它的所有子。

内存泄漏

似乎有5个条目保留在我上次的快照,应该不存在:(数组),(编译代码),(字符串),(系统)和SimpleClass。

其中只有SimpleClass看起来很熟悉,因为它来自示例应用程序中的以下代码。

12 var randomObject = new SimpleClass(randomData);

可能很有可能先通过(数组)或(字符串)条目开始查找。摘要视图中的所有对象按其构造函数名称分组。在数组或字符串的情况下,这些是JavaScript引擎内部的构造函数。虽然你的程序肯定坚持通过这些构造函数创建的一些数据,你也会在那里得到很多噪音,使得更难找到内存泄漏的来源。

这就是为什么最好跳过这些,而是看看你是否可以发现任何更明显的嫌疑犯,如示例应用程序中的SimpleClass构造函数。

单击SimpleClass构造函数中的下拉箭头,并从结果列表中选择任何创建的对象,将填充窗口下部的保留路径(参见上图)。从那里,很容易跟踪leakyData数组持有我们的数据。

如果你在你的应用程序没有幸运,像我在我的示例应用程序,你可能需要看看内部构造函数(如字符串),并试图找出是什么导致内存泄漏。在这种情况下,诀窍是尝试识别在一些内部构造器组中经常出现的值组,并尝试使用它作为指向可疑内存泄漏的提示。

例如,在示例应用程序案例中,您可能会观察到很多字符串看起来像转换为字符串的随机数。如果您检查其保留路径,Chrome开发工具将指向leakyData数组。

步骤4.确认问题已解决

在您确定并修复了可疑的内存泄漏后,您应该会发现堆使用情况有很大的不同。

如果我们在示例应用中取消注释以下行:

12 cleanUpData(leakyData, randomObject); //<– Forgot to clean up

并按照步骤1中所述重新运行应用程序,请注意以下输出:

12345678910111213141516 Program is using 3756664 bytes of Heap.Program is using 3862504 bytes of Heap.Program is using 3763208 bytes of Heap.Program is using 3763400 bytes of Heap.Program is using 3763424 bytes of Heap.Program is using 3763448 bytes of Heap.Program is using 3763472 bytes of Heap.Program is using 3763496 bytes of Heap.Program is using 3763784 bytes of Heap.Program is using 3763808 bytes of Heap.Program is using 3763832 bytes of Heap.Program is using 3758368 bytes of Heap.Program is using 3758368 bytes of Heap.Program is using 3758368 bytes of Heap.Program is using 3758368 bytes of Heap.

如果我们绘制数据,它将看起来如下:

无内存泄漏

Hooray,内存泄漏了。

注意,内存使用的初始峰值仍然存在,这是正常的,而你等待程序稳定。注意你的分析中的尖峰,以确保你不会将其解释为内存泄漏。

链接到一些其他资源

使用Chrome DevTools进行内存分析

您在本文中阅读的大部分内容都来自上面的视频。本文存在的唯一原因是,我必须在两个星期内观看这个视频3次,以发现(我相信是)的关键点,我想让发现过程更容易为其他人。

我强烈建议观看这个视频补充这篇文章。

另一个有用的工具 - memwatch-next

这是另一个很酷的工具,我认为值得一提。你可以在这里阅读更多的一些推理(短读,值得你的时间)。

或者直接去回购:https://github.com/marcominetti/node-memwatch

为了节省您的点击,您可以安装它 npm install memwatch-next

然后使用它与两个事件:

12345678910 var memwatch = require(‘memwatch-next’);memwatch.on(‘leak’, function(info) { /Log memory leak info, runs when memory leak is detected / });memwatch.on(‘stats’, function(stats) { /Log memory stats, runs when V8 does Garbage Collection/ }); //It can also do this…var hd = new memwatch.HeapDiff();// Do something that might leak memoryvar diff = hd.end();console.log(diff);

最后一个控制台日志将输出如下内容,显示内存中已经生成了什么类型的对象。

12345678910111213141516171819 { “before”: { “nodes”: 11625, “size_bytes”: 1869904, “size”: “1.78 mb” }, “after”: { “nodes”: 21435, “size_bytes”: 2119136, “size”: “2.02 mb” }, “change”: { “size_bytes”: 249232, “size”: “243.39 kb”, “freed_nodes”: 197, “allocated_nodes”: 10007, “details”: [ { “what”: “String”, “size_bytes”: -2120, “size”: “-2.07 kb”, “+”: 3, “-“: 62 }, { “what”: “Array”, “size_bytes”: 66687, “size”: “65.13 kb”, “+”: 4, “-“: 78 }, { “what”: “LeakingClass”, “size_bytes”: 239952, “size”: “234.33 kb”, “+”: 9998, “-“: 0 } ] }}

很酷。

从developer.chrome.com的JavaScript内存分析

https://developer.chrome.com/devtools/docs/javascript-memory-profiling

绝对是必读。它涵盖了我所涉及的所有主题和更多,更多的细节,更准确的🙂

不要忽略底部的Addy Osmani的演讲,他提到了一堆调试提示和资源。

你可以幻灯片在这里:和示例代码在这里:

概要

请考虑在Amazon上查看本指南,如果您发现它有帮助。

  1. 尝试重现和识别内存泄漏时手动触发垃圾收集。您可以从程序中运行带有--expose-gc标志和调用的Node global.gc()。
  2. 使用https://github.com/bnoordhuis/node-heapdump采取至少3堆堆转储
  3. 使用3堆转储方法隔离内存泄漏
  4. 确认内存泄漏已消失
  5. 利润
  • 原文:Simple Guide to Finding a JavaScript Memory Leak in Node.js

JavaScript继承与原型链

发表于 2017-03-14

JavaScript对于有基于类的语言经验的开发人员来说有点令人困惑 (如Java或C ++) ,因为它是动态的,并且本身不提供类实现.。(在ES2015/ES6中引入了class关键字,但是只是语法糖,JavaScript 仍然是基于原型的)。

当谈到继承时,Javascript 只有一种结构:对象。每个对象都有一个内部链接到另一个对象,称为它的原型 prototype。该原型对象有自己的原型,等等,直到达到一个以null为原型的对象。根据定义,null没有原型,并且作为这个原型链 **prototype chain**中的最终链接。

虽然,原型继承经常被视作 JavaScript 的一个弱点,但事实上,原型继承模型比经典的继承模型更强大。举例来说,在原型继承模型的基础之上建立一个经典的继承模型是相当容易的。

基于原型链的继承

继承属性

JavaScript 对象是动态的属性“包”(指其自己的属性)。JavaScript 对象有一个指向一个原型对象的链。当试图访问一个对象的属性时,它不仅仅在该对象上搜寻,还会搜寻该对象的原型,以及该对象的原型的原型,依此层层向上搜索,直到找到一个名字匹配的属性或到达原型链的末尾。

遵循ECMAScript标准,someObject.[[Prototype]] 符号是用于指派 someObject的原型。这个等同于 JavaScript 的 __proto__ 属性。从 ECMAScript 6 开始, [[Prototype]] 可以用Object.getPrototypeOf()和Object.setPrototypeOf()访问器来访问。

这里演示当尝试访问属性时会发生什么:

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
// 让我们假设我们有一个对象 o, 其有自己的属性 a 和 b:
// {a: 1, b: 2}
// o 的原型 o.[[Prototype]]有属性 b 和 c:
// {b: 3, c: 4}
// 最后, o.[[Prototype]].[[Prototype]] 是 null.
// 这就是原型链的末尾,即 null,
// 根据定义,null 没有[[Prototype]].
// 综上,整个原型链如下:
// {a:1, b:2} ---> {b:3, c:4} ---> null
console.log(o.a); // 1
// a是o的自身属性吗?是的,该属性的值为1
console.log(o.b); // 2
// b是o的自身属性吗?是的,该属性的值为2
// o.[[Prototype]]上还有一个'b'属性,但是它不会被访问到.这种情况称为"属性遮蔽 (property shadowing)".
console.log(o.c); // 4
// c是o的自身属性吗?不是,那看看o.[[Prototype]]上有没有.
// c是o.[[Prototype]]的自身属性吗?是的,该属性的值为4
console.log(o.d); // undefined
// d是o的自身属性吗?不是,那看看o.[[Prototype]]上有没有.
// d是o.[[Prototype]]的自身属性吗?不是,那看看o.[[Prototype]].[[Prototype]]上有没有.
// o.[[Prototype]].[[Prototype]]为null,停止搜索,
// 没有d属性,返回undefined

创建一个对象它自己的属性的方法就是设置这个对象的属性。唯一例外的获取和设置的行为规则就是当有一个 getter或者一个setter 被设置成继承的属性的时候。

继承方法

JavaScript 并没有其他基于类的语言所定义的“方法”。在 JavaScript 里,任何函数都可以添加到对象上作为对象的属性。函数的继承与其他的属性继承没有差别,包括上面的“属性遮蔽”(这种情况相当于其他语言的方法重写)。

当继承的函数被调用时,this 指向的是当前继承的对象,而不是继承的函数所在的原型对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var o = {
a: 2,
m: function(){
return this.a + 1;
}
};
console.log(o.m()); // 3
// 当调用 o.m 时,'this'指向了o.
var p = Object.create(o);
// p是一个对象, p.[[Prototype]]是o.
p.a = 12; // 创建 p 的自身属性a.
console.log(p.m()); // 13
// 调用 p.m 时, 'this'指向 p.
// 又因为 p 继承 o 的 m 函数
// 此时的'this.a' 即 p.a,即 p 的自身属性 'a'

使用不同的方法来创建对象和生成原型链

使用普通语法创建对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var o = {a: 1};
// o这个对象继承了Object.prototype上面的所有属性
// 所以可以这样使用 o.hasOwnProperty('a').
// hasOwnProperty 是Object.prototype的自身属性。
// Object.prototype的原型为null。
// 原型链如下:
// o ---> Object.prototype ---> null
var a = ["yo", "whadup", "?"];
// 数组都继承于Array.prototype
// (indexOf, forEach等方法都是从它继承而来).
// 原型链如下:
// a ---> Array.prototype ---> Object.prototype ---> null
function f(){
return 2;
}
// 函数都继承于Function.prototype
// (call, bind等方法都是从它继承而来):
// f ---> Function.prototype ---> Object.prototype ---> null

使用构造器创建对象

在 JavaScript 中,构造器其实就是一个普通的函数。当使用 new 操作符 来作用这个函数时,它就可以被称为构造方法(构造函数)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Graph() {
this.vertexes = [];
this.edges = [];
}
Graph.prototype = {
addVertex: function(v){
this.vertexes.push(v);
}
};
var g = new Graph();
// g是生成的对象,他的自身属性有'vertices'和'edges'.
// 在g被实例化时,g.[[Prototype]]指向了Graph.prototype.

使用 Object.create 创建对象

ECMAScript 5 中引入了一个新方法:Object.create()。可以调用这个方法来创建一个新对象。新对象的原型就是调用 create 方法时传入的第一个参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
var a = {a: 1};
// a ---> Object.prototype ---> null
var b = Object.create(a);
// b ---> a ---> Object.prototype ---> null
console.log(b.a); // 1 (继承而来)
var c = Object.create(b);
// c ---> b ---> a ---> Object.prototype ---> null
var d = Object.create(null);
// d ---> null
console.log(d.hasOwnProperty); // undefined, 因为d没有继承Object.prototype

使用 class 关键字

ECMAScript6 引入了一套新的关键字用来实现 class。使用基于类语言的开发人员会对这些结构感到熟悉,但它们是不一样的。 JavaScript 仍然是基于原型的。这些新的关键字包括 class, constructor, static, extends, 和 super.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
"use strict";
class Polygon {
constructor(height, width) {
this.height = height;
this.width = width;
}
}
class Square extends Polygon {
constructor(sideLength) {
super(sideLength, sideLength);
}
get area() {
return this.height * this.width;
}
set sideLength(newLength) {
this.height = newLength;
this.width = newLength;
}
}
var square = new Square(2);

性能

在原型链上查找属性比较耗时,对性能有副作用,这在性能要求苛刻的情况下很重要。另外,试图访问不存在的属性时会遍历整个原型链。

遍历对象的属性时,原型链上的每个可枚举属性都会被枚举出来。

检测对象的属性是定义在自身上还是在原型链上,有必要使用 hasOwnProperty 方法,所有继承自 Object.proptotype 的对象都包含这个方法。

hasOwnProperty 是 JavaScript 中唯一一个只涉及对象自身属性而不会遍历原型链的方法。

注意:仅仅通过判断值是否为 undefined 还不足以检测一个属性是否存在,一个属性可能存在而其值恰好为 undefined。

不好的实践:扩展原生对象的原型

一个经常被用到的错误实践是去扩展 Object.prototype 或者其他内置对象的原型。

该技术被称为 monkey patching,它破坏了原型链的密封性。尽管,一些流行的框架(如 Prototype.js)在使用该技术,但是并没有足够好的理由要用其他非标准的方法将内置的类型系统搞乱。

我们去扩展内置对象原型的唯一理由是引入新的 JavaScript 引擎的某些新特性,比如 Array.forEach。

示例

B 将继承自 A:

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
function A(a){
this.varA = a;
}
// 以上函数 A 的定义中,既然 A.prototype.varA 总是会被 this.varA 遮蔽,
// 那么将 varA 加入到原型(prototype)中的目的是什么?
A.prototype = {
varA : null,
/*
既然它没有任何作用,干嘛不将 varA 从原型(prototype)去掉 ?
也许作为一种在隐藏类中优化分配空间的考虑 ?
https://developers.google.com/speed/articles/optimizing-javascript
如果varA并不是在每个实例中都被初始化,那这样做将是有效果的。
*/
doSomething : function(){
// ...
}
}
function B(a, b){
A.call(this, a);
this.varB = b;
}
B.prototype = Object.create(A.prototype, {
varB : {
value: null,
enumerable: true,
configurable: true,
writable: true
},
doSomething : {
value: function(){ // override
A.prototype.doSomething.apply(this, arguments);
// call super
// ...
},
enumerable: true,
configurable: true,
writable: true
}
});
B.prototype.constructor = B;
var b = new B();
b.doSomething();

最重要的部分是:

  • 类型被定义在 .prototype 中
  • 而你用 Object.create() 来继承

prototype 和 Object.getPrototypeOf

对于从 Java 或 C++ 转过来的开发人员来说 JavaScript 会有点让人困惑,因为它全部都是动态的,都是运行时,而且不存在类(classes)。所有的都是实例(对象)。即使我们模拟出的 “类(classes)”,也只是一个函数对象。

你可能已经注意到,我们的函数 A 有一个特殊的属性叫做原型。这个特殊的属性与 JavaScript 的 new 运算符一起工作。对原型对象的引用会复制到新实例内部的 [[Prototype]] 属性。例如,当你这样: var a1 = new A(), JavaScript 就会设置:a1.[[Prototype]] = A.prototype(在内存中创建对象后,并在运行 this 绑定的函数 A()之前)。然后在你访问实例的属性时,JavaScript 首先检查它们是否直接存在于该对象中(即是否是该对象的自身属性),如果不是,它会在 [[Prototype]] 中查找。也就是说,你在原型中定义的元素将被所有实例共享,甚至可以在稍后对原型进行修改,这种变更将影响到所有现存实例。

像上面的例子中,如果你执行 var a1 = new A(); var a2 = new A(); 那么 a1.doSomething 事实上会指向Object.getPrototypeOf(a1).doSomething,它就是你在 A.prototype.doSomething 中定义的内容。比如:Object.getPrototypeOf(a1).doSomething == Object.getPrototypeOf(a2).doSomething == A.prototype.doSomething。

简而言之, prototype 是用于类型的,而 Object.getPrototypeOf() 是用于实例的(instances),两者功能一致。

[[Prototype]] 看起来就像**递归**引用, 如a1.doSomething,Object.getPrototypeOf(a1).doSomething,Object.getPrototypeOf(Object.getPrototypeOf(a1)).doSomething 等等等, 直到它找到 doSomething 这个属性或者 Object.getPrototypeOf 返回 null。

因此,当你执行:

1
var o = new Foo();

JavaScript 实际上执行的是:

1
2
3
var o = new Object();
o.[[Prototype]] = Foo.prototype;
Foo.call(o);

(或者类似上面这样的),然后当你执行:

1
o.someProp;

它会检查是否存在 someProp 属性。如果没有,它会查找Object.getPrototypeOf(o).someProp ,如果仍旧没有,它会继续查找Object.getPrototypeOf(Object.getPrototypeOf(o)).someProp ,一直查找下去,直到它找到这个属性 或者 Object.getPrototypeOf() 返回 null 。

结论

在用原型继承编写复杂代码前理解原型继承模型十分**重要**。同时,还要清楚代码中原型链的长度,并在必要时结束原型链,以避免可能存在的性能问题。此外,除非为了兼容新 JavaScript 特性,否则,永远**不要**扩展原生的对象原型。

JavaScript内存管理

发表于 2017-03-14

简介

诸如 C 语言这般的低级语言一般都有低级的内存管理接口,比如 malloc() 和 free()。而另外一些高级语言,比如 JavaScript, 其在变量(对象,字符串等等)创建时分配内存,然后在它们不再使用时“自动”释放。后者被称为垃圾回收。“自动”是容易让人混淆,迷惑的,并给 JavaScript(和其他高级语言)开发者一个印象:他们可以不用关心内存管理。然而这是错误的。

内存生命周期

不管什么程序语言,内存生命周期基本是一致的:

  1. 分配你所需要的内存
  2. 使用分配到的内存(读、写)
  3. 不需要时将其释放\归还

在所有语言中第一和第二部分都很清晰。最后一步在低级语言中很清晰,但是在像JavaScript 等高级语言中,这一步是隐藏的、透明的。

JavaScript 的内存分配

值的初始化

为了不让程序员费心分配内存,JavaScript 在定义变量时就完成了内存分配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var n = 123; // 给数值变量分配内存
var s = "azerty"; // 给字符串分配内存
var o = {
a: 1,
b: null
}; // 给对象及其包含的值分配内存
// 给数组及其包含的值分配内存(就像对象一样)
var a = [1, null, "abra"];
function f(a){
return a + 2;
} // 给函数(可调用的对象)分配内存
// 函数表达式也能分配一个对象
someElement.addEventListener('click', function(){
someElement.style.backgroundColor = 'blue';
}, false);

通过函数调用的内存分配

有些函数调用结果是分配对象内存:

1
2
3
var d = new Date(); // 分配一个 Date 对象
var e = document.createElement('div'); // 分配一个 DOM 元素

有些方法分配新变量或者新对象:

1
2
3
4
5
6
7
8
9
10
var s = "azerty";
var s2 = s.substr(0, 3); // s2 是一个新的字符串
// 因为字符串是不变量,
// JavaScript 可能决定不分配内存,
// 只是存储了 [0-3] 的范围。
var a = ["ouais ouais", "nan nan"];
var a2 = ["generation", "nan nan"];
var a3 = a.concat(a2);
// 新数组有四个元素,是 a 连接 a2 的结果

值的使用

使用值的过程实际上是对分配内存进行读取与写入的操作。读取与写入可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。

当内存不再需要使用时释放

大多数内存管理的问题都在这个阶段。在这里最艰难的任务是找到“所分配的内存确实已经不再需要了”。它往往要求开发人员来确定在程序中哪一块内存不再需要并且释放它。

高级语言解释器嵌入了“垃圾回收器”,它的主要工作是跟踪内存的分配和使用,以便当分配的内存不再使用时,自动释放它。这只能是一个近似的过程,因为要知道是否仍然需要某块内存是无法判定的 (无法通过某种算法解决).

垃圾回收

如上文所述自动寻找是否一些内存“不再需要”的问题是无法判定的。因此,垃圾回收实现只能有限制的解决一般问题。本节将解释必要的概念,了解主要的垃圾回收算法和它们的局限性。

引用

垃圾回收算法主要依赖于引用(reference)的概念。在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。例如,一个Javascript对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用)。

在这里,“对象”的概念不仅特指 JavaScript 对象,还包括函数作用域(或者全局词法作用域)。

引用计数垃圾收集

这是最简单的垃圾收集算法。此算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var o = {
a: {
b:2
}
};
// 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量o
// 很显然,没有一个可以被垃圾收集
var o2 = o; // o2变量是第二个对“这个对象”的引用
o = 1; // 现在,“这个对象”的原始引用o被o2替换了
var oa = o2.a; // 引用“这个对象”的a属性
// 现在,“这个对象”有两个引用了,一个是o2,一个是oa
o2 = "yo"; // 最初的对象现在已经是零引用了
// 他可以被垃圾回收了
// 然而它的属性a的对象还在被oa引用,所以还不能回收
oa = null; // a属性的那个对象现在也是零引用了
// 它可以被垃圾回收了

限制:循环引用

该算法有个限制:无法处理循环引用。在下面的例子中,两个对象被创建,并互相引用,形成了一个循环。它们被调用之后不会离开函数作用域,所以它们已经没有用了,可以被回收了。然而,引用计数算法考虑到它们互相都有至少一次引用,所以它们不会被回收

1
2
3
4
5
6
7
8
9
10
function f(){
var o = {};
var o2 = {};
o.a = o2; // o 引用 o2
o2.a = o; // o2 引用 o
return "azerty";
}
f();

实际例子

IE 6, 7 使用引用计数方式对 DOM 对象进行垃圾回收。该方式常常造成对象被循环引用时内存发生泄露:

1
2
3
4
5
6
var div;
window.onload = function(){
div = document.getElementById("myDivElement");
div.circularReference = div;
div.lotsOfData = new Array(10000).join("*");
};

在上面的例子里,myDivElement 这个 DOM 元素里的 circularReference 属性引用了 myDivElement,造成了循环引用。如果该属性没有显示移除或者设为 null,引用计数式垃圾收集器将总是且至少有一个引用,并将一直保持在内存里的 DOM 元素,即使其从DOM 树中删去了。如果这个 DOM 元素拥有大量的数据 (如上的 lotsOfData 属性),而这个数据占用的内存将永远不会被释放。

标记-清除算法

这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”。

这个算法假定设置一个叫做根(root)的对象(在Javascript里,根是全局对象)。定期的,垃圾回收器将从根开始,找所有从根开始引用的对象,然后找这些对象引用的对象……从根开始,垃圾回收器将找到所有可以获得的对象和所有不能获得的对象。

这个算法比前一个要好,因为“有零引用的对象”总是不可获得的,但是相反却不一定,参考“循环引用”。

从2012年起,所有现代浏览器都使用了标记-清除垃圾回收算法。所有对JavaScript垃圾回收算法的改进都是基于标记-清除算法的改进,并没有改进标记-清除算法本身和它对“对象是否不再需要”的简化定义。

循环引用不再是问题了

在上面的示例中,函数调用返回之后,两个对象从全局对象出发无法获取。因此,他们将会被垃圾回收器回收。

第二个示例同样,一旦 div 和其事件处理无法从根获取到,他们将会被垃圾回收器回收
。

限制: 那些无法从根对象查询到的对象都将被清除

尽管这是一个限制,但实践中我们很少会碰到类似的情况,所以开发者不太会去关心垃圾回收机制。

JavaScript模块系统对决(PK):CommonJS vs AMD vs ES2015

发表于 2017-03-11

了解目前使用的不同JavaScript模块系统,并找出哪些是您的项目的最佳选择。

随着JavaScript开发越来越普遍,命名空间和depedencies更难以处理。开发了不同的解决方案以模块系统的形式来处理这个问题。在这篇文章中,我们将探讨开发人员目前使用的不同解决方案以及他们尝试解决的问题。阅读!


简介:为什么需要JavaScript模块?

如果你熟悉其他开发平台,你可能有一些概念的封装和依赖的概念。通常孤立地开发不同的软件,直到先前存在的软件需要满足某些需求。在将其他软件带入项目的时刻,在它和新的代码之间创建依赖关系。由于这些软件需要一起工作,因此它们之间不会出现冲突是很重要的。这可能听起来很小,但是没有某种封装,这是两个模块相互冲突之前的时间问题。这是C库中的元素之一通常带有前缀的原因之一:

1
2
3
4
5
6
7
8
9
10
11
12
13
#ifndef MYLIB_INIT_H
#define MYLIB_INIT_H
enum mylib_init_code {
mylib_init_code_success,
mylib_init_code_error
};
enum mylib_init_code mylib_init(void);
// (...)
#endif //MYLIB_INIT_H

封装对于防止冲突和缓解发展至关重要。

当涉及到依赖关系时,在传统的客户端JavaScript开发中,它们是隐式的。换句话说,开发者的任务是确保在执行任何代码块时都满足依赖关系。开发人员还需要确保依赖关系以正确的顺序满足(某些库的要求)。

以下示例是Backbone.js的示例的一部分。脚本以正确的顺序手动加载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Backbone.js Todos</title>
<link rel="stylesheet" href="todos.css"/>
</head>
<body>
<script src="../../test/vendor/json2.js"></script>
<script src="../../test/vendor/jquery.js"></script>
<script src="../../test/vendor/underscore.js"></script>
<script src="../../backbone.js"></script>
<script src="../backbone.localStorage.js"></script>
<script src="todos.js"></script>
</body>
<!-- (...) -->
</html>

随着JavaScript开发变得越来越复杂,依赖管理可能变得麻烦。重构也受损:在哪里应该更新的依赖关系来维持负载链的正确顺序?

JavaScript模块系统试图处理这些问题和其他问题。他们出生的必要性,以适应不断增长的JavaScript景观。让我们看看不同的解决方案带来的表。

一个Ad-Hoc解决方案:显露模块模式

大多数模块系统相对较新。在它们可用之前,特定的编程模式开始越来越多地被使用在越来越多的JavaScript代码:揭示模块模式。

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
var myRevealingModule = (function () {
var privateVar = "Ben Cherry",
publicVar = "Hey there!";
function privateFunction() {
console.log( "Name:" + privateVar );
}
function publicSetName( strName ) {
privateVar = strName;
}
function publicGetName() {
privateFunction();
}
// Reveal public pointers to
// private functions and properties
return {
setName: publicSetName,
greeting: publicVar,
getName: publicGetName
};
})();
myRevealingModule.setName( "Paul Kinlan" );

这个例子取自Addy Osmani的JavaScript设计模式书。

JavaScript范围(至少达到ES2015中let的外观)在函数级别工作。换句话说,在函数中声明的任何绑定都不能逃避它的作用域。正是由于这个原因,揭示模块模式依赖于封装私有内容的函数(像许多其他JavaScript模式一样)。

在上面的示例中,公共符号在返回的字典中显示。所有其他声明由包围它们的函数作用域保护。不必使用var和立即调用包含私有作用域的函数; 一个命名函数也可以用于模块。

这种模式已经在JavaScript项目中使用了相当长的时间,并且与封装事物相当好。它不做太多关于依赖性问题。正确的模块系统也尝试处理这个问题。另一个限制在于,包括其他模块不能在同一源(除非使用eval)。

优点

  • 足够简单,可以在任何地方实现(没有库,不需要语言支持)。
  • 可以在单个文件中定义多个模块。

缺点

  • 没有办法以编程方式导入模块(除了使用eval)。
  • 依赖需要手动处理。
  • 模块的异步加载是不可能的。
  • 循环依赖可能很麻烦。
  • 很难分析静态代码分析器。

CommonJS

CommonJS是一个旨在定义一系列规范以帮助开发服务器端JavaScript应用程序的项目。CommonJS团队尝试解决的一个领域是模块。Node.js开发人员原本打算遵循CommonJS规范,但后来决定反对它。当涉及到模块时,Node.js的实现非常受它的影响:

1
2
3
4
5
6
7
8
9
10
// In circle.js
const PI = Math.PI;
exports.area = (r) => PI * r * r;
exports.circumference = (r) => 2 * PI * r;
// In some file
const circle = require('./circle.js');
console.log( `The area of a circle of radius 4 is ${circle.area(4)}`);

在一个晚上,当我提到一个令人沮丧的请求一个功能,我认为是一个可怕的想法,Joyent对我说,“忘记CommonJS。它已经死了,我们是服务器端的JavaScript。- NPM创建者Isaac Z. Schlueter引用Node.js创建者Ryan Dahl

在Node.js的模块系统的顶部有以库的形式的抽象,以桥接Node.js的模块和CommonJS之间的差距。为了这篇文章的目的,我们将只显示大致相同的基本功能。

在Node和CommonJS的模块中,基本上有两个元素与模块系统交互:require和exports。require是一个可用于将符号从另一个模块导入到当前作用域的函数。传递给require的参数是模块的id。在Node的实现中,它是目录中模块的名称node_modules(或者,如果它不在该目录中,则是它的路径)。exports是一个特殊的对象:放在其中的任何东西将被导出为一个公共元素。字段的名称保留。Node和CommonJS之间的特殊区别是以module.exports对象的形式出现。在Node中,module.exports是被导出的真正的特殊对象,而exports只是一个默认绑定的变量module.exports。module.exports另一方面,CommonJS没有对象。实际的含义是,在节点中,不可能导出完全预构造的对象,而不通过module.exports:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// This won't work, replacing exports entirely breaks the binding to
// modules.exports.
exports = (width) => {
return {
area: () => width * width
};
}
// This works as expected.
module.exports = (width) => {
return {
area: () => width * width
};
}

CommonJS模块的设计考虑了服务器开发。当然,API是同步的。换句话说,模块在源文件中的时刻和它们所需的顺序被加载。

“CommonJS模块的设计考虑了服务器开发。TWEET这个 img

优点

  • 简单:开发人员可以抓住这个概念,而不用看文档。
  • 集成了依赖性管理:模块需要其他模块,并按需要加载。
  • require 可以在任何地方调用:模块可以以编程方式加载。
  • 支持循环依赖性。

缺点

  • 同步API使其不适合某些用途(客户端)。
  • 每个模块一个文件。
  • 浏览器需要加载器库或翻译。
  • 没有模块的构造函数(Node支持这个功能)。
  • 很难分析静态代码分析器。

实现

我们已经谈到了一个实现(部分形式):Node.js.

Node.js JavaScript模块

对于客户端,目前有两个受欢迎的选项:webpack和browserify。Browserify被明确发展解析节点般的模块定义(多节点程序包工作外的开箱即用的吧!),并捆绑你的代码加上这些模块中携带的所有依赖一个单一的文件中的代码。在另一方面Webpack中被开发用于处理发布之前创建源转换的复杂管道。这包括将CommonJS模块捆绑在一起。

异步模块定义(AMD)

AMD是由一群不喜欢CommonJS所采用的方向的开发者组成的。事实上,AMD在开发初期就从CommonJS中分离出来。AMD和CommonJS的主要区别在于它支持异步模块加载。

“AMD和CommonJS的主要区别在于它支持异步模块加载。TWEET这个 img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//Calling define with a dependency array and a factory function
define(['dep1', 'dep2'], function (dep1, dep2) {
//Define the module value by returning a value.
return function () {};
});
// Or:
define(function (require) {
var dep1 = require('dep1'),
dep2 = require('dep2');
return function () {};
});

异步加载是通过使用JavaScript的传统闭包成语实现的:当所请求的模块完成加载时调用一个函数。模块定义和导入模块由相同的函数承载:当模块被定义时,其依赖性被显式化。因此,AMD加载器可以在运行时对给定项目的依赖图的完整图片。因此,可以同时加载彼此不依赖于加载的库。这对于浏览器尤其重要,因为启动时间对于良好的用户体验至关重要。

优点

  • 异步加载(更好的启动时间)。
  • 支持循环依赖性。
  • 兼容性require和exports。
  • 依赖管理完全集成。
  • 如果需要,模块可以分割成多个文件。
  • 支持构造函数。
  • 插件支持(自定义加载步骤)。

缺点

  • 句法稍微复杂一些。
  • 加载器库是必需的,除非传递。
  • 很难分析静态代码分析器。

实现

目前最流行的AMD实现是require.js和Dojo。

Require.js for JavaScript模块

使用require.js非常简单:在HTML文件中包含库,并使用data-main属性告诉require.js应该首先加载哪个模块。Dojo有类似的设置。

ES2015模块

幸运的是,ECMA团队背后的标准化JavaScript决定解决模块的问题。结果可以在最新版本的JavaScript标准中看到:ECMAScript 2015(以前称为ECMAScript 6)。结果是语法上愉快的,并且与同步和异步操作模式兼容。

1
2
3
4
5
6
7
8
9
10
11
12
13
//------ lib.js ------
export const sqrt = Math.sqrt;
export function square(x) {
return x * x;
}
export function diag(x, y) {
return sqrt(square(x) + square(y));
}
//------ main.js ------
import { square, diag } from 'lib';
console.log(square(11)); // 121
console.log(diag(4, 3)); // 5

示例取自Axel Rauschmayer博客

该import伪指令可以用于将模块带入命名空间。这个指令,与require和define不是动态的(即它不能在任何地方被调用)。export另一方面,该指令可以用于将元素显式地公开。

静态特性import和export静态指令允许静态分析器构建一个完整的依赖关系树,而不需要运行代码。ES2015不支持动态加载模块,但草案规范:

1
2
3
4
5
6
7
System.import('some_module')
.then(some_module => {
// Use some_module
})
.catch(error => {
// ...
});

实际上,ES2015 只指定静态模块装载器的语法。实际上,在解析这些指令之后,ES2015实现不需要做任何事情。仍然需要模块加载器,如System.js。提供了浏览器模块加载的草案规范。

这个解决方案通过集成在语言中,使运行时选择模块的最佳加载策略。换句话说,当异步加载产生好处时,它可以被运行时使用。

更新(2017年2月):现在有一个动态加载模块的规范。这是对ECMAScript标准的未来版本的提议。

优点

  • 支持同步和异步加载。
  • 语法简单。
  • 支持静态分析工具。
  • 集成在语言(最终支持到处,不需要图书馆)。
  • 支持循环依赖。

缺点

  • 仍然不支持全部。

实现

遗憾的是,没有一个主要的JavaScript运行时在其当前稳定的分支中支持ES2015模块。这意味着在Firefox,Chrome或Node.js中不支持。幸运的是,许多转换器支持模块,并且polyfill也可用。目前,为Babel预设的ES2015 可以毫无问题地处理模块。

Babel for JavaScript模块

一体化解决方案:System.js

你可能会发现自己试图使用一个模块系统远离遗留代码。或者你可能想确保发生了什么,你选择的解决方案仍然可以工作。输入System.js:支持CommonJS,AMD和ES2015模块的通用模块加载程序。它可以与转换器一起工作,如Babel或Traceur,并且可以支持Node和IE8 +环境。使用它是在代码中加载System.js,然后将其指向您的基本URL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script src="system.js"></script>
<script>
// set our baseURL reference path
System.config({
baseURL: '/app',
// or 'traceur' or 'typescript'
transpiler: 'babel',
// or traceurOptions or typescriptOptions
babelOptions: {
}
});
// loads /app/main.js
System.import('main.js');
</script>

由于System.js可以即时完成所有工作,因此使用ES2015模块通常应该在生产模式下的构建步骤中保留给转换器。当不处于生产模式时,System.js可以为您调用转换程序,提供生产和调试环境之间的无缝转换。

Aside:我们在Auth0使用什么

在Auth0,我们大量使用JavaScript。对于我们的服务器端代码,我们使用CommonJS风格的Node.js模块。对于某些客户端代码,我们更喜欢AMD。对于我们基于React的无密码锁库,我们选择了ES2015模块。

喜欢你看到的?注册)并开始在您的项目中使用Auth0。

你是一个开发人员,喜欢我们的代码?如果是,请立即申请工程学位置。我们有一个真棒团队!

结论

构建模块和处理依赖性在过去是麻烦的。较新的解决方案,以图书馆或ES2015模块的形式,已经消耗了大部分的痛苦。如果你正在寻找一个新的模块或项目,ES2015是正确的方法去。它将始终被支持,并且使用transpiler和polyfills的当前支持是优秀的。另一方面,如果你更喜欢使用纯ES5代码,那么客户端的AMD和服务器的CommonJS / Node之间的通常分割仍然是通常的选择。不要忘记在下面的评论部分留下你的想法。Hack on!

Javascript中4种类型的内存泄漏和如何摆脱它们

发表于 2017-03-11

4 Types of Memory Leaks in JavaScript and How to Get Rid Of Them

了解JavaScript中的内存泄漏,以及可以做什么来解决它!

在本文中,我们将探讨客户端JavaScript代码中的常见类型的内存泄漏。我们还将学习如何使用Chrome开发工具找到它们!

介绍

内存泄漏是每个开发人员最终面临的问题。即使使用内存管理的语言,也有可能泄漏内存的情况。泄漏是整类问题的原因:减速,崩溃,高延迟,甚至与其他应用程序的问题。

诸如 C 语言这般的低级语言一般都有低级的内存管理接口,比如 malloc() 和 free()。而另外一些高级语言,比如 JavaScript, 其在变量(对象,字符串等等)创建时分配内存,然后在它们不再使用时“自动”释放。后者被称为垃圾回收。“自动”是容易让人混淆,迷惑的,并给 JavaScript(和其他高级语言)开发者一个印象:他们可以不用关心内存管理。然而这是错误的。

什么是内存泄露?

实质上,内存泄漏可以定义为应用程序不再需要的内存,因为某种原因,该内存不会返回到操作系统或可用内存池。编程语言有利于不同的管理内存的方式。这些方式可以减少泄漏内存的机会。然而,某一块内存是否未被使用实际上是一个不可判定的问题。换句话说,只有开发人员才能明确是否可以将一块内存返回到操作系统。某些编程语言提供了帮助开发人员做这些的功能。其他人期望开发人员完全明确一段内存未使用。维基百科有关于手动和自动内存管理的好文章。

JavaScript中的内存管理

JavaScript是所谓的垃圾收集语言之一。垃圾收集语言通过定期检查哪些先前分配的内存仍然可以从应用程序的其他部分“达到”来帮助开发人员管理内存。换句话说,垃圾收集语言将管理内存的问题从“还需要什么内存?降低到“应用程序的其他部分仍然可以重新分配内存?”。差别是微妙的,但重要的是:虽然只有开发人员知道将来是否需要一块分配的内存,取不到的内存可以通过算法确定并标记为返回到操作系统。

非垃圾收集语言通常使用其他技术来管理内存:显式管理,开发人员明确告诉编译器何时不需要一块内存; 和引用计数,其中使用计数与存储器的每个块相关联(当计数达到零时,其被返回到OS)。这些技术有自己的权衡(和潜在的泄漏原因)。

JavaScript中的内存溢出

垃圾收集语言内存泄漏的主要原因是不需要的引用。要理解什么是不需要的引用,首先我们需要了解垃圾回收器如何确定是否可以到达一块内存。

“垃圾收集语言泄漏的主要原因是不需要的引用。TWEET这个 img

标记和扫描

大多数垃圾收集器使用称为标记和扫描的算法。该算法由以下步骤组成:

  1. 垃圾回收器构建“根”的列表。根通常是在代码中保存引用的全局变量。在JavaScript中,“window”对象是可以充当根的全局变量的示例。窗口对象总是存在,所以垃圾收集器可以考虑它和它的所有子对象总是存在(即不是垃圾)。
  2. 所有根被检查并标记为活动(即不是垃圾)。所有孩子也被递归检查。从根可以到达的一切都不被认为是垃圾。
  3. 所有未标记为活动的内存块现在可以被认为是垃圾。收集器现在可以释放该内存并将其返回到操作系统。

现代垃圾收集器以不同的方式改进了该算法,但本质是相同的:可访问的内存段被标记为这样,其余被认为是垃圾。

不需要的引用是对开发者知道他或她将不再需要,但由于某种原因保存在活动根的树内部的存储器的引用。在JavaScript的上下文中,不需要的引用是保存在代码中某处的变量,它不再被使用,并指向可以被释放的一块内存。有些人会认为这些都是开发者的错误。

所以要了解哪些是JavaScript中最常见的内存泄漏,我们需要知道在哪些方式引用通常被遗忘。

JavaScript内存泄漏的三种类型

1:意外全局变量

JavaScript背后的目标之一是开发一种看起来像Java的语言,但是它允许足以被初学者使用。JavaScript允许的方式之一是处理未声明的变量:对未声明的变量的引用在全局对象内创建一个新的变量。在浏览器的情况下,全局对象是window。换一种说法:

1
2
3
function foo(arg) {
bar = "this is a hidden global variable";
}

其实是:

1
2
3
function foo(arg) {
window.bar = "this is an explicit global variable";
}

如果bar应该在foo函数的范围内保存对变量的引用,并且您忘记使用var它来声明它,那么会创建一个意外的全局变量。在这个例子中,泄漏一个简单的字符串不会做很多伤害,但它肯定可能更糟。

可以创建偶然的全局变量的另一种方式是this:

1
2
3
4
5
6
7
function foo() {
this.variable = "potential accidental global";
}
// Foo called on its own, this points to the global object (window)
// rather than being undefined.
foo();

为了防止发生这些错误,请'use strict';在JavaScript文件的开头添加。这使得可以更严格地解析JavaScript以防止意外全局变量。

关于全局变量的注释

即使我们谈论不可预测的全局变量,仍然是这样的情况,许多代码是与显式的全局变量。这些是根据定义不可收集的(除非被取消或重新分配)。特别地,用于临时存储和处理大量信息的全局变量是令人关注的。如果必须使用全局变量来存储大量数据,请确保将其置空或在完成后重新分配它。与全局变量有关的增加的内存消耗的一个常见原因是高速缓存)。缓存存储重复使用的数据。为了有效率,高速缓存必须具有其大小的上限。无限增长的缓存可能导致高内存消耗,因为无法收集其内容。

2:被遗忘的计时器或回调

setInterval在JavaScript中使用是相当常见的。其他图书馆提供观察员和其他设施来接受回调。大多数这些库在自己的实例变得不可访问之后,负责使任何对回调的引用不可达。在setInterval的情况下,然而,像这样的代码是很常见的:

1
2
3
4
5
6
7
8
var someResource = getData();
setInterval(function() {
var node = document.getElementById('Node');
if(node) {
// Do stuff with node and someResource.
node.innerHTML = JSON.stringify(someResource));
}
}, 1000);

此示例说明了可能发生的悬挂计时器:计时器,引用不再需要的节点或数据。由node未来表示的对象可能会被删除,使得区间处理程序内部的整个块不必要。但是,处理程序(因为时间间隔仍处于活动状态)无法收集(需要停止时间间隔才能发生这种情况)。如果无法收集间隔处理程序,则也无法收集其依赖项。这意味着,someResource不可能收集大概存储大小的数据。

对于观察者的情况,重要的是进行显式调用,以便在不再需要它们时删除它们(或者相关对象即将无法访问)。在过去,以前特别重要,因为某些浏览器(Internet Explorer 6)无法管理循环引用(参见下面的更多信息)。现在,大多数浏览器可以并将收集观察者处理程序,一旦观察到的对象变得不可达,即使没有明确删除侦听器。但是,在处理对象之前显式删除这些观察者仍然是良好的做法。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
var element = document.getElementById('button');
function onClick(event) {
element.innerHtml = 'text';
}
element.addEventListener('click', onClick);
// Do stuff
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// Now when element goes out of scope,
// both element and onClick will be collected even in old browsers that don't
// handle cycles well.

关于对象观察者和循环引用的注释

观察者和循环引用曾经是JavaScript开发者的祸根。这是由于Internet Explorer的垃圾回收器中的错误(或设计决策)。旧版本的Internet Explorer无法检测DOM节点和JavaScript代码之间的循环引用。这是典型的观察者,通常保持对可观察者的引用(如上例所示)。换句话说,每当观察者被添加到Internet Explorer中的一个节点时,它就会导致泄漏。这是开发人员在节点或在观察者内引用null引用之前显式删除处理程序的原因。现在,现代浏览器(包括Internet Explorer和Microsoft Edge)使用现代垃圾收集算法,可以检测这些周期并正确处理它们。换一种说法,removeEventListener

框架和库(例如jQuery)在处理节点之前删除侦听器(当为其使用特定的API时)。这是由库内部处理,并确保不产生泄漏,即使在有问题的浏览器(如旧的Internet Explorer)下运行。

3:超出DOM引用

有时,将DOM节点存储在数据结构中可能很有用。假设要快速更新表中多个行的内容。在字典或数组中存储对每个DOM行的引用可能是有意义的。当发生这种情况时,将保留对同一DOM元素的两个引用:一个在DOM树中,另一个在字典中。如果在将来的某个时候决定删除这些行,则需要使这两个引用不可访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var elements = {
button: document.getElementById('button'),
image: document.getElementById('image'),
text: document.getElementById('text')
};
function doStuff() {
image.src = 'http://some.url/image';
button.click();
console.log(text.innerHTML);
// Much more logic
}
function removeButton() {
// The button is a direct child of body.
document.body.removeChild(document.getElementById('button'));
// At this point, we still have a reference to #button in the global
// elements dictionary. In other words, the button element is still in
// memory and cannot be collected by the GC.
}

对此的另外考虑与对DOM树内的内部或叶节点的引用有关。假设您<td>在JavaScript代码中保留对表(标签)的特定单元格的引用。在将来的某个时候,您决定从DOM中删除表,但保留对该单元格的引用。直观地,可以假定GC将收集除了该单元之外的所有东西。在实践中,这不会发生:单元格是该表的子节点,并且子级保持对其父级的引用。换句话说,从JavaScript代码对表单元格的引用导致整个表保留在内存中。在保持对DOM元素的引用时仔细考虑这一点。

4:关闭

JavaScript开发的一个关键方面是闭包:从父作用域捕获变量的匿名函数。Meteor开发人员发现了一种特殊情况,由于JavaScript运行时的实现细节,可能以微妙的方式泄漏内存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var theThing = null;
var replaceThing = function () {
var originalThing = theThing;
var unused = function () {
if (originalThing)
console.log("hi");
};
theThing = {
longStr: new Array(1000000).join('*'),
someMethod: function () {
console.log(someMessage);
}
};
};
setInterval(replaceThing, 1000);

这个片段做了一件事:每次replaceThing调用,theThing获取一个包含大数组和一个新的闭包(someMethod)的新对象。同时,变量unused保存有一个引用originalThing(theThing从上一次调用replaceThing)的闭包。已经有点混乱了,是吗?重要的是,一旦为同一父作用域中的闭包创建了作用域,则该作用域是共享的。在这种情况下,为闭包创建的作用域由someMethod共享unused。unused有引用originalThing。即使unused从未使用,someMethod可以通过使用theThing。并且someMethod与封闭范围共享unused,即使unused从未使用,originalThing其引用强制其保持活动(防止其收集)。当此代码段重复运行时,可以观察到内存使用量的稳定增加。这在GC运行时不会变小。实质上,创建了闭包的链接列表(其根以theThing变量的形式),并且这些闭包的范围中的每一个都对大数组进行间接引用,导致相当大的泄漏。

这是一个实现工件。可以处理这种情况的闭包的不同实现是可能的,如Meteor博客文章中所解释的。

垃圾收集者的不直观行为

虽然垃圾收集器很方便,他们有自己的一套权衡。这些权衡之一是非确定性。换句话说,GC是不可预测的。通常不可能确定何时执行收集。这意味着在某些情况下,正在使用比程序实际需要的更多的内存。在其他情况下,短暂停顿在特别敏感的应用中可能是明显的。虽然非确定性意味着无法确定何时执行集合,但大多数GC实现都在分配期间共享执行集合传递的常见模式。如果没有执行分配,则大多数GC保持静止。考虑以下情况:

  1. 执行相当大的一组分配。
  2. 大多数这些元素(或所有这些元素)被标记为不可达(假设我们将指向我们不再需要的缓存的引用置空)。
  3. 不执行进一步的分配。

在这种情况下,大多数GC将不会运行任何进一步的集合过程。换句话说,即使有不可达的引用可用于收集,收集器也不要求这些引用。这些不是严格的泄漏,但仍然导致高于通常的内存使用。

Google在他们的JavaScript内存分析文档中提供了这种行为的一个很好的例子,示例#2。

Chrome内存分析工具概述

Chrome提供了一组很好的工具来分析JavaScript代码的内存使用情况。有两个与内存相关的基本视图:时间轴视图和配置文件视图。

时间轴视图

Google开发工具时间表行动时间轴视图对于在代码中发现异常内存模式至关重要。如果我们正在寻找大的泄漏,周期性的跳跃,不收缩,因为收集后他们长大了是一个红旗。在这个截图中,我们可以看到泄漏对象的稳定增长可能是什么样子。即使在大收集结束后,使用的内存总量高于开始时。节点计数也较高。这些都是代码中某处泄漏的DOM节点的迹象。

配置文件视图

Google开发工具配置文件这是你将花费大部分时间看的视图。配置文件视图允许您获取快照并比较JavaScript代码的内存使用快照。它还允许您记录分配的时间。在每个结果视图中,可以使用不同类型的列表,但对于我们的任务最相关的是摘要列表和比较列表。

摘要视图为我们概述了分配的不同类型的对象及其聚合大小:浅大小(特定类型的所有对象的总和)和保留大小(浅大小加上由于此对象保留的其他对象的大小)。它也给了我们一个对象相对于它的GC根(距离)有多远的概念。

比较列表给了我们相同的信息,但允许我们比较不同的快照。这是特别有用的找到泄漏。

示例:使用Chrome查找泄漏

基本上有两种类型的泄漏:泄漏导致内存使用的周期性增加,以及一次发生的泄漏,并且不会进一步增加内存。由于显而易见的原因,当它们是周期性的时更容易发现泄漏。这些也是最麻烦的:如果内存在时间上增加,这种类型的泄漏将最终导致浏览器变慢或停止脚本的执行。非周期性泄漏可以很容易地发现,当它们足够大,在所有其他分配中显着。这通常不是这样,所以他们通常保持不被注意。在某种程度上,发生一次的小泄漏可以被认为是优化问题。然而,周期性的泄漏是错误并且必须是固定的。

对于我们的示例,我们将使用Chrome的文档中的一个示例。完整代码粘贴如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var x = [];
function createSomeNodes() {
var div,
i = 100,
frag = document.createDocumentFragment();
for (;i > 0; i--) {
div = document.createElement("div");
div.appendChild(document.createTextNode(i + " - "+ new Date().toTimeString()));
frag.appendChild(div);
}
document.getElementById("nodes").appendChild(frag);
}
function grow() {
x.push(new Array(1000000).join('x'));
createSomeNodes();
setTimeout(grow,1000);
}

当grow被调用时,它将开始创建div节点并将它们附加到DOM。它还将分配一个大数组,并将其附加到全局变量引用的数组。这将导致使用上述工具可以找到的内存的稳定增加。

垃圾收集的语言通常显示振荡存储器使用的模式。如果代码在执行分配的循环中运行,这是预期的,这是通常的情况。我们将寻找在收集后不会回退到之前级别的内存的定期增加。

了解内存是否周期性增加

时间表视图是伟大的。在Chrome中打开示例,打开开发工具,转到时间轴,选择内存并单击记录按钮。然后转到该页面并单击The Button以开始泄漏内存。过一会儿停止录音,看看结果:

时间轴视图中的内存泄漏

此示例将继续每秒泄漏内存。停止录制后,请在grow函数中设置断点,以停止脚本强制Chrome关闭页面。

在这个图像有两个大的迹象,显示我们正在泄漏的记忆。节点(绿线)和JS堆(蓝线)的图形。节点正在稳步增加,从不减少。这是一个大的警告标志。

JS堆还显示内存使用的稳定增长。这是很难看到由于垃圾回收器的影响。您可以看到初始内存增长的模式,随后是大幅下降,随后是增加,然后是峰值,继续记忆的下降。在这种情况下的关键在于事实,在每次内存使用后,堆的大小保持大于在上一次下降。换句话说,尽管垃圾收集器正在成功地收集大量的存储器,但是它的一些被周期性地泄漏。

我们现在确定我们有一个泄漏。让我们找到它。

获取两个快照

要查找泄漏,我们现在将转到Chrome的开发工具的profiles部分。要将内存使用限制在可管理的级别,请在执行此步骤之前重新加载页面。我们将使用Take Heap Snapshot函数。

重新加载页面,并在完成加载后立即获取堆快照。我们将使用此快照作为我们的基线。之后,The Button再次点击,等待几秒钟,并拍摄第二个快照。捕获快照后,建议在脚本中设置断点,以防止泄漏使用更多内存。

堆快照

有两种方法可以查看两个快照之间的分配。选择摘要,然后选择右侧选择在快照1和快照2之间分配的对象,或选择比较而不是摘要。在这两种情况下,我们将看到在两个快照之间分配的对象的列表。

在这种情况下,很容易找到泄漏:他们很大。看看的Size Delta的的(string)构造函数。8MBs有58个新对象。这看起来很可疑:新对象被分配但是不被释放,并且8MB被消耗。

如果我们打开构造函数的(string)分配列表,我们会注意到在许多小的分配中有一些大的分配。大者立即引起我们的注意。如果我们选择其中的任何一个,我们在下面的retainers部分得到一些有趣的东西。

所选对象的保留位置

我们看到我们选择的分配是数组的一部分。反过来,数组由x全局window对象内的变量引用。这给了我们从我们的大对象到其不可收回的根(window)的完整路径。我们发现我们的潜在泄漏和被引用的地方。

到现在为止还挺好。但我们的例子很容易:大分配,例如在这个例子中的分配不是常态。幸运的是,我们的例子也泄漏了DOM节点,它们更小。使用上面的快照很容易找到这些节点,但是在更大的网站中,事情变得更麻烦。最新版本的Chrome提供了一个最适合我们工作的附加工具:记录堆分配功能。

记录堆分配以查找泄漏

禁用之前设置的断点,让脚本继续运行,然后返回Chrome的开发工具的“ 个人档案”部分。现在点击Record Heap Allocations。当工具运行时,您会注意到在顶部的图中的蓝色尖峰。这些表示分配。每秒大的分配由我们的代码执行。让它运行几秒钟,然后停止它(不要忘记再次设置断点,以防止Chrome吃更多的内存)。

记录的堆分配

在此图像中,您可以看到此工具的杀手级功能:选择一段时间线以查看在该时间段内执行的分配。我们将选择设置为尽可能接近一个大峰值。列表中只显示了三个构造函数:其中一个是与我们的大泄漏((string))相关的,另一个是与DOM分配相关的,最后一个是Text构造函数(包含文本的叶DOM节点的构造函数)。

HTMLDivElement从列表中选择一个构造函数,然后选择Allocation stack。

堆分配结果中选择的元素

BAM!我们现在知道该元素的分配位置(grow- > createSomeNodes)。如果我们密切关注图中每个秒杀,我们会发现,HTMLDivElement构造函数被调用了很多。如果我们回到我们快照比较认为我们会发现,这个构造显示有多少拨款,但没有删除。换句话说,它是稳定,而不允许在GC收回一些它分配内存。这有泄漏的种种迹象加上我们确切地知道被分配这些对象(createSomeNodes函数)。现在它的时间回到代码,研究它,并修复内存泄漏。

另一个有用的功能

在堆分配结果视图中,我们可以选择Allocation视图而不是Summary。

结果是堆分配中的分配

这个视图给了我们一个与它们相关的函数和内存分配的列表。我们可以立即看到grow和createSomeNodes站出来。当选择时,grow我们看看它所调用的关联对象构造函数。我们注意到(string),HTMLDivElement和Text它现在我们已经知道是被泄露的对象的构造函数。

这些工具的组合可以大大有助于发现泄漏。玩他们。在生产站点中进行不同的分析运行(理想情况下使用非最小化或混淆代码)。看看你能找到的泄漏或对象被保留超过他们应该(提示:这些更难找到)。

要使用此功能,请转到Dev Tools - >设置并启用“记录堆分配堆栈跟踪”。在拍摄之前必须这样做。

进一步阅读

  • 内存管理 - Mozilla开发人员网络
  • JScript内存泄漏 - Douglas Crockford(旧的,关于Internet Explorer 6泄漏)
  • JavaScript内存分析 - Chrome开发者文档
  • 内存诊断 - Google Developers
  • 有趣的JavaScript内存泄漏 - 流星博客
  • Grokking V8关闭

结论

内存泄漏可以并且确实发生在垃圾收集语言中,如JavaScript。这些可以被忽视一段时间,最终他们将肆虐。因此,内存分析工具对于查找内存泄漏至关重要。分析运行应该是开发周期的一部分,特别是对于中型或大型应用程序。开始这样做,为您的用户提供最好的体验。Hack on!

Node.js权限控制管理模块

发表于 2017-03-11

https://github.com/tenodi/permission Npm package for hangling user permissions for routes based on roles.

https://github.com/kieronwiltshire/letu Simple permission evaluation.

current popularity rank (based on npmjs.com dowloads count)

  1. acl
  2. connect-roles
  3. authorized
  4. virgen-acl
  5. permission
  6. ability
  7. ability
  8. simplepermissions
  9. entitlement

项目中用到的用户权限管理系统

发表于 2017-03-11

权限管理是每个系统中用户管理必需的管理组件,通常需要通过判断用户所具有的权限来控制用户对系统的操作内容,并把资源调配给用户,限制用户的增删改查等操作。

通常权限管理的访问控制模型有以下两种方式:

ACL:Access Control List,访问控制列表(节点控制),是比较流行的设计方式。通过把用户和权限挂钩来实现。

RBAC:Role Based Access Control,基于的角色访问控制系统,是另一个实现思路。、就是把用户和角色关联,角色来对应权限,用户和权限没有直接关联,对复杂的系统来说,更加容易管理。

RBAC物理模型

img

当用户的数量非常大时,要给系统每个用户逐一授权(授角色),是件非常烦琐的事情。这时,就需要给用户分组,每个用户组内有多个用户。除了可给用户授权外,还可以给用户组授权。这样一来,用户拥有的所有权限,就是用户个人拥有的权限与该用户所在用户组拥有的权限之和。(下图为用户组、用户与角色三者的关联关系)

img

​ “用户-角色-权限-资源” 授权模型

img

点击查看原始大小图片

img

img

img

ACL实体模型

img

通过ACL(访问控制列表)把Role、User、Module、Permission、status(允许/禁止)关联起来。用于记录用户或者角色对资源拥有的权限

img

内容来源网络整理而成

123
tomoat

tomoat

21 日志
1 标签
© 2017 tomoat
由 Hexo 强力驱动
主题 - NexT.Muse