1.关于闭包
什么是闭包?
闭包是有权限访问其它函数作用域内的变量的一个函数。
在js中,变量分为全局变量和局部变量,局部变量的作用域属于函数作用域,在函数执行完以后作用域就会被销毁,内存也会被回收,但是由于闭包是建立在函数内部的子函数,由于其可访问上级作用域的原因,即使上级函数执行完,作用域也不会被销毁,此时的子函数——也就是闭包,便拥有了访问上级作用域中变量的权限,即使上级函数执行完以后作用域内的值也不会被销毁。
闭包解决了什么?
本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁。由于闭包可以缓存上级作用域,这样函数外部就可以访问到函数内部的变量。
闭包的应用场景
ajax请求成功的回调
一个事件绑定的回调方法
setTimeout的延时回调
一个函数内部返回另一个匿名函数
闭包的优缺点
优点:让代码更加规范、简介
缺点:使用闭包过多,内存消耗大,造成内存的泄露
2.原型和原型链
这张图看不懂就算了
(1).所有的引用类型都有一个_proto_(隐式原型)属性,属性值是一个普通的对象
(2).所有的函数除了有_proto_属性,还都有一个prototype(显式原型)属性,属性值是一个普通的对象
(3).所有引用类型都有一个constructor(构造函数)属性,该属性(是一个指针)指向它的构造函数
(4.).所有引用类型的_proto_属性指向它构造函数的prototype
当一个对象调用自身不存在的属性/方法时,会先去它的_proto_上查找,也就是它的构造函数的prototype;如果没有找到,就会去该构造函数的prototype的proto指向的上一级函数的prototype中查找,最后指向null。这样一层一层向上查找的关系会形成一个链式结构,称为原型链。
用自己的方式(图)理解constructor、prototype、proto和原型链
3.ES5继承和ES6继承
ES5:组合式继承,先创建子类的实例对象,然后再将父类的方法通过call方法添加到this上,通过原型和构造函数的机制来实现
示例:
// 定义一个父类functionParent(){this.name='爸爸'this.age=50this.sex='男'}// 在父类的原型上添加一个方法Parent.prototype.play=function(){console.log('去打麻将')}// 定义一个子类functionChild(name){this.name=name// 第一步:继承父类的属性Parent.call(this)}// 第二步:实现子类继承父类的方法(儿子从爸爸那里学会了打麻将)Child.prototype=newParent()// 第三步:找回丢失的构造函数Child.prototype.constructor=Child
ES6:先创建父类的实例对象this(所以必须先调用父类的super()方法,然后在用子类的构造函数修改this),通过class关键字定义类,类之间通过extends关键字实现继承,子类必须在constructor方法中调用super方法。因为子类没有自己的this对象,而是继承了父类的this对象,然后对其加工,如果不调用super方法,子类得不到this对象。
示例:
// 定义一个父类classParent{constructor(name,sex){this.name=namethis.sex=sex}play(){console.log(this.name+'打麻将超级垃圾')}speak(){console.log('性别是:'+this.sex)}}// 父类的实例化letfather=newParent('老高','男')father.play()// 打麻将超级垃圾father.speak()// 性别是男// 创建一个子类去继承父类classChildextendsParent{constructor(name,sex){// 调用父类的 constructorsuper(name,sex)}}// 子类的实例化letson=newChild('小高','女')son.play()son.speak()
4.原生AJAX请求步骤
五步使用法:
(1).创建XMLHTTPRequest对象
(2).使用open方法设置和服务器的交互信息
(3).设置发送的数据,开始和服务器端交互
(4).注册事件
(5).更新界面
Get请求:
// 第一步:创建异步对象letxhr=newXMLHttpRequest()// 第二步:设置请求的url参数,参数1是请求的类型,参数2是请求的url,可以携带参数xhr.open('get','/baidu.com?username=1')// 第三步:设置发送的数据,开始和服务端交互xhr.send()// 第四步:注册事件onreadystatechange,当状态改变时会调用xhr.onreadystatechange=function(){if(xhr.readyState===4&&xhr.status===200){// 第五步:如果到达这一步,说明数据返回,请求的页面是存在的console.log(xhr.responseText)}}
POST请求:
// 第一步:创建异步对象letxhr=newXMLHttpRequest()// post请求一定要添加请求头,不然会报错xhr.setRequestHeader('Content-type','application/x-www-form-urlencoded')// 第二步:设置请求的url参数,参数1是请求的类型,参数2是请求的url,可以携带参数xhr.open('post','/baidu.com')// 第三步:设置发送的数据,开始和服务端交互xhr.send('username=1&password=123')// 第四步:注册事件onreadystatechange,当状态改变时会调用xhr.onreadystatechange=function(){if(xhr.readyState===4&&xhr.status===200){// 第五步:如果到达这一步,说明数据返回,请求的页面是存在的console.log(xhr.responseText)}}
5.关于事件委托
什么是事件委托
事件委托也叫事件代理,就是利用事件冒泡,只指定一个事件处理程序,就可以管理某一类型的所有事件。
事件委托的作用
(1).提高性能:每一个函数都会占用内存空间,只需添加一个时间处理程序代理所有事件,所占用的内存空间更少;
(2).动态监听:使用事件委托可以自动绑定动态添加的元素,即新增的节点不需要主动添加也可以具有和其它元素一样的事件。
实现方式
我们先来看看,如果不用事件委托,需要绑定多个相同事件的时候是如何实现的:
<!DOCTYPE html><htmllang="en"><head><metacharset="UTF-8"><title>Title</title></head><body><ul><liclass="item">按钮</li><liclass="item">按钮</li><liclass="item">按钮</li><liclass="item">按钮</li></ul></body><script>window.onload = function () { let lis = document.getElementsByClassName('item') for (let i = 0; i < lis.length; i++) { lis[i].onclick = function () { console.log('用力的点我') } } }</script></html>
不使用事件委托,那就要遍历每一个li元素,给每个li元素绑定一个点击事件,这样的做法非常耗费内存,如果有100个、1000个li元素,那对性能的影响是非常大的。
那么使用事件委托是怎么实现的呢?
<!DOCTYPE html><htmllang="en"><head><metacharset="UTF-8"><title>Title</title></head><body><ulid="wrap"><liclass="item">按钮</li><liclass="item">按钮</li><liclass="item">按钮</li><liclass="item">按钮</li></ul></body><script>window.onload = function () { let ul = document.getElementById('wrap') ul.onclick = function (ev) { // 获取到事件对象 let e = ev || window.event // 如果点击的元素的calssName为item if (e.target.className === 'item') { console.log('用力的点我') } } }</script></html>
这样一来,通过事件委托,只需要在li元素的父元素ul上绑定一个点击事件,通过事件冒泡的机制,就可以实现li的点击效果。并且通过js动态添加li元素,也能绑定点击事件。
6.null不是一个对象,但为什么typeof null === object
原理是这样的,不同的对象在底层都会表示为二进制,在js中如果二进制的前三位都为0,就会被判断为object类型,null的二进制全为0,自然前三位也是0,所以typeof null === objcet。
7.关于深拷贝和浅拷贝
浅拷贝:只复制指向某个对象的指针,而不复制对象本身,新旧对象还是共享同一块内存
实现:
方法1:直接用=赋值
let obj1 = {a: 1}let obj2 = obj1
方法2:Object.assign
letobj1={a:1}letobj2={}Object.assign(obj2,obj1)
方法3:for in循环只遍历第一层
functionshallowObj(obj){letresult={}for(letkeyinobj){if(obj.hasOwnProperty(key)){result[key]=obj[key]}}returnresult}letobj1={a:1,b:{c:2}}letobj2=shallowObj(obj1)obj1.b.c=3console.log(obj2.b.c)// 3
深拷贝:
方法1:用 JSON.stringify 把对象转换成字符串,再用 JSON.parse 把字符串转换成新的对象
letobj1={a:1,b:2,}letobj2=JSON.parse(JSON.stringify(obj1))
方法2:采用递归去拷贝所有层级属性
functiondeepClone(obj){// 如果传入的值不是一个对象,就不执行if(Object.prototype.toString.call(obj)!=='[object Object]')return// 根据传入值的类型初始化返回结果letresult=objinstanceofArray?[]:{}for(letkeyinobj){if(obj.hasOwnProperty(key)){// 如果obj是个对象,就递归调用deepClone去遍历obj的每一层属性,如果不是对象就直接返回值result[key]=Object.prototype.toString.call(obj[key])==='[object Object]'?deepClone(obj[key]):obj[key]}}returnresult}// 改进判断对象的方法console.log(typeofnull==='object')// trueconsole.log(Object.prototype.toString.call(null)==='[object Object]')// false
方法3:lodash函数库实现深拷贝
let obj1 = { a: 1, b: 2,}let obj2 = _.cloneDeep(obj1)
方法4:通过jQuery的extend方法实现深拷贝
let array = [1,2,3,4]let newArray = $.extend(true,[],array) // true为深拷贝,false为浅拷贝
方法5:用slice实现对数组的深拷贝
let arr1 = ["1","2","3"]let arr2 = arr1.slice(0)arr2[1] = "9"console.log(arr2) // ['1', '9', '3']console.log(arr1) // ['1', '2', '3']
方法6:使用扩展运算符实现深拷贝
let obj1 = {brand: "BMW", price: "380000", length: "5米"}let obj2 = { ...car, price: "500000" }
8.谈谈js的垃圾回收机制
js拥有自动的垃圾回收机制,当一个值在内存中失去引用时,垃圾回收机制会根据特殊的算法找到它,并将其回收,释放内存。
标记清除法(常用)
(1).标记阶段:垃圾回收器会从根对象开始遍历。每一个可以从根对象访问到的对象都会被添加一个标识,于是这个对象就被标识为可到达对象;
(2).清除阶段:垃圾回收器会对堆内存从头到尾进行线性遍历,如果发现有对象没有被标识为可到达对象,那么就将此对象占用的内存回收,并且将原来标记为可到达对象的标识清除,以便进行下一次垃圾回收操作;
优点:实现简单
缺点:可能会造成大量的内存碎片
引用计数清除法
(1).引用计数的含义就是跟踪记录每个值被引用的次数,当声明了一个变量并将一个引用类型赋值给该变量时,这个值的引用次数就是1。相反,如果包含对这个值引用的变量又取得了另外一个值,这个值的引用次数就减1。
(2).当这个引用次数变成0时,则说明没有办法再访问这个值了,就可以将其所占的内存空间给回收。这样,垃圾收集器下次再运行时,就会释放那些引用次数为0的值所占的内存。
优点:
(1).可即刻回收垃圾
缺点:
(1).计数器值的增减处理繁重
(2).实现繁琐复杂
(3).循环引用无法回收
9.如何阻止事件冒泡和默认事件
标准的DOM对象中可以使用事件对象的stopPropagation()方法来阻止事件冒泡,但在IE8以下中的事件对象通过设置事件对象的cancelBubble属性为true来阻止冒泡
默认事件通过事件对象的preventDefault()方法来阻止,而IE通过设置事件对象的returnValue属性为false来阻止默认事件
10.函数去抖和函数节流
函数去抖(debounce):
当调用函数n秒后,才会执行该动作,若在这n秒内又调用该函数则取消前一次并重新计算执行时间(频繁触发的情况下,只有足够的空闲时间,才执行代码一次)
functiondebounce(delay,cb){lettimerreturnfunction(){if(timer)clearTimeout(timer)timer=setTimeout(function(){cb()},delay)}}
函数节流(throttle):
函数节流的基本思想是函数预先设定一个执行周期,当调用动作的时刻大于等于执行周期则执行该动作,然后进入下一个新周期(一定时间内js方法只跑一次。比如人的眨眼睛,就是一定时间内眨一次)
functionthrottle(cb,delay){letstartTime=Date.now()returnfunction(){letcurrTime=Date.now()if(currTime-startTime>delay){cb()startTime=currTime}}}
JavaScript专题之跟着 underscore 学节流
11.谈谈js的事件循环机制
程序开始执行之后,主程序则开始执行同步任务,碰到异步任务就把它放到任务队列之中,等到同步任务全部执行完后,js引擎便去查看任务队列有没有可以执行的异步任务,将异步任务转成同步任务并开始执行,执行完同步任务后继续查看任务队列。这个过程是一直循环的,因此这个过程就是所谓的事件循环,其中任务队列也被称为事件队列。通过一个任务队列,单线程的js实现了异步任务的执行,给人的感觉好像是多线程的。
12.箭头函数和普通函数的区别
普通函数:
(1).this总是代表它的直接调用者
(2).在默认情况下,没找到直接调用者,this指向window
(3).在严格模式下,没有直接调用者的函数中的this是undefined
(4).使用call,apply,bind绑定,this指的是绑定的对象
箭头函数:
(1).在使用 => 定义函数的时候,this的指向是定义时所在的对象,而不是使用时所在的对象,bind()、call()、apply()均无法改变指向
(2).不能用做构造函数,也就是说不能使用new命令,否则就会抛出一个错误
(3).不能使用arguments对象,但是可以使用…rest参数
(4).不能使用yield命令
(5).没有原型属性
13.call()、apply()、bind()的区别
call()、apply()、bind()是用来改变this的指向的
call():Function.call(obj, param1,param2,param3)
接收到的是param1,param2,param3三个参数
apply():Function.apply(obj, [param1,param2,param3])
接收到的是param1,param2,param3三个参数
call和apply的区别是参数一个不用[],一个要用[]
bind():const newFn = Funtion.bind(obj, param1,param2)
返回值是一个函数,需要()来调用
newFn(param3,param4)
接收到的是param1,param2,param3,param4四个参数
14.实现一个sleep函数
js不像java一样有sleep()方法,但由于js是单线程的,可以利用伪死循环阻塞主线程来达到延迟执行的效果
functionsleep(delay){// 获取一个初始时间letstartTime=newDate().getTime()// 如果时间差小于延迟时间,就一直循环while(newDate().getTime()-startTime<delay){continue}}
15.进程和线程的区别
进程(process):是cpu资源分配的最小单位(是能拥有资源和独立运行的最小单位),是并发执行的程序在执行过程中分配和管理资源的基本单位,是一个动态概念。
线程(thread):是cpu调度的最小单位(是建立在进程基础上的一次程序运行单位),是进程内可调度的实体,比进程更小的独立运行的基本单位。
一个进程有一个或多个线程,线程之间共同完成进程分配下来的任务,打个比方:
● 假如进程是一个工厂,工厂有它的独立的资源
● 工厂之间相互独立
● 线程是工厂中的工人,多个工人协作完成任务
● 工厂内有一个或多个工人
● 工人之间共享空间
再完善完善概念:
● 工厂的资源 -> 系统分配的内存(独立的一块内存)
● 工厂之间的相互独立 -> 进程之间相互独立
● 多个工人协作完成任务 -> 多个线程在进程中协作完成任务
● 工厂内有一个或多个工人 -> 一个进程由一个或多个线程组成
● 工人之间共享空间 -> 同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)
16.ES6、ES7、ES8的新特性
ES6的特性
(1). 类(class)
对熟悉Java、C、C++等语言的开发者来说,class一点都不陌生。ES6引入了class(类),让JS的面向对象编程变得更加简单和易于理解。
(2).模块化(Module)
ES5不支持原生的模块化,在ES6中模块作为重要的组成部分被添加进来。模块的功能主要由export和import组成。每一个模块都有自己单独的作用域,模块之间的相互调用关系是通过 export 来规定模块对外暴露的接口,通过import来引用其它模块提供的接口。同时还为模块创造了命名空间,防止函数的命名冲突。
导出(export)
ES6运行在一个模块中使用export来导出多个变量或函数
// 导出变量exportletname='gg'// 导出常量exportconstname='gg'// 导出多个变量leta=2letb=4export{a,b}// 导出函数exportfunctionmyModule(someArg){returnsomeArg}
导入(import)
定义好模块的输出以后就可以在另外一个模块通过import引用。
import{myModule}from'myModule'import{a,b}from'test'
(3).箭头(Arrow)函数
这是ES6中最令人激动的特性之一。=>不只是关键字function的简写,它还带来了其它好处。箭头函数与包围它的代码共享同一个this,能很好的解决this的指向问题。
(4).函数参数默认值
ES6支持在定义函数的时候为其设置默认值,当函数的参数为布尔值false时,可以规避一些问题
// 使用默认值functionfoo(height=50,color='red'){//}// 不使用默认值functionfoo(height,color){letheight=height||50letcolor=color||'red'}
(5).模板字符串
// 不使用模板字符串letname='Your name is '+first+' '+last+'.'// 使用模板字符串letname=`Your name is ${first} ${last}.`
(6).解构赋值
通过解构赋值可以方便的交换两个变量的值:
leta=1letb=3[a,b]=[b,a];console.log(a)// 3console.log(b)// 1
获取对象中的值:
conststudent={name:'Ming',age:'18',city:'Shanghai'}const{name,age,city}=studentconsole.log(name)// "Ming"console.log(age)// "18"console.log(city)// "Shanghai"
(7).延展操作符(Spread operator)和剩余运算符(rest operator)
当三个点(...)在等号右边,或者放在实参上,是 spread运算符
myFunction(...arr)letarr1=[1,2,3,4]letarr2=[...arr1,4,5,6]console.log(arr2)// [1, 2, 3, 4, 4, 5, 6]
当三个点(...)在等号左边,或者放在形参上,是 rest 运算符
functionmyFunction(...arr){}let[a,...temp]=[1,2,4]console.log(a)// 1console.log(temp)// [2, 4]
(8).对象属性简写
在ES6中允许我们在设置一个对象的属性的时候不指定属性名
不使用ES6:
constname='Ming',age='18',city='Shanghai';conststudent={name:name,age:age,city:city};console.log(student)//{name: "Ming", age: "18", city: "Shanghai"}
使用ES6:
constname='Ming',age='18',city='Shanghai'conststudent={name,age,city};console.log(student)//{name: "Ming", age: "18", city: "Shanghai"}
(9).Promise
Promise 是异步编程的一种解决方案,比传统的解决方案callback更加的优雅。它最早由社区提出和实现的,ES6 将其写进了语言标准,统一了用法,原生提供了Promise对象。
不使用ES6
setTimeout(function(){console.log('Hello')// 1秒后输出"Hello"setTimeout(function(){console.log('Hi')// 2秒后输出"Hi"},1000)},1000)
使用ES6
letwaitSecond=newPromise(function(resolve,reject){setTimeout(resolve,1000)});waitSecond.then(function(){console.log("Hello")// 1秒后输出"Hello"returnwaitSecond}).then(function(){console.log("Hi")// 2秒后输出"Hi"})
(10).支持let与const
在之前JS是没有块级作用域的,const与let填补了这方便的空白,const与let都是块级作用域。
let和var的区别:
● let没有变量提升,存在暂时性死区,必须等let声明完以后,变量才能使用
● let变量不能重复声明
● let声明的变量只在let代码块有效
ES7的特性
(1).Array.prototype.includes()
includes() 函数用来判断一个数组是否包含一个指定的值,如果包含则返回 true,否则返回false
letarr=['react','angular','vue']if(arr.includes('react')){console.log('react存在')}
(2).指数操作符
在ES7中引入了指数运算符,具有与Math.pow(..)等效的计算结果。
console.log(Math.pow(2,10))// 输出1024console.log(2**10)// 输出1024
ES8的特性
(1).async/await
在ES8中加入了对async/await的支持,也就我们所说的异步函数,这是一个很实用的功能。 async/await相当于一个语法糖,解决了回调地狱的问题
(2).Object.values()
Object.values()是一个与Object.keys()类似的新函数,但返回的是Object自身属性的所有值,不包括继承的值。
constobj={a:1,b:2,c:3,}// 不使用Object.values()constvals=Object.keys(obj).map(e=>obj[e])console.log(vals)// [ 1, 2, 3 ]// 使用Object.values()console.log(Object.values(obj))// [ 1, 2, 3 ]
(3).Object.entries
Object.entries()函数返回一个给定对象自身可枚举属性的键值对的数组。
constobj={a:1,b:2,c:3,}// 不使用Object.entries()Object.keys(obj).forEach(key=>{console.log('key:'+key+' value:'+obj[key])})//key:a value:1//key:b value:2//key:c value:3// 使用Object.entries()for(let[key,value]ofObject.entries(obj1)){console.log(`key: ${key} value:${value}`)}//key:a value:1//key:b value:2//key:c value:3
(4).String padding
在ES8中String新增了两个实例函数String.prototype.padStart和String.prototype.padEnd,允许将空字符串或其他字符串添加到原始字符串的开头或结尾
String.padStart(targetLength,[padString])
targetLength:当前字符串需要填充到的目标长度。如果这个数值小于当前字符串的长度,则返回当前字符串本身。
padString:(可选)填充字符串。如果字符串太长,使填充后的字符串长度超过了目标长度,则只保留最左侧的部分,其他部分会被截断,此参数的缺省值为 " "。
console.log('0.0'.padStart(4,'10'))//10.0console.log('0.0'.padStart(20))// 0.00
String.padEnd(targetLength,padString])
targetLength:当前字符串需要填充到的目标长度。如果这个数值小于当前字符串的长度,则返回当前字符串本身。
padString:(可选) 填充字符串。如果字符串太长,使填充后的字符串长度超过了目标长度,则只保留最左侧的部分,其他部分会被截断,此参数的缺省值为 " ";
console.log('0.0'.padEnd(4,'0'))//0.00 console.log('0.0'.padEnd(10,'0'))//0.00000000
(5).函数参数列表结尾允许逗号
// 不使用ES8//程序员Aletf=function(a,b){...}//程序员Bletf=function(a,b,//变更行c//变更行){...}//程序员Cletf=function(a,b,c,//变更行d//变更行){...}// 使用ES8//程序员Aletf=function(a,b,){...}//程序员Bletf=function(a,b,c,//变更行){...}//程序员Cletf=function(a,b,c,d,//变更行){...}
(6).Object.getOwnPropertyDescriptors()
Object.getOwnPropertyDescriptors()函数用来获取一个对象的所有自身属性的描述符,如果没有任何自身属性,则返回空对象。
constobj2={name:'Jine',getage(){return'18'}};Object.getOwnPropertyDescriptors(obj2)// {// age: {// configurable: true,// enumerable: true,// get: function age(){}, //the getter function// set: undefined// },// name: {// configurable: true,// enumerable: true,// value:"Jine",// writable:true// }// }
ES6、ES7、ES8特性一锅炖(ES6、ES7、ES8学习指南)
17.性能监控平台是如何捕获错误的
全局捕获
通过全局的接口,将捕获代码集中写在一个地方,可以利用的接口有:
(1).window.addEventListener(‘error’) / window.addEventListener(“unhandledrejection”) / document.addEventListener(‘click’) 等
(2).框架级别的全局监听,例如aixos中使用interceptor进行拦截,vue、react都有自己的错误采集接口
(3).通过对全局函数进行封装包裹,实现在在调用该函数时自动捕获异常
(4).对实例方法重写(Patch),在原有功能基础上包裹一层,例如对console.error进行重写,在使用方法不变的情况下也可以异常捕获
单点捕获
在业务代码中对单个代码块进行包裹,或在逻辑流程中打点,实现有针对性的异常捕获:
(1).try…catch中throw err
(2).专门写一个函数来收集异常信息,在异常发生时,调用该函数
(3).专门写一个函数来包裹其他函数,得到一个新函数,该新函数运行结果和原函数一模一样,只是在发生异常时可以捕获异常
18.函数柯里化
在数学和计算机科学中,柯里化是一种将使用多个参数的一个函数转换成一系列使用一个参数的函数的技术
示例:
functionadd(a,b){returna+b;}// 执行 add 函数,一次传入两个参数即可add(1,2)// 3// 假设有一个 curry 函数可以做到柯里化varaddCurry=curry(add);addCurry(1)(2)// 3addCurry(1)(2)=add(1,2)
实现:
(1).最简单的方式,使用lodash库的_.curry
functionsum(a,b){returna+b}// 使用来自 lodash 库的 _.curryletcurriedSum=_.curry(sum)alert(curriedSum(1,2))// 3alert(curriedSum(1)(2))// 3
(2).自定义函数实现
functioncurry(func){returnfunctioncurried(...args){if(args.length>=func.length){returnfunc.apply(this,args)}else{returnfunction(...args2){returncurried.apply(this,args.concat(args2))}}}}letcurrfn=curry(add)console.log(currfn(1)(3))// 4
19.new关键字做了什么?
使用new操作符调用构造函数实际上会经历以下4个步骤:
(1).创建一个新对象
(2).将构造函数的作用域赋给新对象(因此this就指向了这个新对象)
(3).执行构造函数中的代码(为这个新对象添加属性、方法)
(4).返回新对象
varobj={}obj.__proto__=Base.prototypeBase.call(obj)
作者:_前端小弟
链接:https://www.jianshu.com/p/4840a90751e1
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。