深入理解 React JS 中的 setState

深入理解 React JS 中的 setState

此文主要探讨了 React JS 中的 setState 背后的机制,供深入学习 React 研究之用。

setState()的 “怪异”

我们看下面一段简单的代码,代码通过点击一个按钮,改变 state 中的 clicked 值。在修改值后进行 clicked 值的输出,你尝试猜测一下输出的值是什么?

我们在自己写代码遇到类似逻辑的时候都会发现,console.log(this.state.clicked); 这段代码输出的不是我们预期的 true,而是 false。

这是为什么呢?

setState 的内部机制

遇到问题我们还是去官方文档找线索。
我们看到 state 的章节有下面这段话。

文章链接在这里

我们会发现其实 React 的 setState 方法是一个异步的方法,React 会将所有的 setState 方法打包成一次进行更新,类似于快递点寄快递,囤积了一些包裹后一次投递,而不是你每次修改 state 都会进行更新。
这样的设计主要是为了提高 UI 更新的性能,我们知道 React 中 state 的改变会导致 UI 的更新。
如果需要进行同步操作逻辑,那么在回调函数里添加逻辑即可。

1
2
3
4
5
handleClick = () => {
this.setState({
clicked: true
}, () => console.log(this.state.clicked)) //这时候输出的是 true
}

state 的更新时机

任何 state 的更新都会导致 React 进行重新渲染。props 也会导致 React 进行重新渲染。组件与父组件的更改同样也会引起 React 的重新渲染。
那么我们有没有办法手动控制 React 是否进行渲染呢?
这里,你应该想起来生命周期函数里有一个方法 shouldComponentUpdate。

shouldComponentUpdate 方法官方文档

此方法默认每次在需要进行重新渲染时返回 true,但是在这个函数里你可以添加自己的逻辑,控制 React 不进行渲染以及渲染的条件。
那么,同样,我们也可以在此函数中定义那些我们关注的 state ,只有当它们变化才让 React 进行重新渲染,而其他一些不相关的 state 的值即使变化了,我们也可以让 React 不进行渲染。
理解了这些,那么在你进行相关性能优化时就非常有用。

JavaScript语言编程规范(ES6)

本篇博文参考Airbnb规范制定,有错误或不当请您务必指出

代码风格

导出的默认函数使用驼峰命名、文件名与函数完全一致。

1
2
3
4
function makeStyleGuide(){

}
export default makeStyleGuide;

导出单例、函数库、空对象时使用帕斯卡式命名(帕斯卡式命名法是在命名的时候将首字母大写, 如: DisplayInfo)。

1
2
3
4
const AirbnbStyleGuide = {
es6: {}
}
export default AirbnbStyleGuide

类型和变量

变星必须显式声明作用域

  • var => 用于声明全局变量或函数级变量
  • let => 用于声明块级的局部变量
  • const => 声明块级域的只读局部变量。
1
2
3
4
5
6
7
8
9
const names = [];
names.push('john');
console.log(names);
// 变量名字在内存中的指针不能够改变, 但是指向这个变量的值可能改变。
name = [] // ERROR
{
let name;
name = 'tom'
}

在ES6中, const代表一个值的“常量索引”, 换句话说, 变量名字在内存中的指针不能够改变, 但是指向这个变量的值可能改变。

尽量对所有的引用使用const, 不要使用var。 如果你一定需要使用可变动的引用, 使用let代替var。

不好

1
2
3
4
5
var a=1;
var count=1;
if(true){
count+=1
}

1
2
3
4
5
const a=1;
let count=1;
if(true){
count+=1
}

说明:const和let的作用域更小, 写代码更容易控制。 const可确保无法对引用重新赋值, const引用的指针不变, 重新赋值会报错, 避免不小心的重新赋值给覆盖了。

将所有的const和let分组,并隔行

不好

1
2
3
4
let i;
const items=[];
let d;
const flag=true;

1
2
3
4
5
const flag=true;
const items=[];

let i;
let d;

在需要的地方给变量 赋值, 但请把它们放在一个合理的位置。

说明:let和const是作用域而不是函数作用域, 不用担心变量定义会被前移导致问题, 把变量的赋值和调用代码放在一起会使逻辑更加清晰, 可读性更好。

不好

1
2
3
4
5
6
7
8
function(name){
const name=getName();
if(name){
return false;
}
this.setName(name);
return true;
}

1
2
3
4
5
6
7
8
function(name){
if(name){
return false;
}
const name=getName();
this.setName(name);
return true;
}

对象和引用

创建有动态属性名的对象时, 尽量在一个地方定义对象的所有属性。

不好

1
2
3
4
5
const obj={
id:'aadadadaffaxvv',
name:'zhangsan',
}
obj[getKey('enabled')]=true;

1
2
3
4
5
6
const obj={
id:'aadadadaffaxvv',
name:'zhangsan',
obj[getKey('enabled')]:true,
}

使用对象方法的简写。

说明:ES6中, 对象字面量被增强了, 写法更加简洁与灵活, 同时在定义对象的时候, 能够做的事件更多了。

不好

1
2
3
4
5
6
const atom = {
value: 1,
addValue:function(value) {
return atom.value + value;
}
};

1
2
3
4
5
6
7
const atom = {
value: 1,
addValue(value) {
return atom.value + value;
}
};

使用对象属性的简写

不好

1
2
3
const obj = {
a: a,
};

1
2
3
const obj = {
a,
};

数组

使用扩展运算符 … 复制数组

扩展运算符可以减少赋值语句的使用, 或者减少通过下标访问数组或对象的方式, 使用代码更加简洁优雅, 可读性更佳。

不好

1
2
3
4
5
6
7
const len = items.length;
const itemsCopy = [];

let i;
for(i=0; i<len; i++) {
itemsCopy[i] = items[i];
}

1
const itemsCopy = [...items];

属性

使用 . 来访问对象的属性, 只有属性是动态的时候使用 []。

1
2
const key=getKey();
const props=obj[key];

逗号、分号

逗号写在行尾, 并且增加结尾的逗号。

不好

1
2
3
4
const obj = {
firstName: 'Dana',
lastName: 'Scally'
};

1
2
3
4
const obj = {
firstName: 'Dana',
lastName: Scally',
};

使用分号, 以分号作为语句的结束符。

1
2
3
;(()=>{
const name='a';
})();

函数

使用函数声明代替函数表达式

说明:因为函数声明是可命名的, 所以他们在调用栈中更容易被识别。 此外, 函数声明会把整个函数提升, 而函数表达式只会把函数引用的变量名提升。 这条规则使得箭头函数可以取代函数表达式。

不好

1
2
3
const function = foo(){

}

1
2
3
function foo(){

}

不要使用arguments, 可以选择rest语法 … 替代。

说明:使用 … 能明确你要传入的参数, 另外, rest语法参数是一个真正的数组, 而arguments是一个类数组。

不好

1
2
3
4
function concatenateAll() {
const args = Array.prototype.slice.call(arguments);
return args.join(' ');
}

1
2
3
function concatenateAll(...args) {
return args.join(' ');
}

直接给函数的参数指定默认值, 不要使用一个变化的函数参数。

1
2
3
function handle(opts={}){

}

直接给函数参数赋值时, 需要避免副作用。

不好

1
2
3
4
5
6
var b=1;
function count(a=b++){

}
count();//1
count();//2

代码块

使用大括号包裹所有的多行代码块。

不好

1
2
3
4
if(true) 
return false;

function (){ return false }

1
2
3
4
5
6
7
8
if(true) return false;
if(true) {
return false;
}

function (){
return false
}
  1. 如果通过if和else使用多行代码块, 把else放在if代码块关闭括号的同一行。

不好

1
2
3
4
5
6
if(arg) {
return arg;
}
else {
return false;
}

1
2
3
4
5
if(arg) {
return arg;
} else {
return false;
}

模块

代码中总是使用ES6标准的模块(import/export)方式, 而不是使用非标准的模块加载器。

不好

1
2
const AirbnbStyleGuide=require('./AirbnbStyleGuide');
moudle.exports=AirbnbStyleGuide.es6;

1
2
import AirbnbStyleGuide from '/AirbnbStyleGuide';
export default AirbnbStyleGuide.es6;

更好

1
2
import { es6 } from '/AirbnbStyleGuide';
export default es6;

不要使用通配符 *的import 。

说明:这样可以确保被import的模块只有一个默认的export项。

不好

1
import * as AirbnbStyleGuide from '/AirbnbStyleGuide';

1
import AirbnbStyleGuide from '/AirbnbStyleGuide';

不要从import中直接export.

不好

1
export {es6 as defaults} from './ablier.js'; 

1
2
import {es6} from './ablier.js';
export default es6;

如果你的文件只输出一个类, 那你的文件名必须和类名完全保持一致。

1
import CheckBox from './CheckBox';

箭头函数

别保存this的引用, 使用箭头函数或Function.bind。

说明:箭头函数提供了更简洁的语法, 并且箭头函数中的this对象指向是不变的, this绑定到定义时所在的对象。 通常情况下, 这是我们想要的, 有很好的代码可读性, 而保存this对象的使用方式, 会让开发人员搞混。

不好

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo(){
const self=this;
return function(){
console.log(self)
}
}

function foo(){
const that=this;
return function(){
console.log(that)
}
}

1
2
3
4
5
function foo(){
return () =>{
console.log(this)
}
}

当你必须使用函数表达式(或传递一个匿名函数)时, 使用箭头函数。

说明:箭头函数创造了一个新的this执行环境。 通常情况下, 能满足你的需求, 而且这样的写更为简洁。 如果你有一个相当复杂的函数, 那么可以把逻辑部分转移到一个函数声明上。

不好

1
2
3
[1, 2, 3].map(function(x) {
return x * x;
});

1
2
3
[1, 2, 3].map( (x) => {
return x * x;
});

如果一个函数适用一行写出并且只有一个参数, 那就把花括号、圆括号和return都省略掉, 如果不是, 那就不要省略。

1
2
3
4
5
6
7
[1, 2, 3].map( x => x * x;);


[1, 2, 3].reduce((total,n)=>{
return total+n;
}, 0)

构造器

采用class关键字定义类

不好

1
2
3
4
5
6
7
8
9
function Queue(contents=[]{
this._queue=[...contents];
})
Queue.prototype.pop=function(){
const value=this._queue[0];
this.queue.splice(0,1);
return value;
}

1
2
3
4
5
6
7
8
9
10
class Queue{
constructor(contents=[]){
this._queue=[...contents];
}
pop(){
const value=this._queue[0];
this.queue.splice(0,1);
return value;
}
}

采用extends关键字实现继承。

说明:因为extends是内建的继承方式, 并不会破坏instanceof原型检查。

不好

1
2
3
4
5
6
7
8
9
const inherits=require('inherits');
function peekableQueue(contents){
Queue.apply(this, contents)
}
inherits(peekableQueue, Queue);
peekableQueue.prototype.peek=function(){
return this._queue[0];
}

1
2
3
4
5
class peekableQueue extends Queue{
peek(){
return this._queue[0];
}
}

解构

使用解构存取和使用多属性对象。

说明:ES6允许按照一定的模式, 从数组和对象中提取值、对变量进行赋值, 这称之为解构, 解构赋值避免了临时变量或对象, 给JavaScript书写带来了很大的便利性, 同时也提高了代码的可读性。

不好

1
2
3
4
5
function getFullName (user) {
const firstName = user.firstName;
const lastName = user.lastName;
return `${firstName} ${lastName}`;
}

1
2
3
function getFullName ({firstName, lastName}) {
return `${firstName} ${lastName}`;
}

将数组成员赋值给变量时, 使用数组解析。

不好

1
2
3
const arr = [1,2,3]
const first=arr[0];
const second=arr[1];

1
const [first, second]=arr;

需要回传多个值时, 使用对象解构, 而不是数组解构。

说明:对象解构在增加属性或改变排序时, 无需改变调用时的位置。

不好

1
2
3
4
5
6
function input(inputType){
return [left, right, top, bottom]
}

// 调用时候考虑回调函数顺序
const [left, _, top]=input(inputType);

1
2
// 调用时候只选择需要的数据
const [left, top]=input(inputType);

String

长度超过80的字符串应该使用字符串连接换行。

构建字符串时, 使用字符串模板而不是字符串连接。

1
2
3
function sayHi(name) {
return `How are you, ${name}`;
}

数组遍历采用for/of, 对象遍历采用for/in。

不好

1
2
3
4
5
let arr = [1, 2, 3, 4];
let sum = 0;
for (let num in arr) {
sum += num;
}

1
2
3
4
5
let arr = [1, 2, 3, 4];
let sum = 0;
for (let num of arr) {
sum += num;
}

存取器

属性的存取函数不是必须的, 如果需要存储函数使用get方法和set方法。 如果属性是布尔值, 存取函数是isVal()或hasVal()。

不好

1
2
3
4
5
dragon.age();
dragon.age(25);
if (!dragon.age()) {
return false;
}

1
2
3
4
5
dragon.getAge();
dragon.setAge(25);
if (!dragon.getAge()) {
return false;
}

创建get()和set()函数要保持一致。

1
2
3
4
5
6
7
8
9
10
11
12
class Demo {
constructor (options = {}) {
const lightsaber = options.lightsaber || 'blue';
this.set('lightsaber', lightsaber);
}
set (key, val) {
this[key] = val;
}
get (key) {
return this[key];
}
}

React-UI

使用开源的ant-design库开发项目指南

最流行的开源React UI组件库

ant-design使用入门

使用create-react-app搭建react开发环境

  
1
2
3
4
npm install create-react-app -g
create-react-app antd-demo
cd antd-demo
npm start

搭建antd的基本开发环境

下载

1
npm install antd --save

src/App.js

    import React, { Component } from 'react';
    import { Button } from 'antd';
    import './App.css';
    
    class App extends Component {
      render() {
        return (
          <div className="App">
            <Button type="primary">Button</Button>
          </div>
        );
      }
    }

    export default App;

src/App.css

    @import '~antd/dist/antd.css';
    
    .App {
      text-align: center;
    }

实现按需加载(css/js)

使用 eject 命令将所有内建的配置暴露出来

1
npm run eject

下载babel-plugin-import(用于按需加载组件代码和样式的 babel 插件)
1
npm install babel-plugin-import --save-dev

修改配置: config/webpack.config.dev.js

    // Process JS with Babel.
    {
      test: /\.(js|jsx)$/,
      include: paths.appSrc,
      loader: 'babel',
      query: {
      +   plugins: [
        +     ['import', [{ libraryName: "antd", style: 'css' }]],
        +   ],
      
        // This is a feature of `babel-loader` for webpack (not Babel itself).
        // It enables caching results in ./node_modules/.cache/babel-loader/
        // directory for faster rebuilds.
        cacheDirectory: true
      }
    },

去除引入全量样式的语句: src/App.css

    
1
@import '~antd/dist/antd.css'

ant-design组件使用小样

欢迎Fork

React组件生命周期和钩子函数的介绍

React生命周期

组件生命周期的执行次数是什么样子的???

  • 只执行一次: constructor、componentWillMount、componentDidMount

  • 执行多次:render 、子组件的componentWillReceiveProps、componentWillUpdate、componentDidUpdate

  • 有条件的执行:componentWillUnmount(页面离开,组件销毁时)

  • 不执行的:根组件(ReactDOM.render在DOM上的组件)的componentWillReceiveProps(因为压根没有父组件给传递props)

组件的生命周期执行顺序是什么样子的???

生命周期代码探究

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
class LifeCycle extends React.Component {
constructor(props){
super(props);
this.state = {
person:{
name:'fuhq',
age: 18
}
}
console.log('constructor()')
}
// 组件将要被挂载
componentWillMount(){
//console.log(this);
console.log('componentWillMount() 组件将要被挂载')
// 发送ajax请求, 开启定时器
// 需要手动绑定改变this指向
// setTimeout(function(){
// this.setState({
// person:{
// name:'yanggq',
// age: 19
// }
// })
// }.bind(this), 1000)
// 箭头函数更方便
setTimeout(()=>{
this.setState({
person:{
name:'yanggq',
age: 19
}
})
}, 2000)
}
// 组件挂载完毕
componentDidMount(){
console.log('componentDidMount() 组件挂载完毕')
// 官方推荐在这个钩子里面发送请求
// 发送ajax请求, 开启定时器
setTimeout(()=>{
// 卸载
ReactDom.unmoutComponentAtNode(document.getElementById('example'))
}, 4000)

this.intervalId = setInterval(function(){
console.log('setInterval()')
}, 1000)
}
// 组件将要更新
componentWillUpdate(){
console.log('componentWillUpdate() 组件将要更新')
}
// 组件更新完毕
componentDidUpdate(){
console.log('componentDidUpdate() 组件更新完毕')
}
// 组件将要被卸载
componentWillUnmount(){
console.log('componentWillUnmount() 组件将要被卸载')
// 做一些收尾工作,关掉定时器
clearInterval(this.intervalId)
}
render(){
console.log('render()')
return (
<div>{person.name}:{person.age}</div>
)
}
}
React.render(<LifeCycle />, document.getElementById('example'));

控制台查看结果

总结起来如下图

React概览

几个重要概念理解

  • 模块与组件
    模块:
     理解: 向外提供特定(局部)功能的js程序, 一般就是一个js文件
     为什么: js代码更多更复杂
     作用: 复用js, 简化js的编写, 提高js运行效率
    
    组件:
     理解: 用来实现特定功能效果的代码集合(html/css/js)
     为什么: 一个界面的功能更复杂
     作用: 复用编码, 简化项目编码, 提高运行效率
    
  • 模块化与组件化
    模块化:
     当应用的js都以模块来编写的, 这个应用就是一个模块化的应用
    
    组件化:
     当应用是以多组件的方式实现功能, 这上应用就是一个组件化的应用
    

React的基本认识

  • Facebook 发布用来动态构建用户界面的一个js库
  • React的特点
    Declarative(声明式编码)
    Component-Based(组件化编码)
    Learn Once, Write Anywhere(支持客户端与服务器渲染)
    高效
      React高效的原因
          虚拟(virtual)DOM, 不总是直接操作DOM(批量更新, 减少更新的次数) 
          高效的DOM Diff算法, 最小化页面重绘(减小页面更新的区域)
    
    单向数据流

使用React

  • 导入相关js库文件(react.js, react-dom.js, babel.min.js)

编码:

1
2
3
4
5
<div id="container"></div>
<script type="text/babel">
var aa = 123
ReactDOM.render(<h1>{aa}</h1>, containerDOM);
</script>

JSX

  • 全称: JavaScript XML
  • react定义的一种类似于XML的JS扩展语法: XML+JS,标签的class属性必须改为className属性
  • 作用: 用来创建react虚拟DOM(元素)对象,js中直接可以套标签, 但标签要套js需要放在{}中

把数据的数组转换为标签的数组:

1
2
3
var liArr = dataArr.map(function(item, index){
return <li key={index}>{item}</li>
})

虚拟DOM

  • 虚拟DOM是什么?
    一个虚拟DOM(元素)是一个一般的js对象, 准确的说是一个对象树(倒立的)
    虚拟DOM保存了真实DOM的层次关系和一些基本属性,与真实DOM一一对应
    如果只是更新虚拟DOM, 页面是不会重绘的
  • Virtual DOM 算法的基本步骤
    用 JavaScript 对象结构表示 DOM 树的结构;然后用这个树构建一个真正的 DOM 树,插到文档当中
    当状态变更的时候,重新构造一棵新的对象树。然后用新的树和旧的树进行比较,记录两棵树差异
    把2所记录的差异应用到步骤1所构建的真正的DOM树上,视图就更新了
  • 进一步理解
    Virtual DOM 本质上就是在 JS 和 DOM 之间做了一个缓存。
    可以类比 CPU 和硬盘,既然硬盘这么慢,我们就在它们之间加个缓存:既然 DOM 这么慢,我们就在它们 JS 和 DOM 之间加个缓存。
    CPU(JS)只操作内存(Virtual DOM),最后的时候再把变更写入硬盘(DOM)。

Component : React是面向组件编程的(组件化编码开发)

基本理解和使用

自定义的标签: 组件类(函数)/标签

创建组件类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//方式1: 无状态函数(最简洁, 推荐使用)
function MyComponent1() {
return <h1>自定义组件标题11111</h1>;
}
//方式2: ES6类语法(复杂组件, 推荐使用)
class MyComponent3 extends React.Component {
render () {
return <h1>自定义组件标题33333</h1>;
}
}
//方式3: ES5老语法(不推荐使用了)
var MyComponent2 = React.createClass({
render () {
return <h1>自定义组件标题22222</h1>;
}
});

渲染组件标签

1
ReactDOM.render(<MyComp />,  cotainerEle);

ReactDOM.render()渲染组件标签的基本流程

React内部会创建组件实例对象/调用组件函数, 得到虚拟DOM对象
将虚拟DOM并解析为真实DOM
插入到指定的页面元素内部

props

  • 所有组件标签的属性的集合对象
  • 给标签指定属性, 保存外部数据(可能是一个function)
  • 在组件内部读取属性: this.props.propertyName
  • 作用: 从目标组件外部向组件内部传递数据

对props中的属性值进行类型限制和必要性限制

1
2
3
4
Person.propTypes = {
name: React.PropTypes.string.isRequired,
age: React.PropTypes.number.isRequired
}

扩展属性: 将对象的所有属性通过props传递

1
2
<Person {...person}/>

组件的组合

  • 组件标签中包含子组件标签
  • 拆分组件: 拆分界面, 抽取组件
  • 通过props传递数据

refs

  • 组件内包含ref属性的标签元素的集合对象
  • 给操作目标标签指定ref属性, 打一个标识
  • 在组件内部获得标签对象: this.refs.refName(只是得到了标签元素对象)
  • 作用: 操作组件内部的真实标签dom元素对象

事件处理

  • 给标签添加属性: onXxx={this.eventHandler}

  • 在组件中添加事件处理方法

    1
    2
    3
    eventHandler(event) {

    }
  • 使自定义方法中的this为组件对象

  • 在constructor()中bind(this)

  • 使用箭头函数定义方法(ES6模块化编码时才能使用)

state

  • 组件被称为”状态机”, 页面的显示是根据组件的state属性的数据来显示
  • 初始化指定:
1
2
3
4
5
6
7
constructor() {
super();
this.state = {
stateName1 : stateValue1,
stateName2 : stateValue2
};
}

读取显示:

  • this.state.stateName1
  • 更新状态–>更新界面 :
  • this.setState({stateName1 : newValue})

实现一个双向绑定的组件

  • React是单向数据流
  • 需要通过onChange监听手动实现

组件生命周期

  • 组件的三个生命周期状态:
  • Mount:插入真实 DOM
  • Update:被重新渲染
  • Unmount:被移出真实 DOM

生命周期流程:

第一次初始化显示

1
2
3
4
constructor()
componentWillMount() : 将要插入回调
render() : 用于插入虚拟DOM回调
componentDidMount() : 已经插入回调

每次更新state

1
2
3
4
componentWillReceiveProps(): 接收父组件新的属性
componentWillUpdate() : 将要更新回调
render() : 更新(重新渲染)
componentDidUpdate() : 已经更新回调

删除组件

1
2
ReactDOM.unmountComponentAtNode(document.getElementById('example')) : 移除组件
componentWillUnmount() : 组件将要被移除回调

常用的方法

render(): 必须重写, 返回一个自定义的虚拟DOM
constructor(): 初始化状态, 绑定this(可以箭头函数代替)
componentDidMount() : 只执行一次, 已经在dom树中, 适合启动/设置一些监听

更详细的介绍和实例请点击这里

ajax

  • React没有ajax模块
  • 集成其它的js库(如axios/fetch/jQuery/), 发送ajax请求
    axios
      封装XmlHttpRequest对象的ajax
      promise
      可以用在浏览器端和服务器
    
    fetch
      不再使用XmlHttpRequest对象提交ajax请求
      fetch就是用来提交ajax请求的函数, 只是新的浏览才内置了fetch
      为了兼容低版本的浏览器, 可以引入fetch.js
    
    在哪个方法去发送ajax请求
      只显示一次(请求一次): componentDidMount()
      显示多次(请求多次): componentWillReceiveProps()
    

vue2.x组件生命周期和钩子函数的介绍

生命周期图示

下图展示了实例的生命周期。随着你的不断学习和使用,它的参考价值会越来越高。

vue1.x和vue2.x对比图示

生命周期代码探究

直接粘到html文件中即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
<!DOCTYPE html>
<html>
<head>
<title></title>
<meta name="" content="" charset="utf-8"/>
<script type="text/javascript" src="https://cdn.jsdelivr.net/vue/2.1.3/vue.js"></script>
</head>
<body>
<div id="app">
<p>{{ message }}</p>
</div>
<script type="text/javascript">
var app = new Vue({
el: '#app',
data: {
message : "xuxiao is boy"
},
beforeCreate: function () {
console.group('beforeCreate 创建前状态===============》');
console.log("%c%s", "color:red" , "el : " + this.$el); //undefined
console.log("%c%s", "color:red","data : " + this.$data); //undefined
console.log("%c%s", "color:red","message: " + this.message)
},
created: function () {
console.group('created 创建完毕状态===============》');
console.log("%c%s", "color:red","el : " + this.$el); //undefined
console.log("%c%s", "color:red","data : " + this.$data); //已被初始化
console.log("%c%s", "color:red","message: " + this.message); //已被初始化
},
beforeMount: function () {
console.group('beforeMount 挂载前状态===============》');
console.log("%c%s", "color:red","el : " + (this.$el)); //已被初始化
console.log(this.$el);
console.log("%c%s", "color:red","data : " + this.$data); //已被初始化
console.log("%c%s", "color:red","message: " + this.message); //已被初始化
},
mounted: function () {
console.group('mounted 挂载结束状态===============》');
console.log("%c%s", "color:red","el : " + this.$el); //已被初始化
console.log(this.$el);
console.log("%c%s", "color:red","data : " + this.$data); //已被初始化
console.log("%c%s", "color:red","message: " + this.message); //已被初始化
},
beforeUpdate: function () {
console.group('beforeUpdate 更新前状态===============》');
console.log("%c%s", "color:red","el : " + this.$el);
console.log(this.$el);
console.log("%c%s", "color:red","data : " + this.$data);
console.log("%c%s", "color:red","message: " + this.message);
},
updated: function () {
console.group('updated 更新完成状态===============》');
console.log("%c%s", "color:red","el : " + this.$el);
console.log(this.$el);
console.log("%c%s", "color:red","data : " + this.$data);
console.log("%c%s", "color:red","message: " + this.message);
},
beforeDestroy: function () {
console.group('beforeDestroy 销毁前状态===============》');
console.log("%c%s", "color:red","el : " + this.$el);
console.log(this.$el);
console.log("%c%s", "color:red","data : " + this.$data);
console.log("%c%s", "color:red","message: " + this.message);
},
destroyed: function () {
console.group('destroyed 销毁完成状态===============》');
console.log("%c%s", "color:red","el : " + this.$el);
console.log(this.$el);
console.log("%c%s", "color:red","data : " + this.$data);
console.log("%c%s", "color:red","message: " + this.message)
}
})
</script>
</body>
</html>

create 和 mounted 相关

咱们在chrome浏览器里打开,F12看console就能发现

  • beforecreated:el 和 data 并未初始化
  • created:完成了 data 数据的初始化,el没有
  • beforeMount:完成了 el 和 data 初始化
  • mounted :完成挂载

另外在标红处,我们能发现el还是 ,这里就是应用的 Virtual DOM(虚拟Dom)技术,先把坑占住了。到后面mounted挂载的时候再把值渲染进去。

update 相关

这里我们在 chrome console里执行以下命令
app.message= ‘yes !! I do’;
下面就能看到data里的值被修改后,将会触发update的操作。

destroy 相关

有关于销毁,暂时还不是很清楚。我们在console里执行下命令app.$destroy();对 vue实例进行销毁。销毁完成后,我们再重新改变message的值,vue不再对此动作进行响应了。但是原先生成的dom元素还存在,可以这么理解,执行了destroy操作,后续就不再受vue控制了。

总结

  • beforecreate : 举个栗子:可以在这加个loading事件
  • created :在这结束loading,还做一些初始化,实现函数自执行
  • mounted : 在这发起后端请求,拿回数据,配合路由钩子做一些事情
  • beforeDestory: 你确认删除XX吗?
  • destoryed :当前组件已被删除,清空相关内容

深,浅拷贝

深拷贝,浅拷贝的概念

简单点来说,就是假设通过B复制了A,当修改A时,看B是否会发生变化,如果B也跟着变了,说明这是浅拷贝,如果B没变,那就是深拷贝。

为什么拷贝有深浅之分

我们来举个浅拷贝例子:

1
2
3
4
5
let a=[0,1,2,3,4],
b=a;
console.log(a===b);
a[0]=1;
console.log(a,b);

结果如下:

b复制给了a,为啥修改数组a,数组b也跟着变了。

那么这里,就得引入基本数据类型与引用数据类型的概念了

基本数据类型有,number,string,boolean,null,undefined,symbol以及未来ES10新增的BigInt(任意精度整数)七类。

引用数据类型(Object类)有常规名值对的无序对象{a:1},数组[1,2,3],以及函数等。

而这两类数据存储分别是这样的:

a.基本类型–名值存储在栈内存中,例如let a=1;

当你b=a复制时,栈内存会新开辟一个内存,例如这样:

所以当你此时修改a=2,对b并不会造成影响,因为此时的b已自食其力,翅膀硬了,不受a的影响了。当然,let a=1,b=a;虽然b不受a影响,但这也算不上深拷贝,因为深拷贝本身只针对较为复杂的object类型数据。

b.引用数据类型–名存在栈内存中,值存在于堆内存中,但是栈内存会提供一个引用的地址指向堆内存中的值,我们以上面浅拷贝的例子画个图:

当b=a进行拷贝时,其实复制的是a的引用地址,而并非堆里面的值。

而当我们a[0]=1时进行数组修改时,由于a与b指向的是同一个地址,所以自然b也受了影响,这就是所谓的浅拷贝了。

那,要是在堆内存中也开辟一个新的内存专门为b存放值,就像基本类型那样,岂不就达到深拷贝的效果了

实现深拷贝的方法

利用 递归 来实现深复制,对属性中所有引用类型的值,遍历到是基本类型的值为止

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function deepClone(source){    
if(!source && typeof source !== 'object'){
throw new Error('error arguments', 'shallowClone');
}
var targetObj = Array.isArray(source) ? [] : {};
for(var keys in source){
if(source.hasOwnProperty(keys)){
if(source[keys] && typeof source[keys] === 'object'){
targetObj[keys] = deepClone(source[keys]); //递归
}else{
targetObj[keys] = source[keys];
}
}
}
return targetObj;
}

检测一下

1
2
3
4
5
6
var a = {name:"jack",age:20};
var b = deepClone(a);
console.log(a === b);
a.age = 30;
console.log(a);
console.log(b);

jQuery中的 extend(true, target, object1 [, objectN ])

  • $.extend( [deep ], target, object1 [, objectN ] )
    deep表示是否深拷贝,为true为深拷贝,为false,则为浅拷贝

    target Object类型 目标对象,其他对象的成员属性将被附加到该对象上。

    object1 objectN可选。 Object类型 第一个以及第N个被合并的对象。

深拷贝:

1
2
3
4
5
6
7
8
var obj = {name:'xixi',age:20,company : { name : '腾讯', address : '深圳'} };
var obj_extend = $.extend(true,{}, obj);
//extend方法,第一个参数为true,为深拷贝,为false,或者没有为浅拷贝。
console.log(obj === obj_extend);
obj.company.name = "ali";
obj.name = "hei";
console.log(obj);
console.log(obj_extend);

浅拷贝

1
2
3
4
5
6
var obj = {name:"xixi",age:20};
var obj_extend = $.extend(false,{}, obj); //extend方法,第一个参数为true,为深拷贝,为false,或者没有为浅拷贝。
console.log(obj === obj_extend);
obj.name = "heihei";
console.log(obj);
console.log(obj_extend);

通过引入js的实用库 Lodash

例子:

1
2
3
4
5
6
var objects = [{ 'a': 1 }, { 'b': 2 }];

var deep = _.cloneDeep(objects);
console.log(deep[0] === objects[0]);
// => false

实现浅拷贝的方法

JSON.parse(JSON.stringify())

1
2
3
4
5
6
7
8
9
10
11
12
var syb = Symbol('obj');
var person = {
name :'tino',
say: function(){
console.log('hi');
},
ok: syb,
un: undefined
}
var copy = JSON.parse(JSON.stringify(person))
// copy
// {name: "tino"}

ps: 当值为undefined、function、symbol 会在转换过程中被忽略。。。所以,对象值有这三种的话用这种方法会导致属性丢失。

Array 的 slice 和 concat 方法

1
2
3
4
5
6
var a = [[1,2,3],4,5];
var b = a.slice();
console.log(a === b);
a[0][0] = 6;
console.log(a);
console.log(b);

Object.assgin()

1
2
3
4
5
6
7
8
9
10
11
12
var person1 = {
name: "xiaoming",
age: 18,
job: "programmer"
}
var person2 = {
age: 21,
stature: "181cm"
}
var person = Object.assign(person1,person2);

console.log(person)

其实总结一下就是:

Array 的 slice 和 concat 和 Object.assign(),他们都会复制第一层的值,对于 第一层 的值都是 深拷贝,而到 第二层 如果key是引用类型的时候 就是 浅拷贝 。

总结

如果要复制的对象或者数组都是简单数据类型,放心大胆的使用。如果存在深层次的引用,选择方法的时候要慎重。

这一次,彻底弄懂JavaScript执行机制

这一次,彻底弄懂 JavaScript 执行机制

因为javascript是一门单线程语言,所以我们可以得出结论:

  • javascript是按照语句出现的顺序执行的

所以我们以为js都是这样的:

1
2
3
4
5
const a = '1';
console.log(a);

const b = '2';
console.log(b);

然而实际上js是这样的:

1
2
3
4
5
setTimeout(function(){
console.log('定时器开始啦')
});

console.log('代码执行结束');

依照js是按照语句出现的顺序执行这个理念,我自信的写下输出结果:

1
2
//"定时器开始啦"
//"代码执行结束"

去chrome上验证下,

1
2
//"代码执行结束"
//"定时器开始啦"

结果是这样的,瞬间懵了,说好的一行一行执行的呢?
这回我们真的要彻底弄明白javascript的执行机制了。

为什么JavaScript是单线程?

JavaScript语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。那么,为什么JavaScript不能有多个线程呢?这样能提高效率啊。

JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。

javascript事件循环

既然js是单线程,那就像只有一个窗口的银行,客户需要排队一个一个办理业务,同理js任务也要一个一个顺序执行。如果一个任务耗时过长,那么后一个任务也必须等着。那么问题来了,假如我们想浏览新闻,但是新闻包含的超清图片加载很慢,难道我们的网页要一直卡着直到图片完全显示出来?因此聪明的程序员将任务分为两类:

  • 同步任务
  • 异步任务

当我们打开网站时,网页的渲染过程就是一大堆同步任务,比如页面骨架和页面元素的渲染。而像加载图片音乐之类占用资源大耗时久的任务,就是异步任务。关于这部分有严格的文字定义,但本文的目的是用最小的学习成本彻底弄懂执行机制,所以我们用导图来说明:

导图要表达的内容用文字来表述的话:

  • 同步和异步任务分别进入不同的执行”场所”,同步的进入主线程,异步的进入Event Table并注册函数。
  • 当指定的事情完成时,Event Table会将这个函数移入Event Queue。
  • 主线程内的任务执行完毕为空,会去Event Queue读取对应的函数,进入主线程执行。
  • 上述过程会不断重复,也就是常说的Event Loop(事件循环)。

我们不禁要问了,那怎么知道主线程执行栈为空啊?js引擎存在monitoring process进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去Event Queue那里检查是否有等待被调用的函数。
说了这么多文字,不如直接一段代码更直白:

1
2
3
4
5
6
7
8
9
const data = [];
$.ajax({
url:www.javascript.com,
data:data,
success:() => {
console.log('发送成功!');
}
})
console.log('代码执行结束');

上面是一段简易的ajax请求代码:

  • ajax进入Event Table,注册回调函数success。
  • 执行console.log(‘代码执行结束’)。
  • ajax事件完成,回调函数success进入Event Queue。
  • 主线程从Event Queue读取回调函数success并执行。

相信通过上面的文字和代码,你已经对js的执行顺序有了初步了解。接下来我们来研究进阶话题:setTimeout。

又爱又恨的setTimeout

大名鼎鼎的setTimeout无需再多言,大家对他的第一印象就是异步可以延时执行,我们经常这么实现延时3秒执行:

1
2
3
setTimeout(() => {
console.log('延时3秒');
},3000)

渐渐的setTimeout用的地方多了,问题也出现了,有时候明明写的延时3秒,实际却5,6秒才执行函数,这又咋回事啊?
先看一个例子:

1
2
3
4
setTimeout(() => {
task();
},3000)
console.log('执行console');

根据前面我们的结论,setTimeout是异步的,应该先执行console.log这个同步任务,所以我们的结论是:

1
2
//执行console
//task()

去验证一下,结果正确!然后我们修改一下前面的代码:

1
2
3
4
5
setTimeout(() => {
task()
},3000)

sleep(10000000)

乍一看其实差不多嘛,但我们把这段代码在chrome执行一下,却发现控制台执行task()需要的时间远远超过3秒,说好的延时三秒,为啥现在需要这么长时间啊?
这时候我们需要重新理解setTimeout的定义。我们先说上述代码是怎么执行的:

  • task()进入Event Table并注册,计时开始。
  • 执行sleep函数,很慢,非常慢,计时仍在继续。
  • 3秒到了,计时事件timeout完成,task()进入Event Queue,但是sleep也太慢了吧,还没执行完,只好等着。
  • sleep终于执行完了,task()终于从Event Queue进入了主线程执行。

上述的流程走完,我们知道setTimeout这个函数,是经过指定时间后,把要执行的任务(本例中为task())加入到Event Queue中,又因为是单线程任务要一个一个执行,如果前面的任务需要的时间太久,那么只能等着,导致真正的延迟时间远远大于3秒。
我们还经常遇到setTimeout(fn,0)这样的代码,0秒后执行又是什么意思呢?是不是可以立即执行呢?
答案是不会的,setTimeout(fn,0)的含义是,指定某个任务在主线程最早可得的空闲时间执行,意思就是不用再等多少秒了,只要主线程执行栈内的同步任务全部执行完成,栈为空就马上执行。举例说明:

1
2
3
4
5
6
7
8
9
//代码1
console.log('先执行这里');
setTimeout(() => {
console.log('执行啦')
},0);复制代码//代码2
console.log('先执行这里');
setTimeout(() => {
console.log('执行啦')
},3000);

代码1的输出结果是:

1
2
3
4
5
//先执行这里
//执行啦复制代码代码2的输出结果是:
//先执行这里
// ... 3s later
// 执行啦

关于setTimeout要补充的是,即便主线程为空,0毫秒实际上也是达不到的。根据HTML的标准,最低是4毫秒。有兴趣的同学可以自行了解。

又恨又爱的setInterval

上面说完了setTimeout,当然不能错过它的孪生兄弟setInterval。他俩差不多,只不过后者是循环的执行。对于执行顺序来说,setInterval会每隔指定的时间将注册的函数置入Event Queue,如果前面的任务耗时太久,那么同样需要等待。
唯一需要注意的一点是,对于setInterval(fn,ms)来说,我们已经知道不是每过ms秒会执行一次fn,而是每过ms秒,会有fn进入Event Queue。一旦setInterval的回调函数fn执行时间超过了延迟时间ms,那么就完全看不出来有时间间隔了。这句话请读者仔细品味。

Promise与process.nextTick(callback)

传统的定时器我们已经研究过了,接着我们探究Promise与process.nextTick(callback)的表现。
Promise的定义和功能本文不再赘述,不了解的读者可以学习一下阮一峰老师的Promise。而process.nextTick(callback)类似node.js版的”setTimeout”,在事件循环的下一次循环中调用 callback 回调函数。
我们进入正题,除了广义的同步任务和异步任务,我们对任务有更精细的定义:

  • macro-task(宏任务):包括整体代码script,setTimeout,setInterval
  • micro-task(微任务):Promise,process.nextTick

不同类型的任务会进入对应的Event Queue,比如setTimeout和setInterval会进入相同的Event Queue。
事件循环的顺序,决定js代码的执行顺序。进入整体代码(宏任务)后,开始第一次循环。接着执行所有的微任务。然后再次从宏任务开始,找到其中一个任务队列执行完毕,再执行所有的微任务。听起来有点绕,我们用文章最开始的一段代码说明:

1
2
3
4
5
6
7
8
9
10
11
setTimeout(function() {
console.log('setTimeout');
})

new Promise(function(resolve) {
console.log('promise');
}).then(function() {
console.log('then');
})

console.log('console');
  • 这段代码作为宏任务,进入主线程。
  • 先遇到setTimeout,那么将其回调函数注册后分发到宏任务Event Queue。(注册过程与上同,下文不再描述)
  • 接下来遇到了Promise,new Promise立即执行,then函数分发到微任务Event Queue。
  • 遇到console.log(),立即执行。
  • 好啦,整体代码script作为第一个宏任务执行结束,看看有哪些微任务?我们发现了then在微任务Event Queue里面,执行。
  • ok,第一轮事件循环结束了,我们开始第二轮循环,当然要从宏任务Event Queue开始。我们发现了宏任务Event Queue中setTimeout对应的回调函数,立即执行。
  • 结束。

事件循环,宏任务,微任务的关系如图所示:

我们来分析一段较复杂的代码,看看你是否真的掌握了js的执行机制:

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

setTimeout(function() {
console.log('2');
process.nextTick(function() {
console.log('3');
})
new Promise(function(resolve) {
console.log('4');
resolve();
}).then(function() {
console.log('5')
})
})
process.nextTick(function() {
console.log('6');
})
new Promise(function(resolve) {
console.log('7');
resolve();
}).then(function() {
console.log('8')
})

setTimeout(function() {
console.log('9');
process.nextTick(function() {
console.log('10');
})
new Promise(function(resolve) {
console.log('11');
resolve();
}).then(function() {
console.log('12')
})
})

第一轮事件循环流程分析如下:

  • 整体script作为第一个宏任务进入主线程,遇到console.log,输出1。
  • 遇到setTimeout,其回调函数被分发到宏任务Event Queue中。我们暂且记为setTimeout1。
  • 遇到process.nextTick(),其回调函数被分发到微任务Event Queue中。我们记为process1。
  • 遇到Promise,new Promise直接执行,输出7。then被分发到微任务Event Queue中。我们记为then1。
  • 又遇到了setTimeout,其回调函数被分发到宏任务Event Queue中,我们记为setTimeout2。

宏任务Event Queue

  • setTimeout1
  • process1

微任务Event Queue

  • setTimeout2
  • then1

上面是第一轮事件循环宏任务结束时各Event Queue的情况,此时已经输出了1和7。

我们发现了process1和then1两个微任务。

  • 执行process1,输出6。
  • 执行then1,输出8。

好了,第一轮事件循环正式结束,这一轮的结果是输出1,7,6,8。那么第二轮时间循环从setTimeout1宏任务开始:

  • 首先输出2。接下来遇到了process.nextTick(),同样将其分发到微任务Event Queue中,记为process2。new Promise立即执行输出4,then也分发到微任务Event Queue中,记为then2。

宏任务Event Queue

  • setTimeout2

微任务Event Queue

  • process2

  • then2

  • 第二轮事件循环宏任务结束,我们发现有process2和then2两个微任务可以执行。

  • 输出3。

  • 输出5。

  • 第二轮事件循环结束,第二轮输出2,4,3,5。

  • 第三轮事件循环开始,此时只剩setTimeout2了,执行。

  • 直接输出9。

  • 将process.nextTick()分发到微任务Event Queue中。记为process3。

  • 直接执行new Promise,输出11。

  • 将then分发到微任务Event Queue中,记为then3。

宏任务Event Queue

微任务Event Queue

  • process3

  • then3

  • 第三轮事件循环宏任务执行结束,执行两个微任务process3和then3。

  • 输出10。

  • 输出12。

  • 第三轮事件循环结束,第三轮输出9,11,10,12。

整段代码,共进行了三次事件循环,完整的输出为1,7,6,8,2,4,3,5,9,11,10,12。

尾声

Tips: js执行和运行是有很大区别的

  • javascript在不同的环境下,比如node,浏览器,Ringo等等,执行方式是不同的。
  • 运行大多指javascript解析引擎,是统一的。

https://juejin.im/post/59e85eebf265da430d571f89
http://www.ruanyifeng.com/blog/2014/10/event-loop.html

使用Atomics避免SharedArrayBuffers中的竞争条件

关于ECMAScript2017特性SharedArrayBuffer和atomics的背景知识:

使用Atomics避免SharedArrayBuffers中的竞争条件

在上一部分中,我谈到了使用SharedArrayBuffers如何导致竞争条件。这使得使用SharedArrayBuffers变得困难。我们不希望应用程序开发人员直接使用SharedArrayBuffers。

但是,具有其他语言多线程编程经验的库开发人员可以使用这些新的低级API来创建更高级别的工具。然后,应用程序开发人员可以直接使用这些工具而无需触及SharedArrayBuffers或Atomics。

即使您可能不应该直接使用SharedArrayBuffers和Atomics,我认为了解它们的工作原理仍然很有趣。因此,在本文中,我将解释并发可以带来什么样的竞争条件,以及Atomics如何帮助库避免它们。

但首先,什么是竞争条件?

比赛条件:您可能已经看过的一个例子
当你有一个在两个线程之间共享的变量时,就会发生一个非常简单的竞争条件示例。假设一个线程想要加载一个文件而另一个线程检查它是否存在。他们共享一个变量fileExists,进行沟通。

最初,fileExists设置为false。

只要线程2中的代码首先运行,就会加载该文件。

但是如果线程1中的代码首先运行,那么它将向用户记录一个错误,说该文件不存在。

但那不是问题。这不是文件不存在。真正的问题是竞争条件。

许多JavaScript开发人员都遇到过这种竞争条件,即使在单线程代码中也是如此。你不必了解多线程的任何信息,看看为什么这是一场比赛。

但是,有些种类的竞争条件在单线程代码中是不可能的,但是当您使用多个线程编程并且这些线程共享内存时可能会发生这种情况。

不同类别的竞争条件以及Atomics如何提供帮助
让我们探讨一下您可以在多线程代码中使用的各种竞争条件以及Atomics如何帮助防止它们。这并未涵盖所有可能的竞争条件,但应该让您知道为什么API提供它所执行的方法。

在我们开始之前,我想再说一遍:你不应该直接使用Atomics。编写多线程代码是一个众所周知的难题。相反,您应该使用可靠的库来处理多线程代码中的共享内存。

随着那个…

单次操作中的比赛条件
假设您有两个线程正在递增相同的变量。您可能认为无论哪个线程首先出现,最终结果都是相同的。

但即使在源代码中,递增变量看起来像一个操作,当您查看已编译的代码时,它不是单个操作。

在CPU级别,递增值需要三条指令。这是因为计算机具有长期记忆和短期记忆。(我在另一部分中更多地讨论了这一切是如何工作的)。

所有线程共享长期记忆。但短期内存 - 寄存器 - 不在线程之间共享。

每个线程都需要将内存中的值拉入其短期内存中。之后,它可以在短期记忆中对该值进行计算。然后它将该值从其短期记忆中写回到长期记忆中。

如果线程1中的所有操作首先发生,然后线程2中的所有操作都发生,我们将得到我们想要的结果。

但是如果它们在时间上交错,则线程2拉入其寄存器的值与内存中的值不同步。这意味着线程2不考虑线程1的计算。相反,它只是破坏了线程1用自己的值写入内存的值。

原子操作所做的一件事是将人类认为是单个操作的这些操作,但计算机视为多个操作,并使计算机将它们视为单个操作。

这就是他们被称为原子操作的原因。这是因为他们采取的操作通常会有多个指令 - 指令可以暂停和恢复 - 并且它使得它们看起来都是瞬间发生的,就像它是一条指令一样。它就像一个不可分割的原子。

使用原子操作,递增的代码看起来会有所不同。

现在我们正在使用Atomics.add,增加变量所涉及的不同步骤不会在线程之间混淆。相反,一个线程将完成其原子操作并阻止另一个线程启动。然后另一个将开始自己的原子操作。

有助于避免这种竞争的原子方法是:

  • Atomics.add
  • Atomics.sub
  • Atomics.and
  • Atomics.or
  • Atomics.xor
  • Atomics.exchange

您会注意到此列表相当有限。它甚至不包括除法和乘法之类的东西。但是,库开发人员可以为其他事情创建类似原子的操作。

为此,开发人员将使用Atomics.compareExchange。这样,您可以从SharedArrayBuffer获取一个值,对其执行操作,并且如果您第一次检查后没有其他线程更新它,则只将其写回SharedArrayBuffer。如果另一个线程已更新它,那么您可以获取该新值并再试一次。

多个操作的竞争条件
因此,这些原子操作有助于在“单一操作”期间避免竞争条件。但有时您希望更改对象上的多个值(使用多个操作)并确保没有其他人同时对该对象进行更改。基本上,这意味着在对象的每次更改过程中,该对象都处于锁定状态,并且其他线程无法访问。

Atomics对象不提供任何直接处理此工具的工具。但它确实提供了图书馆作者可以用来处理这个问题的工具。库作者可以创建的是锁。

如果代码想要使用锁定数据,则必须获取数据锁。然后它可以使用锁来锁定其他线程。只有在锁定处于活动状态时才能访问或更新数据。

要构建锁,库作者将使用Atomics.wait和Atomics.wake,以及其他诸如Atomics.compareExchange和Atomics.store。如果您想了解这些是如何工作的,请看一下这个基本的锁实现。

在这种情况下,线程2将获取数据的锁定并将值设置locked为true。这意味着在线程2解锁之前,线程1无法访问数据。

如果线程1需要访问数据,它将尝试获取锁。但由于锁已经在使用,它不能。然后线程将等待 - 因此它将被阻止 - 直到锁可用。

一旦线程2完成,它将调用解锁。锁将通知一个或多个等待线程它现在可用。

然后该线程可以挖出锁并锁定数据供自己使用。

锁库将在Atomics对象上使用许多不同的方法,但对于此用例最重要的方法是:

  • Atomics.wait
  • Atomics.wake

指令重新排序引起的竞争条件
Atomics会解决第三个同步问题。这个可能令人惊讶。

您可能没有意识到这一点,但是您编写的代码很可能没有按照您期望的顺序运行。编译器和CPU都重新排序代码以使其运行得更快。

例如,假设您已经编写了一些代码来计算总计。您想在计算完成时设置标志。

为了编译它,我们需要决定每个变量使用哪个寄存器。然后我们可以将源代码翻译成机器的指令。

到目前为止,一切都如预期。

如果您不了解计算机在芯片级别的工作方式(以及它们用于执行代码工作的管道如何),那么我们的代码中的第2行需要等待一段时间才能执行,这一点并不明显。

大多数计算机将执行指令的过程分解为多个步骤。这可以确保CPU的所有不同部分始终处于忙碌状态,因此可以充分利用CPU。

以下是指令执行步骤的一个示例:

  • 从内存中获取下一条指令
  • 弄清楚指令告诉我们做什么(也就是解码指令),并从寄存器中获取值
  • 执行指令
  • 将结果写回寄存器

这就是一条指令通过管道的方式。理想情况下,我们希望在它之后直接跟随第二条指令。一旦进入第2阶段,我们想要获取下一条指令。

问题是指令#1和指令#2之间存在依赖关系。

我们可以暂停CPU直到指令#1 subTotal在寄存器中更新。但这会减慢事情的速度。

为了提高效率,许多编译器和CPU将做的是重新排序代码。他们将寻找不使用其他指令subTotal或total与这两条线之间搬完英寸

这样可以保持稳定的指令流在管道中移动。

因为第3行不依赖于第1行或第2行中的任何值,所以编译器或CPU认为像这样重新排序是安全的。当你在一个线程中运行时,无论如何,在整个函数完成之前,其他任何代码都不会看到这些值。

但是当你在另一个处理器上同时运行另一个线程时,情况并非如此。另一个线程不必等到函数完成才能看到这些更改。几乎只要它们被写回内存就可以看到它们。所以它可以告诉它isDone在总计之前设定。

如果你使用isDone的total是已经计算好并且准备在另一个线程中使用的标志,那么这种重新排序会产生竞争条件。

Atomics试图解决其中的一些错误。当您使用Atomic写入时,就像在代码的两个部分之间放置一个栅栏。

原子操作不会相对于彼此重新排序,并且其他操作不会在它们周围移动。特别是,通常用于强制排序的两个操作是:

  • Atomics.load
  • Atomics.store

Atomics.store函数源代码中的所有变量更新都保证在将Atomics.store其值写回内存之前完成。即使非原子指令相对于彼此重新排序,它们也不会移动到Atomics.store源代码中的下面的调用之下。

并且Atomics.load在函数之后的所有变量加载都保证在Atomics.load获取其值之后完成。同样,即使非原子指令被重新排序,它们也不会被移动Atomics.load到源代码中位于它们之上的指令之上。

注意:我在这里展示的while循环称为自旋锁,效率非常低。如果它在主线程上,它可以使您的应用程序停止。你几乎肯定不想在实际代码中使用它。

再一次,这些方法并不真正意味着直接在应用程序代码中使用。相反,库会使用它们来创建锁。

结论
编程共享内存的多个线程很难。有许多不同种类的竞争条件等着你绊倒你。

这就是您不希望直接在应用程序代码中使用SharedArrayBuffers和Atomics的原因。相反,您应该依赖于具有多线程经验并且花时间研究内存模型的开发人员经过验证的库。

SharedArrayBuffer和Atomics仍处于早期阶段。那些图书馆尚未创建。但是这些新的API提供了构建的基础。

ArrayBuffers和SharedArrayBuffers的介绍

关于ECMAScript2017特性SharedArrayBuffer和atomics的背景知识:

ArrayBuffers和SharedArrayBuffers的介绍

在上一部分中,我解释了像JavaScript这样的内存管理语言如何处理内存。我还解释了手动内存管理如何在C语言中工作。

当我们谈论ArrayBuffers和SharedArrayBuffers时,为什么这很重要?

这是因为ArrayBuffers为您提供了一种手动处理某些数据的方法,即使您使用的是具有自动内存管理功能的JavaScript。

为什么这是你想做的事情?

正如我们在上一部分中所讨论的那样,在自动内存管理方面存在一种权衡。对开发人员来说更容易,但它增加了一些开销。在某些情况下,这种开销会导致性能问题。

例如,当您在JS中创建变量时,引擎必须猜测这是什么类型的变量以及它应该如何在内存中表示。因为它在猜测,JS引擎通常会保留比变量真正需要的空间更多的空间。根据变量,内存插槽可能比它需要的大2-8倍,这可能导致大量浪费的内存。

此外,某些创建和使用JS对象的模式可能会使收集垃圾变得更加困难。如果您正在进行手动内存管理,则可以选择适合您正在使用的用例的分配和取消分配策略。

大多数时候,这不值得麻烦。大多数用例对性能不敏感,您需要担心手动内存管理。对于常见用例,手动内存管理可能会更慢。

但是,当您需要在低级别工作以尽可能快地使代码时,ArrayBuffers和SharedArrayBuffers会为您提供一个选项。

那么ArrayBuffer是如何工作的呢?
它基本上就像使用任何其他JavaScript数组一样。除了使用ArrayBuffer时,您不能将任何JavaScript类型放入其中,如对象或字符串。您可以添加的唯一内容是字节(您可以使用数字表示)。

我应该在这里说清楚的一点是,你实际上并没有将这个字节直接添加到ArrayBuffer中。就其本身而言,这个ArrayBuffer不知道该字节应该有多大,或者不应该将不同类型的数字转换为字节。

ArrayBuffer本身只是一堆零和一行。ArrayBuffer不知道该数组中第一个元素和第二个元素之间的除法位置。

为了提供上下文,实际上将其分解为框,我们需要将其包装在所谓的视图中。可以使用类型化数组添加这些数据视图,并且可以使用许多不同类型的类型化数组。

例如,您可以使用Int8类型的数组,将其分解为8位字节。

或者你可以有一个无符号的Int16数组,它可以将它分解为16位的数据,并且还可以像处理无符号整数一样处理它。

您甚至可以在同一个基本缓冲区上拥有多个视图。不同的视图将为您提供相同操作的不同结果。

例如,如果我们从这个ArrayBuffer的Int8视图中获取元素0和1,它将在Uint16视图中给出与元素0不同的值,即使它们包含完全相同的位。

通过这种方式,ArrayBuffer基本上就像原始内存一样。它模仿了像C这样的语言的直接内存访问。

您可能想知道为什么我们不让程序员直接访问内存而不是添加这个抽象层。直接访问内存会打开一些安全漏洞。我将在以后的部分中详细解释这个问题。

那么,什么是SharedArrayBuffer?
为了解释SharedArrayBuffers,我需要解释一下并行运行代码和JavaScript。

您可以并行运行代码以使代码运行得更快,或使代码更快地响应用户事件。为此,您需要拆分工作。

在一个典型的应用程序中,工作由一个人 - 主线程完成。我之前已经谈过这个……主线程就像一个全栈开发者。它负责JavaScript,DOM和布局。

您可以采取任何措施从主线程的工作负载中删除工作。在某些情况下,ArrayBuffers可以减少主线程必须完成的工作量。

但有时候减少主线程的工作量是不够的。有时你需要引进增援……你需要分开工作。

在大多数编程语言中,通常使用称为线程的方法来分解工作。这基本上就像有多个人在一个项目上工作。如果您的任务彼此非常独立,则可以将它们分配给不同的线程。然后,这两个线程可以同时处理它们各自的任务。

在JavaScript中,您执行此操作的方式是使用称为Web worker的东西。这些Web工作者与您在其他语言中使用的线程略有不同。默认情况下,它们不共享内存。

这意味着如果要与其他线程共享某些数据,则必须将其复制。这是通过函数postMessage完成的。

postMessage接受您放入其中的任何对象,对其进行序列化,将其发送给其他Web工作者,然后将其反序列化并放入内存中。

这是一个非常缓慢的过程。

对于某些类型的数据,如ArrayBuffers,您可以执行所谓的传输内存。这意味着移动特定的内存块,以便其他Web工作者可以访问它。

但是第一个Web工作者再也无法访问它了。

这适用于某些用例,但对于许多需要具有此类高性能并行性的用例,您真正需要的是拥有共享内存。

这就是SharedArrayBuffers为您提供的。

使用SharedArrayBuffer,两个Web工作者(两个线程)都可以写入数据并从同一块内存中读取数据。

这意味着他们没有使用postMessage的通信开销和延迟。两个Web工作人员都可以立即访问数据。

但是,同时从两个线程立即访问存在一些危险。它可以导致所谓的竞争条件。

我将在下一部分中详细解释这些内容。

SharedArrayBuffers的当前状态是什么?
SharedArrayBuffers将很快出现在所有主流浏览器中。

它们已经在Safari中运行(在Safari 10.1中)。Firefox和Chrome都将在7月/ 8月的版本中发布它们。Edge计划在秋季Windows更新中发布它们。

即使它们在所有主流浏览器中都可用,我们也不希望应用程序开发人员直接使用它们。事实上,我们建议不要这样做。您应该使用可用的最高级别的抽象。

我们所期望的是JavaScript库开发人员将创建库,使您可以更轻松,更安全地使用SharedArrayBuffers。

此外,一旦SharedArrayBuffers内置到平台中,WebAssembly就可以使用它们来实现对线程的支持。一旦到位,您就可以使用像Rust这样的语言的并发抽象,它将无畏并发作为其主要目标之一。

在接下来的部分中,我们将看看工具(原子能),这些库作者会使用来建立这些抽象,同时避免竞争条件。