《JavaScript》JavaScript进阶知识(一)
简介
为了更好的学习JavaScript,本系列旨在整理一些不属于基础知识的知识点,以便于在工作中可以灵活应用,使得代码开始追求质量,追求可靠性;
知识点
立即执行函数
立即执行函数,简单的说,就是定义了一个函数,并且立刻执行了,这么做的目的是什么呢?乍一看好像没什么意义,还多此一举了,既然需要立刻执行,那么干嘛不直接写在JS主体里?
实际上,立即执行函数解决了一个最大的问题,就是变量污染,在ES6之前是没有块级作用域的概念的,那么不可避免的就出现了一些变量污染的问题(项目一大,就有可能出现后定义的变量跟前面的变量重名,后面的变量不小心覆盖了前面的变量的值等等),而JS有的是函数作用域,定义在函数内部的变量,函数,外部是访问不到的,因此就有立即执行函数,将函数,变量定义在一个函数的内部,并且这个函数立刻执行,这样就相当于在主体里写了代码,而且函数内部的变量也不会跟别的变量冲突;
示例
(function(){
var a = 10
})()
console.log(a) //报错,因为没有定义a
上面这个,相信第一次看到的人都有点懵,大约知道是一个函数,因为有function关键字,实际上立即执行函数是一种最终的简写结果,其本质就是定义了一个函数并且立刻执行了;
var a = 1;
var count = function(num){
console.log(num);
}
count(a);
相信这一步学到这的你都能看得懂,就是定义了一个变量a,定义了一个函数表达式count,之后调用了count,并且将a作为参数传递进去了
先简写第一步,直接将变量传递到count中
var count = function(num){
console.log(num);
}
count(1);
这样就去掉了a,之后发现好像还可以简写,可以将count的定义直接去掉,直接改成调用,如下
function(num){
console.log(num);
}(1) //报错
这么写之后发现,js不识别这种写法,因为不管在任何时候,关键字function都会被识别成函数定义的开始,因此不能这么写;
然而发现,函数表达式的后面则可以根括号,到这一步的关键就是要将函数声明转成函数表达式,那么如果转换呢,其实也简单,就是加一对括号
(function(num){
console.log(num);
})(1)
这就是一个立即执行函数,定义了一个匿名函数,并且立即执行了,外界的变量可以通过参数传递进入,比如需要将window对象传递进去,那么可以
//这个window就是全局环境window
(function(window){
console.log(window);
})(window)
闭包
什么是闭包?《高程》一书中的解释是:指有权访问另外一个函数作用域中的变量的函数,这里有几个关键词,通俗点讲,就是首先闭包是一个函数,其次这个函数存在于另外一个函数体内,之后函数体内的函数访问了不属于自己用用于内的变量,可能有点绕口,示例如下
function func(){
var number = 1;
function printNum(){
console.log(number);
}
}
这就是一个闭包,虽然没有什么用,因为这么写并没有什么实际用处,通常闭包的使用是将函数体内的函数作为返回值返回出去,也就是这样:
function func(){
var number = 1;
return function (){
console.log(number);
}
}
为什么要这么做?其中最大的目的就是为了解决一个问题,为了保留执行结果且不污染全局环境,在了解这个结论之前,首先得明白,JS因为其垃圾回收机制,会导致变量只要离开环境,其值就会被释放,也就是会被销毁,为了不被销毁,只有想办法不让它释放,这么写可能有点空洞,看个例子,比如:
现在项目中有一个函数,每次执行过后要保存执行的结果,之后根据当前结果判断按哪种方式继续执行,具体的话就是有如下例,函数每次执行的时候都需要在上次的执行结果上+1,之后返回
function count(){/*代码*/}
count() //1
count() //2
count() //3
function count(){
var i = 0;
i++;
return i;
}
count() //1
count() //1
这个函数执行肯定不行,因为每次count执行的时候,都会重新定义变量i,之后i++,再之后返回i,执行结束以后,count函数就会被释放(销毁)掉,这也就导致了每次count的执行结果都是1;
考虑到函数每次执行会重新定义i,那么只有将变量i提出到函数体外,i的值才能保留,比如:
var i = 0;
function count(){
i++;
return i;
}
count() //1
count() //2
能实现要求,但是又带来了一个新问题,变量i定义到了全局环境重,我们不确定i这个变量会不会在其他部分被使用过,重新定义会不会有什么问题,即使假设这部分没问题,也不确定这个变量是不是在团队其他成员使用过了,如果使用过,合并代码的时候,会出现各种异常,到时候难免又是加班加点排查错误,因此,肯定不能将i定义在全局环境中,因为其风险我们不可控,那怎么办?在立即执行函数中说了,因为作用域的关系,函数体内的变量,不会影响到父级作用域,因此,改变一下,在外层我们在嵌套一个output函数,比如
function output(){
var i = 0;
function count(){
i++;
return i;
}
}
这么写是没什么问题,变量i也不在全局环境中了,但是我们这么访问这个count函数呢?这么写访问不了,那就通过return返回出去
function output(){
var i = 0;
return function count(){
i++;
return i;
}
}
//因为返回的是一个函数,所以必须先定义一个变量来接收这个函数
var count = output();
//相当于
var count = function count(){
i++;
return i;
}
这么一看返回的函数名count没有存在的必要,因为返回的是一个函数,而外界肯定要接收,所以直接返回一个匿名函数就好
function output(){
var i = 0;
return function(){
i++;
return i;
}
}
//因为返回的是一个函数,所以必须先定义一个变量来接收这个函数
var count = output();
count() //1
count() //2
这下总算可以了,实现了题目中的要求,这种函数也就是我们最常见的闭包,其作用就是帮助我们保留执行结果,并且不污染全局环境;
千万不要以为到这就结束了,闭包有一个非常大的缺陷,就是执行过后,这个称作闭包的函数没有被释放(我们也就是借助这一点保留了执行结果),正常情况下,函数执行了,引擎就会将函数释放掉,销毁掉,之后外界也就访问不到当前结果了,可闭包不是,它的值一直存在于当前的环境中,这也就导致了内存泄漏,又或者被“有心人”收集当前的执行结果,为了解决这种问题,等到不用的时候记的手动释放
var count = output();
count() //1
count() //2
//释放
count = null;
函数式编程
函数式编程是一种编程的规范,也可以说是一种编码风格,与函数式编程对应的是命令式编程;
示例
假设现在有一个题目:有一个数组[1, 2, 3, 4],对数组进行操作,操作后,生成一个新的数组,其值是原数组的每项+1
命令式编程,就是为了达到最终效果,将执行的步骤每一步就详细的描述出来,然后让引擎去按设定好的步骤执行,比如:
//创建一个数组
let arr = [1, 2, 3, 4];
//创建一个新数组
let newArr = [];
//对老数组的每一项进行遍历
arr.forEach((el) => {
//将老数组的每一项都+1,然后push到新数组里
newArr.push(el+1)
})
//打印新数组
console.log(newArr)
//又或者,通过函数返回一个新数组
let newArr = (arr) => {
let res = []
arr.forEach((el) => {
res.push(el+1)
})
return res
}
console.log(newArr(arr))
这两种都是命令式编程,让引擎按照自己的意愿执行每一步,达到最终效果,命令式编程有一个最大的问题,就是所有的代码都是写死的,不可复用,假如那天产品经理拿着新需求过来了,他说:按照统计,用户不喜欢将数组的每一项都加1,而是每一项都加10,这个时候代码就复用不了,你必须去新建一段代码或函数,重新写一遍逻辑,实在是费时;
因此,到了这里,就不得不考虑如何提高效率了,基于此,也就有了函数式编程,其旨在尽可能的对函数复用,为了复用,就需要将函数拆解,使得函数的颗粒度达到最小,换句话说,就是一个函数只干一件事,绝不多干;
因此我们可以对上面的需求进行拆解:一个原数组,进行了一些操作,返回了一个新数组,具体如下
let arr = [1, 2, 3, 4];
let newArr = (arr,fn) => {
let res = [];
arr.forEach((el) => {
res.push(fn(el))
})
return res
}
let add_1 = el => el+1;
console.log(newArr(arr,add_1))
和上面的区别,将对数组的运算独立了出来,将运算方式作为参数传递进去,这样,如果需求变更成+10那么只需要新建一个+10的函数,比如
let arr = [1, 2, 3, 4];
let newArr = (arr,fn) => {
let res = [];
arr.forEach((el) => {
res.push(fn(el))
})
return res
}
let add_1 = el => el+1;
let add_10 = el => el+10;
console.log(newArr(arr,add_10))
甚至,同为加法运算,加的具体数字也可以作为参数传递进去
let add = (el,num) => el+num;
从例子可以看出,函数式编程就是将一个函数的执行过程,尽可能的细化,尽量写成是一系列函数的嵌套过程,这样,如果又其中一部分因为需求变更,那么只需要将变更的这部分函数重新设计编写,剩下的绝大部分逻辑都可以复用,以便达到提高效率(减少加班)的目的;
纯函数
如果函数的调用参数相同,则永远返回相同的结果,它不依赖于程序执行期间函数外部任何状态或数据的变化,必须只依赖于其输入参数;
简单的说,相同的输入,永远有相同的输出,为什么要这样?如果函数的执行结果取决于当前的外部变量结果,那么这种不可控不是一件很可怕的事情吗!
示例
let a = 10;
let add = b = b + a;
add(10); //20
a = 1;
add(10); //11
这就不是纯函数,因为明明执行了两次相同的代码,结果确实不一样的,试想一下,你执行了一个函数,结果是20,并用其结果进行了某些逻辑操作,之后再另外的地方又需要这个函数的结果作为参数了,待代码运行的时候,发现结果于预期不符合,这个时候要查错误,就比较麻烦了,因为没有报错,但结果就是不对;
因此为了保证程序的稳定性,应该尽可能的使用纯函数,避免出现意料之外的情况,当然这种是相对的,具体情况还需要更具项目具体分析,只是说,能用纯函数的时候千万别弄别的幺蛾子;
高阶函数
顾名思义,高阶函数也是一种函数,与普通函数不同的是:高阶函数接收函数作为参数,或者返回的是一个函数;
let arr = [1, 2, 3, 4];
let newArr = (arr,fn) => {
let res = [];
arr.forEach((el) => {
res.push(fn(el))
})
return res
}
let add_1 = el => el+1;
console.log(newArr(arr,add_1))
函数式编程中的函数newArr,这个就是一个高阶函数,它接收了一个函数作为参数,因此它就是一个高阶函数,同样,闭包也是一个高阶函数,因为它把一个函数作为返回值返回出去,也符合高阶函数的描述;
JS中有很多内置的高阶函数,比如数组方法中的map,reduce等等,下面有几个示例
//使用reduce实现数组去重
let arr = [1, 2, 3, 4, 5, 6, 6, 7, 7, 7]
let newArr = arr.reduce((prev, cur) => {
prev.indexof(cur) === -1 && prev.push(cur);
},[])
//实现数组拍平
const arr = [1, [2], [3, [4, [5]]]];
//给Array扩展一个flat
Array.prototype.flat = function () {
let arr = function (curarr) {
return curarr.reduce((tol, cur)=>{
//判断当前元素是否是数组
//如果是数组,对其进行递归后再合并,如果不是数组,直接使用扩展预算父合并
return Array.isArray(cur) ? [...tol, ...arr(cur)] : [...tol, cur]
},[])
}
return arr(this)
}
console.log(arr.flat())
递归
递归函数在项目中认为是比较常见的函数了,其过程就是执行的过程中调用自身,形成一层层函数嵌套;请直接看示例:
function count(num){
if(num <= 1){
return 1;
}
else {
return num * count(num-1)
}
}
console.log(count(4))
这是一个简单的递归函数,看下来之后发现,递归其实也是一个循环,既然是循环那么必定存在终止循环的条件,比如上例的return 1,就是终止循环的条件,简单分析一下这个递归:
执行函数后:
- 第一个阶段:发现参数num的值是4,不符合if条件,因此执行else语句,发现里面是一个return,但是执行到4* 后面的时候发现又是一个函数,因此4*这个表达式就暂缓,搁置了,得先执行函数count;
- 第二个阶段:此时count的参数因执行num-1,就变成了3,但3仍然不符合if语句,还是得执行else,因此在执行else的时候3*这个表达式依旧被暂缓执行,还是得先执行函数count;
- 第三个阶段:这次执行的count参数的值是2,发现不符合if,因此执行else,执行的时候2*被搁置暂缓了,依旧得先执行count;
- 第四个阶段:这次count的参数值是1,符合if条件,因此返回1,到这里就没有嵌套函数了;
第四阶段执行完毕后,有了一个明确的返回值,不再有嵌套函数,因此就要开始执行前面被暂缓的表达式了,最终第三阶段后面的count函数的值是1,因此第三阶段的返回的表达式是21,第三阶段执行完毕返回,那么第二阶段的count函数也有了计算结果,最终就是32,第二阶段执行完毕,有了返回值,那么第一阶段的count的值就是6,因此执行的就是4*6,因此整个count执行的结果就是24
柯里化
是把接收多个参数的函数转成接收单一参数的函数,并且返回接收剩余参数的函数;
我的理解是,原本有一个函数,它能接收多个参数,现在将函数改成链式的调用,每次只接收一个参数,具体示例
//普通函数
function boy(name, age, single){
return `我是${name},今年${age}岁,我${single}单身`
}
boy("张三", 18, "是");
//柯里化之后
function boy(name){
return function (age){
return function (single){
return `我是${name},今年${age}岁,我${single}单身`
}
}
}
boy("张三")(18)("是")
后面这个就是柯里化的函数,可以明确,这是一个高阶函数,因为返回的是函数,那么柯里化有什么优点呢?上面的例子好像没什么区别,实际上柯里化在现实开发项目中使用的是不大多,我本人使用的最多的是参数的固定,比如表单的验证上要确认每项输入不能有空格,具体请看示例
//正常函数
let macthInput = (reg,str) => reg.test(str);
macthInput(/\s+/g,"hello world");
macthInput(/\s+/g,"helloworld");
上例有一个验证函数matchInput,接收两个参数,第一个是一个正则,第二个是待检验的字符串,而注册表单等等表单上往往就十多项输入,每次检验都是需要输入正则,因此可以使用柯里化将正则参数固定下来,之后每次只需要输入待检验字符串就可以了
let macthInpuit = (reg) => {
return (str) => {
return reg.test(str)
}
}
let macthing = macthInpuit(/\s+/g);
macthing("hello world");
macthing("helloworld");
现在很多验证都已经内置在了UI框架中了,这种技巧平时使用的也不太多,不过思想得了解,当工作中用到多次使用同一个固定参数的时候,就可以考虑用柯里化的技术将参数固定下来;
防抖和节流
在前端中,resize,scroll,mousemove,mousehover等等事件,会不断的被触发,甚至一秒内会被触发几十几百次,如此高频的被触发,不仅造成计算机资源的浪费,还会降低程序的运行速度,造成浏览器卡死,奔溃;
防抖
当持续触发事件时,一定时间段内没有再触发事件,事件处理函数才会执行一次,如果设定的时间到来之前,又一次触发了事件,就重新开始延时;
let deBounce = (fn,delay) => {
let timer = null;
//返回一个函数,...arg,将argument数组化
return (...arg) => {
//如果存在timer,就清除
if(timer){
clearTimeout(timer);
}
//到这里说明timer是null,所以就设定了一个延时,延时之后触发函数,并且将作用域this传递过去
timer = setTimeout(()=>{
fn(...arg)
},delay)
}
}
//因为返回的是函数,所以相当于onmousemove绑定的就是一个函数
oDiv.onmousemove = deBounce(changeNum,200)
总的来说,防抖就是通过setTimeout,设定了一个延时,判断在这个事件内是否是被不断的触发,如果不断的被触发,则不断的清空之前设定的延时,直到最后一次触发,那么在延时之后,执行这个函数,确保函数只执行一次;
节流
在持续触发事件时,保证一个时间段内只调用一次事件处理函数;
let throttle = (fn,delay) => {
let flag = true;
return (...arg) => {
if(!flag) return
flag = false;
setTimeout(()=>{
fn(...arg);
flag = true;
},delay)
}
}
总的来说,就是通过设置一个开关变量,来控制当前是否可以触发函数,之后通过setTimeout,来控制时间段内执行一次函数;
应用场景
简单的说个应用场景,就是用户输入账号和密码后自动登录账号,如果没有使用防抖函数,那么每次触发input事件都将向服务器发送请求,这样就会很浪费资源,并且不断的发送请求也会增加服务器负担,因此,通过使用防抖函数,只有当输入停止满一定时间后,才会想服务器发送一次请求,即使用户是中断输入思考账号密码,那请求的次数也是大大的减少了;
深拷贝和浅拷贝
深拷贝和浅拷贝,都是一种拷贝,是对原数据进行一次复制,因为在实际开发中,很多时候往往不能对原数据进行修改,需要将原数据进行复制保存,再对复制的数据进行筛选,操作;
在了解深拷贝,浅拷贝之前,首先得明确,原始数据类型和引用数据类型,原始数据类型存储在内存的栈中,引用数据类型存储在数据的堆中,原始数据的复制只需要赋值就可以了
let a = 1;
let b = a;
b = 2;
console.log(b,a) //2,1
对原始数据类型的操作并不会影响其值的来源,这也就是按值传递,也就是例子中即使对b进行了修改操作,也不会影响到变量a的值,但是引用类型就不是了
let a = {a:1};
let b = a;
b.a = 2;
console.log(b.a,a.a) //2,2
对引用类型类型的修改,会追溯到其数据存储的地址中,也就会导致所有引用该地址的变量的值同时被修改,这也就是按址传递;
**
所以,深拷贝浅拷贝都是针对于引用类型而言的;通常对于对象的复制,是通过对对象的遍历然后复制,最后返回一个新对象,新对象的属性的修改于老对象没有关系;
浅拷贝
我的理解是:如果复制的新对象中存在与老对象相互影响的部分,那么本次复制就是浅拷贝;下例是一个简单的浅拷贝示例
let a = {
a:1,
b:2,
c:{
d:10
}
}
let copy = obj => {
let rst = {}
for(let key in obj){
rst[key] = obj[key]
}
return rst
}
let newObj = copy(a);
通过遍历,将对象a的属性复制到了对象newObj,其中,属性a,属性b,修改相互不影响,但是属性c的部分,却又相互影响,那么本次就是浅拷贝;
深拷贝
和浅拷贝相反,深拷贝就是:复制的新对象与老对象任何部分都不相互影响,那么本次复制就是深拷贝;
通过浅拷贝例子中的遍历方式,可以实现深拷贝,那么如果属性的属性值是对象,那么毫无疑问就要使用到递归了
let deepClone = obj => {
let newObj = Array.isArray(obj)?[]:{};
if(obj && typeof obj === "object"){
for(let key in obj){
if(obj.hasOwnProperty(key)){
if(obj[key]&&typeof obj[key] === "object"){
newObj[key] = deepClone(obj[key])
}
else{
newObj[key] = obj[key]
}
}
}
}
return newObj;
}
小结
不知道各位看到本文的大佬如何看待这些知识点,其实我个人认为,其实这些知识点都在阐述一个行为,为了减少工作量,提高工作效率,代码的复用,参数的复用,代码的稳定设计,都是为了在保证程序上线之后稳定可靠后,换句话说就是为了不再加班,享受生活, = =!,因此能复用的复用,能部分复用的就部分复用;