二、不变性与可变性——安全与速度之间的平衡
近年来,开发实践已经转向一种更功能性的编程风格。 这意味着较少关注可变编程(当我们想要修改某些东西时,更改变量而不是创建新变量)。 当我们把一个变量从一件事变成另一件事时,可变性就发生了。 这可以是更新一个数字,更改消息的内容,甚至将项目从字符串更改为数字。 可变状态会导致很多编程缺陷,比如状态不确定、多线程环境中的死锁,甚至在我们不想改变数据类型时改变数据类型(也称为副作用)。 现在,我们有许多库和语言可以帮助我们减少这种行为。
所有这些都促使人们使用不可变的数据结构和函数来基于输入创建新对象。 虽然这可以减少可变状态方面的错误,但它会带来许多其他问题,主要是更高的内存使用量和更低的速度。 大多数 JavaScript 运行时没有允许这种编程风格的优化。 当我们关心内存和速度时,我们需要尽可能多的优势,这就是可变编程给我们的优势。
在本章中,我们将重点讨论以下主题:
- 当前的趋势与不变的 web
- 编写安全的可变代码
- web 上的函数式编程
技术要求
以下是本章的先决条件:
- 一个网络浏览器,最好是 Chrome
- 一个编辑器; VS Code 优先
- 了解当前的状态库,如 Redux 或 Vuex
- 相关代码见https://github.com/PacktPublishing/Hands-On-High-Performance-Web-Development-with-JavaScript/tree/master/Chapter02。
当前对不变性的迷恋
看看当前的网络趋势,就会发现人们对利用不变性的迷恋。 像 React 这样的库可以在没有其不变状态对应库的情况下使用,但它们通常与 Redux 或 Facebook 的 Flow 库一起使用。 这些库中的任何一个都将展示不变性如何能导致更安全的代码和更少的错误。
For those of you who do not know, immutability means that we cannot change the variable once it has been set with data. This means that once we assign something to a variable, we can no longer change that variable. This helps prevent unwanted changes from happening, and can also lead to a concept called pure functions. We will not be going into what pure functions are, but just be aware that it is a concept that many functional programmers have been bringing to JavaScript.
但是,这是否意味着我们需要它,它是否会导致一个更快的系统? 在 JavaScript 的情况下,它可以依赖。 一个具有文档和测试的管理良好的项目可以很容易地展示我们可能不需要这些库。 在此之上,我们可能需要实际改变对象的状态。 我们可以写入一个位置上的对象,但有许多其他部分从该对象读取。
有许多开发模式可以为我们提供与不变性类似的好处,而无需创建大量临时对象,甚至无需进入完全纯函数式编程风格。 我们可以利用诸如Resource Acquisition Is Initialization(RAII)这样的系统。 我们可能会发现我们想要使用一些不变性,在这种情况下,我们可以使用内置的浏览器工具,如Object.freeze()
或Object.seal()
。
然而,我们做得太超前了。 让我们看看上面提到的几个库,看看它们是如何处理不可变状态的,以及它们在编码时可能导致的问题。
一头扎进 Redux
Redux是一个伟大的状态管理系统。 当我们开发复杂的系统时,比如谷歌 Docs,或者让我们实时查看各种用户统计数据的报告系统,它可以管理我们的应用的状态。 然而,它可能导致一些过于复杂的系统,这些系统可能不需要它所代表的状态管理。
Redux 采用的理念是,任何一个对象都不能改变应用的状态。 所有的状态都需要托管在一个位置上,并且应该有处理状态更改的函数。 这意味着一个位置用于写入,多个位置能够读取数据。 这与我们稍后要使用的一些概念类似。
然而,它确实更进一步,许多文章会希望我们传递全新的东西。 这是有原因的。 许多对象,特别是那些有多层的对象,是不容易复制的。 简单的复制操作,如使用Object.assign({}, obj)
或使用数组的扩展操作符,将只是复制它们所包含的引用。 在编写基于 redux 的应用之前,让我们看一个这样的示例。
如果我们从存储库中打开not_deep_copy.html
,我们将看到控制台打印相同的内容。 如果我们看一下代码,我们会看到复制对象和数组的一种非常常见的情况:
const newObj = Object.assign({}, obj);
const newArr = [...arr];
如果我们让它只有一个单层的深度,我们会看到它实际上执行了一个拷贝。 下面的代码将展示这一点:
const obj2 = {item : 'thing', another : 'what'};
const arr2 = ['yes', 'no', 'nope'];
const newObj2 = Object.assign({}, obj2);
const newArr2 = [...arr2]
我们将详细讨论这种情况以及如何真正执行深度复制,但我们可以开始看到 Redux 如何隐藏仍然存在于我们系统中的问题。 让我们构建一个简单的 Todo 应用,至少展示 Redux 及其功能。 所以,让我们开始:
- 首先,我们需要拿下 Redux。 我们可以利用Node Package Manager(npm)并将其安装到我们的系统中。 它就像
npm install redux
一样简单。 - 现在,我们将进入新创建的文件夹,抓取
redux.min.js
文件,并将其放入工作目录。 - 现在我们将创建一个名为
todo_redux.html
的文件。 这将容纳我们所有的主要逻辑。 - 在它的顶部,我们将添加 Redux 库作为依赖项。
- 然后,我们将添加将要在我们的商店上执行的操作。
- 然后,我们将设置要用于应用的还原器。
- 然后,我们将设置存储,并为数据更改做好准备。
- 然后我们将订阅这些数据更改并对 UI 进行更新。
我们正在处理的示例是对 Redux 示例中的 Todo 应用稍加修改的版本。 一个好的方面是,我们将使用普通的 DOM,而不使用其他库,如 React,所以我们可以看到 Redux 如何适合任何应用,如果有需要。
- 因此,我们的操作将添加一个
todo
元素,切换一个todo
元素以完成或不完成,并设置我们想要看到的todo
元素。 该代码如下所示:
const addTodo = function(test) {
return { type : ACTIONS.ADD_TODO, text };
}
const toggleTodo = function(index) {
return { type : ACTIONS.TOGGLE_TODO, index };
}
const setVisibilityFilter = function(filter) {
return { type : ACTIONS.SET_VISIBILITY_FILTER, filter };
}
- 接下来,减速器将被分离,一个用于我们的能见度过滤器,另一个用于实际的
todo
元素。
降低能见度的方法非常简单。 它检查动作的类型,如果是SET_VISIBILITY_FILTER
,类型,我们将处理它,否则,我们只是传递状态对象。 对于我们的todo
减速器,如果我们看到一个ADD_TODO
的动作,我们将返回一个新的物品列表,我们的物品位于底部。 如果切换其中一项,则返回一个新列表,该列表将该项设置为与其设置相反的值。 否则,我们只是传递状态对象。 所有这些看起来如下:
const visibilityFilter = function(state = 'SHOW_ALL', action) {
switch(action.type) {
case 'SET_VISIBILITY_FILTER': {
return action.filter;
}
default: {
return state;
}
}
}
const todo = function(state = [], action) {
switch(action.type) {
case 'ADD_TODO': {
return [
...state,
{
text : action.text,
completed : false
}
}
case 'TOGGLE_TODO': {
return state.map((todo, index) => {
if( index === action.index ) {
return Object.assign({}, todo, {
completed : !todo.completed
});
}
return todo;
}
}
default: {
return state;
}
}
}
- 之后,我们把两个减速机放入一个单一减速机,并设置
state
对象。
我们逻辑的核心在于 UI 实现。 注意,我们设置它是为了消除数据。 这意味着数据可以被传递到我们的函数中,UI 将相应地更新。 我们可以反过来做,但让 UI 由数据驱动是一个很好的范例。 我们首先有一个以前的州商店。 我们可以进一步利用它,只更新实际更新的内容,但我们只在第一次检查时使用它。 我们获取当前状态并检查两者之间的差异。 如果我们看到长度发生了变化,我们知道应该添加一个todo
项。 如果我们看到可见性过滤器改变了,我们将相应地更新 UI。 最后,如果这两个都不是,我们将检查并检查哪一项被检查或未检查。 代码如下所示:
store.subscribe(() =>
const state = store.getState();
// first type of actions ADD_TODO
if( prevState.todo.length !== state.todo.length ) {
container.appendChild(createTodo(state.todo[state.todo.length
- 1].text));
// second type of action SET_VISIBILITY_FILTER
} else if( prevState.visibilityFilter !==
state.visibilityFilter ) {
setVisibility(container.children, state);
// final type of action TOGGLE_TODO
} else {
const todos = container.children;
for(let i = 0; i < todos.length; i++) {
if( state.todo[i].completed ) {
todos[i].classList.add('completed');
} else {
todos[i].classList.remove('completed');
}
}
}
prevState = state;
});
如果我们运行这个,我们应该得到一个简单的 UI,我们可以通过以下方式进行交互:
- 添加
todo
项目。 - 将现有的
todo
项标记为完整。
我们还可以通过点击下面三个按钮中的一个来获得不同的视图,如下面的截图所示。 如果我们只想看到所有已完成的任务,可以单击 Update 按钮。
现在,如果我们愿意,我们可以将状态保存为离线存储,或者我们可以将状态发送回服务器以进行持续更新。 这就是 Redux 非常棒的地方。 然而,在使用 Redux 时,有一些注意事项与我们之前所说的有关:
- 首先,我们需要向 Todo 应用添加一些东西,以便能够处理我们状态中的嵌套对象。 在这个 Todo 应用中遗漏了一条信息,它设置了我们想要完成该项目的日期。 因此,让我们添加一些字段来设置完成日期。 我们将添加三个新的数字输入如下:
<input id="year" type="number" placeholder="Year" />
<input id="month" type="number" placeholder="Month" />
<input id="day" type="number" placeholder="Day" />
- 然后,我们将添加另一种滤镜类型
Overdue
:
<button id="SHOW_OVERDUE">Overdue</button>
- 确保将此添加到
visibilityFilters
对象。 现在,我们需要更新我们的addTodo
动作。 我们还将传递一个Date
对象。 这也意味着我们需要更新我们的ADD_TODO
案例,将action.date
添加到新的todo
对象中。 然后,我们将更新我们的onclick
处理程序的添加按钮,并调整它与以下:
const year = document.getElementById('year');
const month = document.getElementById('month');
const day = document.getElementById('day');
store.dispatch(addTodo(input.value), {year : year.value, month : month.value, day : day.value}));
year.value = "";
month.value = "";
day.value = "";
- 我们可以将日期保存为一个
Date
对象(这样更有意义),但为了显示可能出现的问题,我们将只保存一个具有year
、month
和day
字段的新对象。 然后,我们将通过添加另一个span
元素并使用这些字段的值填充它,在 Todo 应用中展示这个日期。 最后,我们需要用逻辑更新我们的setVisibility
方法,以显示过期项。 它应该如下所示:
case visibilityFilters.SHOW_OVERDUE: {
const currTodo = state.todo[i];
const tempTime = currTodo.date;
const tempDate = new Date(`${tempTime.year}/${tempTime.month}/${tempTime.day}`);
if( tempDate < currDay && !currTodo.completed ) {
todos[i].classList.remove('hide');
} else {
todos[i].classList.add('hide');
}
}
有了这些,我们现在应该有了一个工作的 Todo 应用,并展示了我们的过期项。 现在,在使用 Redux 等状态管理系统时,可能会出现混乱。 当我们想要对一个已经创建的项目进行修改,但它不是一个简单的平面对象时,会发生什么? 我们可以获取这个条目,然后在国家系统中更新它。 让我们为它添加代码:
- 首先,我们将创建一个新的按钮和输入,它将更改最后一个条目的年份。 我们将为 Update 按钮添加一个点击处理程序:
document.getElementById('UPDATE_LAST_YEAR').onclick = function(e) {
store.dispatch({ type : ACTIONS.UPDATE_LAST_YEAR, year :
document.getElementById('updateYear').value });
}
- 然后,我们将为
todo
系统添加这个新的操作处理程序:
case 'UPDATE_LAST_YEAR': {
const prevState = state;
const tempObj = Object.assign({}, state[state.length -
1].date);
tempObj.year = action.year;
state[state.length - 1].date = tempObj;
return state;
}
现在,如果我们在系统中运行代码,我们会注意到一些事情。 我们的代码没有通过订阅中的检查对象条件:
if( prevState === state ) {
return;
}
我们直接更新状态,因此 Redux 永远不会创建一个新对象,因为它没有检测到变化(我们直接更新了一个没有减速器的对象的值)。 现在,我们可以为日期创建另一个减速器,但我们也可以重新创建数组并传递它:
case 'UPDATE_LAST_YEAR': {
const prevState = state;
const tempObj = Object.assign({}, state[state.length - 1].date);
tempObj.year = action.year;
state[state.length - 1].date = tempObj;
return [...state];
}
现在,我们的系统检测到有一个变化,我们可以通过我们的方法来更新代码。
The better implementation would be to split out our todo
reducer into two separate reducers. But, since we are working on an example, it was made as simple as possible.
有了这一切,我们可以看到我们需要如何遵守 Redux 为我们制定的规则。 虽然这个工具在大型应用中对我们有很大的好处,但对于较小的状态系统甚至组合体系统,我们可能会发现拥有一个真正的可变状态并直接处理它更好。 只要我们控制了对可变状态的访问,那么我们就能够充分利用可变状态。
This is not to take anything away from Redux. It is a wonderful library and it performs well even under heavier loads. But, there are times when we want to work directly with a dataset and mutate it directly. Redux can do this and gives us its event system, but we are able to build this ourselves without all of the other pieces that Redux gives us. Remember that we want to slim the codebase down as much as possible and make it as efficient as possible. Extra methods and extra calls can add up when we are working with tens to hundreds of thousands of data items.
在对 Redux 和状态管理系统的介绍完成之后,我们还应该看看一个使不可变系统成为需求的库:immutable .js。
Immutable.js
同样,利用不变性,我们可以以一种更容易理解的方式编码。 然而,这通常意味着我们无法扩展到真正高性能应用所需的级别。
首先,Immutable.js 在函数式数据结构和方法上做了很大的尝试,这是在 JavaScript 中创建函数式系统所需要的。 这通常会导致更清晰的代码和架构。 但是,我们得到的这些优势导致了速度的下降和/或内存的增加。
记住,当我们使用 JavaScript 时,我们有一个单线程环境。 这意味着我们并不存在死锁、竞态条件或读写访问问题。
We can actually run into these issues when utilizing something like SharedArrayBuffers
between workers or different tabs, but that is a discussion for later chapters. For now, we are working in a single-threaded environment where the issues of multi-core systems do not really crop up.
让我们以可能出现的一个真实的用例为例。 我们希望将列表的列表转换为对象的列表(想想 CSV)。 用普通的旧 JavaScript 和使用 Immutable.js 库构建这个数据结构的代码是什么样子的? 我们的原始 JavaScript 版本可能会出现如下:
const fArr = new Array(fillArr.length - 1);
const rowSize = fillArr[0].length;
const keys = new Array(rowSize);
for(let i = 0; i < rowSize; i++) {
keys[i] = fillArr[0][i];
}
for(let i = 1; i < fillArr.length; i++) {
const obj = {};
for(let j = 0; j < rowSize; j++) {
obj[keys[j]] = fillArr[i][j];
}
fArr[i - 1] = obj;
}
我们构造一个新的数组,其大小为输入列表减去 1(第一行是键)。 然后存储行大小,而不是在以后的内部循环中每次计算行大小。 然后,我们创建另一个数组来保存键,并从输入数组的第一个索引中获取这些键。 接下来,我们循环输入中的其余条目并创建对象。 然后循环每个内部数组,将键设置为值和位置j
,并将值设置为输入的i
和j
值。
通过嵌套数组和循环读取数据可能会令人困惑,但会导致快速读取时间。 在带有 8gb RAM 的双核处理器上,这段代码花了 83 毫秒。
现在,让我们在 Immutable.js 中构建一些类似的东西。 它应该如下所示:
const l = Immutable.List(fillArr);
const _k = Immutable.List(fillArr[0]);
const tFinal = l.map((val, index) => {
if(!index ) return;
return Immutable.Map(_k.zip(val));
});
const final = tfinal.shift();
如果我们理解函数的概念,这就更容易解释了。 首先,我们希望根据输入创建一个列表。 然后我们为键创建另一个临时列表,称为_k
。 对于我们临时的最终列表,我们利用了map
函数。 如果我们在索引0
处,我们只需从函数中return
(因为这是键)。 否则,将返回一个新映射,该映射是通过使用当前值压缩键列表创建的。 最后,我们删除 final 列表的前面,因为它将是未定义的。
这段代码在可读性方面非常好,但是它的性能特征是什么呢? 在当前的机器上,这个过程大约需要 1 秒。 这在速度上是一个很大的不同。 让我们看看它们在内存使用方面的比较。
解决内存(内存运行代码后回到)似乎是相同的,将回落至 1.2 MB 左右。然而,不变的版本的内存峰值大约是 110 MB,而原始 JavaScript 版本只能 48 MB,因此不到一半的内存使用。 让我们来看另一个例子,看看结果。
我们将创建一个值数组,但我们希望其中一个值是错误的。 因此,我们将第 50000 个索引设置为wrong
,代码如下:
const tempArr = new Array(100000);
for(let i = 0; i < tempArr.length; i++) {
if( i === 50000 ) { tempArr[i] = 'wrong'; }
else { tempArr[i] = i; }
}
然后,我们将使用简单的for
循环遍历一个新数组,如下所示:
const mutArr = Array.apply([], tempArr);
const errs = [];
for(let i = 0; i < mutArr.length; i++) {
if( mutArr[i] !== i ) {
errs.push(`Error at loc ${i}. Value : ${mutArr[i]}`);
mutArr[i] = i;
}
}
我们还将测试内置的map
函数:
const mut2Arr = Array.apply([], tempArr);
const errs2 = [];
const fArr = mut2Arr.map((val, index) => {
if( val !== index ) {
errs2.push(`Error at loc: ${index}. Value : ${val}`);
return index;
}
return val;
});
最后,这里是不可变版本:
const immArr = Immutable.List(tempArr);
const ierrs = [];
const corrArr = immArr.map((item, index) => {
if( item !== index ) {
ierrs.push(`Error at loc ${index}. Value : ${item}`);
return index;
}
return item;
});
如果我们运行这些实例,我们将看到最快的是在基本的for
循环和内置的map
函数之间。 不变版本仍然比其他版本慢 8 倍。 当我们增加错误值的数量时会发生什么? 让我们添加一个随机数生成器来构建临时数组,以给出随机数目的错误,并看看它们的执行情况。 代码应该如下所示:
for(let i = 0; i < tempArr.length; i++) {
if( Math.random() < 0.4 ) {
tempArr[i] = 'wrong';
} else {
tempArr[i] = i;
}
}
运行相同的测试,我们得到的平均速度大约是不可变版本的十倍。 现在,这并不是说不变的版本将不会运行得更快在某些情况下,因为我们只摸在地图和列表功能,但它提出的不变性是有代价的内存和速度在应用 JavaScript 库。
在下一节中,我们将讨论为什么可变会导致一些问题,以及我们如何利用 Redux 处理数据的类似思想来处理它。
There is always a time and a place for different libraries, and this is not to say that Immutable.js or libraries like it are bad. If we find that our datasets are small or other considerations come into play, Immutable.js might work for us. But, when we are working on high-performance applications, this usually means two things. One, we will get a large amount of data in a single hit or second, and second, we will get a bunch of events that lead to a lot of data build-up. We need to use the most efficient means possible and these are usually built into the runtime that we are utilizing.
编写安全的可变代码
在开始编写安全的可变代码之前,我们需要讨论一下引用和值。 值可以被认为是任何基本类型。 在 JavaScript 中,基本类型是任何不被认为是对象的东西。 简单地说,数字、字符串、布尔值、空值和 undefined 都是值。 这意味着,如果您创建一个新变量并将其赋给原始变量,它实际上会给它一个新值。 这对我们的代码意味着什么呢? 我们之前在 Redux 中看到,它无法看到我们在状态系统中更新了一个属性,所以我们之前的状态和当前的状态显示它们是相同的。 这是由于一个浅层的相等检验。 这个基本测试测试传入的两个变量是否指向同一个对象。 下面的代码就是一个简单的例子:
let x = {};
let y = x;
console.log( x === y );
y = Object.assign({}, x);
console.log( x === y );
我们将看到第一个版本说这两项相等。 但是,当我们创建对象的副本时,它声明它们不相等。 y
现在有一个全新的对象,这意味着它指向内存中的一个新位置。 虽然更深入地理解通过值和通过引用可以很好,但这应该足以继续使用可变代码。
当编写安全的可变代码时,我们希望给人一种我们正在编写不可变代码的错觉。 换句话说,接口应该看起来像我们在利用不可变系统,但实际上我们在内部利用的是可变系统。 因此,接口与实现是分离的。
我们可以通过以可变的方式编写,但给出一个看起来不可变的接口,从而使实现非常快。 下面是一个例子:
Array.prototype._map = function(fun) {
if( typeof fun !== 'function' ) {
return null;
}
const arr = new Array(this.length);
for(let i = 0; i < this.length; i++) {
arr[i] = fun(this[i]);
}
return arr;
}
我们在数组原型上编写了一个_map
函数,这样每个数组都可以得到它,我们还编写了一个简单的map
函数。 如果我们现在测试运行这段代码,我们将看到一些浏览器使用这个选项的性能更好,而其他浏览器使用内置选项的性能更好。 如前所述,内置循环最终会变得更快,但通常情况下,简单循环会更快。 现在让我们看看另一个使用不可变接口的可变实现示例:
Array.prototype._reduce = function(fun, initial=null) {
if( typeof fun !== 'function' ) {
return null;
}
let val = initial ? initial : this[0];
const startIndex = initial ? 0 : 1;
for(let i = startIndex; i < this.length; i++) {
val = fun(val, this[i], i, this);
}
return val;
}
我们编写了一个reduce
函数,它在所有浏览器中都表现得更好。 现在,它没有相同数量的类型检查,这可以带来更好的性能,但它确实展示了我们如何编写性能更好的函数,但提供系统用户所期望的相同类型的接口。
到目前为止,我们所讨论的是,如果我们为人们编写一个库,让他们的生活更轻松。 如果像大多数应用开发人员那样,我们正在编写我们自己或内部团队将要使用的东西,会发生什么?
在这种情况下,我们有两个选择。 首先,我们可能会发现我们正在一个遗留系统上工作,我们将不得不尝试以类似的风格来编写已经完成的程序,或者我们正在开发一些相当新的东西,我们能够从头开始。
Writing legacy code is a hard job and most people will usually get it wrong. While we should be aiming to improve on the code base, we are also trying to match the style. It is especially difficult for developers to walk through the code and see 10 different code choices used because 10 different developers have worked on the project over its lifespan. If we are working on something that someone else has written, it is usually better to match the code style than to come up with something completely different.
有了一个新系统,我们就可以按照自己的意愿来编写代码,并且有了适当的文档,我们就可以编写一些非常快但也容易被别人理解的代码。 在这种情况下,我们可以编写可能对函数产生副作用的可变代码,但我们能够记录这些情况。
副作用是指当函数不只是返回一个新变量,甚至不返回该变量传入的引用时发生的情况。 当我们更新另一个没有当前作用域的变量时,就会产生副作用。 下面是一个例子:
var glob = 'a single point system';
const implement = function(x) {
glob = glob.concat(' more');
return x += 2;
}
我们有一个全局变量,叫做glob
,我们正在函数内部改变它。 从技术上讲,这个函数的作用域超过了glob
,但是我们应该试着将 implementation 的作用域定义为只传递给它的内容以及在内部定义的临时变量。 由于我们正在改变glob
,我们在代码库中引入了一个副作用。
现在,在某些情况下,需要副作用。 我们可能需要更新单个点,或者我们可能需要在单个位置存储一些东西,但我们应该尝试实现一个接口,为我们做这些,而不是我们直接影响全局项目(这应该开始听起来很像 Redux)。 通过编写一两个函数来影响范围外的项,我们现在可以诊断出哪里可能出现问题,因为我们有那些单入口点。
这看起来像什么呢? 我们可以像创建普通的旧对象一样创建状态对象。 然后,我们可以在全局作用域上编写一个名为updateState
的函数,如下所示:
const updateState = function(update) {
const x = Object.keys(update);
for(let i = 0; i < x.length; i++) {
state[x[i]] = update[x[i]];
}
}
现在,虽然这可能是好的,但我们仍然容易受到某些人通过实际的全局属性更新状态对象的攻击。 幸运的是,通过创建状态对象和函数const
,我们可以确保错误代码不会触及这些实际名称。 让我们更新我们的代码,这样我们的状态就不会被直接更新。 有两种方法可以做到这一点。 第一种方法是用模块编码,然后是我们的状态对象,它的作用域是那个模块。 我们将在本书中进一步研究模块和导入语法。 相反,在这种情况下,我们将使用第二种方法,代码为立即调用函数表达式(IIFE)。 下面展示了这个实现:
const state = {};
(function(scope) {
const _state = {};
scope.update = function(obj) {
const x = Object.keys(obj);
for(let i = 0; i < x.length; i++) {
_state[x[i]] = obj[x[i]];
}
}
scope.set = function(key, val) {
_state[key] = val;
}
scope.get = function(key) {
return _state[key];
}
scope.getAll = function() {
return Object.assign({}, _state);
}
})(state);
Object.freeze(state);
首先,我们创建一个恒定状态。 然后我们 IIFE 并传入状态对象,在其上设置一系列函数。 它在一个内部的scoped _state
变量上工作。 然后我们就有了所有的基本功能,我们期望一个内态系统。 我们还冻结了外部状态对象,这样它就不会再被搅乱了。 可能会出现的一个问题是,为什么我们要返回一个新对象而不是一个引用。 如果我们不想让任何人接触到内部状态,那么我们就不能传递引用; 我们必须传递一个新对象。
我们还有个问题。 如果我们想要更新一个以上的层会发生什么? 我们将再次遇到参考问题。 这意味着我们需要更新更新函数来执行深度更新。 我们可以用多种方法来实现,其中一种方法是将值作为字符串传入,然后按小数点分割。
This is not the best way to handle this since we could technically have a property of an object be named with decimal points, but it will allow us to write something quickly. Balancing between writing something that is functional, and what is considered a complete solution, are two different things and they have to be balanced when writing high-performance code bases.
那么,我们将有一个类似于下面的方法:
const getNestedProperty = function(key) {
const tempArr = key.split('.');
let temp = _state;
while( tempArr.length > 1 ) {
temp = temp[tempArr.shift()];
if( temp === undefined ) {
throw new Error('Unable to find key!');
}
}
return {obj : temp, finalKey : tempArr[0] };
}
scope.set = function(key, val) {
const {obj, finalKey} = getNestedProperty(key);
obj[finalKey] = val;
}
scope.get = function(key) {
const {obj, finalKey} = getNestedProperty(key);
return obj[finalKey];
}
我们正在做的是在十进制字符上破坏密钥。 我们还获取了对内部状态对象的引用。 当列表中仍然有项目时,我们将对象向下移动一级。 如果我们发现它是未定义的,那么我们将抛出一个错误。 否则,一旦我们在我们想要的位置之上一层,我们就返回一个具有该引用和 final 键的对象。 然后我们将在 getter 和 setter 中使用它来替换这些值。
现在,我们仍然有一个问题。 如果我们想让一个引用类型成为内部状态系统的属性值,该怎么办? 好吧,我们会遇到之前看到的同样的问题。 我们将在单个状态对象之外引用。 这意味着我们必须复制方法的每一步,以确保外部引用不指向内部副本中的任何东西。 我们可以通过添加一些检查来创建这个系统,并确保当我们得到一个引用类型时,我们以一种高效的方式克隆它。 代码如下所示:
const _state = {},
checkPrimitives = function(item) {
return item === null || typeof item === 'boolean' || typeof item ===
'string' || typeof item === 'number' || typeof item === 'undefined';
},
cloneFunction = function(fun, scope=null) {
return fun.bind(scope);
},
cloneObject = function(obj) {
const newObj = {};
const keys = Object.keys(obj);
for(let i = 0; i < keys.length; i++) {
const key = keys[i];
const item = obj[key];
newObj[key] = runUpdate(item);
}
return newObj;
},
cloneArray = function(arr) {
const newArr = new Array(arr.length);
for(let i = 0; i < arr.length; i++) {
newArr[i] = runUpdate(arr[i]);
}
return newArr;
},
runUpdate = function(item) {
return checkPrimitives(item) ?
item :
typeof item === 'function' ?
cloneFunction(item) :
Array.isArray(item) ?
cloneArray(item) :
cloneObject(item);
};
scope.update = function(obj) {
const x = Object.keys(obj);
for(let i = 0; i < x.length; i++) {
_state[x[i]] = runUpdate(obj[x[i]]);
}
}
我们所做的只是编写了一个简单的克隆系统。 我们的update
函数将遍历这些键并运行更新。 然后我们将检查各种条件,例如是否我们是一个原始类型。 如果是,只需复制值,否则,需要计算出复类型。 我们首先搜索,看看我们是否是一个函数; 如果是,我们只需绑定值。 如果我们是一个数组,我们将遍历所有的值,并确保它们都不是复杂类型。 最后,如果我们是一个对象,我们将遍历所有的键并尝试运行相同的检查来更新这些键。
然而,我们刚刚做了我们一直在避免的事情; 我们创造了一个不变的状态系统。 我们可以添加更多的铃声和口哨声集中状态系统,如事件,或者我们可以实现一个编码标准,已经存在了很长一段时间,叫做资源配置初始化(RAII)。
有一个非常好的内置 web API 叫代理。 这些本质上是系统,当物体发生变化时,我们能做些什么。 在编写本文的时候,这些仍然非常慢,并且不应该真正使用,除非它是在一个我们不担心时间敏感活动的对象上。 我们不打算详细地讨论它们,但是对于那些想要阅读它们的读者来说是可以得到的。
资源分配是初始化(RAII)
RAII 的想法来自 c++,在 c++中我们没有内存管理器这样的东西。 我们将逻辑封装在可能希望共享使用后需要释放的资源的地方。 这确保了我们没有内存泄漏,并且正在利用该项的对象是以安全的方式这样做的。 它的另一个名称是作用域绑定资源管理(SBRM),并且在另一种最近被称为 Rust 的语言中也被使用。
我们可以在 JavaScript 代码中应用 c++和 Rust 在 RAII 方面所做的相同类型的想法。 有几种方法可以解决这个问题我们来看看。 首先,当我们将一个对象传递给一个函数时,我们可以从调用函数中null
输出该对象。
现在,在大多数情况下,我们必须使用let
来代替const
,但这是一个有用的范例,以确保我们只持有我们需要的对象。
这个概念可以在以下代码中看到:
const getData = function() {
return document.getElementById('container').value;
};
const encodeData = function(data) {
let te = new TextEncoder();
return te.encode(data);
};
const hashData = function(algorithm) {
let str = getData();
let finData = encodeData(str);
str = null;
return crypto.subtle.digest(algorithm, finData);
};
{
let but = document.getElementById('submit');
but.onclick = function(ev) {
let algos = ['SHA-1', 'SHA-256', 'SHA-384', 'SHA-512'];
let out = document.getElementById('output');
for(let i = 0; i < algos.length; i++) {
const newEl = document.createElement('li');
hashData(algos[i]).then((res) => {
let te = new TextDecoder();
newEl.textContent = te.decode(res);
out.append(newEl);
});
}
out = null;
}
but = null;
}
如果我们运行以下代码,我们将注意到我们正在尝试添加到null
。 这就是这个设计可能给我们带来一些麻烦的地方。 我们有一个异步方法,我们试图使用一个我们已经无效的值,即使我们仍然需要它。 处理这种情况的最好方法是什么? 一种方法是一旦我们用完了它,就把它取出来。 因此,我们可以将代码更改为如下所示:
for(let i = 0; i < algos.length; i++) {
let temp = out;
const newEl = document.createElement('li');
hashData(algos[i]).then((res) => {
let te = new TextDecoder();
newEl.textContent = te.decode(res);
temp.append(newEl);
temp = null
});
}
我们还有个问题。 在Promise
(then
方法)的下一部分运行之前,我们仍然可以修改这个值。 最后一个好主意是将输入和输出包装在一个新函数中。 这将给我们带来我们正在寻找的安全性,同时也确保我们遵循 RAII 背后的原则。 下面的代码是由此产生的:
const showHashData = function(parent, algorithm) {
const newEl = document.createElement('li');
hashData(algorithm).then((res) => {
let te = new TextDecoder();
newEl.textContent = te.decode(res);
parent.append(newEl);
});
}
我们还可以去掉前面的一些空值,因为函数将处理这些临时变量。 虽然这个示例相当简单,但它确实展示了在 JavaScript 中处理 RAII 的一种方法。
在此范例之上,我们还可以向传递的项添加属性,以表明它是只读版本。 这将确保我们没有修改条目,但如果我们仍然想从调用函数中读取元素,我们也不需要null
out 元素。 这给了我们一个好处,可以确保我们的对象可以被利用和维护,而不必担心它们会被修改。
我们将取出前面的代码示例并更新它以利用这个只读属性。 首先定义一个函数,将其添加到任何像这样传入的对象中:
const addReadableProperty = function(item) {
Object.defineProperty(item, 'readonly', {
value : true,
writable :false
});
return item;
}
接下来,在我们的onclick
方法中,我们将输出传递给这个方法。 现在它已经附加了readonly
属性。 最后,在我们的showHashData
函数中,当我们试图访问它时,我们在readonly
属性上设置了一个保护。 如果我们注意到对象有它,我们不会尝试添加到它,像这样:
if(!parent.readonly ) {
parent.append(newEl);
}
我们还将这个属性设置为不可写,因此如果某个不法行为者决定操纵对象的readonly
属性,他们仍然会注意到我们不再向 DOM 追加内容。 defineProperty
方法对于编写不容易操作的 api 和库非常强大。 另一种处理方法是冻结对象。 使用freeze
方法,我们可以确保对象的浅副本是只读的。 请记住,这仅适用于浅实例,而不适用于其他持有引用类型的属性。
最后,我们可以利用计数器来看看是否可以设置数据。 我们实际上是在创建一个读侧锁。 这意味着当我们读取数据时,我们不希望设置数据。 这意味着我们必须采取许多预防措施,一旦我们阅读了我们想要的数据,我们就会适当地发布数据。 这看起来像以下内容:
const ReaderWriter = function() {
let data = {};
let readers = 0;
let readyForSet = new CustomEvent('readydata');
this.getData = function() {
readers += 1;
return data;
}
this.releaseData = function() {
if( readers ) {
readers -= 1;
if(!readers ) {
document.dispatchEvent(readyForSet);
}
}
return readers;
}
this.setData = function(d) {
return new Promise((resolve, reject) => {
if(!readers ) {
data = d;
resolve(true);
} else {
document.addEventListener('readydata', function(e) {
data = d;
resolve(true);
}, { once : true });
}
});
}
}
我们所做的是建立一个构造函数。 我们将数据、读取器的数量和自定义事件作为私有变量保存。 然后我们创建三个方法。 首先,getData
将获取数据,并为正在使用它的人添加一个计数器。 接下来,我们有release
方法。 这将减少计数器,如果我们的值为 0,我们将分派一个事件,告诉setData
事件它最终可以写入可变状态。 最后,我们还有setData
函数。 一个承诺将是返回值。 如果没有人持有数据,我们将设置它并立即解决它。 否则,我们将为自定义事件设置一个事件监听器。 一旦发射,我们就会设定数据并解决承诺。
现在,在大多数上下文中不应该使用锁定可变数据的最后一种方法。 可能只有少数时候你会想要利用它,比如热缓存,我们需要确保我们不重写某些东西,而读者正在读取它(这可能会发生在事情的 Node.js 方面)。
所有这些方法都有助于创建安全的可变状态。 通过这些方法,我们可以直接改变对象并共享内存空间。 大多数时候,良好的文档和谨慎控制我们的数据就可以让我们不需要去我们这里的极端,但它是好的 RAII 这些方法术语在我们的口袋里,当我们找到一些作物和变异的东西我们不应该。
Most of the time, the immutable and highly functional code will be more readable in the end and, if something does not need to be highly optimized, it is suggested to go for being readable. But, in high optimization cases, such as encoding and decoding or decorating columns in a table, we will need to squeeze out as much performance as we can. This will be seen later in the book where we utilize a mixture of programming techniques.
尽管可变编程可以很快,但有时,我们希望以函数式的方式实现东西。 下一节将探讨以这种功能方式实现程序的方法。
函数式编程
即使在所有关于函数概念在原始速度方面不是最好的讨论之后,在 JavaScript 中使用它们仍然非常有帮助。 有许多语言不是纯功能性的,所有这些都让我们能够从许多范例中利用最好的想法。 我想到了 f#和 Scala 这样的语言。 当谈到这种编程风格时,有一些很棒的想法,我们可以在 JavaScript 中利用它们内置的概念。
懒惰的评价
在 JavaScript 中,我们可以执行所谓的惰性求值。 惰性计算意味着程序不运行它不需要的程序。 思考这个问题的一种方式是,当有人得到一个问题的答案列表,他们被要求给出问题的正确答案。 如果他们看到答案是他们看的第二个选项,他们就不会继续看剩下的答案了; 他们将在第二项上停下来。 我们在 JavaScript 中使用惰性计算的方式是使用生成器。
生成器是暂停执行的函数,直到调用了next
方法。 一个简单的例子如下:
const simpleGenerator = function*() {
let it = 0;
for(;;) {
yield it;
it++;
}
}
const sg = simpleGenerator();
for(let i = 0; i < 10; i++) {
console.log(sg.next().value);
}
sg.return();
console.log(sg.next().value);
首先,我们注意到function
旁边有一颗星星。 这表明这是一个生成器函数。 接下来,我们设置一个简单的变量来保存我们的值,然后我们有一个无限循环。 有些人可能认为这将持续运行,但懒惰评估表明,我们将只运行到yield
。 这个yield
意味着我们将在这里暂停执行,并获取我们发回的值。
我们启动这个函数。 我们没有什么可以传递给它,所以我们就开始了。 接下来,我们在生成器上调用next
并获取值。 这将给我们一个单独的迭代,并返回yield
语句上的内容。 最后,我们调用return
来表示我们完成了这个生成器。 如果我们想,我们可以在这里获取最终值。
现在,我们会注意到当我们调用 next 并试图获取值时,它返回 undefined。 我们可以看一下生成器,并注意到它有一个名为done
的属性。 这可以让我们用有限的生成器来判断它们是否完成了。 那么,当我们想做某事的时候,这有什么帮助呢? 一个相当简单的例子是定时函数。 我们要做的就是开始定时器之前我们想要东西然后我们将称之为另一个时间来计算的时间运行(非常类似于console.time
和timeEnd
,但它应该展示什么是可用的发电机)。
这个生成器看起来如下所示:
const timing = function*(time) {
yeild Date.now() - time;
}
const time = timing(Date.now());
let sum = 0;
for(let i = 0; i < 1000000; i++) {
sum = sum + i;
}
console.log(time.next().value);
我们现在对一个简单的求和函数计时。 所有这些操作都是将当前时间作为计时生成器的种子。 一旦调用下一个函数,它将运行语句到yield
,并返回yield
中保存的值。 这将给我们一个新的时间与我们过去的时间。 现在我们有一个简单的计时函数。 这对于我们可能无法访问控制台的环境特别有用,我们将在其他地方记录这些信息。
正如前面的代码块所示,我们还可以处理许多不同类型的延迟加载。 使用此接口的最佳类型之一是流。 流在 Node.js 中已经可用很长时间了,但是针对浏览器的流接口有一个基本的标准化,某些部分仍在争论中。 下面的代码可以看到这种类型的延迟加载或延迟读取的一个简单示例:
const nums = function*(fn=null) {
let i = 0;
for(;;) {
yield i;
if( fn ) {
i += fn(i);
} else {
i += 1;
}
}
}
const data = {};
const gen = nums();
for(let i of gen) {
console.log(i);
if( i > 100 ) {
break;
}
data.push(i);
}
const fakestream = function*(data) {
const chunkSize = 10;
const dataLength = data.length;
let i = 0;
while( i < dataLength) {
const outData = [];
for(let j = 0; j < chunkSize; j++) {
outData.push(data[i]);
i+=1;
}
yield outData;
}
}
for(let i of fakestream(data)) {
console.log(i);
}
这个例子展示了延迟求值的概念,以及我们将在后面的章节中看到的流的几个概念。 首先,我们创建一个生成器,它可以接收一个函数,并将其用于创建数字的逻辑函数。 在我们的例子中,我们只会使用默认情况,让它一次生成一个数字。 接下来,我们将通过一个for/of
循环运行它,以生成最高到 101 的数字。
接下来,我们创建一个fakestream
生成器,它将为我们划分数据块。 这类似于让我们一次处理一大块数据的流。 我们可以转换数据(称为TransformStream
),或者我们可以让它通过(一种特殊的TransformStream
类型称为PassThrough
)。 我们在10
创建一个假块大小。 然后,我们运行另一个for/of
循环遍历之前的数据,并简单地记录下来。 然而,如果我们愿意,我们可以决定对这些数据做些什么。
这并不是流所使用的接口,但它确实展示了我们如何在代码中使用生成器进行惰性计算,并且它也内置在某些概念中,如流。 生成器和惰性评估技术还有许多其他的潜在用途,这里不做介绍,但是对于那些正在寻找一种更函数式的方法来列表和映射理解的开发人员来说,它们是可用的。
尾部递归优化
这是许多函数式语言拥有的另一个概念,但大多数 JavaScript 引擎没有(WebKit 例外)。 尾端递归优化允许以某种方式构建的递归函数像简单循环一样运行。 在纯函数语言中,没有循环这种东西,所以处理集合的唯一方法是递归地遍历它。 我们可以看到,如果我们构建一个尾部递归函数,它将破坏我们的堆栈。 下面的代码演示了这一点:
const _d = new Array(100000);
for(let i = 0; i < _d.length; i++) {
_d[i] = i;
}
const recurseSummer = function(data, sum=0) {
if(!data.length ) {
return sum;
}
return recurseSummer(data.slice(1), sum + data[0]);
}
console.log(recurseSummer(_d));
我们创建了一个包含 100,000 项的数组,并将其索引处的所有值赋给它们。 然后尝试使用递归函数对数组中的所有数据进行求和。 由于对函数的最后一次调用是函数本身,一些编译器可以在这里进行优化。 如果它们注意到最后一次调用是对同一个函数的,它们就知道可以销毁当前堆栈(函数没有什么可做的了)。 然而,未优化的编译器(大多数 JavaScript 引擎)不会进行这种优化,所以我们继续向调用系统添加堆栈。 这将导致调用堆栈大小超出,使我们无法利用这个纯函数概念。
然而,JavaScript 还是有希望的。 通过对函数及其调用方式稍加修改,可以利用一个称为蹦床的概念来实现尾端递归。 下面是修改后的代码,利用蹦床,并给我们想要的:
const trampoline = (fun) => {
return (...arguments) => {
let result = fun(...arguments);
while( typeof result === 'function' ) {
result = result();
}
return result;
}
}
const _d = new Array(100000);
for(let i = 0; i < _d.length; i++) {
_d[i] = i;
}
const recurseSummer = function(data, sum=0) {
if(!data.length ) {
return sum;
}
return () => recurseSummer(data.slice(1), sum + data[0]);
}
const final = trampoline(recurseSummer);
console.log(final(_d));
我们正在做的是将递归函数包装在一个简单循环中运行的函数中。 trampoline
函数的工作原理如下:
- 它接受一个函数,并返回一个新构造的函数,该函数将运行我们的递归函数,但会遍历它,检查返回类型。
- 在这个内部函数中,它通过执行函数的第一次运行来启动循环。
- 虽然我们仍然将函数视为返回类型,但它将继续循环。
- 一旦我们最终没有得到函数,我们将返回结果。
我们现在可以利用尾端递归来做一些我们在纯函数世界中会做的事情。 前面已经看到了一个这样的例子(可以将其视为一个简单的 reduce 函数)。 另一个例子如下:
const recurseFilter = function(data, con, filtered=[]) {
if(!data.length ) {
return filtered;
}
return () => recurseFilter(data.slice(1), con, con(data[0]) ?
filtered.length ? new Array(...filtered), data[0]) : [data[0]] : filtered);
const finalFilter = trampoline(recurseFilter);
console.log(finalFilter(_d, item => item % 2 === 0));
通过这个函数,我们可以模拟纯函数语言中基于过滤器的操作。 同样,如果没有长度,我们就在数组的末尾,然后返回经过过滤的数组。 否则,我们将返回一个新函数,它递归地调用自己的一个新列表,我们要过滤的函数,然后是过滤后的列表。 这里有一些奇怪的语法。 如果我们有一个空列表,我们必须返回一个带有新项的数组,否则,它将给我们一个带有我们传入的项数的空数组。
我们可以看到,这两个函数都传递了所谓的尾端递归,它们也是可以用纯函数语言编写的函数。 但是,我们也会看到,这些方法的运行速度比简单的for
循环或甚至这些类型函数的内置数组方法要慢得多。 最后,如果我们想使用尾端递归来编写纯函数式编程,我们是可以的,但最好不要在 JavaScript 中这样做。
局部套用
我们要讲的最后一个概念是咖喱。 curry 是指一个接受多个参数的函数实际上是一系列接受单个参数并返回另一个函数或最终值的函数。 让我们看一个简单的例子,看看这个概念的实际应用:
const add = function(a) {
return function(b) {
return a + b;
}
}
我们所做的是使用一个接受多个参数的函数,例如add
函数。 然后返回一个接受单个参数的函数,在本例中为b
。 然后,该函数将数字a
和b
相加。 这让我们做的事情是使用函数作为我们通常会(除了我们回到美国和传入的函数的第二个参数)或得到的返回值运行在一个参数,然后使用这个函数来添加任何值。 这些概念中的每一个都可以在以下代码中看到:
console.log(add(2)(5), 'this will be 7');
const add5 = add(5);
console.log(add5(5), 'this will be 10');
卷曲有几种用途,它们还展示了一种可以经常使用的概念。 首先,展示了局部应用的思想。 它的作用是为我们设置一些参数并返回一个函数。 然后我们可以在语句链中传递这个函数,并最终使用它来填充剩下的函数。
记住所有的局部作用函数都是局部作用函数,但并不是所有的局部作用函数都是局部作用函数。
下面的代码可以看到部分应用的示例:
const fullFun = function(a, b, c) {
console.log('a', a);
console.log('b', b);
console.log('c', c);
}
const tempFun = fullFun.bind(null, 2);
setTimeout(() => {
const temp2Fun = tempFun.bind(null, 3);
setTimeout(() => {
const temp3Fun = temp2Fun.bind(null, 5);
setTimeout() => {
console.log('temp3Fun');
temp3Fun();
}, 1000);
}, 1000);
console.log('temp2Fun');
temp2Fun(5);
}, 1000);
console.log('tempFun');
tempFun(3, 5);
首先,我们创建一个有三个参数的函数。 然后创建一个新的临时函数,将2
绑定到该函数的第一个参数。 是一个有趣的函数。 它取我们想要的范围作为第一个参数(this
指向的内容),然后取任意长度的参数来填充我们正在处理的函数的参数。 在我们的例子中,我们只将第一个变量绑定到数字2
。 然后创建第二个临时函数,将第一个临时函数的第一个变量绑定到3
。 最后,我们创建了第三个也是最后一个临时函数,将第二个函数的第一个参数绑定到数字5
。
我们可以看到,在每次运行时,我们能够运行这些函数中的每一个,它们接受不同数量的参数,这取决于我们使用的函数版本。 bind
是一个非常强大的工具,允许我们传递函数,在使用最终函数之前,可以从其他函数中填充参数。
curry 是指我们将使用部分应用,但我们将组合一个多参数函数其中有多个嵌套函数。 那么咖喱给了我们什么其他概念做不到的东西呢? 如果我们在纯函数的世界里,我们实际上可以得到很多。 以数组上的map
函数为例。 它希望函数定义一个单独的项(我们将忽略其他通常不使用的参数),并希望函数返回一个单独的项。 当我们有如下一个函数,它可以在map
函数中使用,但它有多个参数时,会发生什么? 下面的代码展示了我们可以用 curry 和这个用例做什么:
const calculateArtbitraryValueWithPrecision = function(prec=0, val) {
return function(val) {
return parseFloat((val / 1000).toFixed(prec));
}
}
const arr = new Array(50000);
for(let i = 0; i < arr.length; i++) {
arr[i] = i + 1000;
}
console.log(arr.map(calculatorArbitraryValueWithPrecision(2)));
我们所做的是取一个泛型函数(任意一个),并将其用于 map 函数中,使其更加具体,在本例中,通过给精度两个小数点。 这允许我们编写非常通用的函数,可以处理任意数据,并从中生成特定的函数。
我们将在我们的代码中使用部分应用,我们可以使用 curry。 但是,一般来说,我们不会像在纯函数式语言中那样使用 curry,因为这会导致速度放缓和更高的内存消耗。 主要思想是局部应用和如何在内部作用域位置中使用外部作用域上的变量。
这三个概念对纯函数式编程的思想非常重要,但我们不会使用其中的大部分。 在高性能的代码中,我们需要尽可能地压缩每一盎司的速度和内存,而这些构造所占用的空间比我们所关心的要多。 某些概念可以在高性能代码中使用到很长的长度。 以下内容将在后面的章节中使用:部分应用,流/惰性计算,可能还有一些递归。 在使用使用这些概念的库时,熟悉函数式代码将会有所帮助,但正如我们已经详细讨论过的,它们的性能不如我们的迭代方法。
总结
在本章中,我们讨论了可变性和不可变性的概念。 我们已经看到了不可变性如何导致速度变慢和更高的内存消耗,并在编写高性能代码时成为一个问题。 我们已经了解了可变性,以及如何确保我们编写的代码既利用了可变性,又使其安全。 在此基础上,我们对可变代码和不可变代码进行了性能比较,并看到了不可变类型的速度和内存消耗增加的地方。 最后,我们介绍了 JavaScript 中的函数式编程,以及如何利用这些概念。 函数式编程可以帮助解决许多问题,比如无锁并发性,但我们也知道 JavaScript 运行时是单线程的,因此这并没有给我们带来优势。 总的来说,我们可以从不同的编程范例中借鉴许多概念,将所有这些概念都放在我们的工具包中可以让我们成为更好的程序员,并帮助我们编写干净、安全、高性能的代码。
在下一章中,我们将看看 JavaScript 是如何发展成为一门语言的。 我们还将了解浏览器是如何改变以满足开发人员的需求的,新 api 涵盖了从访问 DOM 到长期存储的所有内容。
版权属于:月萌API www.moonapi.com,转载请注明出处