前端基础知识整理汇总

HTML页面的生命周期

  • HTML页面的生命周期有以下三个重要事件:

    • DOMContentLoaded —— 浏览器已经完全加载了 HTML,DOM 树已经构建完毕,但是像是 <img> 和样式表等外部资源可能并没有下载完毕。
    • load —— 浏览器已经加载了所有的资源(图像,样式表等)。
    • beforeunload —— 当用户即将离开当前页面(刷新或关闭)时触发。正要去服务器读取新的页面时调用,此时还没开始读取;
    • unload —— 在用户离开页面后触发。从服务器上读到了需要加载的新的页面,在即将替换掉当前页面时调用。
  • 每个事件都有特定的用途:

    • DOMContentLoaded —— DOM 加载完毕,所以 JS 可以访问所有 DOM 节点,初始化界面。
    • load —— 附加资源已经加载完毕,可以在此事件触发时获得图像的大小(如果没有被在 HTML/CSS 中指定)
    • beforeunload —— 该事件可用于弹出对话框,提示用户是继续浏览页面还是离开当前页面。
    • unload —— 删除本地数据localstorage等

DOMContentLoaded

DOMContentLoaded 由 document 对象触发。使用 addEventListener 来监听它:

document.addEventListener("DOMContentLoaded", () => {});

DOMContentLoaded 和脚本

当浏览器在解析 HTML 页面时遇到了 <script>...</script> 标签,将无法继续构建DOM树(UI 渲染线程与 JS 引擎是互斥的,当 JS 引擎执行时 UI 线程会被挂起),必须立即执行脚本。所以 DOMContentLoaded 有可能在所有脚本执行完毕后触发。

外部脚本(带 src 的)的加载和解析也会暂停DOM树构建,所以 DOMContentLoaded 也会等待外部脚本。带 async 的外部脚本,可能会在DOMContentLoaded事件之前或之后执行。带 defer 的脚本肯定会在在DOMContentLoaded事件之前执行。

DOMContentLoaded 与样式表

外部样式表并不会阻塞 DOM 的解析,所以 DOMContentLoaded 并不会被它们影响。

load

window 对象上的 load 事件在所有文件包括样式表,图片和其他资源下载完毕后触发。

window.addEventListener('load', function(e) {...});

window.onload = function(e) { ... };

beforeunload

当窗口即将被卸载(关闭)时, 会触发该事件。此时页面文档依然可见, 且该事件的默认动作可以被取消。beforeunload在unload之前执行,它还可以阻止unload的执行。

// 推荐使用
window.addEventListener('beforeunload', (event) => {
  // Cancel the event as stated by the standard.
  event.preventDefault();
  // Chrome requires returnValue to be set.
  event.returnValue = '关闭提示';
});


window.onbeforeunload = function (e) {
  e = e || window.event;
  // 兼容IE8和Firefox 4之前的版本
  if (e) {
    e.returnValue = '关闭提示';
  }
  // Chrome, Safari, Firefox 4+, Opera 12+ , IE 9+
  return '关闭提示';
};

unload

用户离开页面的时候,window 对象上的 unload 事件会被触发,无法阻止用户转移到另一个页面上。

// 推荐使用
window.addEventListener("unload", function(event) { ... });

window.onunload = function(event) { ... };

readyState

  • document.readyState 表示页面的加载状态,有三个值:
    • loading 加载 —— document仍在加载。
    • interactive 互动 —— 文档已经完成加载,文档已被解析,但是诸如图像,样式表和框架之类的子资源仍在加载。
    • complete —— 文档和所有子资源已完成加载。 load 事件即将被触发。

可以在 readystatechange 中追踪页面的变化状态:

document.addEventListener('readystatechange', () => {
  console.log(document.readyState);
});

Script标签:向HTML插入JS的方法

属性描述
asyncasync立即下载脚本(仅适用于外部脚本)。
charsetcharset表示通过src属性指定的代码的字符集
deferdefer表示脚本可以延迟到文档完全被解析和显示之后再执行(仅适用于外部脚本)。
languagescript(已废弃)表示编写代码使用的脚本语言。用 type 属性代替它。
srcURL规定外部脚本文件的 URL。
xml:spacepreserve规定是否保留代码中的空白。
typetext/xxxlanguage的替换属性,表示编写代码使用的脚本语言的内容类型,也称为MIME属性。

没有 defer 或 async,所有<script>元素会按照在页面出现的先后顺序依次被解析,浏览器会立即加载并执行指定的脚本, 只有解析完前面的script元素的内容后,才会解析后面的代码。

async 和 defer 属性仅仅对外部脚本起作用,在 src 不存在时会被自动忽略。

使用<script>的两种方式

  • 页面中嵌入script代码, 只需指定type属性
<script type="text/javascript">
  function sayHi() {
    console.log('hihihi');
    // 内部不能出现'</script>'字符串,如果必须出现,必须使用转义标签‘\’
    alert('<\/script>');
  }
</script>

包含在<script>元素内的代码会从上而下依次解释,在解释器对<script>元素内的所有代码求值完毕之前,页面中的其余内容都不会被浏览器加载或显示

  • 包含外部js文件, src属性是必须的。
<script src="example.js"></script>
// 带有src属性的元素不应该在标签之间包含额外的js代码,即使包含,只会下载并执行外部文件,内部代码也会被忽略。

与嵌入式js代码一样, 在解析外部js文件时,页面的处理会暂时停止。

改变脚本行为的方法

  • defer: 立即下载,延迟执行

加载和渲染后续文档元素的过程将和脚本的加载并行进行(异步),但是脚本的执行会在所有元素解析完成之后。脚本总会按照声明顺序执行。 在DOMContentLoaded事件之前执行。

<script defer="defer" src="example.js"></script>
  • async: 异步脚本

加载和渲染后续文档元素的过程将和脚本的加载与执行并行进行(异步)。但是async 在下载完毕后的执行会阻塞HTML的解析。脚本加载后马上执行,不能保证异步脚本按照他们在页面中出现的顺序执行。 一定会在load事件之前执行,可能会在DOMContentLoaded事件之前或之后执行。

<script async="async" src="example.js"></script>

区别:

描述deferasync
执行时机在所有元素解析完成之后加载后即执行
执行顺序按照声明顺序加载和执行脚本不管声明的顺序如何,只要加载完了就会立刻执行
DOMContentLoaded脚本会在页面加载和解析完毕后执行,在DOMContentLoaded事件之前执行。(脚本下载并执行完,才触发DOMContentLoaded)可能会在DOMContentLoaded事件之前或之后执行
loadload之前执行load之前执行

meta

META标签是HTML标记HEAD区的一个关键标签,它提供的信息虽然用户不可见,但却是文档的最基本的元信息。<meta>除了提供文档字符集、使用语言、作者等网页相关信息外,还可以设置信息给搜索引擎,目的是为了SEO(搜索引擎优化)。

HTML <meta> 元素表示那些不能由其它 HTML 元相关(meta-related)元素((<base>、<link>, <script>、<style> 或 <title>)之一表示的任何元数据信息。

属性

name

设置元数据的名称。name 和 content 属性可以一起使用,以名-值对的方式给文档提供元数据,content 作为元数据的值。

content

设置与 http-equiv 或 name 属性相关的元信息。

charset

声明了文档的字符编码。如果使用了这个属性,其值必须是与ASCII大小写无关(ASCII case-insensitive)的"utf-8"。

http-equiv

定义了一个编译指示指令,其作用类似于http协议, 告诉浏览器一些关于字符设定,页面刷新,cookie,缓存等等相关信息。属性名叫做 http-equiv 是因为所有允许的值都是HTTP头部的名称。

  • 可设置的值有:
    • content-security-policy:它允许页面作者定义当前页的内容策略。内容策略主要指定允许的服务器源和脚本端点,这有助于防止跨站点脚本攻击。
    • Expires:可以用于设定网页的到期时间,一旦过期则必须到服务器上重新调用。content必须使用GMT时间格式;
    • content-type:如果使用这个属性,其值必须是"text/html; charset=utf-8"。注意:该属性只能用于 MIME type为 text/html 的文档,不能用于MIME类型为XML的文档。
    • default-style:设置默认CSS 样式表组的名称。
    • refresh:定时让网页在指定的时间n内,刷新或跳转;

如果 content 只包含一个正整数,则是n秒后, 页面刷新。 如果 content 包含一个正整数,并且后面跟着字符串 ';url=' 和一个合法的 URL,则是重定向到指定链接的时间间隔(秒)。

meta 元素定义的元数据的类型包括以下几种:

  • 如果设置了 name 属性,meta 元素提供的是文档级别(document-level)的元数据,应用于整个页面。

  • 如果设置了 http-equiv 属性,meta 元素则是编译指令,提供的信息与类似命名的HTTP头部相同。

  • 如果设置了 charset 属性,meta 元素是一个字符集声明,告诉文档使用哪种字符编码。

  • 如果设置了 itemprop 属性,meta 元素提供用户定义的元数据。

注意: 全局属性 name 在 <meta> 元素中具有特殊的语义;另外, 在同一个 <meta> 标签中,name, http-equiv 或者 charset 三者中任何一个属性存在时,itemprop 属性不能被使用。

使用

content值里有多个属性通过,隔开,同时设置多个属性。

/* name */
// 适配移动设备
<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no" />
// 检测html格式:禁止把数字转化为拨号链接
<meta name="format-detection" content="telephone=no" /> 

/* charset */
<meta charset="utf-8">

/* http-equiv */
<meta http-equiv="refresh" content="3;url=https://www.mozilla.org">
<meta http-equiv="Expires" content="Mon,12 May 2001 00:20:00 GMT">

meta viewport元信息

  • 什么是 viewport?

viewport 是浏览器的可视区域,可视区域的大小是浏览器自己设置的。它可能大于移动设备可视区域,也可能小于移动设备可视区域。一般来讲,移动设备上的viewport都是大于移动设备可视区域。在控制台输出window.innerWidth查看Viewport大小。

  • 相关概念

设备像素:设备屏幕分辨率。iphone6p 的分辨率是 1334750; 设备独立像素:设备上程序用来描绘数据的一个个的“点”, 在控制台用 screen.width/height查看。iphone6p 的设备独立像素是375667; 设备像素比(DPR):设备像素(宽)/设备独立像素(宽),DPR越高渲染越精致。在控制台输出window.devicePixelRatio查看设备像素比。iphone6s 的设备像素比就是 750 / 375 = 2; CSS像素:浏览器使用的单位,用来精确度量网页上的内容。在一般情况下(页面缩放比为 1),1 个 CSS 像素等于 1 个设备独立像素。 屏幕尺寸:屏幕对角线的长度,以英尺为单位。 像素密度(PPI):每英寸屏幕拥有的像素数。

  • 为什么要使用meta viewport?

通常情况下,移动设备上的浏览器都会把viewport设为980px或1024px,此时页面会出现横向滚动条,因为移动设备可视区域宽度是比这个默认的viewport的宽度要小。所以出现了meta 标签设置viewport 元始性进行移动端网页优化。

  • meta viewport 属性
    • width:控制 viewport 的大小,可以给它指定一个值(正整数),或者是一个特殊的值(如:device-width 设备独立像素宽度,单位缩放为 1 时);
    • initial-scale:初始缩放比例,即当页面第一次加载时的缩放比例,为一个数字(可以带小数);
    • maximum-scale:允许用户缩放到的最大比例,为一个数字(可以带小数);
    • minimum-scale:允许用户缩放到的最小比例,为一个数字(可以带小数);
    • user-scalable:是否允许用户手动缩放,值为 "no"(不允许) 或 "yes"(允许);
    • height:与 width 相对应(很少使用)。

基本类型和引用类型

基本类型

基本类型:undefined、null、string、number、boolean、symbol

特点

  1. 基本类型的值是不可变得
// 任何方法都无法改变一个基本类型的值
let name = 'jay';
name.toUpperCase(); // 输出 'JAY'
console.log(name); // 输出  'jay'
  1. 基本类型的比较是值的比较
// 只有在它们的值相等的时候它们才相等
let a = 1;
let b = true;
console.log(a == b); //true
// 用==比较两个不同类型的变量时会进行一些类型转换。
// 先会把true转换为数字1再和数字1进行比较,结果就是true了
  1. 基本类型的变量是存放在栈区的(栈区指内存里的栈内存)

引用类型

引用类型:Object、Array、RegExp、Date、Function等 引用类型也可以说是对象。对象是属性和方法的集合,也就是说引用类型可以拥有属性和方法,属性又可以包含基本类型和引用类型。

特点

  1. 引用类型的值是可变的
// 我们可为为引用类型添加属性和方法,也可以删除其属性和方法
let person = { name: 'pig' };
person.age = 22;
person.sayName = () => console.log(person.name);
person.sayName(); // 'pig'
delete person.name;
  1. 引用类型的比较是引用的比较
let person1 = '{}';
let person2 = '{}';
console.log(person1 == person2); // 字符串值相同,true

let person1 = {};
let person2 = {};
console.log(person1 == person2); // 两个对象的堆内存中的地址不同,false
  1. 引用类型的值是同时保存在栈内存和堆内存中的对象

javascript和其他语言不同,其不允许直接访问内存中的位置,也就是说不能直接操作对象的内存空间。

实际上,是操作对象的引用,所以引用类型的值是按引用访问的。准确地说,引用类型的存储需要内存的栈区和堆区(堆区是指内存里的堆内存)共同完成,栈区内存保存变量标识符和指向堆内存中该对象的指针,也可以说是该对象在堆内存的地址。

作用域和执行上下文

  • JavaScript代码的整个执行过程,分为两个阶段,代码编译阶段与代码执行阶段。
    • 编译阶段:由编译器完成,将代码翻译成可执行代码。这个阶段作用域规则会确定。
    • 执行阶段:由引擎完成,主要任务是执行可执行代码。执行上下文在这个阶段创建。

作用域

简单来说作用域就是一个区域,没有变量。作用域可以嵌套。作用域规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。作用域在函数定义时就已经确定了,不是在函数调用确定。

ES6 之前 JavaScript 只有全局作用域和函数作用域。ES6 后,增加了块级作用域(最近大括号的作用范围), 通过let 和 const 声明的变量。

  • 作用域其实由两部分组成:

    • 记录作用域内变量信息(假设变量,常量,函数等统称为变量)和代码结构信息的东西,称之为 Environment Record。
    • 一个引用__outer__,这个引用指向当前作用域的父作用域。全局作用域的__outer__为 null。
  • 词法作用域

JavaScript 采用词法作用域(lexical scoping),也就是静态作用域。

所谓词法(代码)作用域,就是代码在编写过程中体现出来的作用范围,代码一旦写好了,没有运行之前(不用执行),作用范围就已经确定好了,这个就是所谓的词法作用域。

  • 词法作用域的规则:
    • 函数允许访问函数外部的数据
    • 整个代码结构中只有函数才能限定作用域
    • 作用规则首先使用变量提升规则分析
    • 如果当前作用规则里面有该名字,则不考虑外面的外面的名字
var a = 1;
function out() {
  var a = 2;
  inner();
}

function inner() {
  console.log(a)
}
out();  //====>  1

作用域链

当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的指针链表就叫做作用域链。

作用域链本质上是一个指向当前环境与上层环境的一系列变量对象的指针列表(它只引用但不实际包含变量对象),作用域链保证了当前执行环境对符合访问权限的变量和函数的有序访问。

例子:

用一个数组scopeChain来表示作用域链,数组的第一项scopeChain[0]为作用域链的最前端,而数组的最后一项,为作用域链的最末端,所有的最末端都为全局变量对象。

var a = 1;
function out() {
    var b = 2;
    function inner() {
        var c = 3;
        console.log(a + b + c);
    }
    inner();
}
out();

首先,代码开始运行时就创建了全局上下文环境,接着运行到out()时创建 out函数的执行上下文,最后运行到inner()时创建 inner函数的执行上下文,我们设定他们的变量对象分别为VO(global),VO(out), VO(inner)。

当函数创建时,执行上下文为:

// 全局上下文环境
globalEC = {
  VO: {
    out: <out reference>,  // 表示 out 的地址引用
    a: undefined
  },
  scopeChain: [VO(global)], // 作用域链
}

// out 函数的执行上下文
outEC = {
  VO: {
    arguments: {...},
    inner: <inner reference>,  // 表示 inner 的地址引用
    b: undefined
  },
  scopeChain: [VO(out), VO(global)], // 作用域链
}

// inner 函数的执行上下文
innerEC = {
  VO: {
    arguments: {...},
    c: undefined,
  },
  scopeChain: [VO(inner), VO(out), VO(global)], // 作用域链
}

执行上下文

简单来说,当在代码执行阶段执行到一个函数的时候,就会进行准备工作,这里的“准备工作”,就叫做"执行上下文(EC)",也叫执行上下文环境,也叫执行环境。js引擎创建了执行上下文栈(Execution context stack,ECS)来管理执行上下文。

当调用一个函数时,一个新的执行上下文就会被创建。而一个执行上下文的生命周期可

  • 以分为两个阶段:

    • 创建阶段:在这个阶段,执行上下文会分别创建变量对象,建立作用域链,以及确定this的指向。
    • 代码执行阶段:开始执行代码,会完成变量赋值,函数引用,以及执行其他代码。
  • 特点

    • 处于活动状态的执行上下文环境只有一个, 只有栈顶的上下文处于活动状态,执行其中的代码。
    • 函数每调用一次,都会产生一个新的执行上下文环境。
    • 全局上下文在代码开始执行时就创建,只有唯一的一个,永远在栈底,浏览器窗口关闭时出栈。
    • 函数被调用的时候创建上下文环境。

变量对象

变量对象的创建过程

  • 建立arguments对象。检查当前上下文中的参数,建立该对象下的属性与属性值。

  • 检查当前上下文的函数声明,也就是使用function关键字声明的函数。在变量对象中以函数名建立一个属性,属性值为指向该函数所在内存地址的引用。如果函数名的属性已经存在,那么该属性将会被新的引用所覆盖。

  • 检查当前上下文中的变量声明,每找到一个变量声明,就在变量对象中以变量名建立一个属性,属性值为undefined。如果该变量名的属性已经存在,为了防止同名的函数被修改为undefined,则会直接跳过,原属性值不会被修改。

活动对象

变量对象与活动对象其实都是同一个对象,只是处于执行上下文的不同生命周期。不过只有处于函数调用栈栈顶的执行上下文中的变量对象,才会变成活动对象。

执行上下文栈

  • 执行上下文可以理解为当前代码的执行环境,JavaScript中的运行环境大概包括三种情况:
    • 全局环境:JavaScript代码运行起来会首先进入该环境
    • 函数环境:当函数被调用执行时,会进入当前函数中执行代码
    • eval

在代码开始执行时,首先会产生一个全局执行上下文环境,调用函数时,会产生函数执行上下文环境,函数调用完成后,它的执行上下文环境以及其中的数据都会被销毁,重新回到全局执行环境,网页关闭后全局执行环境也会销毁。其实这是一个压栈出栈的过程,全局上下文环境永远在栈底,而当前正在执行的函数上下文在栈顶。

var a = 1;             // 1.进入全局上下文环境
function out() {
    var b = 2;
    function inner() {
        var c = 3;
        console.log(a + b + c);
    }
    inner();          // 3. 进入inner函数上下文环境
}
out(); // 2. 进入out函数上下文环境
  • 以上代码的执行会经历以下过程:
    • 当代码开始执行时就创建全局执行上下文环境,全局上下文入栈。
    • 全局上下文入栈后,其中的代码开始执行,进行赋值、函数调用等操作,执行到out()时,激活函数out创建自己的执行上下文环境,out函数上下文入栈。
    • out函数上下文入栈后,其中的代码开始执行,进行赋值、函数调用等操作,执行到inner()时,激活函数inner创建自己的执行上下文环境,inner函数上下文入栈。
    • inner函数上下文入栈后,其中的代码开始执行,进行赋值、函数调用、打印等操作,由于里面没有可以生成其他执行上下文的需要,所有代码执行完毕后,inner函数上下文出栈。
    • inner函数上下文出栈,又回到了out函数执行上下文环境,接着执行out函数中后面剩下的代码,由于后面没有可以生成其他执行上下文的需要,所有代码执行完毕后,out函数上下文出栈。
    • out函数上下文出栈后,又回到了全局执行上下文环境,直到浏览器窗口关闭,全局上下文出栈。

作用域与执行上下文区别

作用域只是一个“地盘”,其中没有变量。变量是通过作用域对应的执行上下文环境中的变量对象来实现的。所以作用域是静态观念的,而执行上下文环境是动态上的。有闭包存在时,一个作用域存在两个上下文环境也是有的。

同一个作用域下,对同一个函数的不同的调用会产生不同的执行上下文环境,继而产生不同的变量的值,所以,作用域中变量的值是在执行过程中确定的,而作用域是在函数创建时就确定的。

如果要查找一个作用域下某个变量的值,就需要找到这个作用域对应的执行上下文环境,再在其中找到变量的值。

变量提升

在Javascript中,函数及变量的声明都将被提升到函数的最顶部,提升的仅仅是变量的声明,变量的赋值并不会被提升。函数的声明与变量的声明是不一样的,函数表达式和变量表达式只是其声明被提升,函数声明是函数的声明和实现都被提升。

function foo() {
  console.log("global foo");
}

function bar() {
   console.log("global bar");
}

//定义全局变量
var v = "global var";

function hoistMe() {
  // var bar; 被提升到顶部,并未实现
  // var v;
  console.log(typeof foo); //function
  console.log(typeof bar); //undefined
  console.log(v); //undefined

  // 函数里面定义了同名的函数和变量,无论在函数的任何位置定义这些函数和和变量,它们都将被提升到函数的最顶部。

  foo(); //local foo
  bar(); //报错,TypeError "bar is not a function"

    //函数声明,变量foo以及其实现被提升到hoistMe函数顶部
  function foo() {
    alert("local foo");
  }

  //函数表达式,仅变量bar被提升到函数顶部,实现没有被提升
  var bar = function() {
      alert("local bar");
  };

  //定义局部变量
  var v = "local";
}

let 变量提升

console.log(a); // Uncaught ReferenceError: a is not defined
let a = "I am a";

let b = "I am outside B";
if(true){
  console.log(b); // Uncaught ReferenceError: b is not defined
  let b = " I am inside B";
}

如果b没有变量提升,执行到console.log时应该是输出全局作用域中的b,而不是出现错误。

我们可以推知,这里确实出现了变量提升,而我们不能够访问的原因事实上是因为let的死区设计:当前作用域顶部到该变量声明位置中间的部分,都是该let变量的死区,在死区中,禁止访问该变量。由此,我们给出结论,let声明的变量存在变量提升, 但是由于死区我们无法在声明前访问这个变量。

var let 区别

  • var声明的变量,只有函数才能为它创建新的作用域; let声明的变量,支持块级作用域,花括号就能为它创建新的作用域;

  • 相同作用域,var可以反复声明相同标识符的变量,而let是不允许的;

  • let声明的变量禁止在声明前访问

// 全局变量
var i = 0 ;
// 定义外部函数
function outer(){
    // 访问全局变量
    console.log(i); // 0

    function inner1(){
        console.log(i); // 0
    }

    function inner2(){
        console.log(i); // undefined
        var i = 1;
        console.log(i); // 1
    }

    inner1();
    inner2();
    console.log(i); // 0
}

闭包

闭包就是指有权访问另一个函数作用域中的变量的函数。

官方解释:闭包是由函数以及创建该函数的词法环境组合而成。这个环境包含了这个闭包创建时所能访问的所有局部变量。(词法作用域) 通俗解释:闭包的关键在于:外部函数调用之后其变量对象本应该被销毁,但闭包的存在使我们仍然可以访问外部函数的变量对象。

当某个函数被掉用的时候,会创建一个执行环境及相应的作用域链。然后使用arguments和其他命名参数的值来初始化函数的活动对象。但在作用域链中,外部函数的活动对象始终处于第二位,外部函数的外部函数的活动对象处于第三位...直至作为作用域链终点的全局执行环境。

作用域链本质上是一个指向变量对象的指针列表,他只引用但不实际包含变量对象。

无论什么时候在函数中访问一个变量时,就会从作用域链中搜索具有相同名字的变量,一般来讲,当函数执行完毕,局部活动对象就会被销毁,内存中仅保存全部作用域的活动对象。但是,闭包不同。

创建闭包: 在一个函数内部创建另一个函数

function add() {
  let a = 1;
  let b = 3;
  function closure() {
    b++;
    return a + b;
  }
  return closure;
}
// 闭包的作用域链包含着它自己的作用域,以及包含它的函数的作用域和全局作用域。

生命周期

通常,函数的作用域及其所有变量都会在函数执行结束后被销毁。但是,在创建了一个闭包以后,这个函数的作用域就会一直保存到闭包不存在为止。

当闭包中的函数closure从add中返回后,它的作用域链被初始化为包含add函数的活动对象和全局变量对象。这样closure就可以访问在add中定义的所有变量。

更重要的是,add函数在执行完毕后,也不会销毁,因为closure函数的作用域链仍然在引用这个活动对象。

换句话说,当add返回后,其执行环境的作用域链被销毁,但它的活动对象仍然在内存中,直至closure被销毁。

function add(x) {
  function closure(y) {
    return x + y;
  }
  return closure;
}

let add2 = add(2);
let add5 = add(5);
// add2 和 add5 共享相同的函数定义,但是保存了不同的环境
// 在add2的环境中,x为5。而在add5中,x则为10
console.log(add2(3)); // 5
console.log(add5(10)); // 15

// 释放闭包的引用
add2 = null;
add5 = null;

闭包中的this对象

var name = 'window';
var obj = {
  name: 'object',
  getName: () => {
    return () => {
      return this.name;
    }
  }
}
console.log(obj.getName()()); // window

obj.getName()()是在全局作用域中调用了匿名函数,this指向了window。 函数名与函数功能是分割开的,不要认为函数在哪里,其内部的this就指向哪里。 window才是匿名函数功能执行的环境。

  • 使用注意点

    • 由于闭包会让包含函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在IE中可能导致内存泄露。解决方法是,在退出函数之前,将不使用的局部变量全部删除。
    • 闭包会在父函数外部,改变父函数内部变量的值。所以,如果你把父函数当作对象(object)使用,把闭包当作它的公用方法(Public Method),把内部变量当作它的私有属性(private value),这时一定要小心,不要随便改变父函数内部变量的值。
  • 使用

    • 模仿块级作用域
    • 私有变量
    • 模块模式

在循环中创建闭包:一个常见错误

function show(i) {
  console.log(i);
}

function showCallback(i) {
  return () => {
    show(i);
  };
}

// 测试1【3,3,3】
const testFunc1 = () => {
  // var i;
  for (var i = 0; i < 3; i++) {
    setTimeout(() => show(i), 300);
  }
}

// 测试2 【0,1,2】
const testFunc2 = () => {
  for (var i = 0; i < 3; i++) {
    setTimeout(showCallback(i), 300);
  }
}

// 测试3【0,1, 2】 闭包,立即执行函数
// 在闭包函数内部形成了局部作用域,每循环一次,形成一个自己的局部作用域
const testFunc3 = () => {
  for (var i = 0; i < 3; i++) {
    (() => {
       setTimeout(() => show(i), 300);
    })(i);
  }
}

// 测试4【0,1, 2】let
const testFunc4 = () => {
  for (let i = 0; i < 3; i++) {
    setTimeout(() => show(i), 300);
  }
}

setTimeout()函数回调属于异步任务,会出现在宏任务队列中,被压到了任务队列的最后,在这段代码应该是for循环这个同步任务执行完成后才会轮到它

测试1错误原因:赋值给 setTimeout 的是闭包。这些闭包是由他们的函数定义和在 testFunc1 作用域中捕获的环境所组成的。这三个闭包在循环中被创建,但他们共享了同一个词法作用域,在这个作用域中存在一个变量i。这是因为变量i使用var进行声明,由于变量提升,所以具有函数作用域。当onfocus的回调执行时,i的值被决定。由于循环在事件触发之前早已执行完毕,变量对象i(被三个闭包所共享)已经指向了i的最后一个值。

测试2正确原因: 所有的回调不再共享同一个环境, showCallback 函数为每一个回调创建一个新的词法环境。在这些环境中,i 指向数组中对应的下标。

测试4正确原因:JS中的for循环体比较特殊,每次执行都是一个全新的独立的块作用域,用let声明的变量传入到 for循环体的作用域后,不会发生改变,不受外界的影响。

this指向问题

this 就是一个指针,指向我们调用函数的对象。 执行上下文: 是语言规范中的一个概念,用通俗的话讲,大致等同于函数的执行“环境”。具体的有:变量作用域(和 作用域链条,闭包里面来自外部作用域的变量),函数参数,以及 this 对象的值。 找出 this 的指向 this 的值并不是由函数定义放在哪个对象里面决定,而是函数执行时由谁来唤起决定。

var name = "Jay Global";
var person = {
    name: 'Jay Person',
    details: {
        name: 'Jay Details',
        print: function() {
            return this.name;
        }
    },
    print: function() {
        return this.name;
    }
};

console.log(person.details.print());  // 【details对象调用的print】Jay Details
console.log(person.print());          // 【person对象调用的print】Jay Person

var name1 = person.print;
var name2 = person.details;

console.log(name1()); // 【name1前面没有调用对象,所以是window】Jay Global
console.log(name2.print()) // 【name2对象调用的print】Jay Details

this和箭头函数

箭头函数按词法作用域来绑定它的上下文,所以 this 实际上会引用到原来的上下文。箭头函数保持它当前执行上下文的词法作用域不变,而普通函数则不会。换句话说,箭头函数从包含它的词法作用域中继承到了 this 的值。 匿名函数,它不会作为某个对象的方法被调用, 因此,this 关键词指向了全局 window 对象。

var object = {
    data: [1,2,3],
    dataDouble: [1,2,3],
    double: function() {
        console.log(this); // object
        return this.data.map(function(item) { // this是当前object,object调用的double
            console.log(this);   // 传给map()的那个匿名函数没有被任一对象调用,所以是window
            return item * 2;
        });
    },
    doubleArrow: function() {
        console.log(this); // object
        return this.dataDouble.map(item => { // this是当前object,object调用的doubleArrow
            console.log(this);      // doubleArrow是object调用的,这就是上下文,所以是window
            return item * 2;
        });
    }
};
object.double();
object.doubleArrow();

明确设置执行上下文

  • 在 JavaScript 中通过使用内置的特性开发者就可以直接操作执行上下文了。这些特性包括:
    • bind():不需要执行函数就可以将 this 的值准确设置到你选择的一个对象上。通过逗号隔开传递多个参数。设置好 this 关键词后不会立刻执行函数。
    • apply():将 this 的值准确设置到你选择的一个对象上。apply(thisObj, argArray)接收两个参数,thisObj是函数运行的作用域(this),argArray是参数数组,数组的每一项是你希望传递给函数的参数。如果没有提供argArray和thisObj任何一个参数,那么Global对象将用作thisObj。最后,会立刻执行函数。
    • call():将 this 的值准确设置到你选择的一个对象上。然后像bind 一样通过逗号分隔传递多个参数给函数。语法:call(thisObj,arg1,arg2,..., argn);,如果没有提供thisObj参数,那么Global对象被用于thisObj。最后,会立刻执行函数。

this 和 bind

var bobObj = {
    name: "Bob"
};
function print() {
    return this.name;
}
var printNameBob = print.bind(bobObj);
console.log(printNameBob()); // Bob

this 和 call

function add(a, b) {
    return a + b;
}
function sum() {
    return Array.prototype.reduce.call(arguments, add);
}
console.log(sum(1,2,3,4)); // 10

this 和 apply

apply 就是接受数组版本的call。

Math.min(1,2,3,4); // 返回 1
Math.min([1,2,3,4]); // 返回 NaN。只接受数字
Math.min.apply(null, [1,2,3,4]); // 返回 1

function Person(name, age){
  this.name = name;
  this.age = age;
}

function Student(name, age, grade) {
  Person.apply(this, arguments);  //Person.call(this, name, age);
  this.grade = grade;
}
var student = new Student("sansan", 21, "一年级");

console.log("student:", student); // {name: 'sansan'; age: '21', grade: '一年级'}

如果你的参数本来就存在一个数组中,那自然就用 apply,如果参数比较散乱相互之间没什么关联,就用 call。

对象属性类型

数据属性

  • 数据属性包含一个数据值的位置,在这个位置可以读取和写入值,数据属性有4个描述其行为的特性:
    • Configurable: 表示是否通过delete删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。默认值是true
    • Enumerable: 表示能否通过for-in循环返回属性。默认值是true
    • Writable: 表述能否修改属性。默认值是true
    • Value: 包含这个属性的数据值。默认值是true

访问器属性

函数式编程

函数式编程是一种编程范式,是一种构建计算机程序结构和元素的风格,它把计算看作是对数学函数的评估,避免了状态的变化和数据的可变。

纯函数

纯函数是稳定的、一致的和可预测的。给定相同的参数,纯函数总是返回相同的结果。

特性

  1. 如果给定相同的参数,则得到相同的结果

我们想要实现一个计算圆的面积的函数。 不是纯函数会这样做:

let PI = 3.14;
const calculateArea = (radius) => radius * radius * PI;
// 它使用了一个没有作为参数传递给函数的全局对象
calculateArea(10); // returns 314.0

纯函数:

let PI = 3.14;
const calculateArea = (radius, pi) => radius * radius * pi;
// 现在把 PI 的值作为参数传递给函数,这样就没有外部对象引入。
calculateArea(10, PI); // returns 314.0
  1. 无明显副作用

纯函数不会引起任何可观察到的副作用。可见副作用的例子包括修改全局对象或通过引用传递的参数。

现在,实现一个函数,接收一个整数并返对该整数进行加1操作且返回:

let counter = 1;
function increaseCounter(value) {
  counter = value + 1;
}
increaseCounter(counter);
console.log(counter); // 2

该非纯函数接收该值并重新分配counter,使其值增加1。 函数式编程不鼓励可变性(修改全局对象)。

let counter = 1;
const increaseCounter = (value) => value + 1;   // 函数返回递增的值,而不改变变量的值
increaseCounter(counter); // 2
console.log(counter); // 1
  1. 引用透明性

如果一个函数对于相同的输入始终产生相同的结果,那么它可以看作透明的。 实现一个square 函数:

const square = (n) => n * n;
square(2); // 4 将2作为square函数的参数传递始终会返回4

可以把square(2)换成4,我们的函数就是引用透明的。

纯函数使用

单元测试

纯函数代码肯定更容易测试,不需要 mock 任何东西。因此我们可以使用不同的上下文对纯函数进行单元测试。

一个简单的例子是接收一组数字,并对每个数进行加 1 :

let list = [1, 2, 3, 4, 5];
const incrementNumbers = (list) => list.map(number => number + 1);
incrementNumbers(list); // [2, 3, 4, 5, 6]

对于输入[1,2,3,4,5],预期输出是[2,3,4,5,6]。

纯函数也可以被看作成值并用作数据使用

  • 从常量和变量中引用它。

  • 将其作为参数传递给其他函数。

  • 作为其他函数的结果返回它。

其思想是将函数视为值,并将函数作为数据传递。通过这种方式,我们可以组合不同的函数来创建具有新行为的新函数。

假如我们有一个函数,它对两个值求和,然后将值加倍,如下所示:

const doubleSum = (a, b) => (a + b) * 2;

对应两个值求差,然后将值加倍:

const doubleSubtraction = (a, b) => (a - b) * 2

这些函数具有相似的逻辑,但区别在于运算符的功能。如果我们可以将函数视为值并将它们作为参数传递,我们可以构建一个接收运算符函数并在函数内部使用它的函数。

const sum = (a, b) => a + b;
const subtraction = (a, b) => a - b;
const doubleOperator = (f, a, b) => f(a, b) * 2;
doubleOperator(sum, 3, 1); // 8
doubleOperator(subtraction, 3, 1); // 4

Promise

Promise 必须为以下三种状态之一:等待态(Pending)、执行态(Fulfilled)和拒绝态(Rejected)。一旦Promise 被 resolve 或 reject,不能再迁移至其他任何状态(即状态 immutable)。

  • 基本过程:
    • 初始化 Promise 状态(pending)
    • 执行 then(..) 注册回调处理数组(then 方法可被同一个 promise 调用多次)
    • 立即执行 Promise 中传入的 fn 函数,将Promise 内部 resolve、reject 函数作为参数传递给 fn ,按事件机制时机处理
    • Promise中要保证,then方法传入的参数 onFulfilled 和 onRejected,必须在then方法被调用的那一轮事件循环之后的新执行栈中执行。

真正的链式Promise是指在当前promise达到fulfilled状态后,即开始进行下一个promise.

跨域

因为浏览器的同源策略导致了跨域。同源策略是一个重要的安全策略,它用于限制一个origin的文档或者它加载的脚本如何能与另一个源的资源进行交互。它能帮助阻隔恶意文档,减少可能被攻击的媒介。

所谓同源是指"协议+域名+端口"三者相同。不同协议,不同域名,不同端口都会构成跨域。

  • 跨域解决方案
    • jsonp: 需要服务器配合一个callback函数
    • CORS: 需要服务器设置header :Access-Control-Allow-Origin
    • window.name + iframe: 需要目标服务器响应window.name。
    • document.domain : 仅限主域相同,子域不同的跨域应用场景。
    • html5的 postMessage + iframe: 需要服务器或者目标页面写一个postMessage,主要侧重于前端通讯。
    • nginx反向代理: 不用服务器配合,需要搭建一个中转nginx服务器,用于转发请求。

jsonp跨域

在HTML标签里,一些标签比如script、img这样的获取资源的标签是没有跨域限制的。通过动态创建script,再请求一个带参网址实现跨域通信。

需要前后端配合使用。一般后端设置callback ,前端给后台接口中传一个callback 即可。

只能实现get一种请求。

栗子

<script>
    var script = document.createElement('script');
    script.type = 'text/javascript';

    // 传参一个回调函数名给后端,方便后端返回时执行这个在前端定义的回调函数
    script.src = 'http://xxxxxxx:8080/login?callback=handleCallback';
    document.head.appendChild(script);

    function handleCallback(res) {
        alert(JSON.stringify(res));
    }
</script>

CORS - 跨域资源共享

CORS是一个W3C标准,全称是"跨域资源共享"(Cross-origin resource sharing)。

CORS有两种请求,简单请求和非简单请求。只要同时满足以下两大条件,就属于简单请求。

请求方法是以下三种方法之一:HEAD,GET,POST

HTTP的头信息不超出以下几种字段:Accept,Accept-Language,Content-Language,Last-Event-ID,Content-Type【只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain】,没有自定义的HTTP头部。

  • 简单请求
    • 浏览器:把客户端脚本所在的域填充到Origin header里,向其他域的服务器请求资源。
    • 服务器:根据资源权限配置,在响应头中添加Access-Control-Allow-Origin Header,返回结果。
    • 浏览器:比较服务器返回的Access-Control-Allow-Origin Header和请求域的Origin。如果当前域已经得到授权,则将结果返回给页面。否则浏览器忽略此次响应。
    • 网页:收到返回结果或者浏览器的错误提示。

对于简单的跨域请求,只要服务器设置的Access-Control-Allow-Origin Header和请求来源匹配,浏览器就允许跨域。服务器端设置的`Access-Control-Allow-Methods和Access-Control-Allow-Headers对简单跨域没有作用。

  • 非简单请求
    • 浏览器:先向服务器发送一个OPTIONS预检请求,检测服务器端是否支持真实请求进行跨域资源访问,浏览器会在发送OPTIONS请求时会自动添加Origin Header 、Access-Control-Request-Method Header和Access-Control-Request-Headers Header。
    • 服务器:响应OPTIONS请求,会在responseHead里添加Access-Control-Allow-Methods head。这其中的method的值是服务器给的默认值,可能不同的服务器添加的值不一样。服务器还会添加Access-Control-Allow-Origin Header和Access-Control-Allow-Headers Header。这些取决于服务器对OPTIONS请求具体如何做出响应。如果服务器对OPTIONS响应不合你的要求,你可以手动在服务器配置OPTIONS响应,以应对带预检的跨域请求。在配置服务器OPTIONS的响应时,可以添加Access-Control-Max-Age head告诉浏览器在一定时间内无需再次发送预检请求,但是如果浏览器禁用缓存则无效。
    • 浏览器:接到OPTIONS的响应,比较真实请求的method是否属于返回的Access-Control-Allow-Methods head的值之一,还有origin, head也会进行比较是否匹配。如果通过,浏览器就继续向服务器发送真实请求, 否则就会报预检错误:请求来源不被options响应允许,请求方法不被options响应允许或请求中有自定义header不被options响应允许。
    • 服务器:响应真实请求,在响应头中放入Access-Control-Allow-Origin Header、Access-Control-Allow-Methods和Access-Control-Allow-Headers Header,分别表示允许跨域资源请求的域、请求方法和请求头,并返回数据。
    • 浏览器:接受服务器对真实请求的返回结果,返回给网页
    • 网页:收到返回结果或者浏览器的错误提示。

Access-Control-Allow-Origin在响应options请求和响应真实请求时都是有作用的,两者必须同时包含要跨域的源。 Access-Control-Allow-Methods和Access-Control-Allow-Headers只在响应options请求时有作用。

  • 携带cookie

在 CORS 跨域中,浏览器并不会自动发送 Cookie。对于普通跨域请求只需服务端设置,而带cookie跨域请求前后端都需要设置。

浏览器,对于跨域请求,需要设置withCredentials 属性为 true。服务端的响应中必须携带 Access-Control-Allow-Credentials: true 。

除了Access-Control-Allow-Credentials之外,跨域发送 Cookie 还要求 Access-Control-Allow-Origin不允许使用通配符。否则浏览器将会抛出The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' 错误。事实上不仅不允许通配符,而且只能指定单一域名。

  • 计算 Access-Control-Allow-Origin

既然Access-Control-Allow-Origin只允许单一域名, 服务器可能需要维护一个接受 Cookie 的 Origin 列表, 验证 Origin 请求头字段后直接将其设置为Access-Control-Allow-Origin的值。在 CORS 请求被重定向后 Origin 头字段会被置为 null, 此时可以选择从Referer头字段计算得到Origin。

  • 具体实现

服务器端的响应头配置

Access-Control-Allow-Origin 可以设置为* ,表示可以与任意域进行数据共享。

// 设置服务器接受跨域的域名
"Access-Control-Allow-Origin": "http://127.0.0.1:8080",
// 设置服务器接受跨域的请求方法
'Access-Control-Allow-Methods': 'OPTIONS,HEAD,DELETE,GET,PUT,POST',
// 设置服务器接受跨域的headers
'Access-Control-Allow-Headers': 'x-requested-with, accept, origin, content-type',
// 设置服务器不用再次预检请求时间
'Access-Control-Max-Age': 10000,
// 设置服务器接受跨域发送Cookie
'Access-Control-Allow-Credentials': true

document.domain 此方案仅限主域相同,子域不同的跨域应用场景。

实现原理:两个页面都通过js强制设置document.domain为基础主域,就实现了同域。

栗子:

在父页面 http://xxx.com/a.html 中设置document.domain

<iframe id = "iframe" src="http://xxx.com/b.html" onload = "test()"></iframe>
<script type="text/javascript">
    document.domain = 'xxx.com';//设置成主域
    function test(){
       alert(document.getElementById('iframe').contentWindow);
       //contentWindow 可取得子窗口的 window 对象
    }
</script>

在子页面http://xxx.com/b.html 中设置document.domain

<script type="text/javascript">
    document.domain = 'xxx.com';
    //在iframe载入这个页面也设置document.domain,使之与主页面的document.domain相同
</script>
  • window.postMessage

window.postMessage是html5的功能,是客户端和客户端直接的数据传递,既可以跨域传递,也可以同域传递。

  • postMessage(data, origin)方法接受两个参数:
    • data:html5规范支持任意基本类型或可复制的对象,但部分浏览器只支持字符串,所以传参时最好用JSON.stringify()序列化。
    • origin:协议+主机+端口号,也可以设置为"*",表示可以传递给任意窗口,如果要指定和当前窗口同源的话设置为"/"。

栗子:

假如有一个页面,页面中拿到部分用户信息,点击进入另外一个页面,另外的页面默认是取不到用户信息的,你可以通过window.postMessage把部分用户信息传到这个页面中。(需要考虑安全性等方面。)

发送消息:

// 弹出一个新窗口
var domain = 'http://haorooms.com';
var myPopup = window.open(`${domain}/windowPostMessageListener.html`,'myWindow');

// 发送消息
setTimeout(function(){
  var message = {name:"站点",sex:"男"};
  console.log('传递的数据是  ' + message);
  myPopup.postMessage(message, domain);
}, 1000);

接收消息:

// 监听消息反馈
window.addEventListener('message', function(event) {
  // 判断域名是否正确
  if (event.origin !== 'http://haorooms.com') return;
  console.log('received response: ', event.data);
}, false);

如果是使用iframe,代码应该这样写:

// 捕获iframe
var domain = 'http://haorooms.com';
var iframe = document.getElementById('myIFrame').contentWindow;

// 发送消息
setTimeout(function(){
    var message = {name:"站点",sex:"男"};
    console.log('传递的数据是:  ' + message);
    iframe.postMessage(message, domain);
},1000);

接收数据并反馈信息:

// 响应事件
window.addEventListener('message',function(event) {
    if(event.origin !== 'http://haorooms.com') return;
    console.log('message received:  ' + event.data, event);
    event.source.postMessage(event.origin);
}, false);

几个比较重要的事件属性: source – 消息源,消息的发送窗口/iframe。 origin – 消息源的URI(可能包含协议、域名和端口),用来验证数据源。 data – 发送方发送给接收方的数据。

window.name 原理: window对象有个name属性,该属性有个特征:即在一个窗口(window)的生命周期内,窗口载入的所有的页面都是共享一个window.name,每个页面对window.name都有读写的权限,window.name是持久存在一个窗口载入过的所有页面中的。

栗子: 在子页面(b.com/data.html) 设置window.name:

<!-- b.com/data.html -->
<script type="text/javascript">
   window.name = 'I was there!';
   // 这里是要传输的数据,大小一般为2M,IE和firefox下可以大至32M左右
   // 数据格式可以自定义,如json、字符串
</script>

在父页面(a.com/app.html)中创建一个iframe,把其src指向子页面。在父页面监听iframe的onload事件,获取子页面数据:

<!-- a.com/app.html -->
<script type="text/javascript">
    var iframe = document.createElement('iframe');
    iframe.src = 'http://b.com/data.html';
    function iframelLoadFn() {
      var data = iframe.contentWindow.name; 
      console.log(data);
      // 获取数据以后销毁iframe,释放内存;这也保证了安全(不被其他域frame js访问)。
      iframeDestoryFn();
    }

    function iframeDestoryFn() {
      iframe.contentWindow.document.write('');
      iframe.contentWindow.close();
      document.body.removeChild(iframe);
    }

    if (iframe.attachEvent) {
        iframe.attachEvent('onload', iframelLoadFn);
    } else {
        iframe.onload = iframelLoadFn;
    }
    document.body.appendChild(iframe);
</script>
  • http-proxy-middleware

http-proxy-middleware用于把请求代理转发到其他服务器的中间件。 安装:

npm install http-proxy-middleware --save-dev

配置如下:

module.exports = {
  devServer: {
    contentBase: path.resolve(__dirname, 'dev'),
    publicPath: '/',
    historyApiFallback: true,
    proxy: {
      // 请求到 '/device' 下的请求都会被代理到target:http://target.com中
      '/device/*': {
        target: 'http://target.com',
        secure: false, // 接受运行在https上的服务
        changeOrigin: true
      }
    }
  }
}

使用如下:

fetch('/device/space').then(res => {
  // 被代理到 http://target.com/device/space
  return res.json();
});

// 使用的url 必须以/开始 否则不会代理到指定地址
fetch('device/space').then(res => {
  // http://localhost:8080/device/space 访问本地服务
  return res.json();
});
  • nginx反向代理

反向代理(Reverse Proxy)方式是指以代理服务器来接受客户端的连接请求,然后将请求转发给内部网络上的服务器;并将从服务器上得到的结果返回给客户端,此时代理服务器对外就表现为一个服务器。

反向代理服务器对于客户端而言它就像是原始服务器,并且客户端不需要进行任何特别的设置。客户端向反向代理 的命名空间(name-space)中的内容发送普通请求,接着反向代理将判断向何处(原始服务器)转交请求,并将获得的内容返回给客户端,就像这些内容 原本就是它自己的一样。

模块化

AMD/CMD/CommonJs都是JS模块化开发的标准,目前对应的实现是RequireJS,SeaJs, nodeJs;

CommonJS:服务端js CommonJS 是以在浏览器环境之外构建 javaScript 生态系统为目标而产生的写一套规范,主要是为了解决 javaScript 的作用域问题而定义的模块形式,可以使每个模块它自身的命名空间中执行。

实现方法:模块必须通过 module.exports 导出对外的变量或者接口,通过 require() 来导入其他模块的输出到当前模块的作用域中;

主要针对服务端(同步加载文件)和桌面环境中,node.js 遵循的是 CommonJS 的规范;CommonJS 加载模块是同步的,所以只有加载完成才能执行后面的操作。

require()用来引入外部模块;

exports对象用于导出当前模块的方法或变量,唯一的导出口;

module对象就代表模块本身。

// 定义一个module.js文件
var A = () => console.log('我是定义的模块');

// 1.第一种返回方式
module.exports = A;
// 2.第二种返回方式
module.exports.test = A
// 3.第三种返回方式
exports.test = A;

// 定义一个test.js文件【这两个文件在同一个目录下】
var module = require("./module");

//调用这个模块,不同的返回方式用不同的方式调用
// 1.第一种调用方式
module();
// 2.第二种调用方式
module.test();
// 3.第三种调用方式
module.test();

// 执行文件
node test.js

AMD:异步模块定义【浏览器端js】 AMD 是 Asynchronous Module Definition 的缩写,意思是异步模块定义;采用的是异步的方式进行模块的加载,在加载模块的时候不影响后边语句的运行。主要是为前端 js 的表现指定的一套规范。

实现方法:通过define方法去定义模块,通过require方法去加载模块。 define(id?,dependencies?,factory): 它要在声明模块的时候制定所有的依赖(dep),并且还要当做形参传到factory中。没什么依赖,就定义简单的模块(或者叫独立的模块) require([modules], callback): 第一个参数[modules],是需加载的模块名数组;第二个参数callback,是模块加载成功之后的回调函数

主要针对浏览器js,requireJs遵循的是 AMD 的规范;

// module1.js文件, 定义独立的模块
define({
    methodA: () => console.log('我是module1的methodA');
    methodB: () => console.log('我是module1的methodB');
});

// module2.js文件, 另一种定义独立模块的方式
define(() => {
    return {
        methodA: () => console.log('我是module2的methodA');
        methodB: () => console.log('我是module2的methodB');
    };
});

// module3.js文件, 定义非独立的模块(这个模块依赖其他模块)
define(['module1', 'module2'], (m1, m2) => {
    return {
        methodC: () => {
            m1.methodA();
            m2.methodB();
        }
    };
});


//定义一个main.js,去加载这些个模块
require(['module3'], (m3) => {
    m3.methodC();
});


// 为避免造成网页失去响应,解决办法有两个,一个是把它放在网页底部加载,另一个是写成下面这样:
<script src="js/require.js" defer async="true" ></script>
// async属性表明这个文件需要异步加载,避免网页失去响应。
// IE不支持这个属性,只支持defer,所以把defer也写上。

// data-main属性: 指定网页程序的主模块
<script data-main="main" src="js/require.js"></script>

// 控制台输出结果
我是module1的methodA
我是module2的methodB

CMD:通用模块定义【浏览器端js】 CMD 是 Common Module Definition 的缩写,通过异步的方式进行模块的加载的,在加载的时候会把模块变为字符串解析一遍才知道依赖了哪个模块;

主要针对浏览器端(异步加载文件),按需加载文件。对应的实现是seajs

AMD和CMD的区别 对于依赖的模块,AMD 是提前执行,CMD 是延迟执行。不过 RequireJS 从 2.0 开始,也改成可以延迟执行(根据写法不同,处理方式不同)。CMD 推崇 as lazy as possible(尽可能的懒加载,也称为延迟加载,即在需要的时候才加载)。

CMD 推崇依赖就近,AMD 推崇依赖前置。

// CMD
define(function(require, exports, module) {
    var a = require('./a');
    a.doSomething();
    // ...
    var b = require('./b');   // 依赖可以就近书写
    b.doSomething();
    // ...
})

// AMD
define(['./a', './b'], function(a, b) { // 依赖必须一开始就写好
    a.doSomething();
    // ...
    b.doSomething();
    //...
})

import和require区别 import和require都是被模块化使用。

require是CommonJs的语法(AMD规范引入方式),CommonJs的模块是对象。import是es6的一个语法标准(浏览器不支持,本质是使用node中的babel将es6转码为es5再执行,import会被转码为require),es6模块不是对象。

require是运行时加载整个模块(即模块中所有方法),生成一个对象,再从对象上读取它的方法(只有运行时才能得到这个对象,不能在编译时做到静态化),理论上可以用在代码的任何地方。import是编译时调用,确定模块的依赖关系,输入变量(es6模块不是对象,而是通过export命令指定输出代码,再通过import输入,只加载import中导的方法,其他方法不加载),import具有提升效果,会提升到模块的头部(编译时执行)

export和import可以位于模块中的任何位置,但是必须是在模块顶层,如果在其他作用域内,会报错(es6这样的设计可以提高编译器效率,但没法实现运行时加载)。

require是赋值过程,把require的结果(对象,数字,函数等),默认是export的一个对象,赋给某个变量(复制或浅拷贝)。import是解构过程(需要谁,加载谁)。

require/exports:

// require: 真正被require出来的是来自module.exports指向的内存块内容
const a = require('a') //

// exports: 只是 module.exports的引用,辅助module.exports操作内存中的数据
exports.a = a
module.exports = a

import/export:

// import
import a from 'a';
import { default as a  } from 'a';
import  *  as a  from 'a';
import { fun1,fun2 } from 'a';
// export
export default a;
export const a = 1;
export functon a { ... };
export { fun1, fun2 };

http和https

Http:超文本传输协议(Http,HyperText Transfer Protocol)是互联网上应用最为广泛的一种网络协议。设计Http最初的目的是为了提供一种发布和接收HTML页面的方法。它可以使浏览器更加高效。

Http协议是以明文方式发送信息的,如果黑客截取了Web浏览器和服务器之间的传输报文,就可以直接获得其中的信息。

Https:是以安全为目标的Http通道,是Http的安全版。Https的安全基础是SSL。SSL协议位于TCP/IP协议与各种应用层协议之间,为数据通讯提供安全支持。SSL协议可分为两层:SSL记录协议(SSL Record Protocol),它建立在可靠的传输协议(如TCP)之上,为高层协议提供数据封装、压缩、加密等基本功能的支持。

SSL握手协议(SSL Handshake Protocol),它建立在SSL记录协议之上,用于在实际的数据传输开始前,通讯双方进行身份认证、协商加密算法、交换加密密钥等。

HTTP与HTTPS的区别

  • HTTP是超文本传输协议,信息是明文传输,HTTPS是具有安全性的SSL加密传输协议。
  • HTTPS协议需要ca申请证书,一般免费证书少,因而需要一定费用。
  • HTTP和HTTPS使用的是完全不同的连接方式,用的端口也不一样。前者是80,后者是443。
  • HTTP连接是无状态的,HTTPS协议是由SSL+HTTP协议构建的可进行加密传输、身份认证的网络协议,安全性高于HTTP协议。

https的优点

尽管HTTPS并非绝对安全,掌握根证书的机构、掌握加密算法的组织同样可以进行中间人形式的攻击,但HTTPS仍是现行架构下最安全的解决方案,主要有以下几个好处:

  • 使用HTTPS协议可认证用户和服务器,确保数据发送到正确的客户机和服务器;
  • HTTPS协议是由SSL+HTTP协议构建的可进行加密传输、身份认证的网络协议,要比http协议安全,可防止数据在传输过程中不被窃取、改变,确保数据的完整性。
  • HTTPS是现行架构下最安全的解决方案,虽然不是绝对安全,但它大幅增加了中间人攻击的成本。
  • 谷歌曾在2014年8月份调整搜索引擎算法,并称“比起同等HTTP网站,采用HTTPS加密的网站在搜索结果中的排名将会更高”。

Https的缺点

  • Https协议握手阶段比较费时,会使页面的加载时间延长近。
  • Https连接缓存不如Http高效,会增加数据开销,甚至已有的安全措施也会因此而受到影响;
  • SSL证书通常需要绑定IP,不能在同一IP上绑定多个域名,IPv4资源不可能支撑这个消耗。
  • Https协议的加密范围也比较有限。最关键的,SSL证书的信用链体系并不安全,特别是在某些国家可以控制CA根证书的情况下,中间人攻击一样可行。

遍历方法

for

在for循环中,循环取得数组或是数组类似对象的值,譬如arguments和HTMLCollection对象。

不足:

在于每次循环的时候数组的长度都要去获取;

终止条件要明确;

foreach(),map()

两个方法都可以遍历到数组的每个元素,而且参数一致; forEach(): 对数组的每个元素执行一次提供的函数, 总是返回undefined; map(): 创建一个新数组,其结果是该数组中的每个元素都调用一个提供的函数后返回的结果。返回值是一个新的数组;

var array1 = [1,2,3,4,5];

var x = array1.forEach((value,index) => {
    console.log(value);
    return value + 10;
});
console.log(x);   // undefined

var y = array1.map((value,index) => {
    console.log(value);
    return value + 10;
});
console.log(y);   // [11, 12, 13, 14, 15]

for in

经常用来迭代对象的属性或数组的每个元素,它包含当前属性的名称或当前数组元素的索引。 当遍历一个对象的时候,变量 i 是循环计数器 为 对象的属性名, 以任意顺序遍历一个对象的可枚举属性。对于每个不同的属性,语句都会被执行。 当遍历一个数组的时候,变量 i 是循环计数器 为 当前数组元素的索引

不足:

for..in循环会把某个类型的原型(prototype)中方法与属性给遍历出来.

const array = ["admin","manager","db"];
array.color = 'red';
array.prototype.name= "zhangshan";
for(var i in array){
    if(array.hasOwnProperty(i)){
        console.log(array[i]);  // admin,manager,db,color
    }
}
// hasOwnProperty(): 对象的属性或方法是非继承的,返回true

for … of

迭代循环可迭代对象(包括Array,Map,Set,String,TypedArray,arguments 对象)等等。不能遍历对象。只循环集合本身的元素

var a = ['A', 'B', 'C'];
var s = new Set(['A', 'B', 'C']);
var m = new Map([[1, 'x'], [2, 'y'], [3, 'z']]);
a.name = 'array';
for (var x of a) {
  console.log(x); //'A', 'B', 'C'
}
for (var x of s) {
  console.log(x);//'A', 'B', 'C'
}
for (var x of m) {
  console.log(x[0] + '=' + x[1]);//1='x',2='y',3='z'
}

继承

// 定义一个动物类
function Animal(name) {
  // 属性
  this.name = name || 'Animal';
  // 实例方法
  this.sleep = function(){
    console.log(this.name + '正在睡觉!');
  }
}
// 原型方法
Animal.prototype.eat = function(food) {
  console.log(this.name + '正在吃:' + food);
};

原型链继承

核心: 将父类的实例作为子类的原型。

function Dog(age) {
  this.age = age;
}
Dog.protoType = New Animal();
Dog.prototype.name = 'dog';

const dog = new Dog(12);
console.log(dog.name);
console.log(dog.eat('age'));
console.log(dog instanceof Animal); //true
console.log(dog instanceof Dog); //true
  • new 创建新实例对象经过了以下几步:
    • 创建一个新对象
    • 将新对象的_proto_指向构造函数的prototype对象
    • 将构造函数的作用域赋值给新对象 (也就是this指向新对象)
    • 执行构造函数中的代码(为这个新对象添加属性)
    • 返回新的对象
// 1. 创建一个新对象
var Obj = {};
// 2. 将新对象的_proto_指向构造函数的prototype对象
Obj._proto_ =  Animal.prototype();
// 3. 执行构造函数中的代码(为这个新对象添加属性)
Animal.call(Obj);
// 4. 返回新的对象
return Obj;
  • 特点:

    • 实例可继承的属性有:实例的构造函数的属性,父类构造函数属性,父类原型的属性
    • 非常纯粹的继承关系,实例是子类的实例,也是父类的实例
    • 父类新增原型方法/原型属性,子类都能访问到
  • 缺点:

    • 新实例无法向父类构造函数传参。
    • 继承单一。
    • 所有新实例都会共享父类实例的属性。(原型上的属性是共享的,一个实例修改了原型属性,另一个实例的原型属性也会被修改!)
    • 要想为子类新增原型上的属性和方法,必须要在new Animal()这样的语句之后执行,不能放到构造器中

构造函数继承

核心:使用父类的构造函数来增强子类实例,等于是复制父类的实例属性给子类(没用到原型)

function Dog(name) {
  Animal.apply(this, 'dog');
  this.name = name;
}

const dog = new Dog();
console.log(dog.name);
console.log(dog.eat('age'));
console.log(dog instanceof Animal); //false
console.log(dog instanceof Dog); //true

重点:用.call()和.apply()将父类构造函数引入子类函数(在子类函数中做了父类函数的自执行(复制))

  • 特点:

    • 只继承了父类构造函数的属性,没有继承父类原型的属性。
    • 解决了原型链继承缺点1、2、3。
    • 可以实现多继承,继承多个构造函数属性(call多个)。
    • 在子实例中可向父实例传参。
  • 缺点:

    • 能继承父类构造函数的属性。
    • 无法实现构造函数的复用。(每次用每次都要重新调用)
    • 每个新实例都有父类构造函数的副本,臃肿。
    • 实例并不是父类的实例,只是子类的实例

组合继承(原型链继承和构造函数继承)(常用)

核心:通过调用父类构造,继承父类的属性并保留传参的优点,然后通过将父类实例作为子类原型,实现函数复用

function Cat(name){
  Animal.call(this, name);
  this.name = name;
}
Cat.prototype = new Animal();
Cat.prototype.constructor = Cat;

var cat = new Cat();
console.log(cat.name);
console.log(cat.sleep());
console.log(cat instanceof Animal); // true
console.log(cat instanceof Cat); // true

重点:结合了两种模式的优点,传参和复用

  • 特点:

    • 可以继承父类原型上的属性,可以传参,可复用。
    • 每个新实例引入的构造函数属性是私有的。
    • 既是子类的实例,也是父类的实例
  • 缺点:

    • 调用了两次父类构造函数(耗内存),子类的构造函数会代替原型上的那个父类构造函数。

原型式继承

// 先封装一个函数容器,用来输出对象和承载继承的原型
function content(obj) {
    function F() {}
    F.prototype = obj // 继承了传入的参数
    return new F() // 返回函数对象
}

var sup = new Person() // 拿到父类的实例
var sup1 = content(sup)
console.log(sup1.age) // 10 继承了父类函数的属性

重点:用一个函数包装一个对象,然后返回这个函数的调用,这个函数就变成了个可以随意增添属性的实例或对象。object.create()就是这个原理。

  • 特点:

    • 类似于复制一个对象,用函数来包装。
  • 缺点:

    • 所有实例都会继承原型上的属性。
    • 无法实现复用。(新实例属性都是后面添加的)

寄生式继承

function content(obj) {
    function F() {}
    F.prototype = obj // 继承了传入的参数
    return new F() // 返回函数对象
}
var sup = new Person()

// 以上是原型继承,给原型式继承再套个壳子传递参数
function subobject(obj) {
    var sub = content(obj)
    sub.name = 'gar'
    return sub
}

var sup2 = subobject(sup)
// 这个函数经过声明之后就成了可增添属性的对象
console.log(typeof subobject) // function
cosole.log(typeof sup2) // object
console.log(sup2.name) // 'gar' 返回了个sub对象,继承了sub的属性

重点:就是给原型式继承外面套了个壳子。

  • 优点:没有创建自定义类型,因为只是套了个壳子返回对象(这个),这个函数顺理成章就成了创建的新对象。

  • 缺点:没用到原型,无法复用。

寄生组合式继承(常用)

寄生:在函数内返回对象然后调用

  • 组合:
    • 函数的原型等于另一个实例。
    • 在函数中用apply或者call引入另一个构造函数,可传参。
function Cat(name){
  Animal.call(this);
  this.name = name || 'Tom';
}
(function(){
  // 创建一个没有实例方法的类
  var Super = function(){};
  Super.prototype = Animal.prototype;
  //将实例作为子类的原型
  Cat.prototype = new Super();
})();

var cat = new Cat();
Cat.prototype.constructor = Cat; // 需要修复下构造函数
console.log(cat.name);
console.log(cat.sleep());
console.log(cat instanceof Animal); // true
console.log(cat instanceof Cat); //true

重点:修复了组合继承的问题。

Call, bind, apply实现

// call
Function.prototype.myCall = function (context) {
  context = context ? Object(context) : window
  context.fn = this;

  let args = [...arguments].slice(1);
  const result = context.fn(...args);
  delete context.fn;
  return result;
}

// apply
Function.prototype.myApply = function (context) {
  context = context ? Object(context) : window;
  context.fn = this;

  let args = [...arguments][1];
  let result;
  if (args.length === 0) {
      result = context.fn();
  } else {
      result = context.fn(args);
  }
  delete context.fn;
  return result;
}

// bind
Function.prototype.myBind = function (context) {
  let self = this;
  let args = [...arguments].slice(1);
  return function() {
    let newArgs = [...arguments];
    return self.apply(context, args.concat(newArgs));
  }
}

原型与原型链

每一个JavaScript对象(null除外)在创建的时候会关联另一个对象,这个被关联的对象就是原型。每一个JavaScript对象(除了 null)都具有的__proto__属性会指向该对象的原型。

JavaScript中所有的对象都是由它的原型对象继承而来,而原型也是一个对象,它也有自己的原型对象,这样层层上溯,就形成了一个类似链表的结构,这就是原型链。 每一个对象都会从原型"继承"属性。 实例对象和构造函数都可以指向原型, 原型可以指向构造函数,不能指向实例(因为可以有多个实例)。

原型对象有两个属性,constructor 和 __proto__

function Person() {}
var person = new Person();

// 实例原型 === 构造函数原型
person.__proto__ === Person.prototype // true
// 原型构造函数 === 构造函数
Person.prototype.constructor === Person // true

react diff

  • React 通过制定大胆的 diff 策略,将 O(n3) 复杂度的问题转换成 O(n) 复杂度的问题;

  • React 通过分层求异的策略,对 tree diff 进行算法优化;

对树进行分层比较,两棵树只会对同一层次的节点进行比较。

  • React 通过相同类生成相似树形结构,不同类生成不同树形结构的策略,对 component diff 进行算法优化;

    • 如果是同一类型的组件,按照原策略继续比较 virtual DOM tree。
    • 如果不是,则将该组件判断为 dirty component,从而替换整个组件下的所有子节点。
    • 对于同一类型的组件,有可能其 Virtual DOM 没有任何变化,如果能够确切的知道这点那可以节省大量的 diff 运算时间,因此 React 允许用户通过 shouldComponentUpdate() 来判断该组件是否需要进行 diff。
  • React 通过设置唯一 key的策略,对 element diff 进行算法优化;

  • 建议,在开发组件时,保持稳定的 DOM 结构会有助于性能的提升;

遍历对象

  • for...in:遍历对象自身, 包含继承, 可枚举,不含 Symbol 的属性。

  • Object.keys(obj):遍历对象自身, 不含继承,可枚举,不含 Symbol 的属性。【values, entries】

  • Object.getOwnPropertyNames(obj):遍历对象自身, 不含继承, 不含 Symbol 的属性, 不管是否可枚举

  • Object.getOwnPropertySymbols(obj): 遍历对象自身, 不含继承, 所有 Symbol 的属性, 不管是否可枚举

  • Reflect.ownKeys(obj): 遍历对象自身,不含继承,所有键名,不管是否Symbol 和可枚举。

对象其他方法:

  • JSON.stringify():只串行化对象自身,不含继承,可枚举,不含 Symbol属性。【function,undefined, Symbol会丢失, set、map会处理成空对象】

  • Object.assign():只拷贝对象自身,不含继承, 可枚举属性, 不管是否是Symbol 。【全部数据类型属性值】

方法自身属性继承属性可枚举属性Symbol属性
for...in..必须
Object.keys()必须
Object.getOwnPropertyNames(obj)非必须
Object.getOwnPropertySymbols(obj)非必须
Reflect.ownKeys(obj)非必须非必须
JSON.stringify()必须
Object.assign()必须非必须

异步加载脚本

默认情况下,浏览器是同步加载 JavaScript 脚本,即渲染引擎遇到<script>标签就会停下来,等到执行完脚本,再继续向下渲染。如果是外部脚本,还必须加入脚本下载的时间。

异步加载脚本方法:defer与async。

defer与async的区别是:defer要等到整个页面在内存中正常渲染结束(DOM 结构完全生成,以及其他脚本执行完成),才会执行;async一旦下载完,渲染引擎就会中断渲染,执行这个脚本以后,再继续渲染。一句话,defer是“渲染完再执行”,async是“下载完就执行”。另外,如果有多个defer脚本,会按照它们在页面出现的顺序加载,而多个async脚本是不能保证加载顺序的。

浏览器对于带有type="module"的<script>,都是异步加载,不会造成堵塞浏览器,即等到整个页面渲染完,再执行模块脚本,等同于打开了<script>标签的defer属性。

ES6 模块与 CommonJS 模块的差异

CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。

CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。

因为 CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

CommonJS 模块的require()是同步加载模块,ES6 模块的import命令是异步加载,有一个独立的模块依赖的解析阶段。

回流Reflow与重绘Repaint

回流:元素的大小或者位置发生了变化,触发了重新布局,导致渲染树重新计算布局和渲染。页面第一次加载的时候,至少发生一次回流。

  • 添加或删除可见的DOM元素;

  • 元素的位置发生变化;

  • 元素的尺寸发生变化;

  • 内容发生变化(比如文本变化或图片被另一个不同尺寸的图片所替代);

  • 页面一开始渲染的时候(这个无法避免);

  • 浏览器的窗口尺寸变化, 因为回流是根据视口的大小来计算元素的位置和大小的;

重绘:元素的外观,风格改变,而不会影响布局(不包含宽高、大小、位置等不变)。 如:outline, visibility, color, background-color......等

Reflow 的成本比 Repaint 高得多的多。DOM Tree 里的每个结点都会有 reflow 方法,一个结点的 reflow 很有可能导致子结点,甚至父点以及同级结点的 reflow。。回流一定会触发重绘,而重绘不一定会回流

减少重绘与回流

  • CSS方法

    • 使用 visibility 替换 display: none ,因为前者只会引起重绘,后者会引发回流
    • 避免使用table布局,可能很小的一个小改动会造成整个 table 的重新布局。
    • 避免设置多层内联样式,CSS 选择符从右往左匹配查找,避免节点层级过多。
    • 将动画效果应用到position属性为absolute或fixed的元素上,避免影响其他元素的布局,这样只是一个重绘,而不是回流,同时,控制动画速度可以选择 requestAnimationFrame
    • 避免使用CSS表达式,可能会引发回流。
    • 将频繁重绘或者回流的节点设置为图层,图层能够阻止该节点的渲染行为影响别的节点,例如will-change、video、iframe等标签,浏览器会自动将该节点变为图层。
    • CSS3 硬件加速(GPU加速),使用css3硬件加速,可以让transform、opacity、filters这些动画不会引起回流重绘 。但是对于动画的其它属性,比如background-color这些,还是会引起回流重绘的,不过它还是可以提升这些动画的性能。
  • JavaScript方法

    • 避免频繁操作样式,最好一次性重写style属性,或者将样式列表定义为class并一次性更改class属性。
    • 避免频繁操作DOM,创建一个documentFragment,在它上面应用所有DOM操作,最后再把它添加到文档中。
    • 避免频繁读取会引发回流/重绘的属性,如果确实需要多次使用,就用一个变量缓存起来。

CSS3硬件加速(GPU加速)

CSS3 硬件加速又叫做 GPU 加速,是利用 GPU 进行渲染,减少 CPU 操作的一种优化方案。

render tree -> 渲染元素 -> 图层 -> GPU 渲染 -> 浏览器复合图层 -> 生成最终的屏幕图像。

浏览器在获取 render tree后,渲染树中包含了大量的渲染元素,每一个渲染元素会被分到一个个图层中,每个图层又会被加载到 GPU 形成渲染纹理。GPU 中 transform 是不会触发 repaint ,最终这些使用 transform 的图层都会由独立的合成器进程进行处理。

  • CSS3触发硬件加速的属性:
    • transform
    • opacity
    • filter
    • will-change

http请求方法

HTTP1.0定义了三种请求方法:GET, POST 和 HEAD方法。 HTTP1.1新增了五种请求方法:OPTIONS, PUT, DELETE, TRACE 和 CONNECT 方法。

OPTIONS:即预检请求,可用于检测服务器允许的http方法。当发起跨域请求时,由于安全原因,触发一定条件时浏览器会在正式请求之前自动先发起OPTIONS请求,即CORS预检请求,服务器若接受该跨域请求,浏览器才继续发起正式请求。

HEAD: 向服务器索与GET请求相一致的响应,只不过响应体将不会被返回,用于获取报头。

GET:向特定的资源发出请求。注意:GET方法不应当被用于产生“副作用”的操作中

POST:向指定资源提交数据进行处理请求(例如提交表单或者上传文件)。数据被包含在请求体中。POST请求可能会导致新的资源的建立和/或已有资源的修改。

PUT:向指定资源位置上传其最新内容

DELETE:请求服务器删除Request-URL所标识的资源

TRACE:回显服务器收到的请求,主要用于测试或诊断

CONNECT:HTTP/1.1协议中预留给能够将连接改为管道方式的代理服务器

js判断数据类型

  • typeof 操作符

    • 对于基本类型,除 null 以外,均可以返回正确的结果。
    • 对于引用类型,除 function 以外,一律返回 object 类型。
    • 对于 null ,返回 object 类型。
    • 对于 function 返回 function 类型。
  • instanceof

用来判断 A 是否为 B 的实例,检测的是原型。instanceof 只能用来判断两个对象是否属于实例关系, 而不能判断一个对象实例具体属于哪种类型。 instanceof 主要的实现原理就是只要右边变量的 prototype 在左边变量的原型链上即可。

  • constructor

    • null 和 undefined 是无效的对象,不会有 constructor 存在的
    • 函数的 constructor 是不稳定的,这个主要体现在自定义对象上,当开发者重写 prototype 后,原有的 constructor 引用会丢失,constructor 会默认为 Object。为了规范开发,在重写对象原型时一般都需要重新给 constructor 赋值。
  • toString

toString() 是 Object 的原型方法,调用该方法,默认返回当前对象的 [[Class]] 。这是一个内部属性,其格式为 [object Xxx] ,其中 Xxx 就是对象的类型。

浏览器事件模型

DOM事件流(event flow )存在三个阶段:事件捕获阶段、处于目标阶段、事件冒泡阶段。

// useCapture:true, 即采用事件捕获方式
window.addEventListener("click", function(e){
  console.log("window 捕获");
}, true);

// useCapture:false【默认为false】,即采用事件冒泡方式
window.addEventListener("click", function(e){
  console.log("window 冒泡");
}, false);

目标元素(被点击的元素)绑定的事件都会发生在目标阶段,在绑定捕获代码之前写了绑定的冒泡阶段的代码,所以在目标元素上就不会遵守先发生捕获后发生冒泡这一规则,而是先绑定的事件先发生。

不是目标元素,它上面绑定的事件会遵守先发生捕获后发生冒泡的规则。

e.stopPropagation():阻止事件传播。不仅可以阻止事件在冒泡阶段的传播,还能阻止事件在捕获阶段的传播。 e.preventDefault(): 阻止事件的默认行为。默认行为是指:点击a标签就转跳到其他页面、拖拽一个图片到浏览器会自动打开、点击表单的提交按钮会提交表单等

http缓存: 强制缓存和协商缓存

良好的缓存策略可以降低资源的重复加载提高网页的整体加载速度。

  • 缓存原理

    • 浏览器在加载资源时,根据请求头的expires和cache-control判断是否命中强缓存,是则直接从缓存读取资源,不会发请求到服务器。
    • 如果没有命中强缓存,浏览器会发送一个请求到服务器,通过last-modified和etag验证是否命中协商缓存。当向服务端发起缓存校验的请求时,服务端会返回 200 ok表示返回正常的结果或者 304 Not Modified(不返回body)表示浏览器可以使用本地缓存文件。304的响应头也可以同时更新缓存文档的过期时间
    • 如果前面两者都没有命中,直接从服务器加载资源。
  • 实现方式

强缓存通过Expires和Cache-Control实现。 协商缓存是利用的是【Last-Modified,If-Modified-Since】和【ETag、If-None-Match】这两对Header来管理的。

  • Expires

Expires是http1.0提出的一个表示资源过期时间的header,它是一个绝对时间,由服务器返回。Expires 受限于本地时间,如果修改了本地时间,可能会造成缓存失效。 Expires: Wed, 11 May 2018 07:20:00 GMT

Cache-Control Cache-Control 出现于 HTTP / 1.1,优先级高于 Expires , 表示的是相对时间。

no-store:没有缓存。缓存中不得存储任何关于客户端请求和服务端响应的内容。每次由客户端发起的请求都会下载完整的响应内容。
no-cache: 缓存但重新验证。每次有请求发出时,缓存会将此请求发到服务器(译者注:该请求应该会带有与本地缓存相关的验证字段),服务器端会验证请求中所描述的缓存是否过期,若未过期(返回304),则缓存才使用本地缓存副本。
private:只允许客户端浏览器缓存。
public: 允许所有用户缓存。例如中间代理、CDN等
max-age=<seconds>:表示资源能够被缓存的最大时间。相对Expires而言,max-age是距离请求发起的时间的秒数。针对应用中那些不会改变的文件,通常可以手动设置一定的时长以保证缓存有效,例如图片、css、js等静态资源。
must-revalidate:触发缓存验证。验证它的状态,已过期的缓存将不被使用

Last-Modified,If-Modified-Since Last-Modifie表示本地文件最后修改日期,浏览器会在request header加 If-Modified-Since(上次返回的Last-Modified的值),询问服务器在该日期后资源是否有更新,有更新的话就会将新的资源发送回来。

但是如果在本地打开缓存文件,就会造成 Last-Modified 被修改,所以在 HTTP / 1.1 出现了 ETag。

ETag、If-None-Match Etag就像一个指纹,资源变化都会导致ETag变化,跟最后修改时间没有关系,ETag可以保证每一个资源是唯一的。

If-None-Match的header会将上次返回的Etag发送给服务器,询问该资源的Etag是否有更新,有变动就会发送新的资源回来。

ETag的优先级比Last-Modified更高。

  • 具体为什么要用ETag,主要出于下面几种情况考虑:
    • 一些文件也许会周期性的更改,但是他的内容并不改变(仅仅改变的修改时间),这个时候我们并不希望客户端认为这个文件被修改了,而重新GET;
    • 某些文件修改非常频繁,比如在秒以下的时间内进行修改,(比方说1s内修改了N次),If-Modified-Since能检查到的粒度是s级的,这种修改无法判断(或者说UNIX记录MTIME只能精确到秒);
    • 某些服务器不能精确的得到文件的最后修改时间。

防抖和节流

防抖:当你在频繁调用方法时,并不会执行,只有当你在指定间隔内没有再调用,才会执行函数。

节流:在一个单位时间内,只能触发一次函数。如果这个单位时间内触发多次函数,只有一次生效。

// 防抖
function debounce(fn, wait) {
  let time = null;

  return (function() {
    const context = this;
    const args = arguments;

    if (time) {
      clearTimeout(time);
      time = null;
    }
    time = setTimeout(() => {
      fn.call(context, args);
    }, wait);
  });
}
// 节流

function throttle(fn, wait)
{
  let lastTime;

  return (
    function() {
      const context = this;
      const args = arguments;
      let nowTime = + new Date();

      if (nowTime > lastTime + wait || !lastTime) {
        fn.call(context, args);
        lastTime = nowTime;
      }
    }
  );

大小单位区别

px:像素。 em:参考物是父元素的font-size,具有继承的特点。如果自身定义了font-size按自身来计算,整个页面内1em不是一个固定的值。 rem:相对于根元素html的font-size计算,不会像em那样,依赖于父元素的字体大小,而造成混乱。 vw:视窗宽度,1vw等于视窗宽度的1%。 vh:视窗高度,1vh等于视窗高度的1%。 vm:min(vw, vh)。 %:是相对于父元素的大小设定的比率,position:absolute;的元素是相对于已经定位的父元素,position:fixed;的元素是相对可视窗口。

浏览器默认字体是16px, body设置font-size:62.5%, 那么1rem =62.5% * 16=10px 。

谷歌浏览器强制最小字体为12号,即使设置成 10px 最终都会显示成 12px,当把html的font-size设置成10px,子节点rem的计算还是以12px为基准。

  • Box-sizing
    • content-box:这是默认情况,整个元素的高度和宽度就是元素内容
    • border-box:这种情况下,你设置的width和height属性值就是针对整个元素,包括了border,padding,和元素内容。

函数声明和函数表达式

// 函数声明
function wscat(type){
  return 'wscat';
}

// 函数表达式
var oaoafly = function(type){
  return "oaoafly";
}
  • JavaScript 解释器中存在一种变量声明被提升的机制,也就是说函数声明会被提升到作用域的最前面,即使写代码的时候是写在最后面,也还是会被提升至最前面。

  • 用函数表达式创建的函数是在运行时进行赋值,且要等到表达式赋值完成后才能调用

函数声明在JS解析时进行函数提升,因此在同一个作用域内,不管函数声明在哪里定义,该函数都可以进行调用。而函数表达式的值是在JS运行时确定,并且在表达式赋值完成后,该函数才能调用。这个微小的区别,可能会导致JS代码出现意想不到的bug,让你陷入莫名的陷阱中。

事件循环EventLoop

JavaScript是一个单线程的脚本语言。

所有同步任务都在主线程上执行,形成一个执行栈 (Execution Context Stack);而异步任务会被放置到 Task Table(异步处理模块),当异步任务有了运行结果,就将注册的回调函数移入任务队列(两种队列)。

一旦执行栈中的所有同步任务执行完毕,引擎就会读取任务队列,然后将任务队列中的第一个任务取出放到执行栈中运行。(所有会进入的异步都是指的事件回调中的那部分代码)

只要主线程空了,就会去读取任务队列,该过程不断重复,这就是所谓的 事件循环。

  • 宏任务和微任务

宏任务会进入一个队列,微任务会进入到另一个队列,且微任务要优于宏任务执行。 宏任务:script(整体代码)、setTimeout、setInterval、I/O、事件、postMessage、 MessageChannel、setImmediate (Node.js) 微任务:Promise.then、 MutaionObserver、process.nextTick (Node.js)

宏任务会进入一个队列,而微任务会进入到另一个不同的队列,且微任务要优于宏任务执行。

Promise

  • Promise.all: 全部fulfilled, 才进入then, 否则 catch
  • Promise.race: 任一个返回,不管是fulfilled还是rejected, 进入相应的then/catch
  • Promise.allSettled: 全部返回,不管是fulfilled还是rejected, 进入then
  • Promise.any: 任一个返回fulfilled, 就进入then, 否则 catch

虚拟dom原理

Virtual DOM是对DOM的抽象,本质上是JavaScript对象,这个对象就是更加轻量级的对DOM的描述.

箭头函数与普通函数区别

  • 语法更加简洁、清晰

  • 不绑定this,会捕获其所在的上下文的this值,作为自己的this值

  • 箭头函数继承而来的this指向永远不变

  • .call()/.apply()/.bind()无法改变箭头函数中this的指向

  • 不能作为构造函数使用, 因为没有自己的 this,无法调用 call,apply;没有 prototype 属性 ,而 new 命令在执行时需要将构造函数的 prototype 赋值给新的对象的__prpto__ 。

  • 没有自己的arguments,在箭头函数中访问arguments实际上获得的是外层局部(函数)执行环境中的值。如果要用,可以用 rest 参数代替。

  • 没有原型prototype, 指向 undefined

  • 不能使用yeild关键字

new

  • new 关键字会进行如下的操作:
    • 创建一个空的简单JavaScript对象(即{});
    • 链接该对象(设置该对象的__proto__)到构造器函数的原型 ;
    • 将新创建的对象作为this的上下文 ;
    • 返回。如果该函数没有返回对象,则返回this。
function newFunc(father, ...rest) {
  // 首先创建一个空对象
  var result = {};
  // 将空对象的原型赋值为构造器函数的原型
  result.__proto__ = father.prototype;
  // 更改构造器函数内部this,将其指向新创建的空对象
  var result2 = father.apply(result, rest);

  if ((typeof result2 === 'object' || typeof result2 === 'function') && result2 !== null) {
    return result2;
  }
  return result;
}

水平与垂直居中实现方式

  • 水平居中
    • text-align: center; 行内元素适用
    • margin: 0 auto; 适用块级元素
    • width: fit-content; 若子元素包含 float:left 属性, 为了让子元素水平居中, 则可让父元素宽度设置为fit-content, 并且配合margin。
.parent {
  width:fit-content;
  margin:0 auto;
}
/* flex */

.parent {
  display: flex;
  justify-content: center;
}
/* 盒模型, 使用flex 2009年版本 */


.parent {
  display: box;
  box-orient: horizontal;
  box-pack: center;
}
/* transform */

.son {
  position: absolute;
  left: 50%;
  transform: translate(-50%, 0);
}
/* 两种不同的绝对定位方法 */

.son {
  position: absolute;
  width: 固定;
  left: 50%;
  margin-left: -0.5 * 宽度;
}

.son {
  position: absolute;
  width: 固定;
  left: 0;
  right: 0;
  margin: 0 auto;
}
  • 垂直居中
    • 单行文本, line-height
    • 行内块级元素, 使用 display: inline-block, vertical-align: middle; 加上伪元素辅助实现
.parent::after, .son {
    display:inline-block;
    vertical-align:middle;
}
.parent::after {
    content:'';
    height:100%;
}
  • vertical-align。vertical-align只有在父层为 td 或者 th 时, 才会生效, 对于其他块级元素, 例如 div、p 等, 默认情况是不支持的. 为了使用vertical-align, 我们需要设置父元素display:table, 子元素 display:table-cell;vertical-align:middle;
/* flex */

.parent {
  display: flex;
  align-items: center;
}
/* 盒模型 */

.parent {
  display: box;
  box-orient: vertical;
  box-pack: center;
}
/* transform */

.son {
  position: absolute;
  top: 50%;
  transform: translate(0, -50%);
}
  • 两种不同的绝对定位方法
.son {
  position: absolute;
  height: 固定;
  top: 50%;
  margin-top: -0.5 * height;
}

.son {
  position: absolute;
  height: 固定;
  top: 0;
  bottom: 0;
  margin: auto 0;
}

flex, 盒模型, transform, 绝对定位, 这几种方法同时适用于水平居中和垂直居中

排序

  • 冒泡排序
function bubbleSort(arr) {
  const len = arr.length;
  for (let i = 0; i < len - 1; i++) {
    for (let j = i + 1; j < len; j++) {
      if (arr[j] < arr[i]) {
        [arr[j], arr[i]] = [arr[i], arr[j]];
      }
    }
  }
  return arr;
}
  • 选择排序

选择排序(Selection-sort)是一种简单直观的排序算法。它的工作原理:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。

function selectionSort(arr) {
  const len = arr.length;
  for (let i = 0; i < len - 1; i++) {
    let index = i;
    for (let j = i + 1; j < len; j++) {
      if (arr[j] < arr[index]) {
        index = j;
      }
    }
    if (index !== i) {
      [arr[i], arr[index]] = [arr[index], arr[i]];
    }
  }
  return arr;
}
  • 插入排序

插入排序(Insertion-Sort)的算法描述是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

插入排序在实现上,通常采用in-place排序(即只需用到O(1)的额外空间的排序),因而在从后向前扫描过程中,需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间。

function insertionSort(arr) {
  const len = arr.length;
  for (let i = 1; i < len; i++) {
    let j = i - 1;
    const value = arr[i];
    while (arr[j] > value && j >= 0) {
      arr[j + 1] = arr[j];
      j--;
    }
    arr[j + 1] = value;
  }
  return arr;
}
  • 归并排序

归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。归并排序是一种稳定的排序方法。先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2-路归并。

function mergeSort(arr) {  //采用自上而下的递归方法
  var len = arr.length;
  if (len < 2) return arr;

  const middle = Math.floor(len / 2);
  let left = arr.slice(0, middle);
  let right = arr.slice(middle);
  return merge(mergeSort(left), mergeSort(right));
}

function merge(left, right) {
  let result = [];
  while (left.length && right.length) {
    if (left[0] <= right[0]) {
      result.push(left.shift());
    } else {
      result.push(right.shift());
    }
  }
  return result.concat(left).concat(right);
}
  • 快速排序

快速排序的基本思想:通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。

function quickSort(arr) {
  if (arr.length <= 1) return arr;
  const pivotIndex = Math.floor(arr.length / 2);
  const pivot = arr.splice(pivotIndex, 1)[0];
  let left = [];
  let right = [];
  for (let i = 0; i < arr.length; i++){
    if (arr[i] < pivot) {
      left.push(arr[i]);
    } else {
      right.push(arr[i]);
    }
  }
  return quickSort(left).concat([pivot], quickSort(right));
};

洗牌算法

function shuffle(arr){
  const length = arr.length;
  while (length > 0) {
    const random = Math.floor(Math.random() * length);
    length--;
    [arr[length], arr[random]] = [arr[random], arr[length]];
  }
  return arr;
}

// 或
arr.sort(function(){
  return .5 - Math.random();
});

react 生命周期

React v16.0前的生命周期 初始化(initialization)阶段 此阶段只有一个生命周期方法:constructor。

constructor()

用来做一些组件的初始化工作,如定义this.state的初始内容。如果不初始化 state 或不进行方法绑定,则不需要为 React 组件实现构造函数。

为什么必须先调用super(props)?

因为子类自己的this对象,必须先通过父类的构造函数完成塑造,得到与父类同样的实例属性和方法,然后再对其进行加工,加上子类自己的实例属性和方法。如果不调用super方法,子类就得不到this对象。

class Checkbox extends React.Component {
  constructor(props) {
    // 🔴 这时候还不能使用this
    super(props);
    // ✅ 现在开始可以使用this
    console.log(props);      // ✅ {}
    console.log(this.props); // ✅ {}
    this.state = {};
  }
}

为什么super要传 props?

把 props 传进 super 是必要的,这使得基类 React.Component 可以初始化 this.props。

然而,即便在调用 super() 时没有传入 props 参数,你依然能够在 render 和其它方法中访问 this.props。

其实是 React 在调用你的构造函数之后,马上又给实例设置了一遍 props。

// React 内部
class Component {
  constructor(props) {
    this.props = props; // 初始化 this.props
    // ...
  }
}

// React 内部
const instance = new Button(props);
instance.props = props; // 给实例设置 props

// Button类组件
class Button extends React.Component {
  constructor(props) {
    super(); // 😬 我们忘了传入 props
    console.log(props);      // ✅ {}
    console.log(this.props); // 😬 undefined
  }
}

挂载(Mounting)阶段

此阶段生命周期方法:componentWillMount => render => componentDidMount

  1. componentWillMount():

在组件挂载到DOM前调用,且只会被调用一次。 每一个子组件render之前立即调用; 在此方法调用this.setState不会引起组件重新渲染,也可以把写在这边的内容提前到constructor()中。

  1. render(): class 组件唯一必须实现的方法

当 render 被调用时,它会检查 this.props 和 this.state 的变化并返回以下类型之一:

React 元素。通常通过 JSX 创建。例如,<div /> 会被 React 渲染为 DOM 节点,<MyComponent /> 会被 React 渲染为自定义组件,无论是 <div /> 还是 <MyComponent /> 均为 React 元素。

数组或 fragments。使得 render 方法可以返回多个元素。Fragments 允许你将子列表分组,而无需向 DOM 添加额外节点。

Portals。可以渲染子节点到不同的 DOM 子树中。Portal 提供了一种将子节点渲染到存在于父组件以外的 DOM 节点的优秀的方案。

字符串或数值类型。它们在 DOM 中会被渲染为文本节点。

布尔类型或 null。什么都不渲染。

render() 函数应该为纯函数,这意味着在不修改组件 state 的情况下,每次调用时都返回相同的结果,并且它不会直接与浏览器交互。不能在里面执行this.setState,会有改变组件状态的副作用。

  1. componentDidMount

会在组件挂载后(插入 DOM 树中)立即调用, 且只会被调用一次。依赖于 DOM 节点的初始化应该放在这里。 render之后并不会立即调用,而是所有的子组件都render完之后才会调用。

更新(update)阶段

此阶段生命周期方法:componentWillReceiveProps => shouldComponentUpdate => componentWillUpdate => render => componentDidUpdate。

react组件更新机制

setState引起的state更新或父组件重新render引起的props更新,更新后的state和props相对之前无论是否有变化,都将引起子组件的重新render。

  1. 父组件重新render

直接重新渲染。每当父组件重新render导致的重传props,子组件将直接跟着重新渲染,无论props是否有变化。可通过shouldComponentUpdate方法优化。

更新state再渲染。在componentWillReceiveProps方法中,将props转换成自己的state,调用 this.setState() 将不会引起第二次渲染。

因为componentWillReceiveProps中判断props是否变化了,若变化了,this.setState将引起state变化,从而引起render,此时就没必要再做第二次因重传props引起的render了,不然重复做一样的渲染了。

  1. 自身setState

组件本身调用setState,无论state有没有变化。可通过shouldComponentUpdate方法优化。

生命周期分析

  1. componentWillReceiveProps(nextProps)

此方法只调用于props引起的组件更新过程中,响应 Props 变化之后进行更新的唯一方式。 参数nextProps是父组件传给当前组件的新props。根据nextProps和this.props来判断重传的props是否改变,以及做相应的处理。

  1. shouldComponentUpdate(nextProps, nextState)

根据 shouldComponentUpdate() 的返回值,判断 React 组件的输出是否受当前 state 或 props 更改的影响。默认行为是 state 每次发生变化组件都会重新渲染。

当 props 或 state 发生变化时,shouldComponentUpdate() 会在渲染执行之前被调用。返回值默认为 true。

首次渲染或使用 forceUpdate() 时不会调用该方法。

此方法可以将 this.props 与 nextProps 以及 this.state 与nextState 进行比较,返回true时当前组件将继续执行更新过程,返回false则跳过更新,以此可用来减少组件的不必要渲染,优化组件性能。

请注意,返回 false 并不会阻止子组件在 state 更改时重新渲染。

如果在componentWillReceiveProps()中执行了this.setState,更新state,但在render前(如shouldComponentUpdate,componentWillUpdate),this.state依然指向更新前的state,不然nextState及当前组件的this.state的对比就一直是true了。 应该考虑使用内置的 PureComponent 组件,而不是手动编写 shouldComponentUpdate()。PureComponent 会对 props 和 state 进行浅层比较,并减少了跳过必要更新的可能性。

  1. componentWillUpdate(nextProps, nextState)

此方法在调用render方法前执行,在这边可执行一些组件更新发生前的工作,一般较少用。

  1. render

render同上

  1. componentDidUpdate(prevProps, prevState)

此方法在组件更新后立即调用,可以操作组件更新的DOM。 prevProps和prevState这两个参数指的是组件更新前的props和state。

卸载阶段

此阶段只有一个生命周期方法:componentWillUnmount

componentWillUnmount

此方法在组件被卸载前调用,可以在这里执行一些清理工作,比如清楚组件中使用的定时器,清楚componentDidMount中手动创建的DOM元素等,以避免引起内存泄漏。 componentWillUnmount() 中不应调用 setState(),因为该组件将永远不会重新渲染。组件实例卸载后,将永远不会再挂载它。

React v16.0 后的生命周期

React v16.0刚推出的时候,增加了一个componentDidCatch生命周期函数,这只是一个增量式修改,完全不影响原有生命周期函数;

React v16.3,引入了两个新的生命周期:getDerivedStateFromProps,getSnapshotBeforeUpdate, 废弃掉componentWillMount、componentWillReceiveProps 以及 componentWillUpdate 三个周期(直到React 17前还可以使用,只是会有一个警告)。

为什么要更改生命周期?

生命周期函数的更改是因为 16.3 采用了 Fiber 架构,在新的 Fiber 架构中,组件的更新分为了两个阶段:

render phase:这个阶段决定究竟哪些组件会被更新。

commit phase:这个阶段是 React 开始执行更新(比如插入,移动,删除节点)。

commit phase 的执行很快,但是真实 DOM 的更新很慢,所以 React 在更新的时候会暂停再恢复组件的更新以免长时间的阻塞浏览器,这就意味着 render phase 可能会被执行多次(因为有可能被打断再重新执行)。

constructor

componentWillMount

componentWillReceiveProps

componentWillUpdate

getDerivedStateFromProps

shouldComponentUpdate

render

这些生命周期都属于 render phase,render phase 可能被多次执行,所以要避免在 render phase 中的生命周期函数中引入副作用。在 16.3 之前的生命周期很容易引入副作用,所以 16.3 之后引入新的生命周期来限制开发者引入副作用。

getDerivedStateFromProps(nextProps, prevState)

React v16.3中,static getDerivedStateFromProps只在组件创建和由父组件引发的更新中调用。如果不是由父组件引发,那么getDerivedStateFromProps也不会被调用,如自身setState引发或者forceUpdate引发。

在React v16.4中改正了这一点,static getDerivedStateFromProps会在调用 render 方法之前调用,并且在初始挂载及后续更新时都会被调用。

  • 特点:
    • 无副作用 。因为是处于 Fiber 的 render 阶段,所以有可能会被多次执行。所以 API 被设计为了静态函数,无法访问到实例的方法,也没有 ref 来操作 DOM,这就避免了实例方法带来副作用的可能性。但是依旧可以从 props 中获得方法触发副作用,所以在执行可能触发副作用的函数前要三思。
    • 只用来更新 state 。其这个生命周期唯一的作用就是从 nextProps 和 prevState 中衍生出一个新的 state。它应返回一个对象来更新 state,或者返回null来不更新任何内容。

getDerivedStateFromProps前面要加上static保留字,声明为静态方法,不然会被react忽略掉。 getDerivedStateFromProps里面的this为undefined。 static静态方法只能Class来调用,而实例是不能调用,所以React Class组件中,静态方法getDerivedStateFromProps无权访问Class实例的this,即this为undefined。

getSnapshotBeforeUpdate()

getSnapshotBeforeUpdate() 只会调用一次,在最近一次渲染输出(提交到 DOM 节点)之前调用,,所以在这个生命周期能够获取这一次更新前的 DOM 的信息。此生命周期的任何返回值将作为 componentDidUpdate() 的第三个参数 “snapshot” 参数传递, 否则componentDidUpdate的第三个参数将为 undefined。

应返回 snapshot 的值(或 null)。

错误处理

当渲染过程,生命周期,或子组件的构造函数中抛出错误时,会调用如下方法:

static getDerivedStateFromError():此生命周期会在后代组件抛出错误后被调用。它将抛出的错误作为参数,并返回一个值以更新 state

componentDidCatch():此生命周期在后代组件抛出错误后被调用,它应该用于记录错误之类的情况。

它接收两个参数:

error —— 抛出的错误。

info —— 带有 componentStack key 的对象

react 16做了哪些更新

react作为一个ui库,将前端编程由传统的命令式编程转变为声明式编程,即所谓的数据驱动视图。如果直接更新真实dom,比如将生成的html直接采用innerHtml替换,会带来重绘重排之类的性能问题。为了尽量提高性能,React团队引入了虚拟dom,即采用js对象来描述dom树,通过对比前后两次的虚拟对象,来找到最小的dom操作(vdom diff),以此提高性能。

上面提到的reactDom diff,在react 16之前,这个过程我们称之为stack reconciler,它是一个递归的过程,在树很深的时候,单次diff时间过长会造成JS线程持续被占用,用户交互响应迟滞,页面渲染会出现明显的卡顿,这在现代前端是一个致命的问题。

所以为了解决这种问题,react 团队对整个架构进行了调整,引入了fiber架构,将以前的stack reconciler替换为fiber reconciler。采用增量式渲染。引入了任务优先级(expiration)和requestIdleCallback的循环调度算法,简单来说就是将以前的一根筋diff更新,首先拆分成两个阶段:reconciliation与commit;第一个reconciliation阶段是可打断的,被拆分成一个个的小任务(fiber),在每一侦的渲染空闲期做小任务diff。然后是commit阶段,这个阶段是不拆分且不能打断的,将diff节点的effectTag一口气更新到页面上。

由于reconciliation是可以被打断的,且存在任务优先级的问题,所以会导致commit前的一些生命周期函数多次被执行, 如componentWillMount、componentWillReceiveProps 和 componetWillUpdate,但react官方已声明,在React17中将会移除这三个生命周期函数。

由于每次唤起更新是从根节点(RootFiber)开始,为了更好的节点复用与性能优化。在react中始终存workInprogressTree(future vdom) 与 oldTree(current vdom)两个链表,两个链表相互引用。这无形中又解决了另一个问题,当workInprogressTree生成报错时,这时也不会导致页面渲染崩溃,而只是更新失败,页面仍然还在。

React hooks原理

在React 16前,函数式组件不能拥有状态管理?因为16以前只有类组件有对应的实例,而16以后Fiber 架构的出现,让每一个节点都拥有对应的实例,也就拥有了保存状态的能力。

Hooks的本质就是闭包和两级链表。

闭包是指有权访问另一个函数作用域中变量或方法的函数,创建闭包的方式就是在一个函数内创建闭包函数,通过闭包函数访问这个函数的局部变量, 利用闭包可以突破作用链域的特性,将函数内部的变量和方法传递到外部。

hooks 链表

一个组件包含的hooks 以链表的形式存储在fiber节点的memoizedState属性上,currentHook链表就是当前正在遍历的fiber节点的。nextCurrentHook 就是即将被添加到正在遍历fiber节点的hooks的新链表。

let currentHook: Hook | null = null;
let nextCurrentHook: Hook | null = null;

type Hooks = {
  memoizedState: any, // 指向当前渲染节点 Fiber
  baseState: any, // 初始化 initialState, 最新的state
  baseUpdate: Update<any> | null,
  // 当前需要更新的 Update ,每次更新完之后,会赋值上一个 update,方便 react 在渲染错误的边缘,数据回溯
  queue: UpdateQueue<any> | null,// 可以让state变化的,即update或dispach产生的update
  next: Hook | null, // link 到下一个 hooks
}

state

其实state不是hooks独有的,类操作的setState也存在。

memoizedState,cursor 是存在哪里的?如何和每个函数组件一一对应的? react 会生成一棵组件树(或Fiber 单链表),树中每个节点对应了一个组件,hooks 的数据就作为组件的一个信息,存储在这些节点上,伴随组件一起出生,一起死亡。

为什么只能在函数最外层调用 Hook? memoizedState 是按 hook定义的顺序来放置数据的,如果 hook 顺序变化,memoizedState 并不会感知到。

自定义的 Hook 是如何影响使用它的函数组件的? 共享同一个 memoizedState,共享同一个顺序。

“Capture Value” 特性是如何产生的? 每一次 ReRender 的时候,都是重新去执行函数组件了,对于之前已经执行过的函数组件,并不会做任何操作。

react setState 异步更新

setState 实现原理

setState 通过一个队列机制来实现 state 更新,当执行 setState() 时,会将需要更新的 state 浅合并后放入 状态队列,而不会立即更新 state,队列机制可以高效的批量更新 state。如果不通过setState,直接修改this.state 的值,则不会放入状态队列,当下一次调用 setState 对状态队列进行合并时,之前对 this.state 的修改将会被忽略,造成无法预知的错误。

setState()有的同步有的异步?

在React中, 如果是由React引发的事件处理(比如通过onClick引发的事件处理),调用setState不会同步更新this.state,除此之外的setState调用会同步执行this.state 。

所谓“除此之外”,指的是绕过React通过addEventListener直接添加的事件处理函数,还有通过setTimeout/setInterval产生的异步调用。

原因: 在React的setState函数实现中,会根据一个变量isBatchingUpdates判断是直接更新this.state还是放到队列中回头再说,而isBatchingUpdates默认是false,也就表示setState会同步更新this.state,但是,有一个函数batchedUpdates,这个函数会把isBatchingUpdates修改为true,而当React在调用事件处理函数之前就会调用这个batchedUpdates,造成的后果,就是由React控制的事件处理过程setState不会同步更新this.state。

setState的“异步”并不是说内部由异步代码实现,其实本身执行的过程和代码都是同步的,只是合成事件和钩子函数的调用顺序在更新之前,导致在合成事件和钩子函数中没法立马拿到更新后的值,形式了所谓的“异步”,可以通过第二个参数 setState(partialState, callback) 中的callback拿到更新后的结果。

调用风险

当调用 setState 时,实际上是会执行 enqueueSetState 方法,并会对 partialState 及 _pendingStateQueue 队列进行合并操作,最终通过 enqueueUpdate 执行 state 更新。

而 performUpdateIfNecessary 获取 _pendingElement、 _pendingStateQueue、_pendingForceUpdate,并调用 reaciveComponent 和 updateComponent 来进行组件更新。

但,如果在 shouldComponentUpdate 或 componentWillUpdate 方法里调用 this.setState 方法,就会造成崩溃。

这是因为在 shouldComponentUpdate 或 componentWillUpdate 方法里调用 this.setState 时,this._pendingStateQueue!=null,则 performUpdateIfNecessary 方法就会调用 updateComponent 方法进行组件更新,而 updateComponent 方法又会调用 shouldComponentUpdate和componentWillUpdate 方法,因此造成循环调用,使得浏览器内存占满后崩溃。

React Fiber

掉帧:在页面元素很多,且需要频繁刷新的场景下,React 15 会出现掉帧的现象,其根本原因,是大量的同步计算任务阻塞了浏览器的 UI 渲染。

默认情况下,JS 运算、页面布局和页面绘制都是运行在浏览器的主线程当中,他们之间是互斥的关系。

如果 JS 运算持续占用主线程,页面就没法得到及时的更新。

当我们调用setState更新页面的时候,React 会遍历应用的所有节点,计算出差异,然后再更新 UI,整个过程不能被打断。

如果页面元素很多,整个过程占用的时机就可能超过 16 毫秒,就容易出现掉帧的现象。

如何解决主线程长时间被 JS 运算?将JS运算切割为多个步骤,分批完成。在完成一部分任务之后,将控制权交回给浏览器,让浏览器有时间进行页面的渲染。等浏览器忙完之后,再继续之前未完成的任务。

React 15 及以下版本通过递归的方式进行渲染,使用的是 JS 引擎自身的函数调用栈,它会一直执行到栈空为止。

而Fiber实现了自己的组件调用栈,它以链表的形式遍历组件树,可以灵活的暂停、继续和丢弃执行的任务。实现方式是使用了浏览器的requestIdleCallback

window.requestIdleCallback()会在浏览器空闲时期依次调用函数,这就可以让开发者在主事件循环中执行后台或低优先级的任务,而且不会对像动画和用户交互这些延迟触发但关键的事件产生影响。函数一般会按先进先调用的顺序执行,除非函数在浏览器调用它之前就到了它的超时时间。

  • React 框架内部的运作可以分为 3 层:
    • Virtual DOM 层,描述页面长什么样。
    • Reconciler 层,负责调用组件生命周期方法,进行 Diff 运算等。
    • Renderer 层,根据不同的平台,渲染出相应的页面,比较常见的是 ReactDOM 和 ReactNative。

Fiber 表征reconciliation阶段所能拆分的最小工作单元,其实指的是一种链表树,它可以用一个纯 JS 对象来表示:

const fiber = {
  stateNode: {},    // 节点实例
  child: {},        // 子节点
  sibling: {},      // 兄弟节点
  return: {},       // 表示处理完成后返回结果所要合并的目标,通常指向父节点
};

Reconciler区别

  • 以前的 Reconciler 被命名为Stack Reconciler。Stack Reconciler 运作的过程是不能被打断的,必须一条道走到黑;

  • Fiber Reconciler 每执行一段时间,都会将控制权交回给浏览器,可以分段执行;

从Stack Reconciler到Fiber Reconciler,源码层面其实就是干了一件递归改循环的事情。

scheduling(调度)

  • scheduling(调度)是fiber reconciliation的一个过程,主要是进行任务分配,达到分段执行。任务的优先级有六种:
    • synchronous,与之前的Stack Reconciler操作一样,同步执行
    • task,在next tick之前执行
    • animation,下一帧之前执行
    • high,在不久的将来立即执行
    • low,稍微延迟执行也没关系
    • offscreen,下一次render时或scroll时才执行

优先级高的任务(如键盘输入)可以打断优先级低的任务(如Diff)的执行,从而更快的生效。

  • Fiber Reconciler 在执行过程中,会分为 2 个阶段:
    • 阶段一,生成 Fiber 树,得出需要更新的节点信息。这一步是一个渐进的过程,可以被打断。
    • 阶段二,将需要更新的节点一次过批量更新,这个过程不能被打断。

阶段一可被打断的特性,让优先级更高的任务先执行,从框架层面大大降低了页面掉帧的概率。

HOC 与render props区别

Render Props: 把将要包裹的组件作为props属性传入,然后容器组件调用这个属性,并向其传参。

实现方式:

1.通过props.children(props),props.children返回的是UI元素。<RenderProps> JSX 标签中的所有内容都会作为一个 children prop 传递给 RenderProps组件。因为 RenderProps 将 {props.children} 渲染在一个 <div> 中,被传递的这些子组件最终都会出现在输出结果中。

// 定义
const RenderProps = props => <div>
   {props.children(props)}
</div>

// 调用
<RenderProps>
    {() => <>Hello RenderProps</>}
</RenderProps>

2.通过props中的任何函数, 自行定义传入内容

// 定义
const LoginForm = props => {
  const flag = false;
  const allProps = { flag, ...props };

  if (flag) {
    return <>{props.login(allProps)}</>
  } else {
    return <>{props.notLogin(allProps)}</>
  }
}

// 调用
<LoginForm
  login={() => <h1>LOGIN</h1>}
  noLogin={() => <h1>NOT LOGIN</h1>}
/>
  • 优点:
    • 支持ES6
    • 不用担心props命名问题,在render函数中只取需要的state
    • 不会产生无用的组件加深层级
    • render props模式的构建都是动态的,所有的改变都在render中触发,可以更好的利用组件内的生命周期。

HOC: 接受一个组件作为参数,返回一个新的组件的函数。

class Home extends React.Component {
  // UI
}

export default Connect()(Home);

高阶组件由于每次都会返回一个新的组件,对于react来说,这是不利于diff和状态复用的,所以高阶组件的包装不能在render 方法中进行,而只能像上面那样在组件声明时包裹,这样也就不利于动态传参。

  • 优点:

    • 支持ES6
    • 复用性强,HOC为纯函数且返回值为组件,可以多层嵌套
    • 支持传入多个参数,增强了适用范围
  • 缺点:

    • 当多个HOC一起使用时,无法直接判断子组件的props是哪个HOC负责传递的
    • 多个组件嵌套,容易产生同样名称的props
    • HOC可能会产生许多无用的组件,加深了组件的层级

总的来说,render props其实和高阶组件类似,就是在puru component上增加state,响应react的生命周期。

React 通信

react的数据流是单向的,最常见的就是通过props由父组件向子组件传值。

  • 父向子通信:传入props

  • 子向父通信:父组件向子组件传一个函数,然后通过这个函数的回调,拿到子组件传过来的值

  • 父向孙通信:利用context传值。React.createContext()

  • 兄弟间通信:

    • 找一个相同的父组件,既可以用props传递数据,也可以用context的方式来传递数据。
    • 用一些全局机制去实现通信,比如redux等
    • 发布订阅模式

react合成事件

React 合成事件(SyntheticEvent)是 React 模拟原生 DOM 事件所有能力的一个事件对象,即浏览器原生事件的跨浏览器包装器。

为什么要使用合成事件?

  • 进行浏览器兼容,实现更好的跨平台 React 采用的是顶层事件代理机制,能够保证冒泡一致性,可以跨浏览器执行。React 提供的合成事件用来抹平不同浏览器事件对象之间的差异,将不同平台事件模拟合成事件。

  • 避免垃圾回收 事件对象可能会被频繁创建和回收,因此 React 引入事件池,在事件池中获取或释放事件对象。即 React 事件对象不会被释放掉,而是存放进一个数组中,当事件触发,就从这个数组中弹出,避免频繁地去创建和销毁(垃圾回收)。

  • 方便事件统一管理和事务机制

实现原理

在 React 中,“合成事件”会以事件委托方式绑定在 document 对象上,并在组件卸载(unmount)阶段自动销毁绑定的事件。

合成事件和原生事件 当真实 DOM 元素触发事件,会冒泡到 document 对象后,再处理 React 事件;所以会先执行原生事件,然后处理 React 事件;最后真正执行 document 上挂载的事件。 合成事件和原生事件最好不要混用。原生事件中如果执行了stopPropagation方法,则会导致其他React事件失效。因为所有元素的事件将无法冒泡到document上,所有的 React 事件都将无法被注册。

合成事件的事件池

合成事件对象池,是 React 事件系统提供的一种性能优化方式。合成事件对象在事件池统一管理,不同类型的合成事件具有不同的事件池。

react 虚拟dom

什么是虚拟dom?

在 React 中,render 执行的结果得到的并不是真正的 DOM 节点,而是轻量级的 JavaScript 对象,我们称之为 virtual DOM。它通过JS的Object对象模拟DOM中的节点,然后再通过特定的render方法将其渲染成真实的DOM节点。

虚拟 DOM 是 React 的一大亮点,具有batching(批处理) 和高效的 Diff 算法。batching 把所有的 DOM 操作搜集起来,一次性提交给真实的 DOM。diff 算法时间复杂度也从标准的的 Diff 算法的 O(n^3) 降到了 O(n)。

batching(批处理)

主要思想是,无论setState您在React事件处理程序或同步生命周期方法中进行多少次调用,它都将被批处理成一个更新, 最终只有一次重新渲染。

虚拟 DOM 与 原生 DOM

如果没有 Virtual DOM,就需要直接操作原生 DOM。在一个大型列表所有数据都变了的情况下,直接重置 innerHTML还算合理,但是,只有一行数据发生变化时,它也需要重置整个 innerHTML,这就造成了大量浪费。

innerHTML 和 Virtual DOM 的重绘性能消耗: innerHTML: render html string + 重新创建所有 DOM 元素 Virtual DOM: render Virtual DOM + diff + 必要的 DOM 更新

Virtual DOM render + diff 显然比渲染 html 字符串要慢,但是它依然是纯 js 层面的计算,比起后面的 DOM 操作来说,依然便宜了太多。innerHTML 的总计算量不管是 js 计算还是 DOM 操作都是和整个界面的大小相关,但 Virtual DOM 的计算量只有 js 计算和界面大小相关,DOM 操作是和数据的变动量相关。

Real DOMVirtual DOM
更新缓慢。更新更快。
可以直接更新 HTML。无法直接更新 HTML。
如果元素更新,则创建新DOM。如果元素更新,则更新 JSX 。
DOM操作代价很高。DOM 操作非常简单。
消耗的内存较多。很少的内存消耗。

虚拟 DOM 与 MVVM

相比起 React,其他 MVVM 系框架比如 Angular, Knockout , Vue ,Avalon 采用的都是数据绑定。通过 Directive/Binding 对象,观察数据变化并保留对实际 DOM 元素的引用,当有数据变化时进行对应的操作。MVVM 的变化检查是数据层面的,而 React 的检查是 DOM 结构层面的。

MVVM 的性能也根据变动检测的实现原理有所不同:Angular 依赖于脏检查;Knockout/Vue/Avalon 采用了依赖收集。

脏检查:scope digest(watcher count) ) + 必要 DOM 更新

依赖收集:重新收集依赖(data change) ) + 必要 DOM 更新

Angular 最不效率的地方在于任何小变动都有的和 watcher 数量相关的性能代价,当所有数据都变了的时候,Angular更有效。依赖收集在初始化和数据变化的时候都需要重新收集依赖,这个代价在小量更新的时候几乎可以忽略,但在数据量庞大的时候也会产生一定的消耗。

性能比较

在比较性能的时候,要分清楚初始渲染、小量数据更新、大量数据更新这些不同的场合。Virtual DOM、脏检查 MVVM、数据收集 MVVM 在不同场合各有不同的表现和不同的优化需求。

Virtual DOM 为了提升小量数据更新时的性能,也需要针对性的优化,比如 shouldComponentUpdate 或是 immutable data。

  • 初始渲染:Virtual DOM > 脏检查 >= 依赖收集

  • 小量数据更新:依赖收集 >> Virtual DOM + 优化 > 脏检查(无法优化)> Virtual DOM 无优化

  • 大量数据更新:脏检查 + 优化 >= 依赖收集 + 优化 > Virtual DOM(无法/无需优化)>> MVVM 无优化

diff 算法

传统 diff 算法通过循环递归对节点进行依次对比,算法复杂度达到 O(n^3),其中 n 是树中节点的总数。O(n^3) 意味着如果要展示1000个节点,就要依次执行上十亿次的比较, 这是无法满足现代前端性能要求的。

  • diff 算法主要包括几个步骤:
    • 用 JS 对象的方式来表示 DOM 树的结构,然后根据这个对象构建出真实的 DOM 树,插到文档中。
    • 当状态变更的时候,重新构造一棵新的对象树。然后用新的树和旧的树进行比较,记录两棵树的差异, 最后把所记录的差异应用到所构建的真正的DOM树上,视图更新。

diff 策略

  • React 通过制定大胆的diff策略,将diff算法复杂度从 O(n^3) 转换成 O(n) 。
    • React 通过分层求异的策略,对 tree diff 进行算法优化;
    • React 通过相同类生成相似树形结构,不同类生成不同树形结构的策略,对 component diff 进行算法优化;
    • React 通过设置唯一 key的策略,对 element diff 进行算法优化;

tree diff(层级比较)

React 对树进行分层比较,两棵树只会对同一层次的节点进行比较。 当发现节点已经不存在,则该节点及其子节点会被完全删除掉,不会进行进一步的比较。

这样只需要对树进行一次遍历,便能完成整个 DOM 树的比较。 当出现节点跨层级移动时,并不会出现移动操作,而是以该节点为根节点的树被重新创建,这是一种影响 React 性能的操作,因此 React 官方建议不要进行 DOM 节点跨层级的操作。

先进行树结构的层级比较,对同一个父节点下的所有子节点进行比较;

接着看节点是什么类型的,是组件就做 Component Diff;

如果节点是标签或者元素,就做 Element Diff;

注意:在开发组件时,保持稳定的 DOM 结构会有助于性能的提升。例如,可以通过 CSS 隐藏或显示节点,而不是真的移除或添加 DOM 节点。

  • component diff(组件比较)
    • 如果是同一类型的组件,按照原策略继续比较 virtual DOM tree。
    • 如果不是,则将该组件判断为 dirty component,替换整个组件下的所有子节点。举个例子,当一个元素从 <Article> 变成 <Comment>会触发一个完整的重建流程。

对于同一类型的组件,有可能其 Virtual DOM 没有任何变化,如果能够确切的知道这点那可以节省大量的 diff 运算时间。因此 React 允许用户通过 shouldComponentUpdate() 来判断该组件是否需要进行 diff。

对于两个不同类型但结构相似的组件,不会比较二者的结构,而且替换整个组件的所有内容。不同类型的 component 是很少存在相似 DOM tree 的机会,因此这种极端因素很难在实现开发过程中造成重大影响的。

element diff (元素比较)

当节点处于同一层级时,React diff 提供了三种节点操作,分别为:INSERT_MARKUP(插入)、MOVE_EXISTING(移动)和 REMOVE_NODE(删除)。

INSERT_MARKUP,新的 component 类型不在老集合里, 即是全新的节点,需要对新节点执行插入操作。

MOVE_EXISTING,在老集合有新 component 类型,且 element 是可更新的类型,这种情况下需要做移动操作,可以复用以前的 DOM 节点。

REMOVE_NODE,老 component 类型,在新集合里也有,但对应的 element 不同则不能直接复用和更新,需要执行删除操作,或者老 component 不在新集合里的,也需要执行删除操作。

<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

<ul>
  <li>Connecticut</li>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

React 并不会意识到应该保留<li>Duke</li><li>Villanova</li>,而是会重建每一个子元素,不会进行移动 DOM 操作。

key 优化

为了解决上述问题,React 引入了 key 属性, 对同一层级的同组子节点,添加唯一 key 进行区分。

当子元素拥有 key 时,React 使用 key 来匹配原有树上的子元素以及最新树上的子元素。如果有相同的节点,无需进行节点删除和创建,只需要将老集合中节点的位置进行移动,更新为新集合中节点的位置。

在开发过程中,尽量减少类似将最后一个节点移动到列表首部的操作,当节点数量过大或更新操作过于频繁时,在一定程度上会影响 React 的渲染性能。 key 不需要全局唯一,但在列表中需要保持唯一。 Key 应该具有稳定,可预测,以及列表内唯一的特质。不稳定的 key(比如通过 Math.random() 生成的)会导致许多组件实例和 DOM 节点被不必要地重新创建,这可能导致性能下降和子组件中的状态丢失。

react与vue区别

  • 监听数据变化的实现原理不同

Vue通过 getter/setter以及一些函数的劫持,能精确知道数据变化。 React默认是通过比较引用的方式(diff)进行的,如果不优化可能导致大量不必要的VDOM的重新渲染。

  • 数据流不同

Vue1.0中可以实现两种双向绑定:父子组件之间props可以双向绑定;组件与DOM之间可以通过v-model双向绑定。 Vue2.x中父子组件之间不能双向绑定了(但是提供了一个语法糖自动帮你通过事件的方式修改)。 React一直不支持双向绑定,提倡的是单向数据流,称之为onChange/setState()模式。

  • HoC和mixins

Vue组合不同功能的方式是通过mixin,Vue中组件是一个被包装的函数,并不简单的就是我们定义组件的时候传入的对象或者函数。 React组合不同功能的方式是通过HoC(高阶组件)。

  • 模板渲染方式的不同

模板的语法不同,React是通过JSX渲染模板, Vue是通过一种拓展的HTML语法进行渲染。 模板的原理不同,React通过原生JS实现模板中的常见语法,比如插值,条件,循环等。而Vue是在和组件JS代码分离的单独的模板中,通过指令来实现的,比如 v-if 。

举个例子,说明React的好处:react中render函数是支持闭包特性的,所以我们import的组件在render中可以直接调用。但是在Vue中,由于模板中使用的数据都必须挂在 this 上进行一次中转,所以我们import 一个组件完了之后,还需要在 components 中再声明下。

  • 渲染过程不同

Vue可以更快地计算出Virtual DOM的差异,这是由于它会跟踪每一个组件的依赖关系,不需要重新渲染整个组件树。 React当状态被改变时,全部子组件都会重新渲染。通过shouldComponentUpdate这个生命周期方法可以进行控制,但Vue将此视为默认的优化。

  • 框架本质不同

Vue本质是MVVM框架,由MVC发展而来; React是前端组件化框架,由后端组件化发展而来。

性能优化

  • 静态资源使用 CDN

CDN是一组分布在多个不同地理位置的 Web 服务器。当服务器离用户越远时,延迟越高。

  • 无阻塞

头部内联的样式和脚本会阻塞页面的渲染,样式放在头部并使用link方式引入,脚本放在尾部并使用异步方式加载

  • 压缩文件

压缩文件可以减少文件下载时间。

在 webpack 可以使用如下插件进行压缩: JavaScript:UglifyPlugin CSS :MiniCssExtractPlugin HTML:HtmlWebpackPlugin

使用 gzip 压缩。通过向 HTTP 请求头中的 Accept-Encoding 头添加 gzip 标识来开启这一功能。

  • 图片优化

图片懒加载

响应式图片:浏览器根据屏幕大小自动加载合适的图片。

降低图片质量:方法有两种,一是通过 webpack 插件 image-webpack-loader,二是通过在线网站进行压缩。

  • 减少重绘重排

降低 CSS 选择器的复杂性

使用 transform 和 opacity 属性更改来实现动画

用 JavaScript 修改样式时,最好不要直接写样式,而是替换 class 来改变样式。

如果要对 DOM 元素执行一系列操作,可以将 DOM 元素脱离文档流,修改完成后,再将它带回文档。推荐使用隐藏元素(display:none)或文档碎片(DocumentFragement),都能很好的实现这个方案。

  • 使用 requestAnimationFrame 来实现视觉变化

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行

  • webpack 打包, 添加文件缓存 index.html 设置成 no-cache,这样每次请求的时候都会比对一下 index.html 文件有没变化,如果没变化就使用缓存,有变化就使用新的 index.html 文件。 其他所有文件一律使用长缓存,例如设置成缓存一年 maxAge: 1000 * 60 * 60 * 24 * 365。 前端代码使用 webpack 打包,根据文件内容生成对应的文件名,每次重新打包时只有内容发生了变化,文件名才会发生变化。

max-age: 设置缓存存储的最大周期,超过这个时间缓存被认为过期(单位秒)。在这个时间前,浏览器读取文件不会发出新请求,而是直接使用缓存。 指定 no-cache 表示客户端可以缓存资源,每次使用缓存资源前都必须重新验证其有效性

输入url后发生了什么

  1. DNS域名解析;

  2. 建立TCP连接(三次握手);

  3. 发送HTTP请求;

  4. 服务器处理请求;

  5. 返回响应结果;

  6. 关闭TCP连接(四次握手);

  7. 浏览器解析HTML;

  8. 浏览器布局渲染;

  • DNS域名解析:拿到服务器ip

客户端收到你输入的域名地址后,它首先去找本地的hosts文件,检查在该文件中是否有相应的域名、IP对应关系,如果有,则向其IP地址发送请求,如果没有,再去找DNS服务器。

  • 建立TCP链接:客户端链接服务器

TCP提供了一种可靠、面向连接、字节流、传输层的服务。对于客户端与服务器的TCP链接,必然要说的就是『三次握手』。“3次握手”的作用就是双方都能明确自己和对方的收、发能力是正常的。

客户端发送一个带有SYN标志的数据包给服务端,服务端收到后,回传一个带有SYN/ACK标志的数据包以示传达确认信息,最后客户端再回传一个带ACK标志的数据包,代表握手结束,连接成功。

SYN —— 用于初如化一个连接的序列号。 ACK —— 确认,使得确认号有效。 RST —— 重置连接。 FIN —— 该报文段的发送方已经结束向对方发送数据。

客户端:“你好,在家不。” -- SYN 服务端:“在的,你来吧。” -- SYN + ACK 客户端:“好嘞。” -- ACK

  • 发送HTTP请求

  • 服务器处理请求

  • 返回响应结果

  • 关闭TCP连接(需要4次握手)

为了避免服务器与客户端双方的资源占用和损耗,当双方没有请求或响应传递时,任意一方都可以发起关闭请求。

关闭连接时,服务器收到对方的FIN报文时,仅仅表示客户端不再发送数据了但是还能接收数据,而服务器也未必全部数据都发送给客户端,所以服务器可以立即关闭,也可以发送一些数据给对方后,再发送FIN报文给对方来表示同意现在关闭连接,因此,己方ACK和FIN一般都会分开发送,从而导致多了一次。

客户端:“兄弟,我这边没数据要传了,咱关闭连接吧。” -- FIN + seq 服务端:“收到,我看看我这边有木有数据了。” -- ACK + seq + ack 服务端:“兄弟,我这边也没数据要传你了,咱可以关闭连接了。” - FIN + ACK + seq + ack 客户端:“好嘞。” -- ACK + seq + ack

  • 浏览器解析HTML

浏览器需要加载解析的不仅仅是HTML,还包括CSS、JS,以及还要加载图片、视频等其他媒体资源。

浏览器通过解析HTML,生成DOM树,解析CSS,生成CSSOM树,然后通过DOM树和CSSPOM树生成渲染树。渲染树与DOM树不同,渲染树中并没有head、display为none等不必显示的节点。

浏览器的解析过程并非是串连进行的,比如在解析CSS的同时,可以继续加载解析HTML,但在解析执行JS脚本时,会停止解析后续HTML,会出现阻塞问题。

  • 浏览器渲染页面

根据渲染树布局,计算CSS样式,即每个节点在页面中的大小和位置等几何信息。HTML默认是流式布局的,CSS和js会打破这种布局,改变DOM的外观样式以及大小和位置。最后浏览器绘制各个节点,将页面展示给用户。

replaint:屏幕的一部分重画,不影响整体布局,比如某个CSS的背景色变了,但元素的几何尺寸和位置不变。 reflow:意味着元素的几何尺寸变了,需要重新计算渲染树。

前端路由

什么是路由

路由是用来跟后端服务器进行交互的一种方式,通过不同的路径请求不同的资源。 路由这概念最开始是在后端出现, 在前后端不分离的时期, 由后端来控制路由, 服务器接收客户端的请求,解析对应的url路径, 并返回对应的页面/资源。

前端路由

Ajax,全称 Asynchronous JavaScript And XML,是浏览器用来实现异步加载的一种技术方案。

在Ajax没有出现时期,大多数的网页都是通过直接返回 HTML,用户的每次更新操作都需要重新刷新页面,及其影响交互体验。为了解决这个问题,提出了Ajax(异步加载方案), 有了 Ajax 后,用户交互就不用每次都刷新页面。后来出现SPA单页应用。

  • SPA 中用户的交互是通过 JS 改变 HTML 内容来实现的,页面本身的 url 并没有变化,这导致了两个问题:
    • SPA 无法记住用户的操作记录,无论是刷新、前进还是后退,都无法展示用户真实的期望内容。
    • SPA 中虽然由于业务的不同会有多种页面展示形式,但只有一个 url,对 SEO 不友好,不方便搜索引擎进行收录。

前端路由就是为了解决上述问题而出现的。

前端路由的实现方式

前端路由的实现实际上是检测 url 的变化,截获 url 地址,解析来匹配路由规则。有下面两种实现方式:

  • Hash模式 hash 就是指 url 后的 # 号以及后面的字符。 #后面 hash 值的变化,并不会导致浏览器向服务器发出请求,浏览器不发请求,也就不会刷新页面。

hash 的改变会触发 hashchange 事件,可以用onhashchange事件来监听hash值的改变。

// 监听hash变化,点击浏览器的前进后退会触发
window.onhashchange = function() { ... }

window.addEventListener('hashchange', function(event) { ...}, false);
  • History 模式

在 HTML5 之前,浏览器就已经有了 history 对象。但在早期的 history 中只能用于多页面的跳转:

history.go(-1);       // 后退一页
history.go(2);        // 前进两页
history.forward();    // 前进一页
history.back();       // 后退一页

在 HTML5 的规范中,history 新增了几个 API:

history.pushState();   // 向当前浏览器会话的历史堆栈中添加一个状态
history.replaceState();// 修改了当前的历史记录项(不是新建一个)
history.state          // 返回一个表示历史堆栈顶部的状态的值

由于 history.pushState() 和 history.replaceState() 可以改变 url 同时,不会刷新页面,所以在 HTML5 中的 histroy 具备了实现前端路由的能力。 window对象提供了onpopstate事件来监听历史栈的改变,一旦历史栈信息发生改变, 便会触发该事件。

调用history.pushState()或history.replaceState()不会触发popstate事件。只有在做出浏览器动作时,才会触发该事件,例如执行history.back()或history.forward()后触发 window.onpopstate事件。

// 历史栈改变
window.onpopstate = function() { ... }

注意: pushState() 不会造成 hashchange 事件调用, 即使新的URL和之前的URL只是锚的数据不同。

  • 两种模式对比
对比HashHistory
路径带#, 路径丑正常路径
兼容性>=ie8>=ie10
实用性直接使用,无需服务端配合处理。需服务端配合处理
命名空间同一document同源
锚点导致锚点功能失效锚点功能正常

前端路由实践

  • vue-router/react-router 都是基于前端路由的原理实现的~ react-router常用的 history 有三种形式:
    • browserHistory: 使用浏览器中的History API 用于处理 URL。history 在 DOM 上的实现,用于支持 HTML5 history API 的浏览器。
    • hashHistory: 使用 URL 中的 hash(#)部分去创建路由。history 在 DOM 上的实现,用于旧版浏览器。
    • createMemoryHistory: 不会在地址栏被操作或读取,history 在内存上的实现,用于测试或非 DOM 环境(例如 React Native)。

Babel Plugin与preset区别

Babel是代码转换器,比如将ES6转成ES5,或者将JSX转成JS等。借助Babel,开发者可以提前用上新的JS特性。

原始代码 --> [Babel Plugin] --> 转换后的代码

Plugin

实现Babel代码转换功能的核心,就是Babel插件(plugin)。Babel插件一般尽可能拆成小的力度,开发者可以按需引进, 既提高了性能,也提高了扩展性。比如对ES6转ES5的功能,Babel官方拆成了20+个插件。开发者想要体验ES6的箭头函数特性,那只需要引入transform-es2015-arrow-functions插件就可以,而不是加载ES6全家桶。

Preset

可以简单的把Babel Preset视为Babel Plugin的集合。想要将所有ES6的代码转成ES5,逐个插件引入的效率比较低下, 就可以采用Babel Preset。比如babel-preset-es2015就包含了所有跟ES6转换有关的插件。

Plugin与Preset执行顺序

可以同时使用多个Plugin和Preset,此时,它们的执行顺序非常重要。

  • 先执行完所有Plugin,再执行Preset。

  • 多个Plugin,按照声明次序顺序执行。

  • 多个Preset,按照声明次序逆序执行。

  • 比如.babelrc配置如下,那么执行的顺序为:

    • Plugin:transform-react-jsx、transform-async-to-generator
    • Preset:es2016、es2015
{
  "presets": [
    "es2015",
    "es2016"
  ],
  "plugins": [
    "transform-react-jsx",
    "transform-async-to-generator"
  ]
}

怎样开发和部署前端代码

为了进一步提升网站性能,会把静态资源和动态网页分集群部署,静态资源会被部署到CDN节点上,网页中引用的资源也会变成对应的部署路径。当需要更新静态资源的时候,同时也会更新html中的引用。

如果同时改了页面结构和样式,也更新了静态资源对应的url地址,现在要发布代码上线,是先上线页面,还是先上线静态资源?

  • 先部署页面,再部署资源:在二者部署的时间间隔内,如果有用户访问页面,就会在新的页面结构中加载旧的资源,并且把这个旧版本的资源当做新版本缓存起来,其结果就是:用户访问到了一个样式错乱的页面,除非手动刷新,否则在资源缓存过期之前,页面会一直执行错误。

  • 先部署资源,再部署页面:在部署时间间隔之内,有旧版本资源本地缓存的用户访问网站,由于请求的页面是旧版本的,资源引用没有改变,浏览器将直接使用本地缓存,这种情况下页面展现正常;但没有本地缓存或者缓存过期的用户访问网站,就会出现旧版本页面加载新版本资源的情况,导致页面执行错误,但当页面完成部署,这部分用户再次访问页面又会恢复正常了。

这个奇葩问题,起源于资源的 覆盖式发布,用待发布资源覆盖已发布资源,就有这种问题。

解决它也好办,就是实现非覆盖式发布。用文件的摘要信息来对资源文件进行重命名,把摘要信息放到资源文件发布路径中,这样,内容有修改的资源就变成了一个新的文件发布到线上,不会覆盖已有的资源文件。

上线过程中,先全量部署静态资源,再灰度部署页面,整个问题就比较完美的解决了。

  • 大公司的静态资源优化方案,基本上要实现这么几个东西:
    • 配置超长时间的本地缓存 —— 节省带宽,提高性能
    • 采用内容摘要作为缓存更新依据 —— 精确的缓存控制
    • 静态资源CDN部署 —— 优化网络请求
    • 更改资源发布路径实现非覆盖式发布 —— 平滑升级

大数相加

function add(a, b){
   const maxLength = Math.max(a.length, b.length);
   a = a.padStart(maxLength, 0);
   b = b.padStart(maxLength, 0);
   let t = 0;
   let f = 0;
   let sum = "";

   for (let i = maxLength - 1; i >= 0; i--) {
      t = parseInt(a[i]) + parseInt(b[i]) + f;
      f = Math.floor(t / 10);
      sum = `${t % 10}${sum}`;
   }
   if (f === 1){
      sum = "1" + sum;
   }
   return sum;
}

斐波那契数列求和

function fib(n) {
    if (n <= 0) {
        return 0;
    }
    let n1 = 1;
    let n2 = 1;
    let sum = 1;
    for(let i = 3; i <= n; i++) {
        [n1, n2] = [n2, sum];
        sum = n1 + n2;
    }
    return sum;
};
贡献者: mankueng