2025年前端面试复习 - JavaScript篇(二)

173 阅读42分钟

image.png

27. 单点登录

image.png

27.1 什么是单点登录

单点登录(Single Sign On),简称为 SSO,是⽬前⽐较流⾏的企业业务整合的解决⽅案之⼀

SSO的定义是在多个应⽤系统中,⽤户只需要登录⼀次就可以访问所有相互信任的应⽤系统

SSO ⼀般都需要⼀个独⽴的认证中⼼(passport),⼦系统的登录均得通过 passport ,⼦系统本身将不参与登录操作

当⼀个系统成功登录以后, passport 将会颁发⼀个令牌给各个⼦系统,⼦系统可以拿着令牌会获取各⾃的受保护资源,为了减少频繁认证,各个⼦系统在被 passport 授权以后,会建⽴⼀个局部会话,在⼀定时间内可以⽆需再次向 passport 发起认证

image.png 上图有四个系统,分别是 Application1 、 Application2 、 Application3 、和 SSO ,当Application1 、 Application2 、Application3 需要登录时,将跳到 SSO 系统, SSO 系统完成登录,其他的应⽤系统也就随之登录了

27.1.1 举个例子

淘宝、天猫都属于阿⾥旗下,当⽤户登录淘宝后,再打开天猫,系统便⾃动帮⽤户登录了天猫,这种现象就属于单点登录

27.2 如何实现单点登录

27.2.1 同域名下的单点登录

cookie 的 domain 属性设置为当前域的⽗域,并且⽗域的 cookie 会被⼦域所共享。path 属性默认为 web 应⽤的上下⽂路径

利⽤ Cookie 的这个特点,没错,我们只需要将 Cookie 的domain 属性设置为⽗域的域名(主域名),同时将 Cookie 的path 属性设置为根路径,将 Session ID (或 Token )保存到⽗域中。这样所有的⼦域应⽤就都可以访问到这个 Cookie

不过这要求应⽤系统的域名需建⽴在⼀个共同的主域名之下,如 tieba.baidu.com 和 map.baidu.com ,它们都建⽴在 baidu.com 这个主域名之下,那么它们就可以通过这种⽅式来实现单点登录

27.2.2 不同域名下的单点登录(一)

如果是不同域的情况下, Cookie 是不共享的,这⾥我们可以部署⼀个 认证中⼼,⽤于专⻔处理登录请求的独⽴的 Web 服务

⽤户统⼀在认证中⼼进⾏登录,登录成功后,认证中⼼记录⽤户的登录状态,并将 token 写⼊ Cookie (注意这个 Cookie 是认证中⼼的,应⽤系统是访问不到的)

应⽤系统检查当前请求有没有 Token ,如果没有,说明⽤户在当前系统中尚未登录,那么就将⻚⾯跳转⾄认证中⼼

由于这个操作会将认证中⼼的 Cookie ⾃动带过去,因此,认证中⼼能够根据 Cookie 知道⽤户是

否已经登录过了

如果认证中⼼发现⽤户尚未登录,则返回登录⻚⾯,等待⽤户登录

如果发现⽤户已经登录过了,就不会让⽤户再次登录了,⽽是会跳转回⽬标 URL ,并在跳转前⽣成⼀个 Token ,拼接在⽬标URL 的后⾯,回传给⽬标应⽤系统

应⽤系统拿到 Token 之后,还需要向认证中⼼确认下Token 的合法性,防⽌⽤户伪造。确认⽆误后,应⽤系统记录⽤户的登录状态,并将 Token 写⼊ Cookie ,然后给本次访问放⾏。(注意这个 Cookie 是当前应⽤系统的)当⽤户再次访问当前应⽤系统时,就会⾃动带上这个 Token ,应⽤系统验证 Token 发现⽤户已登录,于是就不会有认证中⼼什么事了

此种实现⽅式相对复杂,⽀持跨域,扩展性好,是单点登录的标准做法

27.2.3 不同域名下的单点登录(二)

可以选择将 Session ID (或 Token )保存到浏览器的 LocalStorage 中,让前端在每次向 后端发送请求时,主动将 LocalStorage 的数据传递给服务端

这些都是由前端来控制的,后端需要做的仅仅是在⽤户登录成功后,将 Session ID (或 Token )放在响应体中传递给前端

单点登录完全可以在前端实现。前端拿到 Session ID (或 Token )后,除了将它写⼊⾃⼰的 LocalStorage 中之外,还可以通过特殊⼿段将它写⼊多个其他域下的 LocalStorage 中

关键代码如下:

// 获取 token
var token = result.data.token;
// 动态创建⼀个不可⻅的iframe,在iframe中加载⼀个跨域HTML
var iframe = document.createElement("iframe");
iframe.src = "http://app1.com/localstorage.html";
document.body.append(iframe);
// 使⽤postMessage()⽅法将token传递给iframe
setTimeout(function () {
    iframe.contentWindow.postMessage(token, "http://app1.com");
}, 4000);

setTimeout(function () {
    iframe.remove();
}, 6000);

// 在这个iframe所加载的HTML中绑定⼀个事件监听器,当事件被触发时,把接收到的token数据写localStorage
window.addEventListener('message', function (event) {
    localStorage.setItem('token', event.data)
}, false);

前端通过 iframe + postMessage() ⽅式,将同⼀份 Token 写⼊到了多个域下的 LocalStorage 中,前端每次在向后端发送请求之前,都会主动从 LocalStorage 中读取Token 并在请求中携带,这样就实现了同⼀份 Token 被多个域所共享此种实现⽅式完全由前端控制,⼏乎不需要后端参与,同样⽀持跨域

27.3 流程

image.png

  1. 用户访问系统1的受保护资源,系统1发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数
  2. sso认证中心发现用户未登录,将用户引导至登录页面
  3. 用户输入用户名密码提交登录申请
  4. sso认证中心校验用户信息,创建用户与sso认证中心之间的会话,称为全局会话,同时创建授权令牌
  5. sso认证中心带着令牌跳转至最初的请求地址(系统1)
  6. 系统1拿到令牌,去sso认证中心校验令牌是否有效
  7. sso认证中心校验令牌,返回有效,注册系统1
  8. 系统1使用该令牌创建与用户的会话,成为局部会话,返回受保护资源
  9. 用户访问系统2的受保护资源
  10. 系统2发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数
  11. sso认证中心发现用户已登录,跳转回系统2的地址,并附上令牌
  12. 系统2拿到令牌,去sso认证中心校验令牌是否有效
  13. sso认证中心校验令牌,返回有效,注册系统2
  14. 系统2使用该令牌创建与用户的局部会话,返回受保护资源

⽤户登录成功之后,会与 sso 认证中⼼及各个⼦系统建⽴会话,⽤户与 sso 认证中⼼建⽴的会话称为全局会话

⽤户与各个⼦系统建⽴的会话称为局部会话,局部会话建⽴之后,⽤户访问⼦系统受保护资源将不再通过 sso 认证中⼼

全局会话与局部会话有如下约束关系:

  • 局部会话存在,全局会话⼀定存在
  • 全局会话存在,局部会话不⼀定存在
  • 全局会话销毁,局部会话必须销毁

28. 上拉加载,下拉刷新

image.png

28.1 前言

下拉刷新和上拉加载这两种交互⽅式通常出现在移动端中

本质上等同于PC⽹⻚中的分⻚,只是交互形式不同

开源社区也有很多优秀的解决⽅案,如 iscroll 、 better-scroll 、 pulltorefresh.js 库等等

这些第三⽅库使⽤起来⾮常便捷

我们通过原⽣的⽅式实现⼀次上拉加载,下拉刷新,有助于对第三⽅库有更好的理解与使⽤

28.2 实现原理

上拉加载及下拉刷新都依赖于⽤户交互

最重要的是要理解在什么场景,什么时机下触发交互动作

28.2.1 上拉加载

image.png 上拉加载的本质是 ⻚⾯触底,或者快要触底时的动作

判断页面触底,需要了解的几个属性:

  • scrollTop :滚动视窗的⾼度距离 window 顶部的距离,它会随着往上滚动⽽不断增加,初始值是0,它是⼀个变化的值
  • clientHeight :它是⼀个定值,表示屏幕可视区域的⾼度;
  • scrollHeight :⻚⾯不能滚动时也是存在的,此时scrollHeight等于clientHeight。scrollHeight表示 body 所有元素的总⻓度(包括body元素⾃身的padding)

综上我们得出⼀个触底公式:

scrollTop + clientHeight >= scrollHeight

// 实现
let clientHeight = document.documentElement.clientHeight; //浏览器⾼度
let scrollHeight = document.body.scrollHeight;
let scrollTop = document.documentElement.scrollTop;
let distance = 50; //距离视窗还⽤50的时候,开始触发;
if ((scrollTop + clientHeight) >= (scrollHeight - distance)) {
    console.log("开始加载数据");
}

28.2.2 下拉刷新

下拉刷新的本质是⻚⾯本身置于顶部时,⽤户下拉时需要触发的动作

关于下拉刷新的原⽣实现,主要分成三步:

  • 监听原⽣ touchstart 事件,记录其初始位置的值, e.touches[0].pageY ;
  • 监听原⽣ touchmove 事件,记录并计算当前滑动的位置值与初始位置值的差值,⼤于0表示向下拉动,并借助CSS3的 translateY 属性使元素跟随⼿势向下滑动对应的差值,同时也应设置⼀个允许滑动的最⼤值;
  • 监听原⽣ touchend 事件,若此时元素滑动达到最⼤值,则触发 callback ,同时将 translateY 重设为 0 ,元素回到初始位置

举个例⼦:

Html 结构如下:

<main>
    <p class="refreshText"></p >
    <ul id="refreshContainer">
        <li>111</li>
        <li>222</li>
        <li>333</li>
        <li>444</li>
        <li>555</li>
        ...
    </ul>
</main>
// 1. 监听 touchstart 事件,记录初始的值
var _element = document.getElementById('refreshContainer'),
    _refreshText = document.querySelector('.refreshText'),
    _startPos = 0, // 初始的值
    _transitionHeight = 0; // 移动的距离
_element.addEventListener('touchstart', function(e) {
    _startPos = e.touches[0].pageY; // 记录初始位置
    _element.style.position = 'relative';
    _element.style.transition = 'transform 0s';
}, false);

// 2. 监听 touchmove 移动事件,记录滑动差值
_element.addEventListener('touchmove', function(e) {
    // e.touches[0].pageY 当前位置
    _transitionHeight = e.touches[0].pageY - _startPos; // 记录差值
    if (_transitionHeight > 0 && _transitionHeight < 60) {
        _refreshText.innerText = '下拉刷新';
        _element.style.transform = 'translateY('+_transitionHeight+'px)';
        if (_transitionHeight > 55) {
            _refreshText.innerText = '释放更新';
        }
    }
}, false);

// 3. 最后,就是监听 touchend 离开的事件
_element.addEventListener('touchend', function(e) {
    _element.style.transition = 'transform 0.5s ease 1s';
    _element.style.transform = 'translateY(0px)';
    _refreshText.innerText = '更新中...';
    // todo...
}, false);

从上⾯可以看到,在下拉到松⼿的过程中,经历了三个阶段:

  • 当前⼿势滑动位置与初始位置差值⼤于零时,提示正在进⾏下拉刷新操作
  • 拉到⼀定值时,显示松⼿释放后的操作提示
  • 下拉到达设定最⼤值松⼿时,执⾏回调,提示正在进⾏更新操作

28.3 案例

在实际开发中,我们更多的是使⽤第三⽅库,下⾯以 better-scroll 进⾏举例: Html 结构如下:

<div id="position-wrapper">
    <div>
        <p class="refresh">下拉刷新</p >
        <div class="position-list">
        <!--列表内容-->
        </div>
        <p class="more">查看更多</p >
    </div>
</div>
// 1. 实例化上拉下拉插件,通过 use 来注册插件
import BScroll from "@better-scroll/core";
import PullDown from "@better-scroll/pull-down";
import PullUp from '@better-scroll/pull-up';
BScroll.use(PullDown);
BScroll.use(PullUp);

// 2.实例化 BetterScroll ,并传⼊相关的参数
let pageNo = 1, pageSize = 10, dataList = [], isMore = true;
var scroll= new BScroll("#position-wrapper",{
        scrollY: true,//垂直⽅向滚动
        click: true,//默认会阻⽌浏览器的原⽣click事件,如果需要点击,这⾥要设为true
        pullUpLoad: true,//上拉加载更多
        pullDownRefresh:{
        threshold: 50,//触发pullingDown事件的位置
        stop: 0//下拉回弹后停留的位置
    }
});

//监听下拉刷新
scroll.on("pullingDown",pullingDownHandler);

//监测实时滚动
scroll.on("scroll",scrollHandler);

//上拉加载更多
scroll.on("pullingUp",pullingUpHandler);

async function pullingDownHandler() {
    dataList=[];
    pageNo=1;
    isMore=true;
    $(".more").text("查看更多");
    await getlist();//请求数据
    scroll.finishPullDown();//每次下拉结束后,需要执⾏这个操作
    scroll.refresh();//当滚动区域的dom结构有变化时,需要执⾏这个操作
}

async function pullingUpHandler(){
    if(!isMore){
        $(".more").text("没有更多数据了");
        scroll.finishPullUp();//每次上拉结束后,需要执⾏这个操作
        return;
    }

    pageNo++;

    await this.getlist();//请求数据
    scroll.finishPullUp();//每次上拉结束后,需要执⾏这个操作
    scroll.refresh();//当滚动区域的dom结构有变化时,需要执⾏这个操作
}

function scrollHandler(){
    if(this.y>50) $('.refresh').text("松⼿开始加载");
    else $('.refresh').text("下拉刷新");
}

function getlist(){
    //返回的数据
    let result=....;
    dataList = dataList.concat(result);

    //判断是否已加载完
    if(result.length<pageSize) isMore=false;
    //将dataList渲染到html内容中
}

注意点:

使⽤ better-scroll 实现下拉刷新、上拉加载时要注意以下⼏点:

  • wrapper ⾥必须只有⼀个⼦元素
  • ⼦元素的⾼度要⽐ wrapper 要⾼
  • 使⽤的时候,要确定 DOM 元素是否已经⽣成,必须要等到 DOM 渲染完成后,再 new BScroll()
  • 滚动区域的 DOM 元素结构有变化后,需要执⾏刷新 refresh()
  • 上拉或者下拉,结束后,需要执⾏ finishPullUp() 或者 finishPullDown() ,否则将不会执⾏下次操作
  • better-scroll ,默认会阻⽌浏览器的原⽣ click 事件,如果滚动内容区要添加点击事件,需要在实例化属性⾥设置 click:true

28.4 小结

下拉刷新、上拉加载原理本身都很简单,真正复杂的是封装过程中,要考虑的兼容性、易⽤性、性能等诸多细节

29. 正则表达式

image.png

29.1 什么是正则表达式

正则表达式是⼀种⽤来 匹配字符串 的强有⼒的武器

它的设计思想是⽤⼀种描述性的语⾔定义⼀个规则,凡是符合规则的字符串,我们就认为它“匹配”了, 否则,该字符串就是不合法的

在 JavaScript 中,正则表达式也是对象,构建正则表达式有两种⽅式:

  1. 字面量创建,其中包含在斜杠之间的模式组成
const re = /\d+/g;
  1. 调用 RegExp 对象的构造函数
const re = new RegExp("\\d+","g");
const rul = "\\d+"
const re1 = new RegExp(rul,"g");

使⽤构建函数创建,第⼀个参数可以是⼀个变量,遇到特殊字符 \ 需要使⽤\进⾏转义

29.2 匹配规则

常见匹配规则如下:

规则描述
|转义
匹配输入的开始
$匹配输入的结束
*匹配前一个表达式 0 次或 多次
+匹配前面一个表达式 1 次 或 多次,等价于{1,}
?匹配前面一个表达式 0 次 或 1次,等价于{0,1}
.默认匹配除换行符以外的任何单个字符
x(?=y)匹配'x'(仅仅当'x'后面紧跟着'y'。)这种叫先行断言
(?=y)x匹配'x'(仅仅当'x'前面是'y'。)这种叫后行断言
x(?!y)仅仅当'x' 后面不跟着'y'时匹配'x',这被称为正向否定查找
(?!y)x仅仅当'x' 前面不是'y'时匹配'x',这被称为反向否定查找
x丨y匹配 'x' 或者 'y'
{n}'n' 是一个正整数,匹配了前面一个字符刚好出现了 n 次
{n,}'n' 是一个正整数,匹配了一个字符至少出现了 n 次
{n,m}'n' 和 'm' 是一个正整数,匹配了一个字符至少出现了 n 次, 最多出现 m 次
[xyz]一个字符集合。匹配方括号中的任意字符
[^xyz]匹配任何没有包含在方括号中的字符
\b匹配一个词的边界,例如在字母和空格之间
\B匹配一个非词的边界
\d匹配一个数字
\D匹配一个非数字字符
\f匹配一个换页符
\n匹配一个换行符
\r匹配一个回车符
\s匹配一个空白字符,包括空格、制表符、换页符和换行符
\S匹配一个非空白字符
\w匹配一个单子字符(字母、数字或者下划线)
\W匹配一个非单字字符

29.2.1 正则表达式标记

标志描述
g全局搜索。
i不区分大小写搜索
m多行搜索
s允许 . 匹配换行符
u使用 unicode 码的模式进行匹配
y执行“粘性(sticky)”搜索,匹配从目标字符串的当前位置开始

举个例子:

// 伪代码,此处的 flags 为上述 标识 的统称
var re = /pattern/flags;
var re = new RegExp("pattern", "flags");

console.log(/foo/gi.flags);
// Expected output: "gi"

console.log(/bar/muy.flags);
// Expected output: "muy"

29.2.2 ⼏个正则表达式的特性:

29.2.2.1 贪婪模式
const reg = /ab{1,3}c/

在匹配过程中,尝试可能的顺序是从多往少的⽅向去尝试。⾸先会尝试 bbb ,然后再看整个正则是否 能匹配。不能匹配时,吐出⼀个 b ,即在 bb 的基础上,再继续尝试,以此重复

如果多个贪婪量词挨着,则深度优先搜索

const string = "12345";
const reg = /(\d{1,3})(\d{1,3})/;
console.log( string.match(reg) );
// => ["12345", "123", "45", index: 0, input: "12345"]

其中,前⾯的 \d{1,3} 匹配的是"123",后⾯的 \d{1,3} 匹配的是"45"

29.2.2.2 懒惰模式

惰性量词就是在贪婪量词后⾯加个问号。表示尽可能少的匹配

var string = "12345";
var regex = /(\d{1,3}?)(\d{1,3})/;
console.log( string.match(regex) );
// => ["1234", "1", "234", index: 0, input: "12345"]

其中 \d{1,3}? 只匹配到⼀个字符"1",⽽后⾯的 \d{1,3} 匹配了"234"

29.2.2.3 分组

分组主要是⽤过 () 进⾏实现,⽐如 beyond{3} ,是匹配d字⺟3次。⽽(beyond){3} 是匹配 beyond 三次

在 () 内使⽤|达到或的效果,如(abc | xxx) 可以匹配 abc 或者 xxx

反向引⽤,巧⽤ $ 分组捕获

let str = "John Smith";

// 交换名字和姓⽒
console.log(str.replace(/(john) (smith)/i, '$2, $1')) // Smith, John

29.3 匹配规则

正则表达式常被⽤于某些⽅法,我们可以分成两类:

  • 字符串(str)⽅法: match 、 matchAll 、 search 、replace 、 split
  • 正则对象下(regexp)的⽅法: test 、 exec
方法描述
exec一个在字符串中执行查找匹配的RegExp方法,它返回一个数组(为匹配到则返回null)
test一个在字符串中测试是否匹配的RegExp方法, 它返回 true 或 false
match一个在字符串中执行查找匹配的String方法,它返回一个数组,在为匹配到时返回null
natchAll一个在字符串中执行查找所有匹配的String方法,它返回一个迭代器(iterator)
search一个在字符串中测试匹配的String方法,它返回匹配到的位置索引,或者在失败时返回-1
replace一个在字符串中执行查找匹配的String方法,并且使用替换字符串替换掉匹配的子字符串
split一个使用正则表达式或者一个固定字符串分隔一个字符串,并将分隔后的子字符串存储到数组中的 String 方法

29.3.1 str.match(RegExp)

str.match(regexp) ⽅法在字符串 str 中找到匹配 regexp 的字符

如果 regexp 不带有g标记,则它以数组的形式返回第⼀个匹配项,其中包含分组和属性index (匹配项的位置)、 input (输⼊字符串,等于 str )

let str = "I love JavaScript";
let result = str.match(/Java(Script)/);

console.log( result[0] ); // JavaScript(完全匹配)
console.log( result[1] ); // Script(第⼀个分组)
console.log( result.length ); // 2

// 其他信息:
console.log( result.index ); // 7(匹配位置)
console.log( result.input ); // I love JavaScript(源字符串)

如果 regexp 带有 g 标记,则它将所有匹配项的数组作为字符串返回,⽽不包含分组和其他详细信息

let str = "I love JavaScript";
let result = str.match(/Java(Script)/g);
console.log( result[0] ); // JavaScript
console.log( result.length ); // 1

如果没有匹配项,则 ⽆论是否带有标记 g ,都将返回null

let str = "I love JavaScript";
let result = str.match(/HTML/);

console.log(result); // null

29.3.2 str.matchAll(RegExp)

返回⼀个包含所有匹配正则表达式的结果及分组捕获组的迭代器

const regexp = /t(e)(st(\d?))/g;
const str = 'test1test2';

const array = [...str.matchAll(regexp)];
console.log(array[0]);
// expected output: Array ["test1", "e", "st1", "1"]

console.log(array[1]);
// expected output: Array ["test2", "e", "st2", "2"]

29.3.3 str.search(RegExp)

返回第⼀个匹配项的位置(索引),如果未找到,则返回 -1

let str = "A drop of ink may make a million think"; 
console.log( str.search( /ink/i ) ); // 10(第⼀个匹配位置)

这⾥需要注意的是, search 仅查找第⼀个匹配项

29.3.4 str.replace(RegExp)

替换与正则表达式匹配的⼦串,并返回替换后的字符串。在不设置全局匹配 g 的时候,只替换第⼀个匹配成功的字符串⽚段

const reg1=/javascript/i;

const reg2=/javascript/ig;

console.log('hello Javascript Javascript Javascript'.replace(reg1,'js'));
//hello js Javascript Javascript

console.log('hello Javascript Javascript Javascript'.replace(reg2,'js'));
//hello js js js

29.3.5 str.split(RegExp)

使⽤正则表达式(或⼦字符串)作为分隔符来分割字符串

console.log('12, 34, 56'.split(/,\s*/)) // 数组 ['12', '34', '56']

29.3.6 RegExp.exec(str)

regexp.exec(str) ⽅法返回字符串 str 中的 regexp 匹配项,与以前的⽅法不同,它是在正则表达式⽽不是字符串上调⽤的

根据正则表达式是否带有标志 g ,它的⾏为有所不同

如果没有g,那么 regexp.exec(str) 返回的第⼀个匹配与 str.match(regexp) 完全相同

如果有标记g,调⽤regexp.exec(str) 会返回第⼀个匹配项,并将紧随其后的位置保存在属性 regexp.lastIndex 中。 下⼀次同样的调⽤会从位置 regexp.lastIndex 开始搜索,返回下⼀个匹配项,并将其后的位置保存在 regexp.lastIndex 中

let str = 'More about JavaScript at https://javascript.info';
let regexp = /javascript/ig;
let result;

while (result = regexp.exec(str)) {
    console.log( `Found ${result[0]} at position ${result.index}` );
    // Found JavaScript at position 11
    // Found javascript at position 33
}

29.3.7 RegExp.test(str)

查找匹配项,然后返回 true/false 表示是否存在

let str = "I love JavaScript";

// 这两个测试相同
console.log( /love/i.test(str) ); // true

29.4 应用场景

通过上⾯的学习,我们对正则表达式有了⼀定的了解

下⾯再来看看正则表达式⼀些案例场景:

  1. 验证QQ合法性(5~15位、全是数字、不以0开头):
const reg = /^[1-9][0-9]{4,14}$/
const isvalid = patrn.exec(s)
  1. 校验⽤户账号合法性(只能输⼊5-20个以字⺟开头、可带数字、“_”、“.”的字串):
const reg = /^[a-zA-Z]{1}([a-zA-Z0-9]|[._]{4, 19}$/;
const isvalid = patrn.exec(s)
  1. 将 url 参数解析成对象
const protocol = '(?<protocol>https?:)';

const host = '(?<host>(?<hostname>[^/#?:]+)(?::(?<port>\\d+))?)';

const path = '(?<pathname>(?:\\/[^/#?]+)*\\/?)';

const search = '(?<search>(?:\\?[^#]*)?)';

const hash = '(?<hash>(?:#.*)?)';

const reg = new RegExp(`^${protocol}\/\/${host}${path}${search}${hash}$`);

function execURL(url){
    const result = reg.exec(url);
    if(result){
        result.groups.port = result.groups.port || '';
        return result.groups;
    }

    return {
        protocol:'',
        host:'',
        hostname:'',
        port:'',
        pathname:'',
        search:'',
        hash:'',
    };
}

console.log(execURL('https://localhost:8080/?a=b#xxxx'));

protocol: "https:"
host: "localhost:8080"
hostname: "localhost"
port: "8080"
pathname: "/"
search: "?a=b"
hash: "#xxxx"

再将上⾯的 search 和 hash 进⾏解析

function execUrlParams(str){
    str = str.replace(/^[#?&]/,'');
    const result = {};
    if(!str){ //如果正则可能配到空字符串,极有可能造成死循环,判断很重要
        return result;
    }

    const reg = /(?:^|&)([^&=]*)=?([^&]*?)(?=&|$)/y
    let exec = reg.exec(str);
    while(exec){
        result[exec[1]] = exec[2];
        exec = reg.exec(str);
    }
    return result;
}

console.log(execUrlParams('#'));// {}
console.log(execUrlParams('##'));//{'#':''}
console.log(execUrlParams('?q=3606&src=srp')); //{q: "3606", src: "srp"}
console.log(execUrlParams('test=a=b=c&&==&a='));//{test: "a=b=c", "":"=", a: ""}

30.函数式编程

image.png

30.1 什么是函数式编程

函数式编程是⼀种"编程范式"(programming paradigm),⼀种编写程序的方法论

主要的编程范式有三种:命令式编程,声明式编程和函数式编程

相⽐命令式编程,函数式编程更加强调程序执⾏的结果⽽⾮执⾏的过程,倡导利⽤若⼲简单的执⾏单元让计算结果不断渐进,逐层推导复杂的运算,⽽⾮设计⼀个复杂的执⾏过程

举个例⼦,将数组每个元素进⾏平⽅操作,命令式编程与函数式编程如下

// 命令式编程
var array = [0, 1, 2, 3]
for(let i = 0; i < array.length; i++) {
    array[i] = Math.pow(array[i], 2)
}

// 函数式⽅式
[0, 1, 2, 3].map(num => Math.pow(num, 2))

简单来讲,就是要把过程逻辑写成函数,定义好输⼊参数,只关⼼它的输出结果

即:是⼀种描述集合和集合之间的转换关系,输⼊通过函数都会返回有且只有⼀个输出值 image.png

可以看到,函数实际上是⼀个关系,或者说是⼀种映射,⽽这种映射关系是可以组合的,⼀旦我们知道⼀个函数的输出类型可以匹配另⼀个函数的输⼊,那他们就可以进⾏组合

30.2 相关概念

30.2.1 纯函数

函数式编程旨在尽可能的提⾼代码的⽆状态性和不变性。要做到这⼀点,就要学会使⽤⽆副作⽤的函 数,也就是 纯函数

纯函数是对给定的输⼊返还相同输出的函数,并且要求你所有的数据都是不可变的,即纯函数=⽆状态+数据不可变 image.png

举个例子:

let double = value=>value*2;

特性:

  • 函数内部传⼊指定的值,就会返回确定唯⼀的值
  • 不会造成超出作⽤域的变化,例如修改全局变量或引⽤传递的参数

优势:

  • 使⽤纯函数,我们可以产⽣可测试的代码
test('double(2) 等于 4', () => {
    expect(double(2)).toBe(4);
})
  • 不依赖外部环境计算,不会产⽣副作⽤,提⾼函数的复⽤性
  • 可读性更强 ,函数不管是否是纯函数  都会有⼀个语义化的名称,更便于阅读
  • 可以组装成复杂任务的可能性。符合模块化概念及单⼀职责原则

30.2.2 高阶函数

在我们的编程世界中,我们需要处理的其实也只有“数据”和“关系”,⽽关系就是函数

编程⼯作也就是在找⼀种映射关系,⼀旦关系找到了,问题就解决了,剩下的事情,就是让数据流过这种关系,然后转换成另⼀个数据,如下图所示 image.png

在这⾥,就是⾼阶函数的作⽤。⾼级函数,就是以函数作为输⼊或者输出的函数被称为⾼阶函数

通过⾼阶函数抽象过程,注重结果,如下⾯例⼦:

const forEach = function(arr,fn){
    for(let i=0;i<arr.length;i++){
        fn(arr[i]);
    }
}

let arr = [1,2,3];
forEach(arr,(item)=>{
    console.log(item);
})

上⾯通过⾼阶函数 forEach 来抽象循环如何做的逻辑,直接关注做了什么

⾼阶函数存在缓存的特性,主要是利⽤闭包作⽤

const once = (fn)=>{
let done = false;
return function(){
        if(!done){
            fn.apply(this,fn);
        }else{
            console.log("该函数已经执⾏");
        }
        done = true;
    }
}

20.2.3 柯里化

柯⾥化是把⼀个多参数函数转化成⼀个嵌套的⼀元函数的过程

⼀个⼆元函数如下:

let fn = (x,y)=>x+y;

转化成柯⾥化函数如下:

const curry = function(fn){
    return function(x){
        return function(y){
            return fn(x,y);
        }
    }
}

let myfn = curry(fn);
console.log( myfn(1)(2) );

上⾯的 curry 函数只能处理⼆元情况,下⾯再来实现⼀个实现多参数的情况

// 多参数柯⾥化;
const curry = function(fn){
    return function curriedFn(...args){
        if(args.length<fn.length){
            return function(){
                return curriedFn(...args.concat([...arguments]));
            }
        }
        return fn(...args);
    }
}
const fn = (x,y,z,a)=>x+y+z+a;
const myfn = curry(fn);
console.log(myfn(1)(2)(3)(1));

关于柯⾥化函数的意义如下:

  • 让纯函数更纯,每次接受⼀个参数,松散解耦
  • 惰性执⾏

30.2.4 组合与管道

组合函数,⽬的是将多个函数组合成⼀个函数

举个例子:

function afn(a){
    return a*2;
}

function bfn(b){
    return b*3
}

const compose = (a,b)=>c=>a(b(c));
let myfn = compose(afn,bfn);
console.log( myfn(2)); // 24

可以看到 compose 实现⼀个简单的功能:形成了⼀个新的函数,⽽这个函数就是⼀条从 bfn -> afn 的流⽔线

下⾯再来看看如何实现⼀个多函数组合:

const compose = (...fns)=>val=>fns.reverse().reduce((acc,fn)=>fn(acc),val);

compose 执⾏是从右到左的。⽽管道函数,执⾏顺序是从左到右执⾏的

const pipe = (...fns)=>val=>fns.reduce((acc,fn)=>fn(acc),val);

组合函数与管道函数的意义在于:可以把很多⼩函数组合起来完成更复杂的逻辑

30.3 优缺点

30.3.1 优点

  • 更好的管理状态:因为它的宗旨是⽆状态,或者说更少的状态,能最⼤化的减少这些未知、优化代码、减少出错情况
  • 更简单的复⽤:固定输⼊->固定输出,没有其他外部变量影响,并且⽆副作⽤。这样代码复⽤时,完全不需要考虑它的内部实现和外部影响
  • 更优雅的组合:往⼤的说,⽹⻚是由各个组件组成的。往⼩的说,⼀个函数也可能是由多个⼩函数组成的。更强的复⽤性,带来更强⼤的组合性
  • 隐性好处。减少代码量,提⾼维护性

30.3.1 缺点

  • 性能:函数式编程相对于指令式编程,性能绝对是⼀个短板,因为它往往会对⼀个⽅法进⾏过度包装,从⽽产⽣上下⽂切换的性能开销
  • 资源占⽤:在 JS 中为了实现对象状态的不可变,往往会创建新的对象,因此,它对垃圾回收所产⽣的压⼒远远超过其他编程⽅式
  • 递归陷阱:在函数式编程中,为了实现迭代,通常会采⽤递归操作

31. web常见的攻击方法

image.png

31.1 如何定义web攻击

Web攻击(WebAttack)是针对⽤户上⽹⾏为或⽹站服务器等设备进⾏攻击的⾏为

如植⼊恶意代码,修改⽹站权限,获取⽹站⽤户隐私信息等等

Web应⽤程序的安全性是任何基于Web业务的重要组成部分

确保Web应⽤程序安全⼗分重要,即使是代码中很⼩的 bug 也有可能导致隐私信息被泄露

站点安全就是为保护站点不受未授权的访问、使⽤、修改和破坏⽽采取的⾏为或实践

我们常⻅的Web攻击⽅式有

  • XSS (Cross Site Scripting) 跨站脚本攻击
  • CSRF(Cross-site request forgery)跨站请求伪造
  • SQL注⼊攻击

31.2 XSS

XSS,跨站脚本攻击,允许攻击者将恶意代码植⼊到提供给其它⽤户使⽤的⻚⾯中

XSS 涉及到三⽅,即攻击者、客户端与 Web 应⽤

XSS 的攻击⽬标是为了盗取存储在客户端的 cookie 或者其他⽹站⽤于识别客户端身份的敏感信息。

⼀旦获取到合法⽤户的信息后,攻击者甚⾄可以假冒合法⽤户与⽹站进⾏交互

举个例⼦:

<!-⼀个搜索⻚⾯,根据 url 参数决定关键词的内容->

<input type="text" value="<%= getParameter("keyword") %>">
<button>搜索</button>
<div>
    您搜索的关键词是:<%= getParameter("keyword") %>
</div>

这⾥看似并没有问题,但是如果不按套路出牌呢? ⽤户输⼊ "><script>alert('XSS');</script>,拼接到 HTML 中返回给浏览器。形成了如下的 HTML:

<input type="text" value=""><script>alert('XSS');</script>">
<button>搜索</button>
<div>
    您搜索的关键词是:"><script>alert('XSS');</script>
</div>

浏览器⽆法分辨出 "><script>alert('XSS');</script> 是恶意代码,因⽽将其执⾏,试想⼀下,

如果是获取 cookie 发送对⿊客服务器呢?

根据攻击的来源, XSS 攻击可以分成:

  • 存储型
  • 反射型
  • DOM 型

30.2.1 存储型

存储型 XSS 的攻击步骤:

  1. 攻击者将恶意代码提交到⽬标⽹站的数据库中
  2. ⽤户打开⽬标⽹站时,⽹站服务端将恶意代码从数据库取出,拼接在 HTML 中返回给浏览器
  3. ⽤户浏览器接收到响应后解析执⾏,混在其中的恶意代码也被执⾏
  4. 恶意代码窃取⽤户数据并发送到攻击者的⽹站,或者冒充⽤户的⾏为,调⽤⽬标⽹站接⼝执⾏攻击者指定的操作

这种攻击常⻅于带有⽤户保存数据的⽹站功能,如论坛发帖、商品评论、⽤户私信等

30.2.2 反射型XSS

反射型 XSS 的攻击步骤:

  1. 攻击者构造出特殊的 URL,其中包含恶意代码
  2. ⽤户打开带有恶意代码的 URL 时,⽹站服务端将恶意代码从 URL 中取出,拼接在 HTML 中返回给浏览器
  3. ⽤户浏览器接收到响应后解析执⾏,混在其中的恶意代码也被执⾏
  4. 恶意代码窃取⽤户数据并发送到攻击者的⽹站,或者冒充⽤户的⾏为,调⽤⽬标⽹站接⼝执⾏攻击者指定的操作

反射型 XSS 跟存储型 XSS 的区别是:存储型 XSS 的恶意代码存在数据库⾥,反射型 XSS 的恶意代码存在 URL ⾥。

反射型 XSS 漏洞常⻅于通过 URL 传递参数的功能,如⽹站搜索、跳转等。

由于需要⽤户主动打开恶意的 URL 才能⽣效,攻击者往往会结合多种⼿段诱导⽤户点击。

POST 的内容也可以触发反射型 XSS,只不过其触发条件⽐较苛刻(需要构造表单提交⻚⾯,并引导⽤户点击),所以⾮常少⻅

30.2.3 DOM 型XSS

DOM 型 XSS 的攻击步骤:

  1. 攻击者构造出特殊的 URL,其中包含恶意代码
  2. ⽤户打开带有恶意代码的 URL
  3. ⽤户浏览器接收到响应后解析执⾏,前端 JavaScript 取出 URL 中的恶意代码并执⾏
  4. 恶意代码窃取⽤户数据并发送到攻击者的⽹站,或者冒充⽤户的⾏为,调⽤⽬标⽹站接⼝执⾏攻击者指定的操作

DOM 型 XSS 跟前两种 XSS 的区别:DOM 型 XSS 攻击中,取出和执⾏恶意代码由浏览器端完成,属 于前端 JavaScript ⾃身的安全漏洞,⽽其他两种 XSS 都属于服务端的安全漏洞

30.2.4 XSS的预防

通过前⾯介绍,看到 XSS 攻击的两⼤要素:

  • 攻击者提交⽽恶意代码
  • 浏览器执⾏恶意代码

针对第⼀个要素,我们在⽤户输⼊的过程中,过滤掉⽤户输⼊的恶劣代码,然后提交给后端,但是如果 攻击者绕开前端请求,直接构造请求就不能预防了

⽽如果在后端写⼊数据库前,对输⼊进⾏过滤,然后把内容给前端,但是这个内容在不同地⽅就会有不同显示

例如:

⼀个正常的⽤户输⼊了 5 < 7 这个内容,在写⼊数据库前,被转义,变成了 5 < 7

在客户端中,⼀旦经过了 escapeHTML() ,客户端显示的内容就变成了乱码( 5 < 7 )

在前端中,不同的位置所需的编码也不同。

  • 当 5 < 7 作为 HTML 拼接⻚⾯时,可以正常显示:
<div title="comment">5 < 7</div>
  • 当 5 < 7 通过 Ajax 返回,然后赋值给 JavaScript 的变量时,前端得到的字符串就是转义后的字符。这个内容不能直接⽤于 Vue 等模板的展示,也不能直接⽤于内容⻓度计算。不能⽤于标题、alert 等

可以看到,过滤并⾮可靠的,下⾯就要通过防⽌浏览器执⾏恶意代码:

在使⽤ .innerHTML 、 .outerHTML 、document.write() 时要特别⼩⼼,不要把不可信的数据作为 HTML 插到⻚⾯上,⽽应尽量使⽤ .textContent 、 .setAttribute() 等

如果⽤ Vue/React 技术栈,并且不使⽤ v-html / dangerouslySetInnerHTML 功能,就在前端 render 阶段避免 innerHTML 、outerHTML 的 XSS 隐患

DOM 中的内联事件监听器,如 location 、 onclick 、 onerror 、 onload 、onmouseover 等, <a> 标签的 href 属性,JavaScript 的eval() 、 setTimeout() 、 setInterval() 等,都能把字符串作为代码运⾏。如果不可信的数据拼接到字符串中传递给这些 API,很容易产⽣安全隐患,请务必避免

<!-- 链接内包含恶意代码 -->
< a href=" ">1</ a>

<script>
// setTimeout()/setInterval() 中调⽤恶意代码
setTimeout("UNTRUSTED")
setInterval("UNTRUSTED")

// location 调⽤恶意代码
location.href = 'UNTRUSTED'

// eval() 中调⽤恶意代码
eval("UNTRUSTED")

31.3 CSRF

CSRF(Cross-site request forgery)跨站请求伪造:攻击者诱导受害者进⼊第三⽅⽹站,在第三⽅⽹ 站中,向被攻击⽹站发送跨站请求

利⽤受害者在被攻击⽹站已经获取的注册凭证,绕过后台的⽤户验证,达到冒充⽤户对被攻击的⽹站执 ⾏某项操作的⽬

⼀个典型的CSRF攻击有着如下的流程:

  1. 受害者登录a.com,并保留了登录凭证(Cookie)
  2. 攻击者引诱受害者访问了b.com
  3. b.com 向 a.com 发送了⼀个请求:a.com/act=xx。浏览器会默认携带a.com的Cookie
  4. a.com接收到请求后,对请求进⾏验证,并确认是受害者的凭证,误以为是受害者⾃⼰发送的请求
  5. a.com以受害者的名义执⾏了act=xx
  6. 攻击完成,攻击者在受害者不知情的情况下,冒充受害者,让a.com执⾏了⾃⼰定义的操作

csrf 可以通过 get 请求,即通过访问 img 的⻚⾯后,浏览器⾃动访问⽬标地址,发送请求

同样,也可以设置⼀个⾃动提交的表单发送 post 请求,如下:

<form action="http://bank.example/withdraw" method=POST>
    <input type="hidden" name="account" value="xiaoming" />
    <input type="hidden" name="amount" value="10000" />
    <input type="hidden" name="for" value="hacker" />
</form>

<script> document.forms[0].submit(); </script>

访问该⻚⾯后,表单会⾃动提交,相当于模拟⽤户完成了⼀次 POST 操作

还有⼀种为使⽤ a 标签的,需要⽤户点击链接才会触发

< a href="http://test.com/csrf/withdraw.php?amount=1000&for=hacker" taget="_blank">
    重磅消息!!
<a/>

31.3.1 CSRF的特点

  • 攻击⼀般发起在第三⽅⽹站,⽽不是被攻击的⽹站。被攻击的⽹站⽆法防⽌攻击发⽣
  • 攻击利⽤受害者在被攻击⽹站的登录凭证,冒充受害者提交操作;⽽不是直接窃取数据
  • 整个过程攻击者并不能获取到受害者的登录凭证,仅仅是“冒⽤”
  • 跨站请求可以⽤各种⽅式:图⽚URL、超链接、CORS、Form提交等等。部分请求⽅式可以直接嵌⼊在第三⽅论坛、⽂章中,难以进⾏追踪

31.3.2 CSRF的预防

CSRF通常从第三⽅⽹站发起,被攻击的⽹站⽆法防⽌攻击发⽣,只能通过增强⾃⼰⽹站针对CSRF的防护能⼒来提升安全性

防⽌ csrf 常⽤⽅案如下:

  • 阻⽌不明外域的访问
    • 同源检测
    • Samesite Cookie
  • 提交时要求附加本域才能获取的信息
    • CSRF Token
    • 双重Cookie验证

这⾥主要讲讲 token 这种形式,流程如下:

  • ⽤户打开⻚⾯的时候,服务器需要给这个⽤户⽣成⼀个Token
  • 对于GET请求,Token将附在请求地址之后。对于 POST 请求来说,要在 form 的最后加上
<input type=”hidden” name=”csrftoken” value=”tokenvalue”/>
  • 当⽤户从客户端得到了Token,再次提交给服务器的时候,服务器需要判断Token的有效性

31.4 SQL注入

Sql 注⼊攻击,是通过将恶意的 Sql 查询或添加语句插⼊到应⽤的输⼊参数中,再在后台Sql 服务器上解析执⾏进⾏的攻击

image.png

流程如下所示:

  1. 找出SQL漏洞的注⼊点
  2. 判断数据库的类型以及版本
  3. 猜解⽤户名和密码
  4. 利⽤⼯具查找Web后台管理⼊⼝
  5. ⼊侵和破坏

预防方式如下:

  • 严格检查输⼊变量的类型和格式
  • 过滤和转义特殊字符
  • 对访问数据库的Web应⽤程序采⽤Web应⽤防⽕墙

上述只是列举了常⻅的 web 攻击⽅式,实际开发过程中还会遇到很多安全问题,对于这些问题, 切记不可忽视

32. JavaScript中的内存泄漏

image.png

32.1 什么是内存泄漏

内存泄漏(Memory leak)是在计算机科学中,由于疏忽或错误造成程序未能释放已经不再使⽤的内存

并⾮指内存在物理上的消失,⽽是应⽤程序分配某段内存后,由于设计错误,导致在释放该段内存之前就失去了对该段内存的控制,从⽽造成了内存的浪费

程序的运⾏需要内存。只要程序提出要求,操作系统或者运⾏时就必须供给内存

对于持续运⾏的服务进程,必须及时释放不再⽤到的内存。否则,内存占⽤越来越⾼,轻则影响系统性能,重则导致进程崩溃

image.png

在 C 语⾔中,因为是⼿动管理内存,内存泄露是经常出现的事情。

char * buffer;
buffer = (char*) malloc(42);

// Do something with buffer

free(buffer);

上⾯是 C 语⾔代码, malloc ⽅法⽤来申请内存,使⽤完毕之后,必须⾃⼰⽤ free ⽅法释放内存。

这很麻烦,所以⼤多数语⾔提供⾃动内存管理,减轻程序员的负担,这被称为"垃圾回收机制"

32.2 垃圾回收机制

Javascript 具有⾃动垃圾回收机制(GC:Garbage Collecation),也就是说,执⾏环境会负责管理代码执⾏过程中使⽤的内存

原理:垃圾收集器会定期(周期性)找出那些不在继续使⽤的变量,然后释放其内存

通常情况下有两种实现⽅式:

  • 标记清除
  • 引⽤计数

32.2.1 标记清除

JavaScript 最常用的垃圾回收机制

当变量进⼊执⾏环境是,就标记这个变量为“进⼊环境“。进⼊环境的变量所占⽤的内存就不能释放,当 变量离开环境时,则将其标记为“离开环境“

垃圾回收程序运⾏的时候,会标记内存中存储的所有变量。然后,它会将所有在上下⽂中的变量,以及 被在上下⽂中的变量引⽤的变量的标记去掉

在此之后再被加上标记的变量就是待删除的了,原因是任何在上下⽂中的变量都访问不到它们了

随后垃圾回收程序做⼀次内存清理,销毁带标记的所有值并收回它们的内存

举个例⼦:

var m = 0,n = 19 // 把 m,n,add() 标记为进⼊环境。
add(m, n) // 把 a, b, c标记为进⼊环境。
console.log(n) // a,b,c标记为离开环境,等待垃圾回收。
function add(a, b) {
    a++
    var c = a + b
    return c
}

32.2.2 引⽤计数

语⾔引擎有⼀张"引⽤表",保存了内存⾥⾯所有的资源(通常是各种值)的引⽤次数。如果⼀个值的引⽤ 次数是 0 ,就表示这个值不再⽤到了,因此可以将这块内存释放

如果⼀个值不再需要了,引⽤数却不为 0 ,垃圾回收机制⽆法释放这块内存,从⽽导致内存泄漏

const arr = [1, 2, 3, 4];
console.log('hello world');

上⾯代码中,数组 [1, 2, 3, 4] 是⼀个值,会占⽤内存。变量 arr 是仅有的对这个值的引⽤,因 此引⽤次数为 1 。尽管后⾯的代码没有⽤到 arr ,它还是会持续占⽤内存

如果需要这块内存被垃圾回收机制释放,只需要设置如下:

arr = null

通过设置 arr 为 null ,就解除了对数组 [1,2,3,4] 的引⽤,引⽤次数变为 0,就被垃圾回收了

32.2.3 小结

有了垃圾回收机制,不代表不⽤关注内存泄露。那些很占空间的值,⼀旦不再⽤到,需要检查是否还存在对它们的引⽤。如果是的话,就必须⼿动解除引⽤

32.3 常见的内存泄漏的情况

  • 意外的全局变量
function foo(arg) {
    bar = "this is a hidden global variable";
}
  • 另一种意外的全局变量可能由 this 创建
function foo() {
    this.variable = "potential accidental global";
}

// foo 调⽤⾃⼰,this 指向了全局对象(window)
foo();

上述使用严格模式,可以避免意外的全局变量

  • 定时器也常会造成内存泄露
var someResource = getData();
setInterval(function() {
    var node = document.getElementById('Node');
    if(node) {
        // 处理 node 和 someResource
        node.innerHTML = JSON.stringify(someResource));
    }
}, 1000);

如果 id 为Node的元素从 DOM 中移除,该定时器仍会存在,同时,因为回调函数中包含对 someResource 的引⽤,定时器外⾯的 someResource 也不会被释放

  • 包括我们之前所说的闭包,维持函数内局部变量,使其得不到释放
function bindEvent() {
    var obj = document.createElement('XXX');
    var unused = function () {
        console.log(obj, '闭包内引⽤obj obj不会被释放');
    };
    obj = null; // 解决⽅法
}
  • 没有清理对 DOM 元素的引⽤同样造成内存泄露
const refA = document.getElementById('refA');
document.body.removeChild(refA); // dom删除了
console.log(refA, 'refA'); // 但是还存在引⽤能console出整个div 没有被回收
refA = null; // 解除引⽤
console.log(refA, 'refA'); 
  • 包括使⽤事件监听 addEventListener 监听的时候,在不监听的情况下使⽤ removeEventListener 取消对事件监听

33.JavaScript 如何实现继承

image.png

33.1 继承是什么

继承(inheritance)是⾯向对象软件技术当中的⼀个概念。 如果⼀个类别B“继承⾃”另⼀个类别A,就把这个B称为“A的⼦类”,⽽把A称为“B的⽗类别”也可以称“A是B的超类

33.1.1 继承的优点

继承可以使得⼦类具有⽗类别的各种属性和⽅法,⽽不需要再次编写相同的代码

在⼦类别继承⽗类别的同时,可以重新定义某些属性,并重写某些⽅法,即覆盖⽗类别的原有属性和⽅ 法,使其获得与⽗类别不同的功能

虽然 JavaScript 并不是真正的⾯向对象语⾔,但它天⽣的灵活性,使应⽤场景更加丰富

关于继承,我们举个形象的例⼦:

定义⼀个类(Class)叫汽⻋,汽⻋的属性包括颜⾊、轮胎、品牌、速度、排⽓量等

class Car{
    constructor(color,speed){
        this.color = color
        this.speed = speed
        // ...
    }
}

由汽⻋这个类可以派⽣出“轿⻋”和“货⻋”两个类,在汽⻋的基础属性上,为轿⻋添加⼀个后备厢、给货⻋添加⼀个⼤货箱

// 货⻋
class Truck extends Car{
    constructor(color,speed){
    super(color,speed)
        this.Container = true // 货箱
    }
}

这样轿⻋和货⻋就是不⼀样的,但是⼆者都属于汽⻋这个类,汽⻋、轿⻋继承了汽⻋的属性,⽽不需要再次在“轿⻋”中定义汽⻋已经有的属性

在“轿⻋”继承“汽⻋”的同时,也可以重新定义汽⻋的某些属性,并重写或覆盖某些属性和⽅法,使其获得与“汽⻋”这个⽗类不同的属性和⽅法

// 货⻋
class Truck extends Car{
    constructor(color,speed){
        super(color,speed)
        this.color = "black" //覆盖
        this.Container = true // 货箱
    }
}

从这个例⼦中就能详细说明汽⻋、轿⻋以及卡⻋之间的继承关系

33.2 继承的实现方式

下⾯给出 JavaScripy 常⻅的继承⽅式:

  • 原型链继承
  • 构造函数继承(借助 call)
  • 组合继承
  • 原型式继承
  • 寄⽣式继承
  • 寄⽣组合式继承

33.2.1 原型链继承

原型链继承是⽐较常⻅的继承⽅式之⼀,其中涉及的 构造函数原型实例 ,三者之间存在着⼀定的关系,即: 每⼀个构造函数都有⼀个原型对象,原型对象⼜包含⼀个指向构造函数的指针,⽽实例则包含⼀个原型对象的指针

举个例子

function Parent() {
    this.name = 'parent1';
    this.play = [1, 2, 3]
}

function Child() {
    this.type = 'child2';
}

Child1.prototype = new Parent();
console.log(new Child())

上⾯代码看似没问题,实际存在潜在问题

var s1 = new Child2();
var s2 = new Child2();

s1.play.push(4);
console.log(s1.play, s2.play); // [1,2,3,4]

改变 s1 的 play 属性,会发现s2也跟着发⽣变化了,这是因为两个实例使⽤的是同⼀个原型对象,内存空间是共享的

33.2.2 构造函数继承(借助 call)

借助 call 调用 Parent 函数

function Parent(){
    this.name = 'parent1';
}

Parent.prototype.getName = function () {
    return this.name;
}

function Child(){
    Parent1.call(this);
    this.type = 'child'
}

let child = new Child();
console.log(child); // 没问题
console.log(child.getName()); // 会报错

可以看到,⽗类原型对象中⼀旦存在⽗类之前⾃⼰定义的⽅法,那么⼦类将⽆法继承这些⽅法

相⽐第⼀种原型链继承⽅式,⽗类的引⽤属性不会被共享,优化了第⼀种继承⽅式的弊端,但是只能继承⽗类的实例属性和⽅法,不能继承原型属性或者⽅法

33.2.3 组合继承

前⾯我们讲到两种继承⽅式,各有优缺点。组合继承则将前两种⽅式继承起来

function Parent3 () {
    this.name = 'parent3';
    this.play = [1, 2, 3];
}

Parent3.prototype.getName = function () {
return this.name;
}

function Child3() {
    // 第⼆次调⽤ Parent3()
    Parent3.call(this);
    this.type = 'child3';
}

// 第⼀次调⽤ Parent3()
Child3.prototype = new Parent3();

// ⼿动挂上构造器,指向⾃⼰的构造函数
Child3.prototype.constructor = Child3;

var s3 = new Child3();
var s4 = new Child3();

s3.play.push(4);
console.log(s3.play, s4.play); // 不互相影响
console.log(s3.getName()); // 正常输出'parent3'
console.log(s4.getName()); // 正常输出'parent3'

这种⽅式看起来就没什么问题,⽅式⼀和⽅式⼆的问题都解决了,但是从上⾯代码我们也可以看到 Parent3 执⾏了两次,造成了多构造⼀次的性能开销

33.2.4 原型式继承

这⾥主要借助 Object.create ⽅法实现普通对象的继承

同样举个例⼦

let parent4 = {
    name: "parent4",
    friends: ["p1", "p2", "p3"],
    getName: function() {
        return this.name;
    }
};

let person4 = Object.create(parent4);
person4.name = "tom";
person4.friends.push("jerry");

let person5 = Object.create(parent4);
person5.friends.push("lucy");

console.log(person4.name); // tom
console.log(person4.name === person4.getName()); // true
console.log(person5.name); // parent4

console.log(person4.friends); // ["p1", "p2", "p3","jerry","lucy"]
console.log(person5.friends); // ["p1", "p2", "p3","jerry","lucy"]

这种继承⽅式的缺点也很明显,因为 Object.create ⽅法实现的是浅拷⻉,多个实例的引⽤类型属性指向相同的内存,存在篡改的可能

33.2.5 寄⽣式继承

寄⽣式继承在上⾯继承基础上进⾏优化,利⽤这个浅拷⻉的能⼒再进⾏增强,添加⼀些⽅法

let parent5 = {
    name: "parent5",
    friends: ["p1", "p2", "p3"],
    getName: function() {
        return this.name;
    }
};

function clone(original) {
    let clone = Object.create(original);
    clone.getFriends = function() {
        return this.friends;
    };
    return clone;
}

let person5 = clone(parent5);

console.log(person5.getName()); // parent5
console.log(person5.getFriends()); // ["p1", "p2", "p3"]

其优缺点也很明显,跟上⾯讲的原型式继承⼀样

33.2.6 寄⽣组合式继承

寄⽣组合式继承,借助解决普通对象的继承问题的 Object.create ⽅法,在前⾯⼏种继承⽅式的优缺点基础上进⾏改造,这也是所有继承⽅式⾥⾯相对最优的继承⽅式

function clone (parent, child) {
    // 这⾥改⽤ Object.create 就可以减少组合继承中多进⾏⼀次构造的过程
    child.prototype = Object.create(parent.prototype);
    child.prototype.constructor = child;
}

function Parent6() {
    this.name = 'parent6';
    this.play = [1, 2, 3];
}

Parent6.prototype.getName = function () {
    return this.name;
}

function Child6() {
    Parent6.call(this);
    this.friends = 'child5';
}
clone(Parent6, Child6);
Child6.prototype.getFriends = function () {
    return this.friends;
}

let person6 = new Child6();
console.log(person6); //{friends:"child5",name:"child5",play:[1,2,3],__proto__:Parent6}
console.log(person6.getName()); // parent6
console.log(person6.getFriends()); // child5

可以看到 person6 打印出来的结果,属性都得到了继承,⽅法也没问题

⽂章⼀开头,我们是使⽤ ES6 中的 extends 关键字直接实现 JavaScript 的继承

class Person {
    constructor(name) {
        this.name = name
    }

    // 原型⽅法
    // 即 Person.prototype.getName = function() { }
    // 下⾯可以简写为 getName() {...}
    getName = function () {
        console.log('Person:', this.name)
    }
}

class Gamer extends Person {
    constructor(name, age) {
        // ⼦类中存在构造函数,则需要在使⽤“this”之前⾸先调⽤ super()。
        super(name)
        this.age = age
    }
}

const asuna = new Gamer('Asuna', 20)
asuna.getName() // 成功访问到⽗类的⽅法

利⽤ babel ⼯具进⾏转换,我们会发现 extends 实际采⽤的也是寄⽣组合继承⽅式,因此也证明了这种⽅式是较优的解决继承的⽅式

33.3 总结

image.png 通过 Object.create 来划分不同的继承⽅式,最后的寄⽣式组合继承⽅式是通过组合继承改造之后 的最优继承⽅式,⽽ extends 的语法糖和寄⽣组合继承的⽅式基本类似

34. 有关 JavaScript 数字精度丢失

image.png

34.1 场景复现

⼀个经典的⾯试题

0.1 + 0.2 === 0.3 // false

为什么是 false 呢?

先看下⾯这个⽐喻

⽐如⼀个数 1÷3=0.33333333......

3会⼀直⽆限循环,数学可以表示,但是计算机要存储,⽅便下次取出来再使⽤,但0.333333...... 这个 数⽆限循环,再⼤的内存它也存不下,所以不能存储⼀个相对于数学来说的值,只能存储⼀个近似值,

当计算机存储后再取出时就会出现精度丢失问题

34.2 浮点数

“浮点数”是⼀种表示数字的标准,整数也可以⽤浮点数的格式来存储

我们也可以理解成,浮点数就是⼩数

在 JavaScript 中,现在主流的数值类型是 Number ,⽽ Number 采⽤的是 IEEE754 规范中64位双精度浮点数编码

这样的存储结构优点是可以归⼀化处理整数和⼩数,节省存储空间

对于⼀个整数,可以很轻易转化成⼗进制或者⼆进制。但是对于⼀个浮点数来说,因为⼩数点的存在,⼩数点的位置不是固定的。解决思路就是使⽤科学计数法,这样⼩数点位置就固定了

⽽计算机只能⽤⼆进制(0或1)表示,⼆进制转换为科学记数法的公式如下:

image.png 其中, a 的值为0或者1,e为⼩数点移动的位置

举个例⼦:

27.0转化成⼆进制为11011.0 ,科学计数法表示为: image.png 前⾯讲到, javaScript 存储⽅式是双精度浮点数,其⻓度为8个字节,即64位⽐特

64位⽐特⼜可分为三个部分:

  • 符号位S:第 1 位是正负数符号位(sign),0代表正数,1代表负数
  • 指数位E:中间的 11 位存储指数(exponent),⽤来表示次⽅数,可以为正负数。在双精度浮点数中,指数的固定偏移量为1023
  • 尾数位M:最后的 52 位是尾数(mantissa),超出的部分⾃动进⼀舍零

如下图所示:

image.png 举个例⼦:

27.5 转换为⼆进制11011.1

11011.1转换为科学记数法 image.png

符号位为1(正数),指数位为4+,1023+4,即1027

因为它是⼗进制的需要转换为⼆进制,即 10000000011 ,⼩数部分为 10111 ,补够52位即: 1011

1000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

所以27.5存储为计算机的⼆进制标准形式(符号位+指数位+⼩数部分 (阶数)),既下⾯所示

0+10000000011+011 1000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

34.3 问题分析

再回到上面的问题

0.1 + 0.2 === 0.3 // false

通过上⾯的学习,我们知道,在 javascript 语⾔中,0.1 和 0.2 都转化成⼆进制后再进⾏运算

// 0.1 和 0.2 都转化成⼆进制后再进⾏运算
0.00011001100110011001100110011001100110011001100110011010 +
0.0011001100110011001100110011001100110011001100110011010 =
0.0100110011001100110011001100110011001100110011001100111

// 转成⼗进制正好是 0.30000000000000004

所以输出 false

再来⼀个问题,那么为什么 x=0.1 得到 0.1 ?

主要是存储⼆进制时⼩数点的偏移量最⼤为52位,最多可以表达的位数 是 2^53=9007199254740992 ,对应科学计数尾数是 9.007199254740992 ,这也是 JS 最多能表示的精度 它的⻓度是 16,所以可以使⽤ toPrecision(16) 来做精度运算,超过的精度会⾃动做凑整处理

.10000000000000000555.toPrecision(16)
// 返回 0.1000000000000000,去掉末尾的零后正好为 0.1

但看到的 0.1 实际上并不是 0.1 。不信你可⽤更⾼的精度试试:

0.1.toPrecision(21) = 0.100000000000000005551

如果整数⼤于 9007199254740992 会出现什么情况呢?

由于指数位最⼤值是1023,所以最⼤可以表示的整数是 2^1024 - 1 ,这就是能表示的最⼤整数。但你并不能这样计算这个数字,因为从 2^1024 开始就变成了 Infinity

> Math.pow(2, 1023)
8.98846567431158e+307

> Math.pow(2, 1024)
Infinity

那么对于 (2^53, 2^63) 之间的数会出现什么情况呢?

  • (2^53, 2^54) 之间的数会两个选⼀个,只能精确表示偶数
  • (2^54, 2^55) 之间的数会四个选⼀个,只能精确表示4个倍数
  • ... 依次跳过更多2的倍数

要想解决⼤数的问题你可以引⽤第三⽅库 bignumber.js ,原理是把所有数字当作字符串,重新实现了计算逻辑,缺点是性能⽐原⽣差很多

34.3.1 小结

计算机存储双精度浮点数需要先把⼗进制数转换为⼆进制的科学记数法的形式,然后计算机以⾃⼰的规则{符号位+(指数位+指数偏移量的⼆进制)+⼩数部分}存储⼆进制的科学记数法

因为存储时有位数限制(64位),并且某些⼗进制的浮点数在转换为⼆进制数时会出现⽆限循环,会造成⼆进制的舍⼊操作(0舍1⼊),当再转换为⼗进制时就造成了计算误差

34.4 解决方案

理论上⽤有限的空间来存储⽆限的⼩数是不可能保证精确的,但我们可以处理⼀下得到我们期望的结果

当你拿到 1.4000000000000001 这样的数据要展示时,建议使⽤ toPrecision 凑整并 parseFloat 转成数字后再显示,如下:

parseFloat(1.4000000000000001.toPrecision(12)) === 1.4 // True

封装成⽅法就是:

function strip(num, precision = 12) {
    return +parseFloat(num.toPrecision(precision));
}

对于运算类操作,如 +-*/ ,就不能使⽤ toPrecision 了。正确的做法是把⼩数转成整数后再运算。以加法为例:

/**
* 精确加法
*/
function add(num1, num2) {
    const num1Digits = (num1.toString().split('.')[1] || '').length;
    const num2Digits = (num2.toString().split('.')[1] || '').length;
    const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits));
    return (num1 * baseNum + num2 * baseNum) / baseNum;
}

最后还可以使⽤第三⽅库,如 Math.js 、 BigDecimal.js

35. 尾递归

image.png

35.1 递归

递归(英语:Recursion)

在数学与计算机科学中,是指在函数的定义中使⽤函数⾃身的⽅法

在函数内部,可以调⽤其他函数。如果⼀个函数在内部调⽤⾃身本身,这个函数就是递归函数

其核⼼思想是把⼀个⼤型复杂的问题层层转化为⼀个与原问题相似的规模较⼩的问题来求解

⼀般来说,递归需要有边界条件、递归前进阶段和递归返回阶段。当边界条件不满⾜时,递归前进;当 边界条件满⾜时,递归返回

下⾯实现⼀个函数 pow(x, n) ,它可以计算x的n次⽅

使⽤迭代的⽅式,如下:

function pow(x, n) {
    let result = 1;
    // 再循环中,⽤ x 乘以 result n 次
    for (let i = 0; i < n; i++) {
        result *= x;
    }
    return result;
}

使⽤递归的⽅式,如下:

function pow(x, n) {
    if (n == 1) {
        return x;
    } else {
        return x * pow(x, n - 1);
    }
}

pow(x, n) 被调⽤时,执⾏分为两个分⽀:

            if n==1 = x
            /
pow(x, n) =
            \
            else = x * pow(x, n - 1)

也就是说 pow 递归地调⽤⾃身 直到 n == 1 image.png

为了计算 pow(2, 4) ,递归变体经过了下⾯⼏个步骤:

  1. pow(2, 4) = 2 * pow(2, 3)
  2. pow(2, 3) = 2 * pow(2, 2)
  3. pow(2, 2) = 2 * pow(2, 1)
  4. pow(2, 1) = 2

因此,递归将函数调⽤简化为⼀个更简单的函数调⽤,然后再将其简化为⼀个更简单的函数,以此类推,直到结果

35.2 尾递归

尾递归,即:在函数尾位置调用自身(或是一个尾调用本身的其他函数等等)。尾递归也是递归的一种特殊情形。尾递归是一种特殊的尾调用,即在尾部直接调用自身的递归函数。

尾递归在普通递归调用的基础上,多了两个特征:

  1. 在尾部调用的是函数自身
  2. 可通过优化,使得计算金占用常量栈空间

在递归调用的过程中系统为每一层的返回点、局部变量等开辟了栈来存储,递归次数过多容易造成栈溢出

这时候,我们就可以使用尾递归,即一个函数中所有递归形式的调用都出现在函数的末尾,对于尾递归来说,由于只存在一个调用记录,所以永远不会发生“栈溢出”错误 实现一下阶乘,如果普通的递归,如下:

function factorial(n) {
if (n === 1) return 1;
    return n * factorial(n - 1);
}
factorial(5) // 120

如果 n 等于5,这个⽅法要执⾏5次,才返回最终的计算表达式,这样每次都要保存这个⽅法,就容易造成栈溢出,复杂度为 O(n)

如果我们使⽤尾递归,则如下:

function factorial(n, total) {
    if (n === 1) return total;
    return factorial(n - 1, n * total);
}

factorial(5, 1) // 120

可以看到,每⼀次返回的就是⼀个新的函数,不带上⼀个函数的参数,也就不需要储存上⼀个函数了。

尾递归只需要保存⼀个调⽤栈,复杂度 O(1)

35.3 应用场景

  • 数组求和
function sumArray(arr, total) {
    if(arr.length === 1) {
        return total
    }
    return sum(arr, total + arr.pop())
}
  • 使用尾递归优化求斐波那契数列
function factorial2 (n, start = 1, total = 1) {
    if(n <= 2){
        return total
    }
    return factorial2 (n -1, total, total + start)
}
  • 数组扁平化
let a = [1,2,3, [1,2,3, [1,2,3]]]

// 变成
let a = [1,2,3,1,2,3,1,2,3]

// 具体实现
function flat(arr = [], result = []) {
    arr.forEach(v => {
        if(Array.isArray(v)) {
            result = result.concat(flat(v, []))
        }else {
            result.push(v)
        }
    })
    return result
}
  • 数组对象格式化
let obj = {
    a: '1',
    b: {
        c: '2',
        d: {
            e: '3'
        }
    }
}

// 转化为如下:
let obj = {
    a: '1',
    b: {
        c: '2',
        d: {
            e: '3'
        }
    }
}

// 代码实现
function keysLower(obj) {
    let reg = new RegExp("([A-Z]+)", "g");
    for (let key in obj) {
        if (obj.hasOwnProperty(key)) {
            let temp = obj[key];
            if (reg.test(key.toString())) {
                // 将修改后的属性名重新赋值给temp,并在对象obj内添加⼀个转换后的属性
                temp = obj[key.replace(reg, function (result) {
                    return result.toLowerCase()
                })] = obj[key];
                // 将之前⼤写的键属性删除
                delete obj[key];
            }

            // 如果属性是对象或者数组,重新执⾏函数
            if (typeof temp === 'object' || Object.prototype.toString.call(temp) === '[object Array]') {
                keysLower(temp);
            }
        }
    }
    return obj;
};